From 1395fb2f1de4c5a153f5498b96d06994fdf80376 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 7 Jul 2026 22:02:35 -0400 Subject: [PATCH] feat(core): add FleetManager.listAgentCommands + re-export SlashCommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading the slash commands available to an agent (to populate a command palette / autocomplete) previously required consumers to hand-manage a live `claude` streaming subprocess: openChatSession → listCommands → close, with the close guaranteed on every path including errors. That lifecycle care belongs in the library, not duplicated in each consumer (e.g. Paddock). Add a one-shot `FleetManager.listAgentCommands(agentName, options?)` that opens a chat session, reads its command list, and ALWAYS closes the session (in a `finally`, even if the listing throws): - Returns the full `SlashCommand[]` — { name, description, argumentHint } per command (built-ins + project `.claude/commands` + MCP-provided commands). - Accepts the same `ChatSessionOptions` as openChatSession (workingDirectory, injectedMcpServers) so the list reflects the intended project context. - Runs on the SDK runtime regardless of the agent's configured runtime (works for `cli`-runtime agents) and surfaces StreamingSessionUnsupportedError unchanged for Docker-wrapped agents. Also re-export the `SlashCommand` type from `@herdctl/core` so consumers can `import type { SlashCommand } from "@herdctl/core"` instead of reaching into the Claude Agent SDK directly. Adds 5 tests (happy path, close-on-throw, agent-not-found, invalid-state, docker-unsupported), a fleet-manager library-reference doc entry, and a changeset (minor). Fixes #300. Co-Authored-By: Claude Opus 4.8 --- .changeset/list-agent-commands.md | 29 +++ .../docs/library-reference/fleet-manager.mdx | 60 ++++++ .../__tests__/list-agent-commands.test.ts | 177 ++++++++++++++++++ .../core/src/fleet-manager/fleet-manager.ts | 17 +- .../core/src/fleet-manager/job-control.ts | 47 ++++- packages/core/src/runner/index.ts | 7 +- packages/core/src/runner/runtime/index.ts | 7 +- packages/core/src/runner/runtime/interface.ts | 10 + 8 files changed, 350 insertions(+), 4 deletions(-) create mode 100644 .changeset/list-agent-commands.md create mode 100644 packages/core/src/fleet-manager/__tests__/list-agent-commands.test.ts diff --git a/.changeset/list-agent-commands.md b/.changeset/list-agent-commands.md new file mode 100644 index 00000000..da19a40e --- /dev/null +++ b/.changeset/list-agent-commands.md @@ -0,0 +1,29 @@ +--- +"@herdctl/core": minor +--- + +Add `FleetManager.listAgentCommands(agentName, options?)` — a one-shot way to +read the slash commands available to an agent for populating a command palette / +autocomplete, without hand-managing a live streaming session. + +Internally it opens a chat session, reads its command list, and **always closes +the session** (in a `finally`, even if the listing throws), so consumers never +have to guard the underlying `claude` subprocess lifecycle themselves: + +- Returns the full `SlashCommand[]` — `{ name, description, argumentHint }` per + command (built-ins + project `.claude/commands` + MCP-provided commands) for + the resolved session's cwd/config. +- Accepts the same `ChatSessionOptions` as `openChatSession` (notably + `workingDirectory` and `injectedMcpServers`) so the list reflects the intended + project context. +- Works for `cli`-runtime agents (the session runs on the SDK runtime + regardless of the agent's configured runtime) and surfaces + `StreamingSessionUnsupportedError` unchanged for Docker-wrapped agents. + +Each call spawns and tears down a `claude` subprocess (~seconds); the list is +essentially static per project, so callers that query it repeatedly should +cache the result. + +Also re-exports the `SlashCommand` type from `@herdctl/core`, so consumers can +`import type { SlashCommand } from "@herdctl/core"` instead of reaching into the +Claude Agent SDK directly. diff --git a/docs/src/content/docs/library-reference/fleet-manager.mdx b/docs/src/content/docs/library-reference/fleet-manager.mdx index 2a6efe06..838e7ec0 100644 --- a/docs/src/content/docs/library-reference/fleet-manager.mdx +++ b/docs/src/content/docs/library-reference/fleet-manager.mdx @@ -963,6 +963,66 @@ const sessions = await manager.getAgentSessions('my-agent'); --- +### `listAgentCommands(agentName, options?)` + +Lists the slash commands available to an agent — for populating a command palette or autocomplete — in a single call. + +```typescript +await manager.listAgentCommands( + agentName: string, + options?: ChatSessionOptions +): Promise +``` + +#### Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `agentName` | `string` | **Yes** | Agent qualified name or local name | +| `options.workingDirectory` | `string` | No | Per-call working-directory override so the list reflects the intended project context | +| `options.injectedMcpServers` | `Record` | No | MCP servers to inject, so their commands appear in the list | + +#### Returns + +```typescript +interface SlashCommand { + name: string; // Command name, without the leading slash + description: string; // Human-readable description + argumentHint: string; // Hint for arguments (e.g. ""), "" if none +} +``` + +Returns the full command list — built-ins plus project `.claude/commands` plus any MCP-provided commands — exactly as the CLI reports them for the resolved session's cwd and config. + +#### Description + +A one-shot convenience over `openChatSession()`: it opens a streaming session, reads its command list, and **always closes the session** (in a `finally`, even if the listing throws), so consumers never have to manage the underlying `claude` subprocess lifecycle themselves. + +Accepts the same `ChatSessionOptions` as `openChatSession`. The session runs on the SDK runtime regardless of the agent's configured `runtime`, so this works for `cli`-runtime (Claude-subscription) agents too. + +**Cost**: each call spawns and tears down a `claude` subprocess (~seconds). The command list is essentially static per project, so callers that query it repeatedly should cache the result. + +#### Throws + +| Error | Condition | +|-------|-----------| +| `InvalidStateError` | Fleet manager not initialized | +| `AgentNotFoundError` | Agent doesn't exist | +| `StreamingSessionUnsupportedError` | Agent is Docker-wrapped (surfaced unchanged from `openChatSession`) | + +#### Example + +```typescript +import type { SlashCommand } from '@herdctl/core'; + +const commands: SlashCommand[] = await manager.listAgentCommands('my-agent'); +for (const cmd of commands) { + console.log(`/${cmd.name} ${cmd.argumentHint} — ${cmd.description}`); +} +``` + +--- + ## Action Methods ### `trigger(agentName, scheduleName?, options?)` diff --git a/packages/core/src/fleet-manager/__tests__/list-agent-commands.test.ts b/packages/core/src/fleet-manager/__tests__/list-agent-commands.test.ts new file mode 100644 index 00000000..e792e092 --- /dev/null +++ b/packages/core/src/fleet-manager/__tests__/list-agent-commands.test.ts @@ -0,0 +1,177 @@ +/** + * Tests for FleetManager.listAgentCommands(agentName, opts?): + * - opens a streaming session, reads its slash-command list, and ALWAYS + * closes the session (even when listCommands() throws). + * + * The Claude SDK's `query` is mocked to return a fake Query whose + * `supportedCommands()` and `return()` we control, so no real `claude` + * subprocess is spawned. `openChatSession` (which `listAgentCommands` delegates + * to) builds its own `SDKRuntime` and calls `query()` internally; asserting that + * the fake Query's `return()` was invoked proves the session was torn down. + */ + +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock the Claude SDK to prevent real API calls / subprocess spawns. +vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ + query: vi.fn(), +})); + +import { query } from "@anthropic-ai/claude-agent-sdk"; +import { + AgentNotFoundError, + InvalidStateError, + StreamingSessionUnsupportedError, +} from "../errors.js"; +import { FleetManager } from "../fleet-manager.js"; + +const silentLogger = () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +}); + +const SAMPLE_COMMANDS = [ + { name: "compact", description: "Compact the conversation", argumentHint: "" }, + { name: "review", description: "Review a pull request", argumentHint: "" }, +]; + +/** + * Build a fake SDK Query. `supportedCommands` resolves to the given commands + * (or rejects if `commandsError` is supplied); `return` records that the + * session was closed. Only the members `openSession()` touches are provided. + */ +function fakeQuery(opts: { commands?: typeof SAMPLE_COMMANDS; commandsError?: Error }) { + const returnSpy = vi.fn().mockResolvedValue(undefined); + const supportedCommands = opts.commandsError + ? vi.fn().mockRejectedValue(opts.commandsError) + : vi.fn().mockResolvedValue(opts.commands ?? SAMPLE_COMMANDS); + const q = { + // Iterating is never triggered by listAgentCommands, but the shape needs an + // async-iterator to satisfy the structural cast in openSession(). + [Symbol.asyncIterator]: async function* () {}, + supportedCommands, + interrupt: vi.fn().mockResolvedValue(undefined), + setModel: vi.fn().mockResolvedValue(undefined), + return: returnSpy, + }; + return { q, returnSpy, supportedCommands }; +} + +describe("FleetManager.listAgentCommands()", () => { + let tempDir: string; + let configDir: string; + let stateDir: string; + let workDir: string; + + beforeEach(async () => { + vi.mocked(query).mockReset(); + tempDir = await mkdtemp(join(tmpdir(), "fleet-list-cmds-test-")); + configDir = join(tempDir, "config"); + stateDir = join(tempDir, ".herdctl"); + workDir = join(tempDir, "workspace"); + await mkdir(configDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); + }); + + afterEach(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + await rm(tempDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + }); + + async function createConfig(config: object) { + const configPath = join(configDir, "herdctl.yaml"); + const yaml = await import("yaml"); + await writeFile(configPath, yaml.stringify(config)); + return configPath; + } + + async function createAgentConfig(name: string, config: object) { + const agentDir = join(configDir, "agents"); + await mkdir(agentDir, { recursive: true }); + const agentPath = join(agentDir, `${name}.yaml`); + const yaml = await import("yaml"); + await writeFile(agentPath, yaml.stringify(config)); + return agentPath; + } + + function createTestManager(configPath: string) { + return new FleetManager({ + configPath, + stateDir, + checkInterval: 10000, + logger: silentLogger(), + }); + } + + async function buildManagerWithAgent(extraAgentConfig: object = {}) { + await createAgentConfig("keeper", { + name: "keeper", + working_directory: workDir, + ...extraAgentConfig, + }); + const configPath = await createConfig({ + version: 1, + agents: [{ path: "./agents/keeper.yaml" }], + }); + const manager = createTestManager(configPath); + await manager.initialize(); + return manager; + } + + it("returns the full SlashCommand[] reported by the session", async () => { + const manager = await buildManagerWithAgent(); + const { q, returnSpy } = fakeQuery({ commands: SAMPLE_COMMANDS }); + vi.mocked(query).mockReturnValue(q as never); + + const commands = await manager.listAgentCommands("keeper"); + + expect(commands).toEqual(SAMPLE_COMMANDS); + // Session must be closed after a successful listing. + expect(returnSpy).toHaveBeenCalledTimes(1); + }); + + it("closes the session even when listCommands() throws", async () => { + const manager = await buildManagerWithAgent(); + const boom = new Error("supportedCommands failed"); + const { q, returnSpy } = fakeQuery({ commandsError: boom }); + vi.mocked(query).mockReturnValue(q as never); + + await expect(manager.listAgentCommands("keeper")).rejects.toThrow("supportedCommands failed"); + // finally{} must have torn down the subprocess despite the throw. + expect(returnSpy).toHaveBeenCalledTimes(1); + }); + + it("throws AgentNotFoundError for an unknown agent (no session opened)", async () => { + const manager = await buildManagerWithAgent(); + + await expect(manager.listAgentCommands("ghost")).rejects.toThrow(AgentNotFoundError); + // We never got as far as opening a session. + expect(vi.mocked(query)).not.toHaveBeenCalled(); + }); + + it("throws InvalidStateError before initialize()", async () => { + await createAgentConfig("keeper", { name: "keeper", working_directory: workDir }); + const configPath = await createConfig({ + version: 1, + agents: [{ path: "./agents/keeper.yaml" }], + }); + const manager = createTestManager(configPath); + // not initialized + await expect(manager.listAgentCommands("keeper")).rejects.toThrow(InvalidStateError); + }); + + it("surfaces StreamingSessionUnsupportedError for Docker-wrapped agents", async () => { + const manager = await buildManagerWithAgent({ docker: { enabled: true } }); + + await expect(manager.listAgentCommands("keeper")).rejects.toThrow( + StreamingSessionUnsupportedError, + ); + // Docker path throws before any subprocess is spawned. + expect(vi.mocked(query)).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/fleet-manager/fleet-manager.ts b/packages/core/src/fleet-manager/fleet-manager.ts index 3e0dd212..398247c3 100644 --- a/packages/core/src/fleet-manager/fleet-manager.ts +++ b/packages/core/src/fleet-manager/fleet-manager.ts @@ -23,7 +23,7 @@ import { type ResolvedAgent, type ResolvedConfig, } from "../config/index.js"; -import type { RuntimeSession } from "../runner/index.js"; +import type { RuntimeSession, SlashCommand } from "../runner/index.js"; import { getCliSessionFile, getDockerSessionFile } from "../runner/runtime/cli-session-path.js"; import { Scheduler, type TriggerInfo } from "../scheduler/index.js"; import { @@ -802,6 +802,21 @@ export class FleetManager extends EventEmitter implements FleetManagerContext { async openChatSession(agentName: string, options?: ChatSessionOptions): Promise { return this.jobControl.openChatSession(agentName, options); } + /** + * List the slash commands available to an agent in one shot. + * + * A convenience wrapper that opens a streaming session, reads its command list, + * and always closes the session — so callers get a `SlashCommand[]` (for a + * command palette / autocomplete) without managing a live `claude` subprocess. + * See {@link JobControl.listAgentCommands} for details, cost, and thrown errors + * (including {@link StreamingSessionUnsupportedError} for Docker-wrapped agents). + */ + async listAgentCommands( + agentName: string, + options?: ChatSessionOptions, + ): Promise { + return this.jobControl.listAgentCommands(agentName, options); + } async cancelJob(jobId: string, options?: { timeout?: number }): Promise { return this.jobControl.cancelJob(jobId, options); } diff --git a/packages/core/src/fleet-manager/job-control.ts b/packages/core/src/fleet-manager/job-control.ts index 6290a4cc..47239656 100644 --- a/packages/core/src/fleet-manager/job-control.ts +++ b/packages/core/src/fleet-manager/job-control.ts @@ -11,7 +11,13 @@ import { readFile } from "node:fs/promises"; import { isAbsolute, join, resolve } from "node:path"; import type { HookEvent, ResolvedAgent } from "../config/index.js"; import { type HookContext, HookExecutor } from "../hooks/index.js"; -import { JobExecutor, RuntimeFactory, type RuntimeSession, SDKRuntime } from "../runner/index.js"; +import { + JobExecutor, + RuntimeFactory, + type RuntimeSession, + SDKRuntime, + type SlashCommand, +} from "../runner/index.js"; import { createJob, getJob, getSessionInfo, readJobOutputAll, updateJob } from "../state/index.js"; import type { JobMetadata } from "../state/schemas/job-metadata.js"; import type { FleetManagerContext } from "./context.js"; @@ -390,6 +396,45 @@ export class JobControl { }); } + /** + * List the slash commands available to an agent in one shot. + * + * A convenience over {@link openChatSession}: it opens a streaming session, + * queries {@link RuntimeSession.listCommands}, and **always closes the session** + * (in a `finally`, even if the listing throws) so callers never have to manage + * the underlying `claude` subprocess lifecycle themselves. Use it to populate a + * slash-command autocomplete without hand-holding a live session. + * + * The returned list is the full `SlashCommand[]` — `{ name, description, + * argumentHint }` per command (built-ins + project `.claude/commands` + any + * MCP-provided commands, exactly as the CLI reports them for the resolved + * session's cwd/config). Pass the same {@link ChatSessionOptions} as + * `openChatSession` (notably `workingDirectory` and `injectedMcpServers`) so the + * list reflects the intended project context. + * + * **Cost:** each call spawns and tears down a `claude` subprocess (~seconds). + * The command list is essentially static per project, so callers that query it + * repeatedly should cache the result. + * + * @throws {AgentNotFoundError} If the agent doesn't exist + * @throws {InvalidStateError} If the fleet manager is not initialized + * @throws {StreamingSessionUnsupportedError} If the agent is Docker-wrapped + * (surfaced unchanged from {@link openChatSession}) + */ + async listAgentCommands( + agentName: string, + options?: ChatSessionOptions, + ): Promise { + const session = await this.openChatSession(agentName, options); + try { + return await session.listCommands(); + } finally { + // Guarantee teardown of the underlying subprocess on every path, + // including when listCommands() throws. + await session.close(); + } + } + /** * Cancel a running job gracefully * diff --git a/packages/core/src/runner/index.ts b/packages/core/src/runner/index.ts index ba232157..bfcf525c 100644 --- a/packages/core/src/runner/index.ts +++ b/packages/core/src/runner/index.ts @@ -39,7 +39,12 @@ export { processSDKMessage, } from "./message-processor.js"; // Export runtime types and factory -export type { RuntimeExecuteOptions, RuntimeInterface, RuntimeSession } from "./runtime/index.js"; +export type { + RuntimeExecuteOptions, + RuntimeInterface, + RuntimeSession, + SlashCommand, +} from "./runtime/index.js"; export { encodePathForCli, getCliSessionDir, diff --git a/packages/core/src/runner/runtime/index.ts b/packages/core/src/runner/runtime/index.ts index f2a5092a..b5994195 100644 --- a/packages/core/src/runner/runtime/index.ts +++ b/packages/core/src/runner/runtime/index.ts @@ -24,6 +24,11 @@ export { sessionBelongsToWorkingDirectory, } from "./cli-session-path.js"; export { RuntimeFactory, type RuntimeType } from "./factory.js"; -export type { RuntimeExecuteOptions, RuntimeInterface, RuntimeSession } from "./interface.js"; +export type { + RuntimeExecuteOptions, + RuntimeInterface, + RuntimeSession, + SlashCommand, +} from "./interface.js"; export { MessageQueue } from "./message-queue.js"; export { SDKRuntime } from "./sdk-runtime.js"; diff --git a/packages/core/src/runner/runtime/interface.ts b/packages/core/src/runner/runtime/interface.ts index bc468c34..53a5d15d 100644 --- a/packages/core/src/runner/runtime/interface.ts +++ b/packages/core/src/runner/runtime/interface.ts @@ -13,6 +13,16 @@ import type { SlashCommand } from "@anthropic-ai/claude-agent-sdk"; import type { ResolvedAgent } from "../../config/index.js"; import type { SDKMessage } from "../types.js"; +/** + * A slash command available to an agent's session: its name (no leading slash), + * a human-readable description, and an argument hint for autocomplete. + * + * Re-exported from the Claude Agent SDK so consumers of `@herdctl/core` can type + * command listings (e.g. {@link RuntimeSession.listCommands} or + * `FleetManager.listAgentCommands`) without importing the SDK directly. + */ +export type { SlashCommand }; + /** * Options for executing a runtime */