Skip to content
Closed
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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,10 @@ hyp daemon uninstall # remove the service and detach clients (config + record

`hyp daemon install --dry-run --json` prints the rendered plist or unit
content and target paths without touching the filesystem, useful for
verifying what `hyp init` will install.
verifying what `hyp init` will install. Add `--platform darwin` or
`--platform linux` to render the *other* platform's unit; it is an
inspection flag and requires `--dry-run`, because an install always
targets the host it runs on.

## Troubleshooting

Expand Down
1 change: 1 addition & 0 deletions llp/0009-cli-registry.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
**Author:** Phil / Claude
**Date:** 2026-06-01
**Related:** LLP 0003, LLP 0015
**Extended-by:** [LLP 0270](./0270-help-metadata-is-part-of-the-contract.decision.md) - `#layered-help` and `#central-help-interception`: a registration's help strings are tested claims about its handler, `hyp help <command>` renders that command's help, and top-level help names the global options and aliases that own no command row.

> Command registration, dispatch, and help. Decomposed from
> `hypaware-design.md` (CLI Registry).
Expand Down
109 changes: 109 additions & 0 deletions llp/0270-help-metadata-is-part-of-the-contract.decision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# LLP 0270: Help metadata is part of the command contract

**Type:** Decision
**Status:** Accepted
**Systems:** CLI
**Author:** neutral / Claude
**Date:** 2026-08-18
**Related:** LLP 0009 (#layered-help, #central-help-interception: the help system this extends), LLP 0062 (#bare-remote: the optional target name `remote login --help` has to admit), LLP 0137 (#pathway-defaults: the retention question `init --help` still described), LLP 0214 (#d1, #d2: the previous extension of the same help system)

> Extends [LLP 0009 #layered-help](./0009-cli-registry.spec.md) and
> [#central-help-interception](./0009-cli-registry.spec.md). Dispatch renders
> `summary` / `usage` / `help` straight off the registration, which makes the
> registration the *only* description a user gets, and nothing has been
> checking that it still matches the handler. Four registrations had drifted.
> This settles what a registration owes its handler, adds `hyp help <command>`,
> and gives the working-but-unlisted surfaces (`--version`, `-V`, `unattach`) a
> place in top-level help.

## Context {#context}

LLP 0009 #central-help-interception makes core the sole renderer of per-command
help: a command body never prints its own usage, so the `usage` and `help`
strings on the registration are the entire published interface of that command.
That is a good arrangement precisely because it is centralized, and a bad one
when nothing pins the strings to the code: a handler can grow a flag, drop a
question, or make a positional optional without a single test noticing that the
registration now lies.

The audit that produced issue #835 found four such lies at once (an `init`
walkthrough that no longer asks for retention, a `remote login` whose target
name became optional, a `daemon restart` that restarts rather than stops, a
`skills install` whose `--client` defaults to `all`), plus three surfaces that
work but appear nowhere: `--version`, `-V`, and the `unattach` alias. The
common cause is that help was treated as prose attached to a command rather
than as an assertion about it.

## Decision

<a id="registration-is-the-contract"></a>**A command's `summary`, `usage`, and
`help` are claims about its handler, and are tested as claims.** Every accepted
flag and positional appears in `usage`; an optional positional is spelled
`[name]`, not `<name>`; a summary describes the path the handler actually takes
first, with the fallback path (if any) explained in `help` and qualified as a
fallback. Where a command's shape is settled by an LLP, the registration
carries a `@ref` to it, so the next reader of the help text can see what
constrains it.

Tests assert the *semantic* claim, not the presence of a flag token: "the help
does not say the walkthrough asks for a retention window" is checkable and was
the defect; "the help mentions `--retention-days`" was already true while the
surrounding sentence was false.

<a id="platform-is-a-render-override"></a>**`hyp daemon install --platform
darwin|linux` is public, and requires `--dry-run`.** It was accepted by the
parser, omitted from public usage, and used in-repo only to render the other
platform's unit for inspection. Rendering another platform's plist or unit file
is a genuinely useful thing to be able to ask for and there is no reason to
hide it. Performing an *install* for another platform is not: the install
writes into this host's service directory and then calls this host's service
manager, so a cross-platform install can only produce a broken one. So the flag
is documented, and refused outside a dry run, in the same shape as the
pre-existing `--json requires --dry-run` rule.

<a id="help-verb"></a>**`hyp help <command...>` renders that command's help.**
It is rewritten to `hyp <command...> --help` and routed through ordinary
dispatch rather than answered separately. Routing is what makes it reach
plugin-contributed commands: top-level help renders before `bootKernel`
(LLP 0009 #top-level-help-lists-plugin-commands-without-booting) and so cannot
see them, while the rewritten argv boots normally and hits the same central
`--help` interception every other command help goes through. Bare `hyp help`
keeps its existing meaning, the top-level table, and `hyp help --version` (a
flag, not a command) is left alone.

Answering `hyp help query` with the top-level table was the worst available
behavior: it is indistinguishable from success, so the user reads the wrong
page believing they asked for it.

<a id="global-options"></a>**Top-level help names the global options and the
command aliases.** LLP 0009 #layered-help settles the *command* listing as one
row per top-level token; that is unchanged. `--help/-h` and `--version/-V` are
not commands and never had a row, and an alias routes without one by design, so
under a rule that only lists commands they were undiscoverable by construction.
They get two short sections after the command table instead:

```
Global options:
--help, -h show this list, or a command's help (`hyp help <command>` too)
--version, -V print the version (`hyp version` adds runtime detail)

Aliases:
unattach detach
```

The alias rows are read off the registry, so a new alias lists itself. Only
core aliases appear: top-level help lists plugin commands from their manifests,
which declare no aliases, and booting to find out is exactly the cost LLP 0009
refused to pay for help.

## Consequences {#consequences}

A registration change that contradicts its handler is now a test failure rather
than a documentation bug found in an audit. The cost is that `usage` strings
grow: `remote login` and `daemon install` both gained a flag list they had been
eliding. That is the intended trade; the alternative was a shorter line that
was not true.

`--platform` outside `--dry-run` now exits 2 where it previously attempted an
install. No caller in this repo used it that way, and the ones that would have
were wrong.
58 changes: 47 additions & 11 deletions src/core/cli/core_commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -232,10 +232,16 @@ function buildCoreCommands(registry) {
name: 'init',
summary: 'Initialize a new HypAware install (interactive walkthrough or preset)',
usage: 'hyp init [preset] [flags]',
// @ref LLP 0137#pathway-defaults [constrained-by]: the walkthrough has no retention question to describe, so the help states the pathway defaults instead
help: [
'With no arguments, runs the interactive walkthrough: pick which clients',
'and sources to capture, an export strategy, and a retention window, then',
'write the config, install the daemon, and attach the selected clients.',
'and sources to capture and an export strategy, then write the config,',
'install the daemon, and attach the selected clients.',
'',
'The walkthrough never asks how long to keep cached rows: the pathway',
'sets it (90 days when you join a team, 120 days for a local-only',
'install). --retention-days below is the override, and the written',
'query.cache.retention.default_days stays editable afterwards.',
'',
'Pass a preset name to skip the walkthrough. Passing any flag below also',
'skips it: the non-interactive path is chosen by the presence of a flag,',
Expand Down Expand Up @@ -472,7 +478,8 @@ function buildCoreCommands(registry) {
// kinds of client asset; no separate agents verb.
name: 'skills install',
summary: 'Install registered skills and subagents into AI client directories',
usage: 'hyp skills install [--client <name>]',
usage: 'hyp skills install [--client <name>|all]',
help: 'Without --client the target is all, so every configured client gets the copies.',
run: runSkillsInstall,
},
makeGroupCommand({
Expand All @@ -483,7 +490,18 @@ function buildCoreCommands(registry) {
{
name: 'daemon install',
summary: 'Install the persistent user service (launchd / systemd)',
usage: 'hyp daemon install [--config <path>] [--bin <path>] [--dry-run [--json]]',
usage: 'hyp daemon install [--config <path>] [--bin <path>] [--dry-run [--json] [--platform darwin|linux]]',
// @ref LLP 0270#platform-is-a-render-override [implements]: --platform is public but dry-run only, so it can never install the wrong platform's unit
help: [
' --config <path> config the unit should load (default: the resolved one)',
' --bin <path> hyp binary to record in the unit',
' --dry-run render the plist / unit without touching disk',
' --json machine shape for --dry-run',
' --platform darwin|linux',
' render the other platform\'s unit. Inspection only:',
' it requires --dry-run, because an install always',
' targets the host it runs on.',
].join('\n'),
run: runDaemonInstall,
},
{
Expand Down Expand Up @@ -523,8 +541,16 @@ function buildCoreCommands(registry) {
},
{
name: 'daemon restart',
summary: 'Stop the daemon (and direct the operator to relaunch)',
summary: 'Restart the installed daemon service (launchd / systemd)',
usage: 'hyp daemon restart',
help: [
'Restarts the installed launchd / systemd service in place, so a config',
'change takes effect without an uninstall.',
'',
'With no installed service there is nothing to bring back up: the running',
'foreground daemon is stopped and the command prints how to relaunch it',
'(hyp daemon run --foreground) or how to install the service instead.',
].join('\n'),
run: runDaemonRestart,
},
{
Expand Down Expand Up @@ -574,13 +600,23 @@ function buildCoreCommands(registry) {
{
name: 'remote login',
summary: 'Store the query-scoped token for a remote target (0600)',
usage: 'hyp remote login <name> [--token-file <path>] [--no-forward] [--no-daemon]',
// @ref LLP 0062#bare-remote [constrained-by]: the name is optional here because a bare login resolves the default target
usage:
'hyp remote login [name] [--org <name>] [--host <label>] [--token-file <path>]' +
' [--browser | --no-browser] [--no-forward] [--no-daemon]',
help: [
'Browser sign-in by default; --token-file/stdin for a static token,',
'--org <name> to select an org, --no-browser to print the URL,',
'--host <label> to override the forwarding host label (default: hostname),',
'--no-forward to sign in for queries only (no fleet enrollment),',
'--no-daemon to provision the sink without installing the service.',
'With no name, signs in to the default target: query.default_remote when',
'the config sets one, else the shipped built-in central server.',
'',
' --org <name> select an org to sign in to',
' --host <label> forwarding host label (default: this hostname)',
' --token-file <path> read a static token from a file instead of',
' signing in (piping the token in works too, with',
' no flag at all)',
' --browser force the browser flow even with a piped stdin',
' --no-browser print the authorization URL instead of opening it',
' --no-forward sign in for queries only (no fleet enrollment)',
' --no-daemon provision the sink without installing the service',
].join('\n'),
run: runRemoteLogin,
},
Expand Down
83 changes: 77 additions & 6 deletions src/core/cli/dispatch.js
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,23 @@ export async function dispatch(argv, opts = {}) {
stdout.write(`hypaware ${version}\n`)
return 0
}
// `hyp help <command...>` is the spelled-out form of `hyp <command...>
// --help`, so rewrite it and let the ordinary match path render the same
// registry-backed help. Routing rather than re-implementing is what makes it
// reach plugin commands too: those only exist in the registry after boot,
// which the pre-boot top-level help path deliberately skips.
//
// `typedArgv` keeps what the user actually wrote: the appended `--help` is
// ours, so a diagnostic echoing the rewritten argv would report `hyp help
// bogus` as `unknown command 'bogus --help'`, naming a flag nobody typed.
//
// @ref LLP 0270#help-verb [implements]: `help <command>` is rewritten to that command's `--help`, never silently answered with the top-level table
const typedArgv = argv
let helpVerb = false
if (argv.length > 1 && argv[0] === 'help' && !argv[1].startsWith('-')) {
argv = [...argv.slice(1), '--help']
helpVerb = true
}
if (argv.length > 0 && HELP_FLAGS.has(argv[0])) {
return runHelp({ stdout, registry, devRunId: env.DEV_RUN_ID, argvCount: argv.length, discovery: helpDiscovery })
}
Expand Down Expand Up @@ -384,7 +401,7 @@ export async function dispatch(argv, opts = {}) {
)
}
} else {
stderr.write(`hyp: unknown command '${argv.join(' ')}'\n`)
stderr.write(`hyp: unknown command '${typedArgv.join(' ')}'\n`)
stderr.write(`run 'hyp --help' for the list of available commands\n`)
}
if (ownsKernel) {
Expand Down Expand Up @@ -480,8 +497,16 @@ export async function dispatch(argv, opts = {}) {
// help flag renders registry-backed help (group table when the
// command has subcommands, usage otherwise) instead of running
// the command, so each command body stays help-free.
//
// `helpVerb` is the `hyp help <command...>` spelling. It appends
// `--help` to the end of argv, so any token the user wrote after the
// command name (`hyp help attach claude`) pushes the flag out of
// position 0 and past this test. Asking for help would then *run the
// command* with a stray flag, which a lenient parser accepts: `hyp
// help purge <path>` must never reach `runPurge`.
// @ref LLP 0009#central-help-interception [implements]: help renders inside command.run so it stays in command analytics
if (isHelpFlag(matched.rest[0])) {
// @ref LLP 0270#help-verb [implements]: the rewritten `hyp help <command...>` lands here, whatever else the user wrote after the command name
if (helpVerb || isHelpFlag(matched.rest[0])) {
const children = listGroupChildren(registry, matched.command.name)
if (children.length > 0) {
renderGroupHelp({ stdout, group: matched.command.name, groupCommand: matched.command, children })
Expand Down Expand Up @@ -608,6 +633,18 @@ function isInteractiveStream(stream) {
return !!stream && typeof stream === 'object' && /** @type {{ isTTY?: boolean }} */ (stream).isTTY === true
}

/**
* Render one of dispatch's flag sets in the `--long, -s` spelling top-level
* help prints, dropping any command-shaped token matched alongside the flags
* (the bare `help` verb) that is not one.
*
* @param {Set<string>} flags
* @returns {string}
*/
function flagList(flags) {
return [...flags].filter((f) => f.startsWith('-')).join(', ')
}

/**
* Pick the boot profile based on the requested command. `hyp init`
* (interactive walkthrough or preset) needs bundled defaults plus
Expand Down Expand Up @@ -736,6 +773,10 @@ async function runHelp({ stdout, registry, devRunId, argvCount, discovery }) {
* `daemon`, ...). A group with no bare command (plugin namespaces like
* `graph`) gets a synthesized subcommand listing instead.
*
* Two short sections follow the command table for the surfaces that are
* not commands and so never had a row: the global flags, and the
* registry's command aliases.
*
* @ref LLP 0009#layered-help [implements]: one row per top-level token; subcommand summaries live in group help
* @param {{
* stdout: { write(chunk: string): unknown },
Expand All @@ -744,10 +785,16 @@ async function runHelp({ stdout, registry, devRunId, argvCount, discovery }) {
* }} args
*/
function renderHelp({ stdout, registry, pluginCommands = [] }) {
const core = registry
.list()
.filter((c) => !c.hidden)
.map((c) => ({ name: c.name, summary: c.summary }))
const listed = registry.list().filter((c) => !c.hidden)
const core = listed.map((c) => ({ name: c.name, summary: c.summary }))
// Aliases route but never get a command row of their own, so without this
// section a working spelling (`hyp unattach`) is discoverable only by
// reading the help of the command it forwards to. Read off the registry so
// a new alias lists itself. Manifest-declared plugin commands carry no
// aliases at discovery time, so this is the core set.
const aliasRows = listed
.flatMap((c) => (c.aliases ?? []).map((alias) => ({ alias, target: c.name })))
.sort((a, b) => (a.alias < b.alias ? -1 : a.alias > b.alias ? 1 : 0))
const coreNames = new Set(core.map((c) => c.name))
const merged = [...core, ...pluginCommands.filter((c) => !coreNames.has(c.name))]

Expand Down Expand Up @@ -780,6 +827,30 @@ function renderHelp({ stdout, registry, pluginCommands = [] }) {
for (const name of names) {
stdout.write(` ${name.padEnd(nameWidth)} ${rows.get(name)}\n`)
}
// The rows above are commands; these two are neither, and both work. A user
// who cannot find `--version` in the command table concludes it is absent.
// @ref LLP 0270#global-options [implements]: top-level help names the global options and aliases that route but own no command row
stdout.write('\n')
stdout.write('Global options:\n')
// Spellings come off the same sets dispatch matches on, so a flag added
// there lists itself, exactly as an alias does. `HELP_FLAGS` also carries
// the bare `help` verb, which is a command-shaped token and not a flag.
const globals = [
[flagList(HELP_FLAGS), "show this list, or a command's help (`hyp help <command>` too)"],
[flagList(VERSION_FLAGS), 'print the version (`hyp version` adds runtime detail)'],
]
const globalWidth = Math.max(...globals.map(([flag]) => flag.length))
for (const [flag, summary] of globals) {
stdout.write(` ${flag.padEnd(globalWidth)} ${summary}\n`)
}
if (aliasRows.length > 0) {
stdout.write('\n')
stdout.write('Aliases:\n')
const aliasWidth = Math.max(...aliasRows.map((r) => r.alias.length), 8)
for (const row of aliasRows) {
stdout.write(` ${row.alias.padEnd(aliasWidth)} ${row.target}\n`)
}
}
stdout.write('\n')
stdout.write(`Run 'hyp <command> --help' for subcommands and details.\n`)
// Plugin-contributed commands are omitted when their plugin is inactive, so
Expand Down
2 changes: 1 addition & 1 deletion src/core/commands/clients.js
Original file line number Diff line number Diff line change
Expand Up @@ -2205,7 +2205,7 @@ function isUnderDir(p, dir) {
}

/**
* `hyp skills install [--client <name>]`
* `hyp skills install [--client <name>|all]` (the parser defaults to `all`)
*
* Materializes every registered skill **and subagent** into the right
* per-client directories. One command, because a user asking for their
Expand Down
Loading
Loading