@lucascouts/claude-agent-tui 0.5.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +1 -1
- package/README.md +1 -1
- package/dist/acp-agent.d.ts +249 -21
- package/dist/acp-agent.js +573 -73
- package/dist/agent-catalog.d.ts +95 -0
- package/dist/agent-catalog.js +287 -0
- package/dist/ansi-mirror.d.ts +0 -1
- package/dist/besteffort.d.ts +0 -1
- package/dist/billing/entrypoint-guard.d.ts +0 -1
- package/dist/claude-path.d.ts +0 -1
- package/dist/claude-path.js +6 -0
- package/dist/command-catalog.d.ts +84 -0
- package/dist/command-catalog.js +339 -0
- package/dist/diff-enriched-reader.d.ts +0 -1
- package/dist/diff-source.d.ts +0 -1
- package/dist/drift-checks.d.ts +0 -1
- package/dist/end-of-turn.d.ts +6 -1
- package/dist/end-of-turn.js +8 -1
- package/dist/engine-lifecycle.d.ts +66 -2
- package/dist/engine-lifecycle.js +43 -4
- package/dist/engine-pty.d.ts +70 -3
- package/dist/engine-pty.js +80 -6
- package/dist/engine-watcher.d.ts +0 -1
- package/dist/engine.d.ts +0 -1
- package/dist/event-switch.d.ts +0 -1
- package/dist/gate/port.d.ts +0 -1
- package/dist/gate/settings-writer.d.ts +14 -1
- package/dist/gate/settings-writer.js +49 -0
- package/dist/image-input.d.ts +30 -0
- package/dist/image-input.js +79 -0
- package/dist/image-vision-smoke.d.ts +51 -0
- package/dist/image-vision-smoke.js +111 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.js +6 -0
- package/dist/jsonl.d.ts +0 -1
- package/dist/lib.d.ts +0 -1
- package/dist/linearize.d.ts +1 -2
- package/dist/linearize.js +1 -1
- package/dist/live-diff-env.d.ts +0 -1
- package/dist/live-subagent-env.d.ts +0 -1
- package/dist/mcp-config-writer.d.ts +60 -0
- package/dist/mcp-config-writer.js +172 -0
- package/dist/model-catalog.d.ts +68 -3
- package/dist/model-catalog.js +123 -13
- package/dist/permissions/allow-inject.d.ts +0 -1
- package/dist/permissions/deny.d.ts +12 -1
- package/dist/permissions/deny.js +18 -0
- package/dist/permissions/elicitation-bridge.d.ts +71 -0
- package/dist/permissions/elicitation-bridge.js +146 -0
- package/dist/permissions/gate-wiring.d.ts +23 -3
- package/dist/permissions/gate-wiring.js +123 -1
- package/dist/permissions/hook-server.d.ts +11 -3
- package/dist/permissions/hook-server.js +10 -1
- package/dist/permissions/permission-mode.d.ts +0 -1
- package/dist/permissions/request-permission.d.ts +0 -1
- package/dist/settings.d.ts +0 -1
- package/dist/settings.js +9 -0
- package/dist/stop-reason-map.d.ts +0 -1
- package/dist/subagent-gate.d.ts +0 -1
- package/dist/subagent-source.d.ts +0 -1
- package/dist/subagent-watcher.d.ts +0 -1
- package/dist/tools.d.ts +0 -1
- package/dist/tools.js +5 -1
- package/dist/usage-env.d.ts +0 -1
- package/dist/usage.d.ts +3 -1
- package/dist/usage.js +3 -0
- package/dist/utils.d.ts +0 -1
- package/dist/zed-register.d.ts +0 -1
- package/package.json +12 -9
- package/dist/acp-agent.d.ts.map +0 -1
- package/dist/ansi-mirror.d.ts.map +0 -1
- package/dist/besteffort.d.ts.map +0 -1
- package/dist/billing/entrypoint-guard.d.ts.map +0 -1
- package/dist/claude-path.d.ts.map +0 -1
- package/dist/diff-enriched-reader.d.ts.map +0 -1
- package/dist/diff-source.d.ts.map +0 -1
- package/dist/drift-checks.d.ts.map +0 -1
- package/dist/end-of-turn.d.ts.map +0 -1
- package/dist/engine-lifecycle.d.ts.map +0 -1
- package/dist/engine-pty.d.ts.map +0 -1
- package/dist/engine-watcher.d.ts.map +0 -1
- package/dist/engine.d.ts.map +0 -1
- package/dist/event-switch.d.ts.map +0 -1
- package/dist/gate/port.d.ts.map +0 -1
- package/dist/gate/settings-writer.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/jsonl.d.ts.map +0 -1
- package/dist/lib.d.ts.map +0 -1
- package/dist/linearize.d.ts.map +0 -1
- package/dist/live-diff-env.d.ts.map +0 -1
- package/dist/live-subagent-env.d.ts.map +0 -1
- package/dist/model-catalog.d.ts.map +0 -1
- package/dist/permissions/allow-inject.d.ts.map +0 -1
- package/dist/permissions/deny.d.ts.map +0 -1
- package/dist/permissions/gate-wiring.d.ts.map +0 -1
- package/dist/permissions/hook-server.d.ts.map +0 -1
- package/dist/permissions/permission-mode.d.ts.map +0 -1
- package/dist/permissions/request-permission.d.ts.map +0 -1
- package/dist/settings.d.ts.map +0 -1
- package/dist/stop-reason-map.d.ts.map +0 -1
- package/dist/subagent-gate.d.ts.map +0 -1
- package/dist/subagent-source.d.ts.map +0 -1
- package/dist/subagent-watcher.d.ts.map +0 -1
- package/dist/tools.d.ts.map +0 -1
- package/dist/usage-env.d.ts.map +0 -1
- package/dist/usage.d.ts.map +0 -1
- package/dist/utils.d.ts.map +0 -1
- package/dist/zed-register.d.ts.map +0 -1
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** A single discoverable main-thread agent persona, as advertised to the Zed `agent` selector. */
|
|
2
|
+
export interface AgentCatalogEntry {
|
|
3
|
+
/**
|
|
4
|
+
* The persona reference passed to `claude --agent <value>`. GUARANTEED to match
|
|
5
|
+
* {@link SAFE_AGENT_REF} (a single safe segment, or a namespaced `plugin:name`) — entries whose
|
|
6
|
+
* resolved name fails this are dropped at discovery, so this string is always safe to double-quote
|
|
7
|
+
* into a `-lc` shell command.
|
|
8
|
+
*/
|
|
9
|
+
value: string;
|
|
10
|
+
/**
|
|
11
|
+
* Human-facing label. From the probe path it is the raw reference (e.g. `epic:analyst`); from the
|
|
12
|
+
* glob path it is the frontmatter `name`, else a humanized form of the filename.
|
|
13
|
+
*/
|
|
14
|
+
displayName: string;
|
|
15
|
+
/** The frontmatter `description`, when present (glob path only — the probe lists names, not docs). */
|
|
16
|
+
description?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Injectable seams for {@link discoverAgents} — defaults wire the `node:` stdlib. Tests pass fakes so
|
|
20
|
+
* discovery is exercised against an in-memory fs / canned probe output, never the real disk, the real
|
|
21
|
+
* `~/.claude`, or the real `claude` binary.
|
|
22
|
+
*/
|
|
23
|
+
export interface DiscoverAgentsDeps {
|
|
24
|
+
/**
|
|
25
|
+
* Probe `claude` for its canonical agent list (the PRIMARY source). Returns the raw combined
|
|
26
|
+
* stdout+stderr of `claude --agent <invalid>` (which contains the `Available agents: …` line), or
|
|
27
|
+
* `null` when the probe cannot run (missing binary / timeout) so discovery falls back to the glob.
|
|
28
|
+
* Default: {@link defaultProbeClaudeAgents}. Tests inject a canned string (or `null`) to stay
|
|
29
|
+
* hermetic — a glob-path test MUST pass `() => null` to force the fallback.
|
|
30
|
+
*/
|
|
31
|
+
probeClaudeAgents?: () => string | null;
|
|
32
|
+
/** Resolve the user's home directory (default: `os.homedir`). Glob path only. */
|
|
33
|
+
homedir?: () => string;
|
|
34
|
+
/** List the `*.md` filenames in `dir`; MUST return `[]` when `dir` is missing/unreadable. */
|
|
35
|
+
readdirMd?: (dir: string) => string[];
|
|
36
|
+
/** Read a file's UTF-8 contents (default: `fs.readFileSync(p, "utf8")`). */
|
|
37
|
+
readFile?: (path: string) => string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The persona-SEGMENT allowlist (R3.3). A single un-namespaced segment: letters/digits/underscore/
|
|
41
|
+
* hyphen only (no spaces, no shell metacharacters, no path separators, no `:`). Guards the glob path
|
|
42
|
+
* (on-disk `.md` names) and is the per-segment building block of {@link SAFE_AGENT_REF}.
|
|
43
|
+
*/
|
|
44
|
+
export declare const SAFE_AGENT_NAME: RegExp;
|
|
45
|
+
/** True iff `name` is a non-empty string matching {@link SAFE_AGENT_NAME} (R3.3). */
|
|
46
|
+
export declare function isSafeAgentName(name: unknown): name is string;
|
|
47
|
+
/**
|
|
48
|
+
* The persona-REFERENCE allowlist (R3.6). A `--agent "<value>"` reference is either a single safe
|
|
49
|
+
* segment (a built-in like `Explore`, or a user/project `.md` persona) OR a namespaced plugin persona
|
|
50
|
+
* `plugin:name` — exactly ONE optional `:`-separated second segment, each segment a
|
|
51
|
+
* {@link SAFE_AGENT_NAME}. The `:` is inert inside the double-quoted shell argument.
|
|
52
|
+
*/
|
|
53
|
+
export declare const SAFE_AGENT_REF: RegExp;
|
|
54
|
+
/** True iff `name` is a non-empty string matching {@link SAFE_AGENT_REF} (R3.6). */
|
|
55
|
+
export declare function isSafeAgentRef(name: unknown): name is string;
|
|
56
|
+
/**
|
|
57
|
+
* Extract the agent names from a `claude --agent <invalid>` probe output. Matches the
|
|
58
|
+
* `Available agents: a, b, c` line (tolerant of leading text and a trailing newline — `.` stops at the
|
|
59
|
+
* newline so only that line is captured), splits on commas, trims, and drops empties. Returns `[]` when
|
|
60
|
+
* the line is absent (format changed → the caller falls back to the glob).
|
|
61
|
+
*/
|
|
62
|
+
export declare function parseAvailableAgents(probeOutput: string): string[];
|
|
63
|
+
/** Frontmatter fields we extract — only `name`/`description` matter for the catalog. */
|
|
64
|
+
interface Frontmatter {
|
|
65
|
+
name?: string;
|
|
66
|
+
description?: string;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Minimal `---`-fenced frontmatter line-parser (NOT a full YAML dep). Reads the leading
|
|
70
|
+
* `---\n … \n---` block and pulls `key: value` lines for the keys we care about; surrounding quotes on
|
|
71
|
+
* a value are stripped. A file without an opening `---` fence yields `{}` (filename-fallback applies).
|
|
72
|
+
* Tiny + pure on purpose — the persona frontmatter we consume is flat `key: value`.
|
|
73
|
+
*/
|
|
74
|
+
export declare function parseFrontmatter(content: string): Frontmatter;
|
|
75
|
+
/**
|
|
76
|
+
* Discover the main-thread agent personas selectable via `claude --agent <name>`.
|
|
77
|
+
*
|
|
78
|
+
* PRIMARY (Task 7, R3.5): the {@link DiscoverAgentsDeps.probeClaudeAgents} probe asks `claude` for its
|
|
79
|
+
* canonical list (enabled plugin personas + built-ins, exactly as `claude --agent` resolves them) and
|
|
80
|
+
* we parse the `Available agents:` line; the bare `claude` default and any name failing the R3.6
|
|
81
|
+
* reference allowlist are dropped, and the rest are deduped + sorted by `value`. When the probe yields
|
|
82
|
+
* at least one persona, that is the result.
|
|
83
|
+
*
|
|
84
|
+
* FALLBACK (R3.1): when the probe returns `null` (binary missing / timeout) OR its output has no
|
|
85
|
+
* `Available agents:` line OR every listed name was dropped, discovery degrades to the on-disk glob
|
|
86
|
+
* ({@link globDiscoverAgents}).
|
|
87
|
+
*
|
|
88
|
+
* Never throws: a failed probe, a missing/unreadable dir or file, or an empty/all-unsafe result yields
|
|
89
|
+
* `[]` (the empty-state that hides the picker in a later sub-task).
|
|
90
|
+
*
|
|
91
|
+
* @param cwd the SESSION cwd (project dir) whose `.claude/agents` takes precedence in the fallback.
|
|
92
|
+
* @param deps injectable probe/fs/home seams (default: `node:` stdlib + the real `claude` probe).
|
|
93
|
+
*/
|
|
94
|
+
export declare function discoverAgents(cwd: string, deps?: DiscoverAgentsDeps): AgentCatalogEntry[];
|
|
95
|
+
export {};
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// Story 056 / Task 3.1 + Task 7 (R3.1, R3.3, R3.5, R3.6) — main-thread agent-persona discovery for
|
|
2
|
+
// the Zed Agent Panel's `agent` selector.
|
|
3
|
+
//
|
|
4
|
+
// HYBRID DISCOVERY (refined in Task 7 — was glob-only):
|
|
5
|
+
// PRIMARY (R3.5): ask `claude` itself. We spawn `claude --agent <invalid-sentinel>` through a SAFE
|
|
6
|
+
// args-array child process (NO shell, 8s timeout, EMPTY stdin ⇒ no prompt ⇒ NO tokens/billing).
|
|
7
|
+
// `claude` rejects the sentinel and prints `Available agents: a, b, c` — listing EXACTLY what
|
|
8
|
+
// `claude --agent` accepts: enabled plugin personas (namespaced `plugin:name`) AND the built-ins
|
|
9
|
+
// (Explore, Plan, general-purpose, …). This is the same source the upstream SDK engine uses, so
|
|
10
|
+
// the panel reaches real parity with the original. LIVE-VERIFIED against 2.1.195 (deterministic,
|
|
11
|
+
// cwd-independent). The bare `claude` built-in = the no-persona default and is dropped (it maps to
|
|
12
|
+
// the option's existing "default" sentinel). The spawn mirrors claude-path.ts (execFileSync,
|
|
13
|
+
// args-list, NEVER a shell — C5); the only argument is a CONSTANT sentinel, never user input.
|
|
14
|
+
// FALLBACK (R3.1): when the probe cannot run (binary missing / timeout) OR the error format changes
|
|
15
|
+
// (no `Available agents:` line on a future version), discovery degrades to a glob of the on-disk
|
|
16
|
+
// persona files: `<cwd>/.claude/agents/*.md` (project — PRECEDENCE) + `~/.claude/agents/*.md`
|
|
17
|
+
// (user). Both dirs empty (the common case) yields `[]` and a later sub-task's gate hides the
|
|
18
|
+
// picker entirely.
|
|
19
|
+
//
|
|
20
|
+
// NOTE: `claude agents` (the subcommand) is the *background-agents* manager — it lists dispatched
|
|
21
|
+
// cloud SESSIONS, NOT persona definitions — so we never call it; the persona list comes from the
|
|
22
|
+
// `--agent` rejection message above.
|
|
23
|
+
//
|
|
24
|
+
// SECURITY (R3.3 / R3.6 — the highest-severity item). The `value` we return is interpolated into a
|
|
25
|
+
// `-lc` shell string via `--agent "<name>"` (Task 3.3). A plugin persona is NAMESPACED (`plugin:name`),
|
|
26
|
+
// so the boundary allowlist is {@link SAFE_AGENT_REF} = `/^[A-Za-z0-9_-]+(?::[A-Za-z0-9_-]+)?$/`: one
|
|
27
|
+
// optional `:`-separated segment, each segment letters/digits/`_`/`-` only (the `:` is inert inside the
|
|
28
|
+
// double-quoted argument). Every resolved name is checked and DROPPED if it fails — a name with spaces,
|
|
29
|
+
// quotes, path separators, command substitution, chaining, … is NEVER returned. The second layer is
|
|
30
|
+
// double-quoting at spawn time. {@link SAFE_AGENT_NAME} (single segment) still guards the glob path,
|
|
31
|
+
// where on-disk `.md` persona names are always un-namespaced.
|
|
32
|
+
//
|
|
33
|
+
// PURE + dependency-injectable (cf. claude-path.ts / usage-env.ts): all fs/home/probe access goes
|
|
34
|
+
// through the {@link DiscoverAgentsDeps} seams so the unit tests inject fakes and never touch the real
|
|
35
|
+
// disk or spawn the real `claude` binary. The only `node:` builtins are os/fs/path/child_process; no
|
|
36
|
+
// shell is ever invoked. Dependency-free: a minimal inline frontmatter line-parser stands in for a YAML
|
|
37
|
+
// dependency (FORK.md pins node-pty + the SDK as the only runtime deps; this adds none).
|
|
38
|
+
//
|
|
39
|
+
// node:test runner: `node --experimental-strip-types --test test/agent-catalog.test.ts`
|
|
40
|
+
import { homedir as osHomedir } from "node:os";
|
|
41
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
42
|
+
import { execFileSync } from "node:child_process";
|
|
43
|
+
import { join } from "node:path";
|
|
44
|
+
/**
|
|
45
|
+
* The persona-SEGMENT allowlist (R3.3). A single un-namespaced segment: letters/digits/underscore/
|
|
46
|
+
* hyphen only (no spaces, no shell metacharacters, no path separators, no `:`). Guards the glob path
|
|
47
|
+
* (on-disk `.md` names) and is the per-segment building block of {@link SAFE_AGENT_REF}.
|
|
48
|
+
*/
|
|
49
|
+
export const SAFE_AGENT_NAME = /^[A-Za-z0-9_-]+$/;
|
|
50
|
+
/** True iff `name` is a non-empty string matching {@link SAFE_AGENT_NAME} (R3.3). */
|
|
51
|
+
export function isSafeAgentName(name) {
|
|
52
|
+
return typeof name === "string" && SAFE_AGENT_NAME.test(name);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The persona-REFERENCE allowlist (R3.6). A `--agent "<value>"` reference is either a single safe
|
|
56
|
+
* segment (a built-in like `Explore`, or a user/project `.md` persona) OR a namespaced plugin persona
|
|
57
|
+
* `plugin:name` — exactly ONE optional `:`-separated second segment, each segment a
|
|
58
|
+
* {@link SAFE_AGENT_NAME}. The `:` is inert inside the double-quoted shell argument.
|
|
59
|
+
*/
|
|
60
|
+
export const SAFE_AGENT_REF = /^[A-Za-z0-9_-]+(?::[A-Za-z0-9_-]+)?$/;
|
|
61
|
+
/** True iff `name` is a non-empty string matching {@link SAFE_AGENT_REF} (R3.6). */
|
|
62
|
+
export function isSafeAgentRef(name) {
|
|
63
|
+
return typeof name === "string" && SAFE_AGENT_REF.test(name);
|
|
64
|
+
}
|
|
65
|
+
/** A sentinel `--agent` value that can never be a real persona — forces the "not found" + list. */
|
|
66
|
+
const PROBE_SENTINEL = "__acp_agent_probe_sentinel__";
|
|
67
|
+
/** The bare built-in `claude` = the no-persona default (the option's existing "default" sentinel). */
|
|
68
|
+
const DEFAULT_PERSONA = "claude";
|
|
69
|
+
/**
|
|
70
|
+
* Default {@link DiscoverAgentsDeps.probeClaudeAgents}: spawn `claude --agent <sentinel>` and return
|
|
71
|
+
* its combined output. `execFileSync` (args-array — NO shell, mirroring claude-path.ts / C5) throws on
|
|
72
|
+
* the expected non-zero exit; the `Available agents: …` line is carried on the error's stdout/stderr.
|
|
73
|
+
* Empty stdin + an 8s timeout keep it cheap and tokenless. Any failure (missing binary, timeout,
|
|
74
|
+
* unexpected shape) → `null`, which routes the caller to the glob fallback. The sole argument is the
|
|
75
|
+
* CONSTANT {@link PROBE_SENTINEL} — never user input — so there is no injection surface.
|
|
76
|
+
*
|
|
77
|
+
* OPT-IN (`FORK_AGENT_PROBE=1`): the spawn runs ONLY when production enables it (the fork entrypoint
|
|
78
|
+
* `index.ts` sets the flag). Unit tests import this module directly WITHOUT the flag, so the default
|
|
79
|
+
* probe is a no-op (`null` → glob fallback) — they never spawn the real `claude` and stay hermetic and
|
|
80
|
+
* fast. Tests that DO exercise the probe path inject {@link DiscoverAgentsDeps.probeClaudeAgents}
|
|
81
|
+
* directly, bypassing this gate.
|
|
82
|
+
*/
|
|
83
|
+
function defaultProbeClaudeAgents() {
|
|
84
|
+
if (process.env.FORK_AGENT_PROBE !== "1")
|
|
85
|
+
return null; // opt-in — production (index.ts) enables it
|
|
86
|
+
try {
|
|
87
|
+
// stdio: stdin IGNORED (claude reads EOF → no hang, no prompt → no tokens); stdout+stderr PIPED so
|
|
88
|
+
// the `Available agents:` line (claude writes it to stderr) is captured, NEVER leaked to the fork's
|
|
89
|
+
// own stdout (the ACP wire) or stderr (the log). The list lands on the throw's stderr below.
|
|
90
|
+
const out = execFileSync("claude", ["--agent", PROBE_SENTINEL], {
|
|
91
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
92
|
+
timeout: 8000,
|
|
93
|
+
encoding: "utf8",
|
|
94
|
+
maxBuffer: 1024 * 1024,
|
|
95
|
+
});
|
|
96
|
+
return out.length > 0 ? out : null;
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
const e = err;
|
|
100
|
+
const out = `${e?.stdout ?? ""}${e?.stderr ?? ""}`;
|
|
101
|
+
return out.length > 0 ? out : null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Extract the agent names from a `claude --agent <invalid>` probe output. Matches the
|
|
106
|
+
* `Available agents: a, b, c` line (tolerant of leading text and a trailing newline — `.` stops at the
|
|
107
|
+
* newline so only that line is captured), splits on commas, trims, and drops empties. Returns `[]` when
|
|
108
|
+
* the line is absent (format changed → the caller falls back to the glob).
|
|
109
|
+
*/
|
|
110
|
+
export function parseAvailableAgents(probeOutput) {
|
|
111
|
+
const m = /Available agents:\s*(.+)/.exec(probeOutput);
|
|
112
|
+
if (!m)
|
|
113
|
+
return [];
|
|
114
|
+
return m[1]
|
|
115
|
+
.split(",")
|
|
116
|
+
.map((s) => s.trim())
|
|
117
|
+
.filter((s) => s.length > 0);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Resolve a probe-listed agent name into an entry, or `null` to drop it. The bare `claude` built-in is
|
|
121
|
+
* the no-persona default (mapped to the option's existing "default" sentinel) so it is dropped; any
|
|
122
|
+
* name failing the R3.6 reference allowlist is dropped. `displayName` is the raw reference (e.g.
|
|
123
|
+
* `epic:analyst`), which is what the panel shows.
|
|
124
|
+
*/
|
|
125
|
+
function entryFromProbeName(name) {
|
|
126
|
+
if (name === DEFAULT_PERSONA)
|
|
127
|
+
return null;
|
|
128
|
+
if (!isSafeAgentRef(name))
|
|
129
|
+
return null;
|
|
130
|
+
return { value: name, displayName: name };
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Default `readdirMd`: list `*.md` filenames in `dir`, returning `[]` when the directory is missing or
|
|
134
|
+
* unreadable (the empty-state — never throws). Sorted for a stable first-wins ordering within a dir.
|
|
135
|
+
*/
|
|
136
|
+
function defaultReaddirMd(dir) {
|
|
137
|
+
try {
|
|
138
|
+
return readdirSync(dir)
|
|
139
|
+
.filter((f) => f.toLowerCase().endsWith(".md"))
|
|
140
|
+
.sort();
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** Default `readFile`: UTF-8 file read via the `node:fs` stdlib. */
|
|
147
|
+
function defaultReadFile(path) {
|
|
148
|
+
return readFileSync(path, "utf8");
|
|
149
|
+
}
|
|
150
|
+
/** The leading `name` (sans `.md`) of a persona file, used as the value/displayName fallback. */
|
|
151
|
+
function baseName(filename) {
|
|
152
|
+
return filename.replace(/\.md$/i, "");
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Humanize a filename stem into a display label when the frontmatter has no `name`:
|
|
156
|
+
* `code-reviewer` → `Code Reviewer`, `db_admin` → `Db Admin`. Pure string cosmetics.
|
|
157
|
+
*/
|
|
158
|
+
function humanize(stem) {
|
|
159
|
+
return stem
|
|
160
|
+
.split(/[-_]+/)
|
|
161
|
+
.filter((w) => w.length > 0)
|
|
162
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
163
|
+
.join(" ");
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Minimal `---`-fenced frontmatter line-parser (NOT a full YAML dep). Reads the leading
|
|
167
|
+
* `---\n … \n---` block and pulls `key: value` lines for the keys we care about; surrounding quotes on
|
|
168
|
+
* a value are stripped. A file without an opening `---` fence yields `{}` (filename-fallback applies).
|
|
169
|
+
* Tiny + pure on purpose — the persona frontmatter we consume is flat `key: value`.
|
|
170
|
+
*/
|
|
171
|
+
export function parseFrontmatter(content) {
|
|
172
|
+
// The opening fence must be the very first line (allowing a leading BOM / blank lines is overkill
|
|
173
|
+
// for this format). Bail to `{}` when there is no fenced block.
|
|
174
|
+
const lines = content.split(/\r?\n/);
|
|
175
|
+
if (lines[0]?.trim() !== "---")
|
|
176
|
+
return {};
|
|
177
|
+
const out = {};
|
|
178
|
+
for (let i = 1; i < lines.length; i++) {
|
|
179
|
+
const line = lines[i];
|
|
180
|
+
if (line.trim() === "---")
|
|
181
|
+
break; // closing fence — stop
|
|
182
|
+
const m = /^([A-Za-z][\w-]*)\s*:\s*(.*)$/.exec(line);
|
|
183
|
+
if (!m)
|
|
184
|
+
continue; // not a `key: value` line — skip (lists/comments/blanks)
|
|
185
|
+
const key = m[1].toLowerCase();
|
|
186
|
+
if (key !== "name" && key !== "description")
|
|
187
|
+
continue;
|
|
188
|
+
let value = m[2].trim();
|
|
189
|
+
// Strip a single pair of matching surrounding quotes.
|
|
190
|
+
if (value.length >= 2 &&
|
|
191
|
+
((value.startsWith('"') && value.endsWith('"')) ||
|
|
192
|
+
(value.startsWith("'") && value.endsWith("'")))) {
|
|
193
|
+
value = value.slice(1, -1);
|
|
194
|
+
}
|
|
195
|
+
if (value.length > 0)
|
|
196
|
+
out[key] = value;
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Resolve a single persona `*.md` file into an {@link AgentCatalogEntry}, or `null` when it cannot
|
|
202
|
+
* contribute a SAFE entry. The resolved name is the frontmatter `name` if present, else the filename
|
|
203
|
+
* stem; it is then run through the R3.3 allowlist and the file is DROPPED (→ `null`) on any failure —
|
|
204
|
+
* including an unreadable file. `displayName` falls back to a humanized stem.
|
|
205
|
+
*/
|
|
206
|
+
function resolveEntry(dir, filename, readFile) {
|
|
207
|
+
let fm;
|
|
208
|
+
try {
|
|
209
|
+
fm = parseFrontmatter(readFile(join(dir, filename)));
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return null; // unreadable file — skip it, never throw (R3.1 graceful)
|
|
213
|
+
}
|
|
214
|
+
const stem = baseName(filename);
|
|
215
|
+
const resolvedName = fm.name ?? stem;
|
|
216
|
+
// SECURITY (R3.3): drop any entry whose name escapes the single-segment allowlist — never returned.
|
|
217
|
+
if (!isSafeAgentName(resolvedName))
|
|
218
|
+
return null;
|
|
219
|
+
return {
|
|
220
|
+
value: resolvedName,
|
|
221
|
+
displayName: fm.name ?? humanize(stem),
|
|
222
|
+
...(fm.description ? { description: fm.description } : {}),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
/** Sort entries by `value` for a stable, scope-independent ordering. */
|
|
226
|
+
function sortByValue(entries) {
|
|
227
|
+
return entries.sort((a, b) => (a.value < b.value ? -1 : a.value > b.value ? 1 : 0));
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* FALLBACK glob discovery (R3.1): scan `<cwd>/.claude/agents/*.md` (project — PRECEDENCE) and
|
|
231
|
+
* `~/.claude/agents/*.md` (user), parsing frontmatter `name`/`description` (filename stem as the
|
|
232
|
+
* fallback name), sanitizing against the R3.3 allowlist, deduping by `value` (project beats user;
|
|
233
|
+
* first-wins within a dir), and sorting by `value`. Never throws.
|
|
234
|
+
*/
|
|
235
|
+
function globDiscoverAgents(cwd, deps) {
|
|
236
|
+
const homedir = deps.homedir ?? osHomedir;
|
|
237
|
+
const readdirMd = deps.readdirMd ?? defaultReaddirMd;
|
|
238
|
+
const readFile = deps.readFile ?? defaultReadFile;
|
|
239
|
+
const projectDir = join(cwd, ".claude", "agents");
|
|
240
|
+
const userDir = join(homedir(), ".claude", "agents");
|
|
241
|
+
const byValue = new Map();
|
|
242
|
+
for (const dir of [projectDir, userDir]) {
|
|
243
|
+
for (const filename of readdirMd(dir)) {
|
|
244
|
+
const entry = resolveEntry(dir, filename, readFile);
|
|
245
|
+
if (entry && !byValue.has(entry.value)) {
|
|
246
|
+
byValue.set(entry.value, entry);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return sortByValue([...byValue.values()]);
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Discover the main-thread agent personas selectable via `claude --agent <name>`.
|
|
254
|
+
*
|
|
255
|
+
* PRIMARY (Task 7, R3.5): the {@link DiscoverAgentsDeps.probeClaudeAgents} probe asks `claude` for its
|
|
256
|
+
* canonical list (enabled plugin personas + built-ins, exactly as `claude --agent` resolves them) and
|
|
257
|
+
* we parse the `Available agents:` line; the bare `claude` default and any name failing the R3.6
|
|
258
|
+
* reference allowlist are dropped, and the rest are deduped + sorted by `value`. When the probe yields
|
|
259
|
+
* at least one persona, that is the result.
|
|
260
|
+
*
|
|
261
|
+
* FALLBACK (R3.1): when the probe returns `null` (binary missing / timeout) OR its output has no
|
|
262
|
+
* `Available agents:` line OR every listed name was dropped, discovery degrades to the on-disk glob
|
|
263
|
+
* ({@link globDiscoverAgents}).
|
|
264
|
+
*
|
|
265
|
+
* Never throws: a failed probe, a missing/unreadable dir or file, or an empty/all-unsafe result yields
|
|
266
|
+
* `[]` (the empty-state that hides the picker in a later sub-task).
|
|
267
|
+
*
|
|
268
|
+
* @param cwd the SESSION cwd (project dir) whose `.claude/agents` takes precedence in the fallback.
|
|
269
|
+
* @param deps injectable probe/fs/home seams (default: `node:` stdlib + the real `claude` probe).
|
|
270
|
+
*/
|
|
271
|
+
export function discoverAgents(cwd, deps = {}) {
|
|
272
|
+
// PRIMARY: ask `claude` — the source of truth for what `--agent` accepts.
|
|
273
|
+
const probe = deps.probeClaudeAgents ?? defaultProbeClaudeAgents;
|
|
274
|
+
const probeOut = probe();
|
|
275
|
+
if (probeOut) {
|
|
276
|
+
const byValue = new Map();
|
|
277
|
+
for (const name of parseAvailableAgents(probeOut)) {
|
|
278
|
+
const entry = entryFromProbeName(name);
|
|
279
|
+
if (entry && !byValue.has(entry.value))
|
|
280
|
+
byValue.set(entry.value, entry);
|
|
281
|
+
}
|
|
282
|
+
if (byValue.size > 0)
|
|
283
|
+
return sortByValue([...byValue.values()]);
|
|
284
|
+
}
|
|
285
|
+
// FALLBACK: the probe is unavailable or its format changed — glob the on-disk personas.
|
|
286
|
+
return globDiscoverAgents(cwd, deps);
|
|
287
|
+
}
|
package/dist/ansi-mirror.d.ts
CHANGED
package/dist/besteffort.d.ts
CHANGED
|
@@ -94,4 +94,3 @@ export declare function guardEvent(event: WatchedMessage, hooks: GuardHooks): Gu
|
|
|
94
94
|
* is the spawn helper's anti-recusa concern, distinct from this billing check — §10).
|
|
95
95
|
*/
|
|
96
96
|
export declare function assertCleanBillingEnv(env: Record<string, string | undefined>): void;
|
|
97
|
-
//# sourceMappingURL=entrypoint-guard.d.ts.map
|
package/dist/claude-path.d.ts
CHANGED
package/dist/claude-path.js
CHANGED
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
import { execFileSync } from "node:child_process";
|
|
27
27
|
import { accessSync, constants } from "node:fs";
|
|
28
28
|
import { delimiter, join } from "node:path";
|
|
29
|
+
// Story 058 R3.2 — version-aware image-vision smoke (observability only; NEVER gates image:true, R3.1).
|
|
30
|
+
import { reportImageVisionSmoke } from "./image-vision-smoke.js";
|
|
29
31
|
/**
|
|
30
32
|
* Provenance pin the live `claude` version is measured against — kept in lockstep
|
|
31
33
|
* with `fork/.fork-provenance.json`'s externalRuntimeDeps `claude` entry. A live
|
|
@@ -121,6 +123,10 @@ export function resolveClaudePath(opts = {}) {
|
|
|
121
123
|
log(`[claude-path] resolved claude ${candidate} (version ${detected})`);
|
|
122
124
|
}
|
|
123
125
|
reportVersionDrift(detected, PROVENANCE_CLAUDE_VERSION, log);
|
|
126
|
+
// Story 058 R3.2 — version-aware @image vision smoke: warn ONCE (one-liner) when this claude
|
|
127
|
+
// is NOT a version confirmed to vision-encode @image via Read. Observability only — it never
|
|
128
|
+
// blocks and never touches promptCapabilities.image (stays `image: true`, R3.1).
|
|
129
|
+
reportImageVisionSmoke(detected, log);
|
|
124
130
|
}
|
|
125
131
|
catch {
|
|
126
132
|
// Observability is best-effort; never let it break resolution.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { AvailableCommand } from "@agentclientprotocol/sdk";
|
|
2
|
+
/**
|
|
3
|
+
* The curated built-in slash-command tier (R9) — the interactive `claude` TUI's own built-ins, which are
|
|
4
|
+
* baked into the binary and therefore NOT disk-discoverable. This is a CURATED APPROXIMATION (C1), NOT a
|
|
5
|
+
* live probe: it stands beside the same static-curation idea as `MODEL_CATALOG`. It is the LOWEST-
|
|
6
|
+
* precedence tier, so any disk command/skill of the same name shadows a built-in (R1/R9). Names are
|
|
7
|
+
* lowercase (R3) and every entry carries a non-empty `description` (the SDK requires it). The list is
|
|
8
|
+
* intentionally small and conservative — a documented approximation, not an exhaustive enumeration.
|
|
9
|
+
*/
|
|
10
|
+
export declare const BUILTIN_COMMANDS: readonly AvailableCommand[];
|
|
11
|
+
/**
|
|
12
|
+
* Injectable seams for {@link discoverCommands} — defaults wire the `node:` stdlib and the built-in
|
|
13
|
+
* tier. Tests pass fakes so discovery is exercised against an in-memory fs and an isolated (`[]`)
|
|
14
|
+
* built-in tier, never the real disk or the real `~/.claude` (R5).
|
|
15
|
+
*/
|
|
16
|
+
export interface DiscoverCommandsDeps {
|
|
17
|
+
/** Resolve the user's home directory (default: `os.homedir`). */
|
|
18
|
+
homedir?: () => string;
|
|
19
|
+
/** List the `*.md` filenames in `dir`; MUST return `[]` when `dir` is missing/unreadable. */
|
|
20
|
+
readdirMd?: (dir: string) => string[];
|
|
21
|
+
/**
|
|
22
|
+
* List the IMMEDIATE sub-directory names of `dir` (for the skills tier — R7); MUST return `[]` when
|
|
23
|
+
* `dir` is missing/unreadable. Default: {@link defaultReaddirDirs}.
|
|
24
|
+
*/
|
|
25
|
+
readdirDirs?: (dir: string) => string[];
|
|
26
|
+
/** Read a file's UTF-8 contents (default: `fs.readFileSync(p, "utf8")`). */
|
|
27
|
+
readFile?: (path: string) => string;
|
|
28
|
+
/** The built-in command tier (lowest precedence). Default: {@link BUILTIN_COMMANDS}. */
|
|
29
|
+
builtins?: readonly AvailableCommand[];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The command-name allowlist (R3). A single un-namespaced segment: LOWERCASE letters/digits/underscore/
|
|
33
|
+
* hyphen only (no spaces, no uppercase, no shell metacharacters, no path separators). Slash-command
|
|
34
|
+
* names are conventionally lowercase, so — unlike `agent-catalog.ts`'s {@link SAFE_AGENT_NAME} — this
|
|
35
|
+
* allowlist excludes uppercase.
|
|
36
|
+
*/
|
|
37
|
+
export declare const SAFE_COMMAND_NAME: RegExp;
|
|
38
|
+
/** True iff `name` is a non-empty string matching {@link SAFE_COMMAND_NAME} (R3). */
|
|
39
|
+
export declare function isSafeCommandName(name: unknown): name is string;
|
|
40
|
+
/** The frontmatter fields we extract — `description` and (hyphenated) `argument-hint`. */
|
|
41
|
+
interface CommandFrontmatter {
|
|
42
|
+
description?: string;
|
|
43
|
+
argumentHint?: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Minimal `---`-fenced frontmatter line-parser (NOT a full YAML dep). Reads the leading `---\n … \n---`
|
|
47
|
+
* block and pulls the `description` and `argument-hint` `key: value` lines; a single pair of surrounding
|
|
48
|
+
* quotes on a value is stripped. The frontmatter key `argument-hint` (a HYPHEN) is returned as the
|
|
49
|
+
* camelCase {@link CommandFrontmatter.argumentHint}. A file whose first line is not `---` yields `{}`.
|
|
50
|
+
* Tiny + pure on purpose, mirroring `agent-catalog.ts`'s `parseFrontmatter` — the command frontmatter we
|
|
51
|
+
* consume is flat `key: value`.
|
|
52
|
+
*/
|
|
53
|
+
export declare function parseCommandFrontmatter(content: string): CommandFrontmatter;
|
|
54
|
+
/**
|
|
55
|
+
* Discover the custom slash-commands to advertise via ACP `available_commands` — FULLY OFFLINE (no
|
|
56
|
+
* subprocess, no network).
|
|
57
|
+
*
|
|
58
|
+
* TIERS, highest precedence first:
|
|
59
|
+
* 1. `<cwd>/.claude/commands/*.md` (cwd commands — R1.1 precedence)
|
|
60
|
+
* 2. `<cwd>/.claude/skills/<name>/SKILL.md` (cwd skills — R7)
|
|
61
|
+
* 3. `~/.claude/commands/*.md` (user commands)
|
|
62
|
+
* 4. `~/.claude/skills/<name>/SKILL.md` (user skills — R7)
|
|
63
|
+
* 5. `~/.claude/plugins/marketplaces/<m>/{commands,skills}` (ENABLED-plugin tier — R8; a plugin
|
|
64
|
+
* surface loses a name collision to any higher tier — R8.1)
|
|
65
|
+
* 6. {@link DiscoverCommandsDeps.builtins} (the R9 built-in tier — LOWEST; a built-in loses a name
|
|
66
|
+
* collision to any disk entry)
|
|
67
|
+
*
|
|
68
|
+
* Each command `*.md` file yields `{ name, description, input? }` (R2): `name` = basename sans `.md`,
|
|
69
|
+
* `description` = frontmatter `description` (else `""`), `input: { hint }` from `argument-hint` (omitted
|
|
70
|
+
* when absent). Each skill `<name>/SKILL.md` yields `{ name, description }` (no `input`): `name` = the
|
|
71
|
+
* DIRECTORY name, `description` = the `SKILL.md` frontmatter (R7 / R7.1). Every name is dropped if it
|
|
72
|
+
* fails the R3 {@link SAFE_COMMAND_NAME} allowlist. Names are deduped FIRST-WINS across the ordered
|
|
73
|
+
* surfaces — so a cwd command out-ranks a cwd skill (cmd > skill, same scope), any cwd surface
|
|
74
|
+
* out-ranks any user surface, an enabled-plugin surface out-ranks the built-ins but LOSES to any
|
|
75
|
+
* cwd/user surface (R8.1) — then the merged set is returned FLAT ALPHABETICAL by `name`.
|
|
76
|
+
*
|
|
77
|
+
* Never throws (R3 / R5): a missing/unreadable dir or file yields `[]` for that surface; a skills subdir
|
|
78
|
+
* without a readable `SKILL.md` is skipped.
|
|
79
|
+
*
|
|
80
|
+
* @param cwd the SESSION cwd (project dir) whose `.claude/{commands,skills}` take precedence.
|
|
81
|
+
* @param deps injectable fs/home/builtins seams (default: `node:` stdlib + {@link BUILTIN_COMMANDS}).
|
|
82
|
+
*/
|
|
83
|
+
export declare function discoverCommands(cwd: string, deps?: DiscoverCommandsDeps): AvailableCommand[];
|
|
84
|
+
export {};
|