diff --git a/openspec/changes/local-rule-routing/tasks.md b/openspec/changes/local-rule-routing/tasks.md index 2e805fc0..44631a8c 100644 --- a/openspec/changes/local-rule-routing/tasks.md +++ b/openspec/changes/local-rule-routing/tasks.md @@ -18,10 +18,10 @@ ## 3. Help registration + telemetry (cli-help) -- [ ] 3.1 Confirm the four `.txt` recipes are picked up by the `import.meta.glob` embedding and resolve via `taskless help ` -- [ ] 3.2 Ensure `route`, `existing`, `static`, `remote` appear in the `taskless help` (no-arg) topic index -- [ ] 3.3 Verify `help_` intent telemetry fires for each routing topic -- [ ] 3.4 Add tests for topic resolution, index listing, and telemetry capture +- [x] 3.1 Confirm the four `.txt` recipes are picked up by the `import.meta.glob` embedding and resolve via `taskless help ` +- [x] 3.2 Ensure `route`, `existing`, `static`, `remote` appear in the `taskless help` (no-arg) topic index +- [x] 3.3 Verify `help_` intent telemetry fires for each routing topic +- [x] 3.4 Add tests for topic resolution, index listing, and telemetry capture ## 4. Skill routing posture (skill-taskless) diff --git a/packages/cli/src/commands/help.ts b/packages/cli/src/commands/help.ts index f6e12938..edc80db5 100644 --- a/packages/cli/src/commands/help.ts +++ b/packages/cli/src/commands/help.ts @@ -50,6 +50,16 @@ function buildHelpMaps(): { const { helpMap, anonymousMap } = buildHelpMaps(); +// Help-only recipe topics (no backing subcommand) that should still be +// discoverable from the `taskless help` index. The rule-authoring front +// door (`route`) and its destinations live here so an agent can find them. +const RECIPE_TOPICS: ReadonlyArray<[string, string]> = [ + ["route", "Decide where to author a rule (existing/static/remote)"], + ["existing", "Author a rule in a linter the repo already uses"], + ["static", "Author a local ast-grep rule on this machine (no login)"], + ["remote", "Generate a rule via the Taskless service (login)"], +]; + // Topic → Zod input schema. When a recipe contains the %(INPUT_SCHEMA)s // placeholder, the help command substitutes the JSON Schema rendered // from this Zod source. @@ -173,11 +183,21 @@ export function createHelpCommand(subCommands: SubCommandsDef) { entries.push([name, description]); } - const maxLength = Math.max(...entries.map(([name]) => name.length)); + // Pad commands and recipe topics against a shared width so the two + // sections line up. + const maxLength = Math.max( + ...entries.map(([name]) => name.length), + ...RECIPE_TOPICS.map(([name]) => name.length) + ); for (const [name, description] of entries) { console.log(` ${name.padEnd(maxLength + 2)}${description}`); } + console.log("\nAuthoring recipes:"); + for (const [name, description] of RECIPE_TOPICS) { + console.log(` ${name.padEnd(maxLength + 2)}${description}`); + } + console.log( "\nAppend `--anonymous` to any rule/check command to skip the Taskless API" ); diff --git a/packages/cli/test/help-extensions.test.ts b/packages/cli/test/help-extensions.test.ts index 998dd553..10688669 100644 --- a/packages/cli/test/help-extensions.test.ts +++ b/packages/cli/test/help-extensions.test.ts @@ -58,6 +58,37 @@ describe("taskless help (no args)", () => { const result = await runCli(["help", "-d", cwd]); expect(result.stdout).toContain("--anonymous"); }); + + it("lists the routing recipe topics under Authoring recipes", async () => { + const result = await runCli(["help", "-d", cwd]); + expect(result.stdout).toContain("Authoring recipes:"); + for (const topic of ["route", "existing", "static", "remote"]) { + expect(result.stdout).toContain(topic); + } + }); +}); + +describe("taskless help ", () => { + let cwd: string; + + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), "taskless-help-routing-")); + }); + + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + it.each(["route", "existing", "static", "remote"])( + "resolves the %s recipe without an unknown-topic error", + async (topic) => { + const result = await runCli(["help", topic, "-d", cwd]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(`# Topic: ${topic}`); + expect(result.stdout).toContain("## Goal"); + expect(result.stderr).not.toContain("Unknown command"); + } + ); }); describe("taskless help ", () => { diff --git a/packages/cli/test/help-routing-telemetry.test.ts b/packages/cli/test/help-routing-telemetry.test.ts new file mode 100644 index 00000000..ece49ffe --- /dev/null +++ b/packages/cli/test/help-routing-telemetry.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Spy on the telemetry capture by mocking the telemetry module the help +// command imports. The factory is invoked lazily at import time, so the +// closure over `capture` resolves after initialization (same pattern as +// telemetry.test.ts mocking posthog-node). +const capture = vi.fn(); +vi.mock("../src/telemetry", () => ({ + getTelemetry: vi.fn(() => + Promise.resolve({ + capture, + shutdown: () => Promise.resolve(), + }) + ), + shutdownTelemetry: () => Promise.resolve(), +})); + +const { createHelpCommand } = await import("../src/commands/help"); + +interface RunnableCommand { + run: (context: { + args: { dir: string; anonymous: boolean }; + rawArgs: string[]; + }) => Promise; +} + +describe("help routing topics emit cli_help intent telemetry", () => { + let logSpy: ReturnType; + + beforeEach(() => { + capture.mockClear(); + // Suppress the recipe text the command prints to stdout. + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + it.each(["route", "existing", "static", "remote"])( + "captures cli_help for %s", + async (topic) => { + const command = createHelpCommand({}) as unknown as RunnableCommand; + await command.run({ + args: { dir: process.cwd(), anonymous: false }, + rawArgs: ["help", topic], + }); + + expect(capture).toHaveBeenCalledWith( + "cli_help", + expect.objectContaining({ topic }) + ); + } + ); +});