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
34 changes: 32 additions & 2 deletions .context/TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,16 @@ These have priority because other knowledge ingestion projects depend on them.
feat/pad-undo-snapshot #commit:b9ce72e8
#added:2026-05-27-145715

- [ ] `ctx system`: emit a VERBATIM RELAY on unknown subcommand (replace today's
- [x] `ctx system`: emit a VERBATIM RELAY on unknown subcommand (replace today's
silent help-dump + exit 0). Scope:
`ctx system` ONLY.
`ctx system` ONLY. #completed:2026-05-28 #branch:feat/system-unknown-relay
Shipped: `ctx system <unknown>` now emits a verbatim NudgeBox (via the write
layer) naming the verb + version-skew hint, best-effort fires the event-log +
webhook relay (gated on a session ID read TTY-safely from stdin), suppresses
cobra's help dump, and exits non-zero. Bare `ctx system` and valid subcommands
unchanged. Handler in internal/cli/system/core/unknown (RunE on system.Cmd()
only; parent.Cmd untouched). Verified end-to-end against a real build (box +
EXIT=1). Spec: specs/system-unknown-subcommand-relay.md.
- Problem: `ctx system <unknown>` prints the full Long help and exits 0 (
cobra `legacyArgs` only raises "unknown
command" for the ROOT command, never a non-root group). In a
Expand All @@ -201,6 +208,29 @@ These have priority because other knowledge ingestion projects depend on them.
its own spec before implementation.
#priority:medium #session:96765858 #branch:feat/pad-undo-snapshot #commit:
b9ce72e8 #added:2026-05-27-130130
- DONE 2026-05-28 (branch feat/system-unknown-relay, session 0066d49b).
Spec: specs/system-unknown-subcommand-relay.md.
Approach used: add a RunE on system.Cmd() only (legacyArgs lets the
leftover args reach the group's RunE for non-root); on unknown verb emit a
message.NudgeBox to stdout, set SilenceUsage (else cobra re-dumps the help
we're killing), exit non-zero. system is Hidden so RootCmd PersistentPreRunE
early-returns — no context/git preconditions.
Decisions settled with user: (1) DO fire the event-log + webhook relay leg
(nudge.Relay), gated on a real session ID read best-effort from stdin via
session.ReadID (TTY-safe, timeout-guarded → IDUnknown means skip the leg);
(2) scoped to ctx system only, parent.Cmd untouched.
Follow-up surfaced: ctx hook (and any parent.Cmd group) has the same latent
exit-0-on-unknown behavior — not wired into hooks.json so out of scope here;
capture as its own task if it ever gets hook-wired.

- [ ] Generalize the unknown-subcommand guard beyond `ctx system` (deferred from
the #5 work above). `ctx hook` and any future `parent.Cmd` group still print
help + exit 0 on an unknown subcommand — the same latent pollution #5 fixed for
`ctx system`. Low priority while no other group is wired into hooks.json; the
build-time wiring guard (specs/hooks-wiring-guard.md) only checks `ctx system`
+ `ctx agent` today. If a `ctx hook <verb>` ever gets hook-wired, either extend
the guard's coverage or fold a reusable opt-in into `parent.Cmd` (an optional
unknown-subcommand handler groups opt into). #priority:low #added:2026-05-28

## Important

Expand Down
2 changes: 2 additions & 0 deletions internal/assets/commands/text/errors.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ err.schema.drift:
short: 'schema drift detected'
err.cli.no-tool-specified:
short: 'no tool specified: use --tool <tool> or set the tool field in .ctxrc'
err.cli.unknown-subcommand:
short: 'unknown ctx system subcommand "%s"'
err.config.golden-not-found:
short: "no .claude/settings.golden.json found - run 'ctx permission snapshot' first"
err.config.invalid-tool:
Expand Down
11 changes: 11 additions & 0 deletions internal/assets/commands/text/hooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -564,3 +564,14 @@ specs-nudge.nudge-message:
short: plan-to-specs nudge emitted
version-drift.relay-message:
short: versions out of sync
system-unknown.relay-prefix:
short: 'IMPORTANT: Relay this notice to the user VERBATIM before answering their question.'
system-unknown.box-title:
short: Unknown System Subcommand
system-unknown.body:
short: |-
ctx system: unknown subcommand "%s".

A Claude Code hook (hooks.json) is calling a ctx command this binary no longer ships — a version skew between the installed plugin and the on-PATH ctx binary. Align the plugin and binary to the same release, or fix the hook command.
system-unknown.relay-message:
short: 'ctx system: unknown subcommand "%s" (likely plugin/binary version skew)'
35 changes: 35 additions & 0 deletions internal/cli/system/core/unknown/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// / ctx: https://ctx.ist
// ,'`./ do you remember?
// `.,'\
// \ Copyright 2026-present Context contributors.
// SPDX-License-Identifier: Apache-2.0

// Package unknown holds the RunE the `ctx system` group installs so
// that an unrecognised subcommand fails loud and legible instead of
// dumping help at exit 0.
//
// # Why this exists
//
// `ctx system` is a grouping command. Cobra raises an
// "unknown command" error only for the *root*; for a non-root group
// an unmatched subcommand falls through to the group's own Run/RunE,
// and a group with neither prints help and returns nil — exit 0. In
// a Claude Code UserPromptSubmit hook, exit 0 reads as "hook
// success", so the ~51-line help blob is injected into the agent's
// context every prompt. That is exactly how a stale `hooks.json`
// wiring `ctx system check-anchor-drift` (a command the binary later
// deleted) polluted sessions silently.
//
// A non-zero exit alone does not fix it: the harness swallows a
// failed hook's exit code, so the signal has to travel on hook
// stdout. [Handler] therefore emits a verbatim-relay box naming the
// unknown verb and hinting at the likely cause (plugin/binary
// version skew), best-effort records the event (event log + webhook)
// when a session is present on stdin, suppresses cobra's help dump,
// and returns a non-nil error.
//
// Scope is `ctx system` only — the single group wired into
// hooks.json. The shared [parent.Cmd] is untouched; other groups
// keep cobra's default behavior. See
// specs/system-unknown-subcommand-relay.md.
package unknown
82 changes: 82 additions & 0 deletions internal/cli/system/core/unknown/handle.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// / ctx: https://ctx.ist
// ,'`./ do you remember?
// `.,'\
// \ Copyright 2026-present Context contributors.
// SPDX-License-Identifier: Apache-2.0

package unknown

import (
"fmt"
"os"

"github.com/spf13/cobra"

"github.com/ActiveMemory/ctx/internal/assets/read/desc"
"github.com/ActiveMemory/ctx/internal/cli/system/core/message"
"github.com/ActiveMemory/ctx/internal/cli/system/core/nudge"
coreSession "github.com/ActiveMemory/ctx/internal/cli/system/core/session"
"github.com/ActiveMemory/ctx/internal/config/embed/text"
"github.com/ActiveMemory/ctx/internal/config/hook"
cfgSession "github.com/ActiveMemory/ctx/internal/config/session"
"github.com/ActiveMemory/ctx/internal/config/warn"
"github.com/ActiveMemory/ctx/internal/entity"
errCli "github.com/ActiveMemory/ctx/internal/err/cli"
logWarn "github.com/ActiveMemory/ctx/internal/log/warn"
writeSetup "github.com/ActiveMemory/ctx/internal/write/setup"
)

// relay is the event-log + webhook relay leg, indirected through a
// package variable so tests can observe the call without a live
// webhook or an initialized context. Production binds it to
// [nudge.Relay].
var relay = nudge.Relay

// handle is [Handler] with the stdin source injected for testability.
// Bare invocation prints help; an unknown verb emits the verbatim
// relay box (through the write layer), best-effort records the relay
// event when a session is present, suppresses cobra's help dump, and
// returns the unknown-subcommand error.
//
// Parameters:
// - cmd: the system command (for output and SilenceUsage)
// - args: leftover args; non-empty means an unknown subcommand
// - stdin: hook-input source; ReadID is TTY-safe and timeout-guarded
//
// Returns:
// - error: nil for a bare `ctx system` (help printed); otherwise the
// unknown-subcommand error from [errCli.UnknownSubcommand].
func handle(cmd *cobra.Command, args []string, stdin *os.File) error {
if len(args) == 0 {
// Bare `ctx system`: preserve help + exit 0.
return cmd.Help()
}
verb := args[0]

prefix := desc.Text(text.DescKeySystemUnknownRelayPrefix)
title := desc.Text(text.DescKeySystemUnknownBoxTitle)
body := fmt.Sprintf(desc.Text(text.DescKeySystemUnknownBody), verb)
writeSetup.Nudge(cmd, message.NudgeBox(prefix, title, body))

// Best-effort relay leg: only when a hook supplied a session on
// stdin. ReadID is TTY-safe and timeout-guarded, so a manual typo
// at a terminal returns IDUnknown without blocking. A relay
// failure is logged, not returned: the stdout box already reached
// the agent, and the user's real problem is the unknown verb.
if sid := coreSession.ReadID(stdin); sid != cfgSession.IDUnknown {
msg := fmt.Sprintf(
desc.Text(text.DescKeySystemUnknownRelayMessage), verb,
)
ref := entity.NewTemplateRef(
hook.System, hook.VariantUnknownSubcommand, nil,
)
if relayErr := relay(msg, sid, ref); relayErr != nil {
logWarn.Warn(warn.RelayUnknownSubcommand, relayErr)
}
}

// Suppress cobra's help dump on error — that dump is the very
// pollution this handler exists to kill.
cmd.SilenceUsage = true
return errCli.UnknownSubcommand(verb)
}
21 changes: 21 additions & 0 deletions internal/cli/system/core/unknown/testmain_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// / ctx: https://ctx.ist
// ,'`./ do you remember?
// `.,'\
// \ Copyright 2026-present Context contributors.
// SPDX-License-Identifier: Apache-2.0

package unknown

import (
"os"
"testing"

"github.com/ActiveMemory/ctx/internal/assets/read/lookup"
)

// TestMain loads the embedded description maps before running tests;
// desc.Text returns empty strings until lookup.Init has run.
func TestMain(m *testing.M) {
lookup.Init()
os.Exit(m.Run())
}
29 changes: 29 additions & 0 deletions internal/cli/system/core/unknown/unknown.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// / ctx: https://ctx.ist
// ,'`./ do you remember?
// `.,'\
// \ Copyright 2026-present Context contributors.
// SPDX-License-Identifier: Apache-2.0

package unknown

import (
"os"

"github.com/spf13/cobra"
)

// Handler is the RunE installed on the `ctx system` group. It is
// reached only when cobra finds no matching subcommand (or for a
// bare `ctx system`); a valid subcommand runs its own RunE and never
// reaches here. It delegates to [handle] with the real stdin.
//
// Parameters:
// - cmd: the system command (for output and SilenceUsage)
// - args: leftover args; non-empty means an unknown subcommand
//
// Returns:
// - error: nil for bare `ctx system` (help printed); otherwise the
// unknown-subcommand error after emitting the relay box.
func Handler(cmd *cobra.Command, args []string) error {
return handle(cmd, args, os.Stdin)
}
Loading
Loading