From d9aa618bb44078fc0184acfebf01dd43a0465dce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maple=EF=BC=81?= Date: Wed, 15 Jul 2026 10:51:33 +0800 Subject: [PATCH 1/2] feat: derive CLI commands and skill hint from a single registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The set of cc-session subcommands was hand-copied across three places (main.go doc comment, the dispatch switch, and printUsage), plus the SKILL.md cheat-sheet — so a rename like inject→inherit had to be mirrored by hand and silently rotted when missed. Introduce a command registry in cmd/cc-session/commands.go as the single source of truth. Dispatch and printUsage now derive from it, and a new `cc-session help` renders the intent→command cheat sheet while `cc-session help --argument-hint` emits the one-line hint string. SKILL.md adopts the /wt shape: an `argument-hint` frontmatter (so the input box shows the subcommands), a `## 路由` section that dispatches on $ARGUMENTS, and the static cheat-sheet table replaced by a live !`cc-session help` injection that always matches the installed binary. install.sh / install.ps1 stamp the argument-hint line from `cc-session help --argument-hint` at install time (awk temp-file + mv for BSD/GNU portability), falling back to the committed line when the binary is absent or the output is malformed. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JwariBme7AbQxDciEKSPXt --- SKILL.md | 30 ++++---- cmd/cc-session/commands.go | 132 ++++++++++++++++++++++++++++++++++++ cmd/cc-session/help_cmd.go | 76 +++++++++++++++++++++ cmd/cc-session/main.go | 52 +++++--------- cmd/cc-session/main_test.go | 75 ++++++++++++++++++++ install.ps1 | 36 ++++++++++ install.sh | 43 ++++++++++++ 7 files changed, 395 insertions(+), 49 deletions(-) create mode 100644 cmd/cc-session/commands.go create mode 100644 cmd/cc-session/help_cmd.go diff --git a/SKILL.md b/SKILL.md index cef11b3..3a63847 100644 --- a/SKILL.md +++ b/SKILL.md @@ -4,11 +4,25 @@ description: | 用 cc-session CLI 讀取過去的 Claude Code session,取代直接讀 JSONL。 CLI 在 context 外完成過濾,原始 300K 壓到 30-50K,只保留對話和 tool call 一行摘要。 使用者想回顧、引用、分析過去的對話時使用。 +argument-hint: "[list | inherit | read | context | expand | stats | audit | usage]" allowed-tools: - Bash - Read --- +## 路由 + +根據 `$ARGUMENTS` 決定執行什麼。`$ARGUMENTS` 是使用者在 `/cc-session` 後面輸入的內容。 + +| `$ARGUMENTS` | 執行 | +|------|------| +| 空白 | 跑 `cc-session list`,把清單呈現給使用者,問想看哪個 session | +| `list`(可帶 `-p`、`-n`) | 跑 `cc-session $ARGUMENTS`,呈現清單 | +| 一個裸 session id(沒帶子命令,例如 `16d06326`) | 視為要讀這個 session → 走下方 inherit 分頁流程 | +| 已知子命令 + 參數(`inherit`/`read`/`context`/`expand`/`stats`/`audit`/`usage`) | 跑 `cc-session $ARGUMENTS`;若是 inherit 走分頁流程 | + +`argument-hint` frontmatter 是輸入框看到的子命令提示,由 `cc-session help --argument-hint` 產生,安裝時自動同步,不要手改。 + ## 讀取 session 內容 read 預設截斷在 200 行——大多數 session 遠超這個長度,只看得到開頭一小段。 @@ -29,19 +43,9 @@ inherit 記住讀取進度,重複呼叫同一個命令即自動翻頁: ## 子命令速查 -| 意圖 | 命令 | -|------|------| -| 找目標 session | `cc-session list` — 列出最近 session,`-p` 過濾專案,用過 cc-session 的標 `[refs]` | -| 讀 session(預設) | `cc-session inherit ` — 分頁載入,重複呼叫翻頁 | -| 查特定片段 | `cc-session read ` — 預設 200 行,`-offset` 跳讀 | -| 緊湊單次輸出 | `cc-session context ` — 同 read 但更緊湊,帶 metadata header | -| 展開單一 tool call | `cc-session expand ` — tool-id 取自輸出中的 `[Tool#xxxx]` | -| 展開同類所有 tool call | `cc-session read -verbose-bash` — 也有 `-verbose-agents` / `-verbose-thinking` | -| 分析 token 消耗 | `cc-session stats ` | -| 檢查過濾遺漏 | `cc-session audit ` | -| 查看 CLI 使用紀錄 | `cc-session usage` | - -Session ID 支援 prefix match,前 8 碼通常就夠。各子命令的 flags 用 `-h` 查看。 +以下由 CLI 即時產生,永遠對應目前安裝的版本: + +!`cc-session help` ## 輸出行為 diff --git a/cmd/cc-session/commands.go b/cmd/cc-session/commands.go new file mode 100644 index 0000000..b5ac1b5 --- /dev/null +++ b/cmd/cc-session/commands.go @@ -0,0 +1,132 @@ +package main + +import ( + "fmt" + "os" + + "github.com/Mapleeeeeeeeeee/cc-session-reader/internal/claudecodec" +) + +// command describes one cc-session subcommand. It is the single source of +// truth for dispatch (main's switch), printUsage, and the "cc-session help" +// cheat sheet / --argument-hint output — each of those used to hand-copy the +// command list independently and drift out of sync. +type command struct { + // name is the word typed after "cc-session" (os.Args[1]) and the label + // shown in printUsage / help. + name string + // summary is the one-line Chinese description shown next to name in + // printUsage's "Commands:" block. + summary string + // argHint is the input-box fragment shown in "cc-session help + // --argument-hint", e.g. "read ". Empty means the command is left out + // of that line (it still appears in printUsage/help unless hidden) — + // used for meta commands like "help" and "benchmark" that aren't part of + // the skill's quick-launch surface. + argHint string + // hidden excludes the command from printUsage, help, and the + // argument-hint line, while keeping it dispatchable. Used for the + // deprecated "inject" alias. + hidden bool + // run executes the command. reader is the concrete claudecodec.Codec, + // which satisfies both session.TranscriptReader and session.HeaderScanner + // so one signature covers every cmdXxx regardless of which interface it + // actually needs. + run func(args []string, reader claudecodec.Codec) +} + +// commands is the command registry, in the order printUsage lists them. +// +// It is populated from init() rather than the var initializer itself: the +// "help" entry's run closure calls into buildArgumentHint, which reads this +// same var, and Go's package-init cycle detector treats that as a cycle when +// it's part of the var's initializer expression. Assigning inside init() +// keeps the declaration a plain nil-slice zero value, so there is nothing +// for the cycle check to trip on. +var commands []command + +func init() { + commands = []command{ + { + name: "list", + summary: "列出最近的 session", + argHint: "list", + run: func(args []string, reader claudecodec.Codec) { cmdList(args, reader) }, + }, + { + name: "inherit", + summary: "分頁繼承 session 到 context", + argHint: "inherit ", + run: func(args []string, reader claudecodec.Codec) { cmdInherit(args, reader) }, + }, + { + name: "read", + summary: "完整對話 + tool call 一行摘要", + argHint: "read ", + run: func(args []string, reader claudecodec.Codec) { cmdRead(args, reader) }, + }, + { + name: "context", + summary: "精簡注入格式(帶 metadata header)", + argHint: "context ", + run: func(args []string, reader claudecodec.Codec) { cmdContext(args, reader) }, + }, + { + name: "expand", + summary: "展開特定 tool call 完整內容", + argHint: "expand ", + run: func(args []string, reader claudecodec.Codec) { cmdExpand(args, reader) }, + }, + { + name: "stats", + summary: "字元與 token 分佈統計", + argHint: "stats ", + run: func(args []string, reader claudecodec.Codec) { cmdStats(args, reader) }, + }, + { + name: "audit", + summary: "檢視被過濾的內容取樣", + argHint: "audit ", + run: func(args []string, reader claudecodec.Codec) { cmdAudit(args, reader) }, + }, + { + name: "usage", + summary: "CLI 使用紀錄", + argHint: "usage", + run: func(args []string, _ claudecodec.Codec) { cmdUsage(args) }, + }, + { + name: "help", + summary: "顯示子命令速查表", + // No argHint: help is a meta/discovery command, not part of the + // skill's quick-launch surface (see buildArgumentHint in help_cmd.go). + run: func(args []string, _ claudecodec.Codec) { cmdHelp(args) }, + }, + { + name: "benchmark", + summary: "掃描近期 session,計算壓縮率與成本比較", + // No argHint: benchmark is a maintainer/analysis command, not part of + // the skill's quick-launch surface. + run: func(args []string, reader claudecodec.Codec) { cmdBenchmark(args, reader) }, + }, + { + name: "inject", + hidden: true, + run: func(args []string, reader claudecodec.Codec) { + fmt.Fprintln(os.Stderr, "cc-session inject 已改名為 cc-session inherit,inject 別名將於未來版本移除,請改用 inherit。") + cmdInherit(args, reader) + }, + }, + } +} + +// findCommand looks up a command by its exact name, including hidden ones — +// hidden only affects display surfaces, not dispatch. +func findCommand(name string) (command, bool) { + for _, cmd := range commands { + if cmd.name == name { + return cmd, true + } + } + return command{}, false +} diff --git a/cmd/cc-session/help_cmd.go b/cmd/cc-session/help_cmd.go new file mode 100644 index 0000000..fe0a8ed --- /dev/null +++ b/cmd/cc-session/help_cmd.go @@ -0,0 +1,76 @@ +package main + +import ( + "flag" + "fmt" + "io" + "os" + "strings" +) + +// cheatSheetRow is one row of the "intent -> command" quick reference +// printed by "cc-session help". It is a separate list from the command +// registry because some rows describe a flag variant of an existing command +// (e.g. read -verbose-bash) rather than a standalone subcommand, and the +// registry must not gain an entry for those. +type cheatSheetRow struct { + intent string + command string +} + +var cheatSheet = []cheatSheetRow{ + {"找目標 session", "cc-session list — 列出最近 session,-p 過濾專案,用過 cc-session 的標 [refs]"}, + {"讀 session(預設)", "cc-session inherit — 分頁載入,重複呼叫翻頁"}, + {"查特定片段", "cc-session read — 預設 200 行,-offset 跳讀"}, + {"緊湊單次輸出", "cc-session context — 同 read 但更緊湊,帶 metadata header"}, + {"展開單一 tool call", "cc-session expand — tool-id 取自輸出中的 [Tool#xxxx]"}, + {"展開同類所有 tool call", "cc-session read -verbose-bash — 也有 -verbose-agents / -verbose-thinking"}, + {"分析 token 消耗", "cc-session stats "}, + {"檢查過濾遺漏", "cc-session audit "}, + {"查看 CLI 使用紀錄", "cc-session usage"}, +} + +func cmdHelp(args []string) { + exitOnError(runHelp(args, os.Stdout, os.Stderr)) +} + +func runHelp(args []string, out io.Writer, errOut io.Writer) error { + fs := flag.NewFlagSet("help", flag.ContinueOnError) + fs.SetOutput(errOut) + argumentHint := fs.Bool("argument-hint", false, "print a single-line [cmd | cmd ...] hint for a skill's argument-hint frontmatter") + if err := fs.Parse(args); err != nil { + return err + } + + if *argumentHint { + fmt.Fprintln(out, buildArgumentHint()) + return nil + } + + // Rows are printed as "意圖 → 命令" rather than aligned columns: Go's + // text/tabwriter pads by rune count, but CJK characters render two + // columns wide in a terminal, so column-aligned output goes ragged. + fmt.Fprintln(out, "cc-session 子命令速查表") + fmt.Fprintln(out) + for _, row := range cheatSheet { + fmt.Fprintf(out, "%s → %s\n", row.intent, row.command) + } + fmt.Fprintln(out) + fmt.Fprintln(out, "Session ID 支援 prefix match,前 8 碼通常就夠。各子命令的 flags 用 -h 查看。") + return nil +} + +// buildArgumentHint renders the registry's non-hidden, hinted commands as a +// single "[a | b | c]" line for a Claude Code skill's argument-hint +// frontmatter. Commands with an empty argHint (e.g. "help", "benchmark") and +// hidden commands (e.g. "inject") are left out. +func buildArgumentHint() string { + var hints []string + for _, cmd := range commands { + if cmd.hidden || cmd.argHint == "" { + continue + } + hints = append(hints, cmd.argHint) + } + return "[" + strings.Join(hints, " | ") + "]" +} diff --git a/cmd/cc-session/main.go b/cmd/cc-session/main.go index 431e265..64401a2 100644 --- a/cmd/cc-session/main.go +++ b/cmd/cc-session/main.go @@ -1,6 +1,6 @@ // Package main is the CLI entry point for the Claude session reader. -// Subcommands: list, read, context, stats, audit, expand, usage, inherit. -// "inject" is kept as a hidden, deprecated alias for "inherit". +// Run "cc-session help" for a usage cheat sheet, or see the command +// registry in commands.go for the authoritative subcommand list. package main import ( @@ -43,37 +43,20 @@ func main() { subcommand := os.Args[1] switch subcommand { - case "-h", "--help", "help": + case "-h", "--help": printUsage() return case "-v", "--version", "version": fmt.Printf("cc-session %s\n", version) return - case "list": - cmdList(os.Args[2:], reader) - case "read": - cmdRead(os.Args[2:], reader) - case "context": - cmdContext(os.Args[2:], reader) - case "stats": - cmdStats(os.Args[2:], reader) - case "audit": - cmdAudit(os.Args[2:], reader) - case "expand": - cmdExpand(os.Args[2:], reader) - case "usage": - cmdUsage(os.Args[2:]) - case "inherit": - cmdInherit(os.Args[2:], reader) - case "inject": - fmt.Fprintln(os.Stderr, "cc-session inject 已改名為 cc-session inherit,inject 別名將於未來版本移除,請改用 inherit。") - cmdInherit(os.Args[2:], reader) - case "benchmark": - cmdBenchmark(os.Args[2:], reader) default: - fmt.Fprintf(os.Stderr, "Unknown command: %s\n", subcommand) - printUsage() - os.Exit(1) + cmd, ok := findCommand(subcommand) + if !ok { + fmt.Fprintf(os.Stderr, "Unknown command: %s\n", subcommand) + printUsage() + os.Exit(1) + } + cmd.run(os.Args[2:], reader) } } @@ -81,15 +64,12 @@ func printUsage() { fmt.Fprintln(os.Stderr, "Usage: cc-session [options]") fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "Commands:") - fmt.Fprintln(os.Stderr, " list 列出最近的 session") - fmt.Fprintln(os.Stderr, " read 完整對話 + tool call 一行摘要") - fmt.Fprintln(os.Stderr, " context 精簡注入格式(帶 metadata header)") - fmt.Fprintln(os.Stderr, " stats 字元與 token 分佈統計") - fmt.Fprintln(os.Stderr, " audit 檢視被過濾的內容取樣") - fmt.Fprintln(os.Stderr, " expand 展開特定 tool call 完整內容") - fmt.Fprintln(os.Stderr, " usage CLI 使用紀錄") - fmt.Fprintln(os.Stderr, " inherit 分頁繼承 session 到 context") - fmt.Fprintln(os.Stderr, " benchmark 掃描近期 session,計算壓縮率與成本比較") + for _, cmd := range commands { + if cmd.hidden { + continue + } + fmt.Fprintf(os.Stderr, " %-8s %s\n", cmd.name, cmd.summary) + } fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "Run 'cc-session -h' for command-specific flags.") } diff --git a/cmd/cc-session/main_test.go b/cmd/cc-session/main_test.go index 0fb73ff..7077853 100644 --- a/cmd/cc-session/main_test.go +++ b/cmd/cc-session/main_test.go @@ -1272,6 +1272,81 @@ func TestRunUsage_GivenHelpFlag_ThenReturnsErrHelp(t *testing.T) { } } +func TestRunHelp_GivenHelpFlag_ThenReturnsErrHelp(t *testing.T) { + var stdout, stderr bytes.Buffer + err := runHelp([]string{"-h"}, &stdout, &stderr) + if err == nil || err.Error() != "flag: help requested" { + t.Fatalf("runHelp(-h) = %v, want flag.ErrHelp", err) + } +} + +func TestRunHelp_GivenNoFlags_ThenPrintsIntentToCommandCheatSheet(t *testing.T) { + var stdout, stderr bytes.Buffer + if err := runHelp(nil, &stdout, &stderr); err != nil { + t.Fatalf("runHelp(nil) returned error: %v", err) + } + got := stdout.String() + for _, want := range []string{ + "cc-session inherit ", + "cc-session read ", + "Session ID 支援 prefix match", + } { + if !strings.Contains(got, want) { + t.Fatalf("cheat sheet missing %q, got:\n%s", want, got) + } + } +} + +// buildArgumentHint feeds a Claude Code skill's argument-hint frontmatter, so +// its output must be exactly one bracketed, pipe-separated line with no +// stray whitespace. It must expose only the registry's user-facing, +// session-driven quick actions: not the deprecated "inject" alias (hidden) +// and not "benchmark" (a maintainer command with no argHint). +func TestRunHelp_GivenArgumentHintFlag_ThenPrintsSingleBracketedPipeSeparatedLine(t *testing.T) { + var stdout, stderr bytes.Buffer + if err := runHelp([]string{"--argument-hint"}, &stdout, &stderr); err != nil { + t.Fatalf("runHelp(--argument-hint) returned error: %v", err) + } + + lines := strings.Split(strings.TrimRight(stdout.String(), "\n"), "\n") + if len(lines) != 1 { + t.Fatalf("expected exactly one line, got %d: %q", len(lines), stdout.String()) + } + got := lines[0] + + if !strings.HasPrefix(got, "[") || !strings.HasSuffix(got, "]") { + t.Fatalf("argument-hint line not bracketed: %q", got) + } + if !strings.Contains(got, " | ") { + t.Fatalf("argument-hint entries should be pipe-separated: %q", got) + } + if !strings.Contains(got, "inherit ") { + t.Fatalf("argument-hint missing inherit entry: %q", got) + } + if strings.Contains(got, "inject") { + t.Fatalf("argument-hint must not expose the deprecated inject alias: %q", got) + } + if strings.Contains(got, "benchmark") { + t.Fatalf("argument-hint must not include benchmark (no argHint, not a quick-launch action): %q", got) + } +} + +func TestFindCommand_GivenInjectName_ThenReturnsHiddenButDispatchable(t *testing.T) { + cmd, ok := findCommand("inject") + if !ok { + t.Fatal("findCommand(\"inject\") = not found, want the deprecated alias to remain dispatchable") + } + if !cmd.hidden { + t.Fatal("findCommand(\"inject\").hidden = false, want true so it stays out of usage/help/argument-hint") + } +} + +func TestFindCommand_GivenUnknownName_ThenReturnsNotFound(t *testing.T) { + if _, ok := findCommand("no-such-command"); ok { + t.Fatal("findCommand(\"no-such-command\") = found, want not found") + } +} + // Guards against regression where exitOnError would print "Error: " or // "Error: flag: help requested" to stderr instead of being silent. func TestExitOnError_GivenNil_ThenNoStderrOutput(t *testing.T) { diff --git a/install.ps1 b/install.ps1 index 8061ebd..85ea1a8 100644 --- a/install.ps1 +++ b/install.ps1 @@ -116,6 +116,41 @@ function Update-UserPath { # ── skill install ───────────────────────────────────────────────────────────── +# Sync-ArgumentHint overwrites the installed SKILL.md's "argument-hint:" line +# with the live output of "cc-session help --argument-hint". The CLI's +# command registry is the single source of truth for that hint; without this, +# the skill drifts out of sync whenever the CLI's subcommand order or set +# changes. +# +# Best-effort: leaves the existing line untouched if the binary isn't +# installed, the subcommand errors out (e.g. an older CLI without +# "help --argument-hint"), or the output doesn't look like a hint — a broken +# skill install is worse than a stale hint. +function Sync-ArgumentHint { + param([string]$SkillPath) + + $exePath = Join-Path $InstallDir "cc-session.exe" + if (-not (Test-Path $exePath)) { + return + } + + try { + $hint = & $exePath help --argument-hint 2>$null + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($hint) -or -not $hint.StartsWith('[')) { + return + } + + $lines = Get-Content -Path $SkillPath + $updated = $lines | ForEach-Object { + if ($_ -match '^argument-hint:') { "argument-hint: `"$hint`"" } else { $_ } + } + Set-Content -Path $SkillPath -Value $updated + } catch { + # Sync is best-effort and must not abort the skill install; fall + # through and keep the existing argument-hint line. + } +} + function Install-Skill { if ($NoSkill) { return } @@ -133,6 +168,7 @@ function Install-Skill { try { Invoke-WebRequest -Uri $SkillUrl -OutFile $skillDst -UseBasicParsing + Sync-ArgumentHint -SkillPath $skillDst Write-Host "Skill installed. Use /cc-session in Claude Code to activate it." } catch { Write-Error "Failed to download skill: $_" diff --git a/install.sh b/install.sh index 1a04dc9..a7ed632 100755 --- a/install.sh +++ b/install.sh @@ -170,9 +170,52 @@ install_skill() { wget -qO "$SKILL_DIR/SKILL.md" "$SKILL_URL" fi + sync_argument_hint + echo "Skill installed. Use /cc-session in Claude Code to activate it." } +# ── skill argument-hint sync ────────────────────────────────────────────────── + +# sync_argument_hint overwrites the installed SKILL.md's "argument-hint:" line +# with the live output of "cc-session help --argument-hint". The CLI's command +# registry is the single source of truth for that hint; without this, the +# skill drifts out of sync whenever the CLI's subcommand order or set changes. +# +# Falls back to leaving the existing line untouched (no-op) if the binary +# isn't installed/executable, the subcommand errors out (e.g. an older CLI +# without "help --argument-hint"), or the output doesn't look like a hint — +# a broken skill install is worse than a stale hint. +sync_argument_hint() { + local cli_bin="$INSTALL_DIR/cc-session" + local skill_md="$SKILL_DIR/SKILL.md" + + if [ ! -x "$cli_bin" ]; then + return + fi + + local hint + hint=$("$cli_bin" help --argument-hint 2>/dev/null) || return + + if [ -z "$hint" ] || [[ "$hint" != \[* ]]; then + return + fi + + # sed -i is not an option here: BSD sed (macOS) and GNU sed (Linux) take + # incompatible flags for it. Writing to a temp file and moving it into + # place works identically on both. + local tmp_file + tmp_file=$(mktemp "${skill_md}.XXXXXX") + if awk -v hint="$hint" ' + /^argument-hint:/ { print "argument-hint: \"" hint "\""; next } + { print } + ' "$skill_md" > "$tmp_file"; then + mv "$tmp_file" "$skill_md" + else + rm -f "$tmp_file" + fi +} + # ── main ────────────────────────────────────────────────────────────────────── print_next_steps() { From 87c0782ebf3c6fe753d2a3ff52203deaff8c6f7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maple=EF=BC=81?= Date: Wed, 15 Jul 2026 11:07:15 +0800 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20address=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20make=20help=20derive=20from=20the=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cheat sheet rendered by `cc-session help` was a fourth hand-copied command list living alongside the registry, and the struct doc comment claimed the registry was its single source of truth when it was not — reintroducing the exact drift this refactor set out to remove. - cheatSheetRow now references a registry command by name; the command string (`cc-session `) is derived via findCommand, and commandText() panics if the row points at a missing or hidden command, turning the `hidden` doc comment into an enforced invariant. - New TestCheatSheet_… asserts the cheat sheet covers exactly the non-hidden, hinted registry commands, so a new subcommand can't be added without a matching cheat-sheet row. - New TestSkillMD_… asserts SKILL.md's argument-hint frontmatter equals buildArgumentHint(), so registry changes that aren't mirrored into the committed SKILL.md fail the build instead of drifting silently. - printUsage computes column width from the longest command name so `benchmark` (9 chars) no longer breaks alignment. - Rename bool flag var argumentHint -> isArgumentHint per package convention. - Doc comments in commands.go rewritten to describe only what is true. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JwariBme7AbQxDciEKSPXt --- cmd/cc-session/commands.go | 16 +++++---- cmd/cc-session/help_cmd.go | 72 +++++++++++++++++++++++++++---------- cmd/cc-session/main.go | 19 +++++++++- cmd/cc-session/main_test.go | 64 +++++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 25 deletions(-) diff --git a/cmd/cc-session/commands.go b/cmd/cc-session/commands.go index b5ac1b5..fe725b2 100644 --- a/cmd/cc-session/commands.go +++ b/cmd/cc-session/commands.go @@ -8,9 +8,12 @@ import ( ) // command describes one cc-session subcommand. It is the single source of -// truth for dispatch (main's switch), printUsage, and the "cc-session help" -// cheat sheet / --argument-hint output — each of those used to hand-copy the -// command list independently and drift out of sync. +// truth for dispatch (main's switch), printUsage, and --argument-hint — +// those used to hand-copy the command list independently and drift out of +// sync. The "cc-session help" cheat sheet in help_cmd.go also derives each +// row's "cc-session " fragment from this registry by name, +// but keeps its intent text and teaching asides hand-written since those +// aren't part of the registry. type command struct { // name is the word typed after "cc-session" (os.Args[1]) and the label // shown in printUsage / help. @@ -24,9 +27,10 @@ type command struct { // used for meta commands like "help" and "benchmark" that aren't part of // the skill's quick-launch surface. argHint string - // hidden excludes the command from printUsage, help, and the - // argument-hint line, while keeping it dispatchable. Used for the - // deprecated "inject" alias. + // hidden excludes the command from printUsage, the argument-hint line, + // and the help cheat sheet, while keeping it dispatchable. Used for the + // deprecated "inject" alias. The cheat sheet enforces this by panicking + // if a row ever references a hidden command's name. hidden bool // run executes the command. reader is the concrete claudecodec.Codec, // which satisfies both session.TranscriptReader and session.HeaderScanner diff --git a/cmd/cc-session/help_cmd.go b/cmd/cc-session/help_cmd.go index fe0a8ed..55fe047 100644 --- a/cmd/cc-session/help_cmd.go +++ b/cmd/cc-session/help_cmd.go @@ -9,25 +9,61 @@ import ( ) // cheatSheetRow is one row of the "intent -> command" quick reference -// printed by "cc-session help". It is a separate list from the command -// registry because some rows describe a flag variant of an existing command -// (e.g. read -verbose-bash) rather than a standalone subcommand, and the -// registry must not gain an entry for those. +// printed by "cc-session help". intent and note are hand-written teaching +// text that has no home in the command registry; the "cc-session +// " portion is derived from the registry via commandText, so +// renaming a command or changing its argHint keeps this cheat sheet in sync +// automatically. type cheatSheetRow struct { - intent string - command string + // intent is the plain-language "what do I want to do" column. + intent string + // cmdName looks up the row's registry entry by command.name to build + // "cc-session ". Empty for the one row that documents a + // flag variant of an existing command (read -verbose-bash) rather than a + // standalone registry entry; that row sets override instead. + cmdName string + // note is hand-written text appended after " — ", e.g. "-p 過濾專案". + // Empty when the row has no aside. + note string + // override replaces the registry-derived command string outright. Only + // the "-verbose-bash" row uses this, since it isn't a standalone + // registry entry. + override string +} + +// commandText renders the row's "cc-session ..." command string, followed by +// " — note" when note is set. +func (r cheatSheetRow) commandText() string { + command := r.override + if command == "" { + cmd, ok := findCommand(r.cmdName) + if !ok { + panic("cheatSheet: unknown command name " + r.cmdName) + } + // hidden commands (e.g. the deprecated "inject" alias) must never + // surface in the help cheat sheet; see the hidden field's doc + // comment in commands.go. + if cmd.hidden { + panic("cheatSheet: refuses to surface hidden command " + r.cmdName) + } + command = "cc-session " + cmd.argHint + } + if r.note == "" { + return command + } + return command + " — " + r.note } var cheatSheet = []cheatSheetRow{ - {"找目標 session", "cc-session list — 列出最近 session,-p 過濾專案,用過 cc-session 的標 [refs]"}, - {"讀 session(預設)", "cc-session inherit — 分頁載入,重複呼叫翻頁"}, - {"查特定片段", "cc-session read — 預設 200 行,-offset 跳讀"}, - {"緊湊單次輸出", "cc-session context — 同 read 但更緊湊,帶 metadata header"}, - {"展開單一 tool call", "cc-session expand — tool-id 取自輸出中的 [Tool#xxxx]"}, - {"展開同類所有 tool call", "cc-session read -verbose-bash — 也有 -verbose-agents / -verbose-thinking"}, - {"分析 token 消耗", "cc-session stats "}, - {"檢查過濾遺漏", "cc-session audit "}, - {"查看 CLI 使用紀錄", "cc-session usage"}, + {intent: "找目標 session", cmdName: "list", note: "列出最近 session,-p 過濾專案,用過 cc-session 的標 [refs]"}, + {intent: "讀 session(預設)", cmdName: "inherit", note: "分頁載入,重複呼叫翻頁"}, + {intent: "查特定片段", cmdName: "read", note: "預設 200 行,-offset 跳讀"}, + {intent: "緊湊單次輸出", cmdName: "context", note: "同 read 但更緊湊,帶 metadata header"}, + {intent: "展開單一 tool call", cmdName: "expand", note: "tool-id 取自輸出中的 [Tool#xxxx]"}, + {intent: "展開同類所有 tool call", override: "cc-session read -verbose-bash", note: "也有 -verbose-agents / -verbose-thinking"}, + {intent: "分析 token 消耗", cmdName: "stats"}, + {intent: "檢查過濾遺漏", cmdName: "audit"}, + {intent: "查看 CLI 使用紀錄", cmdName: "usage"}, } func cmdHelp(args []string) { @@ -37,12 +73,12 @@ func cmdHelp(args []string) { func runHelp(args []string, out io.Writer, errOut io.Writer) error { fs := flag.NewFlagSet("help", flag.ContinueOnError) fs.SetOutput(errOut) - argumentHint := fs.Bool("argument-hint", false, "print a single-line [cmd | cmd ...] hint for a skill's argument-hint frontmatter") + isArgumentHint := fs.Bool("argument-hint", false, "print a single-line [cmd | cmd ...] hint for a skill's argument-hint frontmatter") if err := fs.Parse(args); err != nil { return err } - if *argumentHint { + if *isArgumentHint { fmt.Fprintln(out, buildArgumentHint()) return nil } @@ -53,7 +89,7 @@ func runHelp(args []string, out io.Writer, errOut io.Writer) error { fmt.Fprintln(out, "cc-session 子命令速查表") fmt.Fprintln(out) for _, row := range cheatSheet { - fmt.Fprintf(out, "%s → %s\n", row.intent, row.command) + fmt.Fprintf(out, "%s → %s\n", row.intent, row.commandText()) } fmt.Fprintln(out) fmt.Fprintln(out, "Session ID 支援 prefix match,前 8 碼通常就夠。各子命令的 flags 用 -h 查看。") diff --git a/cmd/cc-session/main.go b/cmd/cc-session/main.go index 64401a2..9e74d00 100644 --- a/cmd/cc-session/main.go +++ b/cmd/cc-session/main.go @@ -64,12 +64,29 @@ func printUsage() { fmt.Fprintln(os.Stderr, "Usage: cc-session [options]") fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "Commands:") + nameFormat := fmt.Sprintf(" %%-%ds %%s\n", longestVisibleCommandNameLen()) for _, cmd := range commands { if cmd.hidden { continue } - fmt.Fprintf(os.Stderr, " %-8s %s\n", cmd.name, cmd.summary) + fmt.Fprintf(os.Stderr, nameFormat, cmd.name, cmd.summary) } fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "Run 'cc-session -h' for command-specific flags.") } + +// longestVisibleCommandNameLen returns the length of the longest non-hidden +// command name, so printUsage can pad its name column wide enough for every +// entry (a fixed width goes ragged once a name like "benchmark" exceeds it). +func longestVisibleCommandNameLen() int { + maxLen := 0 + for _, cmd := range commands { + if cmd.hidden { + continue + } + if len(cmd.name) > maxLen { + maxLen = len(cmd.name) + } + } + return maxLen +} diff --git a/cmd/cc-session/main_test.go b/cmd/cc-session/main_test.go index 7077853..452dade 100644 --- a/cmd/cc-session/main_test.go +++ b/cmd/cc-session/main_test.go @@ -1331,6 +1331,70 @@ func TestRunHelp_GivenArgumentHintFlag_ThenPrintsSingleBracketedPipeSeparatedLin } } +// Regression guard for finding 3: the help cheat sheet used to be a fourth +// hand-copied command list with no link to the registry. This asserts the +// set of commands the cheat sheet derives its "cc-session ..." string from +// exactly matches the registry's non-hidden, hinted commands, so adding a +// registry subcommand without a matching cheat-sheet row (or vice versa) +// turns this test red. +func TestCheatSheet_GivenRegistry_ThenCoversExactlyNonHiddenHintedCommands(t *testing.T) { + want := map[string]bool{} + for _, cmd := range commands { + if cmd.hidden || cmd.argHint == "" { + continue + } + want[cmd.name] = true + } + + got := map[string]bool{} + for _, row := range cheatSheet { + if row.cmdName == "" { + continue + } + got[row.cmdName] = true + } + + for name := range want { + if !got[name] { + t.Errorf("cheatSheet has no row deriving from registry command %q", name) + } + } + for name := range got { + if !want[name] { + t.Errorf("cheatSheet row references %q, which is hidden or has no argHint in the registry", name) + } + } +} + +// Regression guard for finding 4: SKILL.md's argument-hint frontmatter is a +// hand-written literal with no compiler-enforced link to buildArgumentHint. +// This asserts it matches verbatim, so a registry change that isn't synced +// into SKILL.md turns this test red instead of silently drifting. +func TestSkillMD_GivenArgumentHintFrontmatter_ThenMatchesBuildArgumentHint(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "SKILL.md")) + if err != nil { + t.Fatalf("read SKILL.md: %v", err) + } + + const linePrefix = "argument-hint: " + var hintLine string + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, linePrefix) { + hintLine = line + break + } + } + if hintLine == "" { + t.Fatal("SKILL.md frontmatter has no argument-hint line") + } + + got := strings.Trim(strings.TrimPrefix(hintLine, linePrefix), `"`) + want := buildArgumentHint() + if got != want { + t.Fatalf("SKILL.md argument-hint = %q, want %q (buildArgumentHint output); sync SKILL.md's frontmatter with the registry", got, want) + } +} + func TestFindCommand_GivenInjectName_ThenReturnsHiddenButDispatchable(t *testing.T) { cmd, ok := findCommand("inject") if !ok {