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
29 changes: 29 additions & 0 deletions .changeset/list-agent-commands.md
Original file line number Diff line number Diff line change
@@ -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.
60 changes: 60 additions & 0 deletions docs/src/content/docs/library-reference/fleet-manager.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SlashCommand[]>
```

#### 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<string, InjectedMcpServerDef>` | 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. "<pr>"), "" 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?)`
Expand Down
Original file line number Diff line number Diff line change
@@ -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: "<pr>" },
];

/**
* 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();
});
});
17 changes: 16 additions & 1 deletion packages/core/src/fleet-manager/fleet-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -802,6 +802,21 @@ export class FleetManager extends EventEmitter implements FleetManagerContext {
async openChatSession(agentName: string, options?: ChatSessionOptions): Promise<RuntimeSession> {
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<SlashCommand[]> {
return this.jobControl.listAgentCommands(agentName, options);
}
async cancelJob(jobId: string, options?: { timeout?: number }): Promise<CancelJobResult> {
return this.jobControl.cancelJob(jobId, options);
}
Expand Down
47 changes: 46 additions & 1 deletion packages/core/src/fleet-manager/job-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<SlashCommand[]> {
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
*
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/runner/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/runner/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Loading
Loading