feat(cli): /init — generate or update AGENTS.md from a project scan

Cross-surface slash command (CLI, gateway, TUI) following the /learn prompt-injection pattern. Port of Codex /init.
This commit is contained in:
teknium1 2026-07-26 12:52:20 -07:00 committed by Teknium
parent 40dc36a842
commit 95b7ea5e5d
8 changed files with 340 additions and 2 deletions

2
cli.py
View file

@ -9462,6 +9462,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self._handle_skills_command(cmd_original)
elif canonical == "learn":
self._handle_learn_command(cmd_original)
elif canonical == "init":
self._handle_init_command(cmd_original)
elif canonical == "memory":
self._handle_memory_command(cmd_original)
elif canonical == "platforms":

View file

@ -11792,6 +11792,34 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
except Exception:
return "Could not start /learn — please try again."
if canonical == "init":
# /init: rewrite the turn to a guidance-laden prompt and fall
# through to normal agent processing (same fall-through as /learn
# so role alternation is preserved). The live agent scans the
# project with its own read-only tools and writes/updates
# AGENTS.md via write_file. No engine, works on any backend.
from hermes_cli.init_command import build_init_prompt_for_cwd
_init_notes = event.get_command_args().strip()
try:
_init_prompt = build_init_prompt_for_cwd(extra=_init_notes)
except Exception:
return "Could not start /init — please try again."
_ack = (
"Updating AGENTS.md from a project scan…"
if "UPDATE the existing AGENTS.md" in _init_prompt
else "Generating AGENTS.md from a project scan…"
)
try:
adapter = self._adapter_for_source(source)
if adapter:
_ack_meta = self._thread_metadata_for_source(source)
await adapter.send(str(source.chat_id), _ack, metadata=_ack_meta)
except Exception:
logger.debug("init ack send failed", exc_info=True)
event.text = _init_prompt
# fall through to agent processing
if canonical == "fast":
return await self._handle_fast_command(event)

View file

@ -1642,6 +1642,32 @@ class CLICommandsMixin:
else: # pragma: no cover - defensive (no live input loop)
print(" /learn needs an active chat session to run.")
def _handle_init_command(self, cmd: str):
"""Handle /init — generate or update AGENTS.md from a project scan.
Mirrors /learn: build a guidance-laden prompt and inject it onto the
agent's input queue as a normal user turn. The live agent scans the
project with its own read-only tools and writes/updates AGENTS.md via
``write_file``. No engine, no model-tool footprint, works on any
terminal backend, and preserves prompt-cache invariants (no system
prompt or history mutation).
"""
from hermes_cli.init_command import build_init_prompt_for_cwd
# Everything after the command word is optional user emphasis.
parts = cmd.strip().split(None, 1)
extra = parts[1].strip() if len(parts) > 1 else ""
msg = build_init_prompt_for_cwd(extra=extra)
if "UPDATE the existing AGENTS.md" in msg:
print("\n⚡ Updating AGENTS.md from a project scan...")
else:
print("\n⚡ Generating AGENTS.md from a project scan...")
if hasattr(self, "_pending_input"):
self._pending_input.put(msg)
else: # pragma: no cover - defensive (no live input loop)
print(" /init needs an active chat session to run.")
def _handle_memory_command(self, cmd: str):
"""Handle /memory slash command — pending review + approval-gate toggle."""
from hermes_cli.write_approval_commands import handle_pending_subcommand

View file

