diff --git a/__init__.py b/__init__.py index 33edfb4..cffc5e8 100644 --- a/__init__.py +++ b/__init__.py @@ -2,7 +2,7 @@ The CLI (``bin/euthyna.js``, installed by ``npm install -g euthyna``) produces the deterministic facts; this plugin registers ``/euthyna`` so a user can invoke -the four CLI subcommands from inside a Hermes session without leaving chat. The +the CLI's subcommands from inside a Hermes session without leaving chat. The handler shells out to the real CLI and returns its output verbatim — it does not reimplement any measurement, and it injects nothing into the conversation. @@ -15,19 +15,27 @@ import shutil import subprocess -# Ponytail: one flat dict, not a command framework — there are exactly four -# subcommands and the CLI already owns usage/exit codes. +# The subcommands /euthyna reaches. This is the single source the guard in +# test/hermes-plugin.test.js compares against the CLI's own dispatch, so a new +# command there is red here until it is wired — which is how `audit` (the +# entry point since 0.4.0) went seven releases unnoticed behind a hand-written +# list of four. +_FORWARDS = ("audit", "coverage", "deps", "gate", "help", "history") + _USAGE = """euthyna slash command — usage: + /euthyna audit [...] history + dependency surface in one run /euthyna history [ ...] deleted-code provenance & security classification /euthyna coverage symbol invocation counts from a coverage run /euthyna deps dependency pinned versions from lockfiles /euthyna gate [--verify] validate an audit report against the six gates + /euthyna help the CLI's own usage, with every flag Args after the subcommand are passed to the CLI unchanged. Requires the CLI: npm install -g euthyna (Node >= 20).""" + def _find_cli(): """Return the absolute path of the euthyna CLI, or None if not installed.""" for name in ("euthyna", "euthyna.cmd", "euthyna.exe"): @@ -40,10 +48,13 @@ def _find_cli(): def _run(raw_args: str): """Handle /euthyna: forward to the CLI, return its output.""" argv = raw_args.split() - if not argv or argv[0] in ("help", "-h", "--help"): + # `-h`/`--help` print this command's own quick usage; bare `help` is forwarded + # so the flag list has exactly one owner — the CLI's help, which the skill and + # the docs both quote. + if not argv or argv[0] in ("-h", "--help"): return _USAGE sub, rest = argv[0], argv[1:] - if sub not in ("history", "coverage", "deps", "gate"): + if sub not in _FORWARDS: return f"unknown subcommand: {sub}\n\n{_USAGE}" cli = _find_cli() @@ -73,8 +84,8 @@ def register(ctx): ctx.register_command( "euthyna", handler=_run, - description="euthyna security audit: run history/coverage/deps/gate fact producers", - args_hint=" ", + description="euthyna security audit: drive the fact-producing CLI (audit is the single entry point)", + args_hint=" ", ) diff --git a/plugin.yaml b/plugin.yaml index f9b3e40..d4426d3 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,6 +1,6 @@ name: euthyna version: 0.9.0 -description: euthyna audit discipline — slash commands that drive the euthyna fact-producing CLI (history / coverage / deps / gate) +description: euthyna audit discipline — the /euthyna slash command driving the euthyna CLI, whose single entry point is `euthyna audit` license: Apache-2.0 homepage: https://github.com/slow-stack/euthyna tags: diff --git a/test/hermes-plugin.test.js b/test/hermes-plugin.test.js index 5654f64..d115018 100644 --- a/test/hermes-plugin.test.js +++ b/test/hermes-plugin.test.js @@ -1,16 +1,17 @@ /** * The Hermes plugin surface: plugin.yaml + __init__.py at the repo root are what * `hermes plugins install slow-stack/euthyna` discovers (the discovery reads - * `/plugin.yaml`), and the /euthyna slash command forwards to four CLI - * subcommands. A CLI subcommand rename is the harmless-looking change that - * breaks the command silently — this pins the two together, and pins the files - * the npm tarball must carry. + * `/plugin.yaml`), and the /euthyna slash command forwards to the CLI's + * dispatched subcommands. A CLI subcommand rename or a newly dispatched command + * is the harmless-looking change that breaks the command silently — this pins + * the two together, and pins the files the npm tarball must carry. */ import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; import { access, readFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { cliSubcommands } from './cli-commands.js'; const ROOT = path.resolve(fileURLToPath(new URL('.', import.meta.url)), '..'); const read = (rel) => readFile(path.join(ROOT, rel), 'utf8'); @@ -29,14 +30,25 @@ describe('the package is installable as a Hermes plugin', () => { assert.match(py, /ctx\.register_command\(\s*\n?\s*"euthyna"/, 'the in-session command is /euthyna'); }); - test('the CLI subcommands the slash command forwards to are exactly the ones bin/euthyna.js dispatches', async () => { + test('the subcommands /euthyna forwards are exactly the ones the CLI dispatches', async () => { const py = await read('__init__.py'); - const cli = await read('src/cli.js'); - const declared = [...py.matchAll(/"(\w+)"/g)].map((m) => m[1]) - .filter((w) => ['history', 'coverage', 'deps', 'gate'].includes(w)); - for (const sub of ['history', 'coverage', 'deps', 'gate']) { - assert.ok(declared.includes(sub), `/euthyna forwards ${sub}`); - assert.match(cli, new RegExp(`command === '${sub}'`), `the CLI dispatches ${sub}`); + // Derived, not hand-written: a whitelist here is what let `audit` ship in + // 0.4.0 without this guard noticing. The tuple is read wherever the handler + // keeps it today, so the comparison fails on the real gap rather than on the + // absence of a marker. + const tuple = /_FORWARDS = \(([^)]*)\)/s.exec(py)?.[1] ?? /\bnot in \(([^)]*)\)/.exec(py)?.[1]; + assert.ok(tuple, 'the handler declares the subcommands it forwards in one tuple'); + const forwarded = [...tuple.matchAll(/"([a-z0-9-]+)"/g)].map((m) => m[1]).sort(); + const dispatched = await cliSubcommands(ROOT); + assert.ok(dispatched.length > 0, 'the dispatch list derived from src/cli.js'); + assert.deepEqual(forwarded, dispatched, 'every CLI command reachable from /euthyna, and nothing else'); + }); + + test('the usage text names every subcommand the CLI dispatches', async () => { + const py = await read('__init__.py'); + const usage = /_USAGE = """([\s\S]*?)"""/.exec(py)?.[1] ?? ''; + for (const sub of await cliSubcommands(ROOT)) { + assert.ok(usage.includes(`/euthyna ${sub} `), `/euthyna ${sub} is documented in the usage text`); } });