Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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 <path> [<path>...] history + dependency surface in one run
/euthyna history <path> [<path> <rev>...] deleted-code provenance & security classification
/euthyna coverage <path> symbol invocation counts from a coverage run
/euthyna deps <path> dependency pinned versions from lockfiles
/euthyna gate <report> [--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"):
Expand All @@ -40,10 +48,13 @@ def _find_cli():
def _run(raw_args: str):
"""Handle /euthyna: forward <subcommand> <args...> 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()
Expand Down Expand Up @@ -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="<history|coverage|deps|gate> <args…>",
description="euthyna security audit: drive the fact-producing CLI (audit is the single entry point)",
args_hint="<audit|history|coverage|deps|gate|help> <args…>",
)


Expand Down
2 changes: 1 addition & 1 deletion plugin.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
34 changes: 23 additions & 11 deletions test/hermes-plugin.test.js
Original file line number Diff line number Diff line change
@@ -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
* `<root>/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.
* `<root>/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');
Expand All @@ -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`);
}
});

Expand Down
Loading