@ -196,6 +196,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
"Tools & Skills", cli_only=True, aliases=("generate-pet",), args_hint="[description]"),
CommandDef("learn", "Learn a reusable skill from anything you describe (dirs, URLs, this chat, notes)",
"Tools & Skills", args_hint="<what to learn from>"),
CommandDef("init", "Generate or update AGENTS.md project instructions from a repo scan",
"Tools & Skills", args_hint="[notes]"),
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
cli_only=True, args_hint="[subcommand]",
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),
@ -1172,7 +1174,10 @@ _SLACK_PRIORITY_ALIASES = ("btw", "bg")
# displacing existing native Slack slash commands at the 50-command cap.
# - debug: the log/report upload surface; reached via /hermes debug on Slack.
# - egress: Docker-only proxy status; reachable as /hermes egress on Slack.
_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "debug", "egress"})
# - init: repo-scan AGENTS.md bootstrap — a cwd-centric dev command that is
# rare from Slack; reachable as /hermes init. Without this entry, adding
# /init clamps /version off the native list and breaks Telegram parity.
_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "debug", "egress", "init"})
def _sanitize_slack_name(raw: str) -> str:

150
hermes_cli/init_command.py Normal file
View file

@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""``/init`` — build the prompt that generates or updates a project AGENTS.md.
Port of Codex ``/init`` (Claude Code has the same for CLAUDE.md). Hermes
already *loads* AGENTS.md / CLAUDE.md / .cursorrules as project context, but
had no command to bootstrap one. ``/init`` hands the live agent ONE
guidance-laden prompt instructing it to:
1. Inspect the project with its own read-only tools (``read_file`` /
``search_files`` on manifests, CI configs, lockfiles, existing docs) to
learn the layout, toolchain, and the exact build/test/lint commands.
2. Write a CONCISE ``AGENTS.md`` (target under 100 lines) with the sections
an agent actually needs overview, setup, commands, conventions,
pitfalls not an essay.
3. If an AGENTS.md already exists, UPDATE it: preserve the user's existing
content and merge in what's missing, never blow it away.
There is no engine and no model-tool footprint: the agent does the work with
its existing toolset, so this works identically on local, Docker, and remote
terminal backends. Every surface (CLI ``/init``, gateway ``/init``, TUI
``/init``) calls :func:`build_init_prompt` and feeds the result to the agent
as a normal user turn the same prompt-injection pattern as ``/learn`` and
``/blueprint``, which preserves prompt-cache invariants (no system-prompt or
history mutation).
"""
from __future__ import annotations
# The quality bar, embedded in every prompt so the generated file reads like a
# maintainer wrote it — concrete and command-exact, not generic advice.
_QUALITY_BAR = """\
Quality bar for the file you write (this is what separates a useful AGENTS.md
from noise):
- CONCISE: target under 100 lines. Agents load this file every session every
line costs context. No essays, no marketing prose, no filler.
- Commands must be EXACT invocations you verified from the repo (package.json
scripts, Makefile targets, pyproject/tox/CI config, existing docs). Write
`npm run test:unit` or `scripts/run_tests.sh tests/foo`, never "run the
tests". NEVER invent a command you didn't see evidence for.
- No generic advice. "Write tests for new code" and "follow best practices"
are banned if a line would be true of any repo, cut it.
- Conventions must be OBSERVED, not assumed: naming patterns, module layout,
error-handling style, commit-message format only what the code actually
shows.
- Include pitfalls that would genuinely trip up a newcomer or an agent
(required env vars, generated files not to hand-edit, slow test suites,
ports already in use), if you found any. Skip the section if you found none.
- Markdown structure: a short title + one-paragraph overview, then focused
sections (e.g. "Dev environment", "Build & test", "Conventions",
"Pitfalls"). Flat and scannable no deep nesting."""
def build_init_prompt(
cwd: str,
existing_file: str | None = None,
extra: str = "",
) -> str:
"""Build the agent prompt for a ``/init`` request.
Args:
cwd: the project directory the agent should scan and write
``AGENTS.md`` into (usually the session working directory).
existing_file: the current content of ``AGENTS.md`` if one already
exists, else ``None``. When present the prompt switches to
update-and-merge discipline instead of fresh generation.
extra: free-text the user gave after ``/init`` emphasis or notes to
honor while authoring (e.g. "focus on the test setup").
Returns:
A complete instruction the agent runs as a normal turn.
"""
extra = (extra or "").strip()
parts: list[str] = [
"[/init] The user wants you to "
+ (
"UPDATE the existing AGENTS.md project-instructions file"
if existing_file is not None
else "generate an AGENTS.md project-instructions file"
)
+ f" for the project at: {cwd}\n",
"AGENTS.md is the instruction file coding agents (Hermes included) "
"load as project context every session. It should teach an agent how "
"to work in THIS repo: what the project is, how to set up, the exact "
"build/test/lint commands, the conventions the code actually follows, "
"and the pitfalls that waste time.\n",
"Do this:\n"
"1. Inspect the project with your read-only tools (`read_file`, "
"`search_files`) — start with manifests and toolchain files "
"(package.json, pyproject.toml, Cargo.toml, go.mod, Makefile, "
"CI workflow configs, lockfiles), then the directory layout, existing "
"README/docs, and test/lint configuration. Learn the real commands, "
"don't guess them.\n"
"2. Write the file to "
f"{cwd.rstrip('/')}/AGENTS.md with `write_file`"
+ (
" — but this is an UPDATE, so follow the merge discipline below."
if existing_file is not None
else "."
)
+ "\n"
"3. Confirm to the user the exact path you wrote and summarize in one "
"or two lines what the file covers.\n",
]
if existing_file is not None:
parts.append(
"MERGE DISCIPLINE — an AGENTS.md already exists (its current "
"content is below). Do NOT overwrite or regenerate it from "
"scratch. Preserve the user's existing content — their wording, "
"their sections, their rules — and merge in only what is missing "
"or verifiably stale (e.g. a command that no longer exists in the "
"repo). When existing content conflicts with what you observed, "
"prefer minimal surgical edits over rewrites, and keep the "
"user's intent. The result must still meet the quality bar.\n\n"
"CURRENT AGENTS.md CONTENT:\n"
"<<<EXISTING_AGENTS_MD\n"
f"{existing_file}\n"
"EXISTING_AGENTS_MD\n"
)
parts.append(_QUALITY_BAR)
if extra:
parts.append(
"\nUSER NOTES — honor these while authoring (they override the "
f"defaults above where they conflict):\n{extra}"
)
return "\n".join(parts)
def build_init_prompt_for_cwd(cwd: str | None = None, extra: str = "") -> str:
"""Convenience wrapper used by the dispatch surfaces.
Resolves ``cwd`` (defaults to the process working directory), reads an
existing ``AGENTS.md`` there if present, and returns the full prompt.
"""
import os
resolved = os.path.abspath(cwd or os.getcwd())
existing: str | None = None
agents_path = os.path.join(resolved, "AGENTS.md")
try:
if os.path.isfile(agents_path):
with open(agents_path, encoding="utf-8", errors="replace") as fh:
existing = fh.read()
except OSError:
existing = None
return build_init_prompt(resolved, existing_file=existing, extra=extra)

View file

@ -0,0 +1,117 @@
"""Tests for /init — generate or update AGENTS.md from a project scan.
Covers the shared prompt builder (hermes_cli.init_command.build_init_prompt)
and the slash-command registry wiring. /init has no engine and no model tool:
it builds a guidance-laden prompt that the live agent runs as a normal turn
(the /learn pattern), so these are the load-bearing behavior contracts.
"""
from hermes_cli.init_command import (
_QUALITY_BAR,
build_init_prompt,
build_init_prompt_for_cwd,
)
class TestBuildInitPrompt:
def test_includes_the_cwd(self):
prompt = build_init_prompt("/home/alice/projects/acme")
assert "/home/alice/projects/acme" in prompt
def test_mentions_agents_md(self):
prompt = build_init_prompt("/tmp/proj")
assert "AGENTS.md" in prompt
def test_fresh_generation_when_no_existing_file(self):
prompt = build_init_prompt("/tmp/proj", existing_file=None)
assert "generate an AGENTS.md" in prompt
# No merge discipline block for a fresh file.
assert "MERGE DISCIPLINE" not in prompt
def test_merge_not_overwrite_when_existing_file_passed(self):
existing = "# My Project\n\nAlways run `make lint` before committing.\n"
prompt = build_init_prompt("/tmp/proj", existing_file=existing)
low = prompt.lower()
# Update mode, with explicit merge-not-overwrite discipline.
assert "UPDATE the existing AGENTS.md" in prompt
assert "merge" in low
assert "do not overwrite" in low or "not overwrite" in low
assert "preserve" in low
# And it carries the current content so the agent can merge.
assert existing.strip() in prompt
def test_empty_existing_file_still_triggers_update_mode(self):
# An existing-but-empty AGENTS.md is still an update, not a fresh gen
# (None means "no file"; "" means "empty file").
prompt = build_init_prompt("/tmp/proj", existing_file="")
assert "UPDATE the existing AGENTS.md" in prompt
def test_includes_extra_notes_verbatim(self):
notes = "focus on the test setup, and mention the flaky e2e suite"
prompt = build_init_prompt("/tmp/proj", extra=notes)
assert notes in prompt
def test_no_notes_section_when_extra_empty(self):
assert "USER NOTES" not in build_init_prompt("/tmp/proj", extra=" ")
assert "USER NOTES" in build_init_prompt("/tmp/proj", extra="hi")
def test_always_includes_the_quality_bar(self):
for existing in (None, "# old content"):
prompt = build_init_prompt("/tmp/proj", existing_file=existing)
assert _QUALITY_BAR in prompt
def test_quality_bar_demands_exact_commands_and_conciseness(self):
low = _QUALITY_BAR.lower()
assert "100 lines" in low
assert "never invent" in low
assert "no generic advice" in low
def test_references_read_only_scan_tools(self):
prompt = build_init_prompt("/tmp/proj")
for tool in ("read_file", "search_files", "write_file"):
assert tool in prompt
class TestBuildInitPromptForCwd:
def test_defaults_to_process_cwd(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
prompt = build_init_prompt_for_cwd()
assert str(tmp_path) in prompt
assert "generate an AGENTS.md" in prompt
def test_reads_existing_agents_md(self, tmp_path):
(tmp_path / "AGENTS.md").write_text(
"# Existing\n\nRun `tox -e py311`.\n", encoding="utf-8"
)
prompt = build_init_prompt_for_cwd(cwd=str(tmp_path))
assert "UPDATE the existing AGENTS.md" in prompt
assert "Run `tox -e py311`." in prompt
def test_passes_extra_through(self, tmp_path):
prompt = build_init_prompt_for_cwd(cwd=str(tmp_path), extra="keep it short")
assert "keep it short" in prompt
class TestInitRegistryWiring:
def test_init_is_registered_and_resolves(self):
from hermes_cli.commands import resolve_command
cmd = resolve_command("init")
assert cmd is not None
assert cmd.name == "init"
def test_init_is_in_tools_and_skills_category(self):
from hermes_cli.commands import resolve_command
assert resolve_command("init").category == "Tools & Skills"
def test_init_works_on_the_gateway(self):
# /init is a both-surfaces command like /learn, not CLI-only.
from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS
assert "init" in GATEWAY_KNOWN_COMMANDS
def test_init_is_not_cli_only(self):
from hermes_cli.commands import resolve_command
assert not resolve_command("init").cli_only

View file

@ -14987,6 +14987,7 @@ _PENDING_INPUT_COMMANDS: frozenset[str] = frozenset(
"moa",
"undo",
"learn",
"init",
"compress",
"compact",
}
@ -15338,6 +15339,14 @@ def _(rid, params: dict) -> dict:
from agent.learn_prompt import build_learn_prompt
return _ok(rid, {"type": "send", "message": build_learn_prompt(arg)})
if name == "init":
# Generate-or-update AGENTS.md: build the guidance-laden prompt and
# submit it as a normal agent turn (same pattern as /learn). The live
# agent scans the project with its own read-only tools and writes or
# merge-updates AGENTS.md via write_file. Works on any backend.
from hermes_cli.init_command import build_init_prompt_for_cwd
return _ok(rid, {"type": "send", "message": build_init_prompt_for_cwd(extra=arg)})
if name == "moa":
# /moa is one-shot sugar only: run a single prompt through the default
# MoA preset, then restore the prior model. To *switch* to a MoA preset

View file

@ -95,6 +95,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
| `/memory [pending\|approve\|reject\|approval]` | Review pending memory writes staged by the write-approval gate (`memory.write_approval`) and toggle the gate. See [Controlling memory writes](/user-guide/features/memory#controlling-memory-writes-write_approval). |
| `/bundles` | List configured skill bundles — `/<name>` slash aliases that preload several skills at once. Configure under `bundles:` in `~/.hermes/config.yaml`. See [Skill Bundles](/user-guide/features/skills#skill-bundles). |
| `/learn <what to learn from>` | Distill a reusable skill from anything you describe — a directory, a URL, the workflow you just walked the agent through, or pasted notes. Open-ended: the agent gathers the sources with its own tools and authors a `SKILL.md` following the house authoring standards. Works in the CLI, the messaging gateway, the TUI, and the dashboard Skills page. |
| `/init [notes]` | Generate or update `AGENTS.md` project instructions from a repo scan (port of Codex `/init`). The agent inspects manifests, layout, and toolchain configs with its read-only tools, then writes a concise `AGENTS.md` — or, if one exists, merge-updates it preserving your content. Optional notes steer the emphasis. Works in the CLI, the messaging gateway, and the TUI. |
| `/cron` | Manage scheduled tasks (list, add/create, edit, pause, resume, run, remove) |
| `/suggestions [accept\|dismiss N\|catalog\|clear]` (alias: `/suggest`) | Review suggested automations. Use `/suggestions` to list pending suggestions, `/suggestions accept <id>` to create the proposed automation, `/suggestions dismiss <id>` to reject one, `/suggestions catalog` to add curated starter automations, and `/suggestions clear` to clear resolved suggestion records. Accepted jobs preserve the current surface as the delivery origin. |
| `/blueprint [name] [slot=value ...]` (alias: `/bp`) | Set up an automation from a blueprint template. Bare `/blueprint` lists the catalog; `/blueprint <name>` starts a guided slot-filling flow on the next agent turn; `/blueprint <name> slot=value ...` creates the job directly. |
@ -259,7 +260,7 @@ The messaging gateway supports the following built-in commands inside Telegram,
- `/skills` is **CLI-only for search/browse/install**; its write-approval review subcommands (`pending`, `approve`, `reject`, `diff`, `approval`) also work on messaging platforms when `skills.write_approval` is on. `/memory` works on **both** surfaces.
- `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config.
- `/sethome`, `/update`, `/restart`, `/approve`, `/deny`, `/topic`, `/platform`, and `/commands` are **messaging-only** commands.
- `/status`, `/egress`, `/version`, `/background`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/debug`, `/fast`, `/footer`, `/curator`, `/kanban`, `/credits`, `/suggestions`, `/blueprint`, `/learn`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway.
- `/status`, `/egress`, `/version`, `/background`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/debug`, `/fast`, `/footer`, `/curator`, `/kanban`, `/credits`, `/suggestions`, `/blueprint`, `/learn`, `/init`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway.
- `/voice join`, `/voice channel`, and `/voice leave` are only meaningful on Discord.
- In the TUI, `/sessions` shows live sessions in the current TUI process. Use `/resume [name]` or `hermes --tui --resume <id-or-title>` for saved or closed transcripts.