toolnexus 0.7.0 → 0.9.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/dist/client.d.ts CHANGED
@@ -32,6 +32,18 @@ export interface ClientOptions {
32
32
  * (open a browser, message a channel, watch a file, forward to another agent). Omit for a durable
33
33
  * host: run() does not hang — it returns { status:"pending", pending:request } to resume later. */
34
34
  waitFor?: (request: Request) => Promise<Answer>;
35
+ /** §8 Gap 1. Extra top-level keys shallow-merged into EVERY LLM body after the client builds
36
+ * its own — a requestParams key WINS on collision (e.g. max_tokens overrides the anthropic
37
+ * default). `messages`/`tools`/`stream` are forbidden here (stripped + warned) — rewrite
38
+ * messages via bodyTransform. Applied on all four paths. Omit ⇒ body byte-identical to today. */
39
+ requestParams?: Record<string, unknown>;
40
+ /** §8 Gap 1. Receives the fully assembled body (after requestParams merge) just before marshal
41
+ * and returns the body to send — the escape hatch for provider-specific rewriting (e.g. tool-role
42
+ * message preprocessing) without a proxy. Returning a falsy value ⇒ unchanged. Runs on every call. */
43
+ bodyTransform?: (body: Record<string, any>) => Record<string, any> | undefined | void;
44
+ /** §8 Gap 2. HTTP transport for LLM requests (retries included). Omit ⇒ global fetch. Scope is the
45
+ * LLM path only; MCP transports use the SDK's own client. */
46
+ fetch?: typeof fetch;
35
47
  }
36
48
  /**
37
49
  * Semantic observability events (§8). NOT counter/histogram primitives — readable records the
@@ -191,6 +203,23 @@ export declare class Client {
191
203
  /** Cumulative Prometheus registry fed by the same events `onMetric` sees. */
192
204
  private readonly registry;
193
205
  constructor(opts: ClientOptions);
206
+ /**
207
+ * §8 Gap 4. The client's conversation store — the exact instance passed in `opts.store`, else
208
+ * the default in-memory store the client created. Read (`get`) and write (`save`) it directly to
209
+ * share state with `ask`; no consumer needs a shadow copy.
210
+ */
211
+ conversationStore(): ConversationStore;
212
+ /**
213
+ * §8 Gap 1. Apply the request-shaping contract to an assembled body: strip forbidden keys from
214
+ * requestParams, shallow-merge requestParams (caller wins), then run bodyTransform last. Called at
215
+ * every LLM body-build site so the ordering contract holds identically on all four paths.
216
+ */
217
+ private finalizeBody;
218
+ /** §8 Gap 5. OpenAI-style body; omits tools/tool_choice when the tool list is empty. Key order
219
+ * matches the pre-change body so a no-options call is byte-identical. */
220
+ private openaiBody;
221
+ /** §8 Gap 5. Anthropic-style body; omits tools when the tool list is empty. */
222
+ private anthropicBody;
194
223
  /** Prometheus text exposition of cumulative metrics (§8). Always valid, empty-but-valid before activity. */
195
224
  metrics(): string;
196
225
  /** Feed a semantic metric event to the built-in registry and the optional onMetric sink. */
package/dist/client.js CHANGED
@@ -150,6 +150,60 @@ export class Client {
150
150
  this.opts = opts;
151
151
  this.store = opts.store ?? new InMemoryConversationStore();
152
152
  }
153
+ /**
154
+ * §8 Gap 4. The client's conversation store — the exact instance passed in `opts.store`, else
155
+ * the default in-memory store the client created. Read (`get`) and write (`save`) it directly to
156
+ * share state with `ask`; no consumer needs a shadow copy.
157
+ */
158
+ conversationStore() {
159
+ return this.store;
160
+ }
161
+ /**
162
+ * §8 Gap 1. Apply the request-shaping contract to an assembled body: strip forbidden keys from
163
+ * requestParams, shallow-merge requestParams (caller wins), then run bodyTransform last. Called at
164
+ * every LLM body-build site so the ordering contract holds identically on all four paths.
165
+ */
166
+ finalizeBody(body) {
167
+ const rp = this.opts.requestParams;
168
+ if (rp) {
169
+ for (const [k, v] of Object.entries(rp)) {
170
+ if (k === "messages" || k === "tools" || k === "stream") {
171
+ console.warn(`[toolnexus] requestParams key "${k}" is not allowed — ignored (use bodyTransform)`);
172
+ continue;
173
+ }
174
+ body[k] = v;
175
+ }
176
+ }
177
+ if (this.opts.bodyTransform) {
178
+ const out = this.opts.bodyTransform(body);
179
+ if (out)
180
+ return out;
181
+ }
182
+ return body;
183
+ }
184
+ /** §8 Gap 5. OpenAI-style body; omits tools/tool_choice when the tool list is empty. Key order
185
+ * matches the pre-change body so a no-options call is byte-identical. */
186
+ openaiBody(messages, tools, stream) {
187
+ const b = { model: this.opts.model, messages };
188
+ if (tools.length) {
189
+ b.tools = tools;
190
+ b.tool_choice = "auto";
191
+ }
192
+ if (stream) {
193
+ b.stream = true;
194
+ b.stream_options = { include_usage: true };
195
+ }
196
+ return this.finalizeBody(b);
197
+ }
198
+ /** §8 Gap 5. Anthropic-style body; omits tools when the tool list is empty. */
199
+ anthropicBody(system, messages, tools, stream) {
200
+ const b = { model: this.opts.model, max_tokens: 4096, system, messages };
201
+ if (tools.length)
202
+ b.tools = tools;
203
+ if (stream)
204
+ b.stream = true;
205
+ return this.finalizeBody(b);
206
+ }
153
207
  /** Prometheus text exposition of cumulative metrics (§8). Always valid, empty-but-valid before activity. */
154
208
  metrics() {
155
209
  return this.registry.render();
@@ -244,7 +298,7 @@ export class Client {
244
298
  let lastErr;
245
299
  for (let attempt = 0; attempt <= retries; attempt++) {
246
300
  try {
247
- const res = await fetch(url, { ...init, signal });
301
+ const res = await (this.opts.fetch ?? fetch)(url, { ...init, signal });
248
302
  if (res.ok || !RETRYABLE.has(res.status) || attempt === retries)
249
303
  return res;
250
304
  const ra = Number(res.headers.get("retry-after"));
@@ -341,7 +395,7 @@ export class Client {
341
395
  const data = await this.llmCallJson(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
342
396
  method: "POST",
343
397
  headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
344
- body: JSON.stringify({ model: this.opts.model, messages, tools, tool_choice: "auto" }),
398
+ body: JSON.stringify(this.openaiBody(messages, tools, false)),
345
399
  }, signal, "openai");
346
400
  addUsage(usage, data.usage, "openai");
347
401
  if (this.opts.hooks?.afterLLM)
@@ -442,7 +496,7 @@ export class Client {
442
496
  "Content-Type": "application/json",
443
497
  ...this.opts.headers,
444
498
  },
445
- body: JSON.stringify({ model: this.opts.model, max_tokens: 4096, system, messages, tools }),
499
+ body: JSON.stringify(this.anthropicBody(system, messages, tools, false)),
446
500
  }, signal, "anthropic");
447
501
  addUsage(usage, data.usage, "anthropic");
448
502
  if (this.opts.hooks?.afterLLM)
@@ -523,7 +577,7 @@ export class Client {
523
577
  const res = await this.llmFetch(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
524
578
  method: "POST",
525
579
  headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
526
- body: JSON.stringify({ model: this.opts.model, messages, tools, tool_choice: "auto", stream: true, stream_options: { include_usage: true } }),
580
+ body: JSON.stringify(this.openaiBody(messages, tools, true)),
527
581
  }, signal);
528
582
  if (!res.ok || !res.body)
529
583
  throw new Error(`LLM ${res.status}: ${await res.text()}`);
@@ -630,7 +684,7 @@ export class Client {
630
684
  const res = await this.llmFetch(endpoint, {
631
685
  method: "POST",
632
686
  headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "Content-Type": "application/json", ...this.opts.headers },
633
- body: JSON.stringify({ model: this.opts.model, max_tokens: 4096, system, messages, tools, stream: true }),
687
+ body: JSON.stringify(this.anthropicBody(system, messages, tools, true)),
634
688
  }, signal);
635
689
  if (!res.ok || !res.body)
636
690
  throw new Error(`LLM ${res.status}: ${await res.text()}`);
package/dist/mcp.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ElicitRequest, ElicitResult } from "@modelcontextprotocol/sdk/types.js";
2
- import { type Tool, type McpStatus, type Request, type Answer } from "./types.js";
2
+ import { type JSONSchema, type Tool, type McpStatus, type Request, type Answer } from "./types.js";
3
3
  /** Map an MCP `elicitation/create` request onto a §10 `Request`. */
4
4
  export declare function elicitationToRequest(params: ElicitRequest["params"]): Request;
5
5
  /** Map a resolved §10 `Answer` back onto an MCP `ElicitResult`. ok→accept; declined→decline; else→cancel. */
@@ -13,6 +13,10 @@ export interface LocalServer {
13
13
  enabled?: boolean;
14
14
  disabled?: boolean;
15
15
  timeout?: number;
16
+ /** §2 Gap 7. Per-server tool allowlist keyed on ORIGINAL tool name; same semantics as the
17
+ * builtins/skills filter (nil/empty ⇒ all; ≥1 true ⇒ allowlist; only-false ⇒ drop-list; unknown
18
+ * ⇒ ignore+warn). Applied to the listed defs before sanitize/prefix. */
19
+ tools?: Record<string, boolean>;
16
20
  }
17
21
  export interface RemoteServer {
18
22
  type?: "remote";
@@ -21,6 +25,8 @@ export interface RemoteServer {
21
25
  enabled?: boolean;
22
26
  disabled?: boolean;
23
27
  timeout?: number;
28
+ /** §2 Gap 7. Per-server tool allowlist (see LocalServer.tools). */
29
+ tools?: Record<string, boolean>;
24
30
  }
25
31
  export type ServerConfig = LocalServer | RemoteServer;
26
32
  export type McpConfig = Record<string, ServerConfig>;
@@ -36,7 +42,29 @@ export interface McpSource {
36
42
  status: Record<string, McpStatus>;
37
43
  close(): Promise<void>;
38
44
  }
39
- /** Connect to every enabled server, list + convert tools. Failures are isolated. */
45
+ /** Connect to every enabled server, list + convert tools. Failures are isolated.
46
+ * §2 Gap 3: an optional `signal` bounds each connect/list by the server timeout and aborts the
47
+ * whole load promptly on parent cancellation. */
40
48
  export declare function loadMcp(input: string | object, opts?: {
41
49
  waitFor?: (request: Request) => Promise<Answer>;
50
+ signal?: AbortSignal;
42
51
  }): Promise<McpSource>;
52
+ /** §2 Gap 6. One listed tool definition (original, unprefixed name). */
53
+ export interface ToolInfo {
54
+ name: string;
55
+ description: string;
56
+ inputSchema: JSONSchema;
57
+ }
58
+ /** §2 Gap 6. Result of a list-only pass over an MCP config. */
59
+ export interface McpInventory {
60
+ /** server → its full listed tool defs, UNFILTERED by ServerConfig.tools (authors the allowlist). */
61
+ tools: Record<string, ToolInfo[]>;
62
+ status: Record<string, McpStatus>;
63
+ }
64
+ /**
65
+ * §2 Gap 6. Connect to every enabled server, list tool definitions, and DISCONNECT before
66
+ * returning — no toolkit, no execute wiring, nothing left running. Failure isolation as in loadMcp.
67
+ */
68
+ export declare function listMcpTools(input: string | object, opts?: {
69
+ signal?: AbortSignal;
70
+ }): Promise<McpInventory>;
package/dist/mcp.js CHANGED
@@ -99,6 +99,42 @@ function joinTextContent(content) {
99
99
  .flatMap((item) => (isTextContent(item) ? [item.text] : []))
100
100
  .join("\n");
101
101
  }
102
+ /**
103
+ * §2 Gap 7. Apply a per-server tool allowlist to the listed defs. Same semantics as the builtins
104
+ * and skills filters: nil/empty ⇒ all; ≥1 true ⇒ allowlist (only true names); only-false ⇒ drop-list
105
+ * over all-on; unknown names ignored + warned once. Keyed on ORIGINAL tool name.
106
+ */
107
+ function applyToolsFilter(server, defs, filter) {
108
+ if (!filter)
109
+ return defs;
110
+ const keys = Object.keys(filter);
111
+ if (keys.length === 0)
112
+ return defs;
113
+ const hasTrue = keys.some((k) => filter[k] === true);
114
+ const present = new Set(defs.map((d) => d.name));
115
+ for (const k of keys) {
116
+ if (!present.has(k))
117
+ console.warn(`[toolnexus] server "${server}" tools filter name "${k}" matched no tool`);
118
+ }
119
+ return defs.filter((d) => (hasTrue ? filter[d.name] === true : filter[d.name] !== false));
120
+ }
121
+ /**
122
+ * §2 Gap 3. Race a promise against a per-server timeout and the caller's abort signal, so a hung
123
+ * connect/list is bounded (the SSE fallback included) and a parent cancellation aborts promptly.
124
+ */
125
+ function raceTimeout(p, ms, signal) {
126
+ return new Promise((resolve, reject) => {
127
+ const onAbort = () => { cleanup(); reject(signal?.reason ?? new Error("aborted")); };
128
+ const t = setTimeout(() => { cleanup(); reject(new Error("mcp connect/list timeout")); }, ms);
129
+ const cleanup = () => { clearTimeout(t); signal?.removeEventListener("abort", onAbort); };
130
+ if (signal) {
131
+ if (signal.aborted)
132
+ return onAbort();
133
+ signal.addEventListener("abort", onAbort, { once: true });
134
+ }
135
+ p.then((v) => { cleanup(); resolve(v); }, (e) => { cleanup(); reject(e); });
136
+ });
137
+ }
102
138
  async function paginateTools(client, timeout) {
103
139
  const result = [];
104
140
  const cursors = new Set();
@@ -144,50 +180,66 @@ function convertTool(server, def, client, timeout) {
144
180
  },
145
181
  };
146
182
  }
147
- /** Connect to every enabled server, list + convert tools. Failures are isolated. */
183
+ /** Create a fresh (not-yet-connected) client, with the elicitation bridge attached if a waitFor
184
+ * exists. Separated from connect so the caller ALWAYS holds a closeable reference — even if the
185
+ * connect is later abandoned by a timeout/abort, `client.close()` still terminates any spawned
186
+ * stdio child (no orphaned processes on cancellation). */
187
+ function newMcpClient(waitFor) {
188
+ const client = new Client({ name: "toolnexus", version: "0.1.0" }, { capabilities: waitFor ? { elicitation: {} } : {} });
189
+ if (waitFor) {
190
+ client.setRequestHandler(ElicitRequestSchema, async (request) => answerToElicitResult(await waitFor(elicitationToRequest(request.params))));
191
+ }
192
+ return client;
193
+ }
194
+ /** Connect an existing client's transport (remote streamable-HTTP with SSE fallback, or local stdio). */
195
+ async function connectTransport(client, cfg) {
196
+ if (isRemote(cfg)) {
197
+ const url = new URL(cfg.url);
198
+ const expanded = expandEnvHeaders(cfg.headers);
199
+ const requestInit = expanded ? { headers: expanded } : undefined;
200
+ try {
201
+ await client.connect(new StreamableHTTPClientTransport(url, { requestInit }));
202
+ }
203
+ catch {
204
+ await client.connect(new SSEClientTransport(url, { requestInit }));
205
+ }
206
+ }
207
+ else {
208
+ const local = cfg;
209
+ const [cmd, ...args] = local.command;
210
+ await client.connect(new StdioClientTransport({
211
+ command: cmd,
212
+ args,
213
+ cwd: local.cwd,
214
+ env: { ...process.env, ...(local.environment ?? local.env ?? {}) },
215
+ stderr: "pipe",
216
+ }));
217
+ }
218
+ }
219
+ /** Connect to every enabled server, list + convert tools. Failures are isolated.
220
+ * §2 Gap 3: an optional `signal` bounds each connect/list by the server timeout and aborts the
221
+ * whole load promptly on parent cancellation. */
148
222
  export async function loadMcp(input, opts) {
149
223
  const config = parseMcpConfig(input);
150
224
  const tools = [];
151
225
  const status = {};
152
226
  const clients = [];
153
227
  const waitFor = opts?.waitFor;
228
+ const signal = opts?.signal;
154
229
  await Promise.all(Object.entries(config).map(async ([name, cfg]) => {
155
230
  if (!isEnabled(cfg)) {
156
231
  status[name] = "disabled";
157
232
  return;
158
233
  }
159
234
  const timeout = cfg.timeout ?? DEFAULT_TIMEOUT;
160
- // Advertise elicitation + register the bridge ONLY when a waitFor exists to satisfy it —
161
- // a waitFor-less host degrades cleanly (the server won't elicit). (§10 elicitation bridge)
162
- const client = new Client({ name: "toolnexus", version: "0.1.0" }, { capabilities: waitFor ? { elicitation: {} } : {} });
163
- if (waitFor) {
164
- client.setRequestHandler(ElicitRequestSchema, async (request) => answerToElicitResult(await waitFor(elicitationToRequest(request.params))));
165
- }
235
+ // Create the client up front so a timed-out/aborted connect still has a closeable handle
236
+ // (its stdio child is killed by close() — never orphaned).
237
+ const client = newMcpClient(waitFor);
166
238
  try {
167
- if (isRemote(cfg)) {
168
- const url = new URL(cfg.url);
169
- const expanded = expandEnvHeaders(cfg.headers);
170
- const requestInit = expanded ? { headers: expanded } : undefined;
171
- try {
172
- await client.connect(new StreamableHTTPClientTransport(url, { requestInit }));
173
- }
174
- catch {
175
- await client.connect(new SSEClientTransport(url, { requestInit }));
176
- }
177
- }
178
- else {
179
- const local = cfg;
180
- const [cmd, ...args] = local.command;
181
- await client.connect(new StdioClientTransport({
182
- command: cmd,
183
- args,
184
- cwd: local.cwd,
185
- env: { ...process.env, ...(local.environment ?? local.env ?? {}) },
186
- stderr: "pipe",
187
- }));
188
- }
189
- const defs = await paginateTools(client, timeout);
190
- for (const def of defs)
239
+ await raceTimeout(connectTransport(client, cfg), timeout, signal);
240
+ const defs = await raceTimeout(paginateTools(client, timeout), timeout, signal);
241
+ const kept = applyToolsFilter(name, defs, cfg.tools);
242
+ for (const def of kept)
191
243
  tools.push(convertTool(name, def, client, timeout));
192
244
  clients.push(client);
193
245
  status[name] = "connected";
@@ -195,7 +247,10 @@ export async function loadMcp(input, opts) {
195
247
  catch (e) {
196
248
  status[name] = "failed";
197
249
  await client.close().catch(() => { });
198
- // one bad server never breaks the toolkit
250
+ // Parent cancellation/deadline aborts the whole load (§2 Gap 3, A1); a per-server timeout
251
+ // within budget isolates just this server.
252
+ if (signal?.aborted)
253
+ throw e;
199
254
  console.warn(`[toolnexus] MCP server "${name}" failed: ${e instanceof Error ? e.message : String(e)}`);
200
255
  }
201
256
  }));
@@ -207,3 +262,48 @@ export async function loadMcp(input, opts) {
207
262
  },
208
263
  };
209
264
  }
265
+ /**
266
+ * §2 Gap 6. Connect to every enabled server, list tool definitions, and DISCONNECT before
267
+ * returning — no toolkit, no execute wiring, nothing left running. Failure isolation as in loadMcp.
268
+ */
269
+ export async function listMcpTools(input, opts) {
270
+ const config = parseMcpConfig(input);
271
+ const toolsByServer = {};
272
+ const status = {};
273
+ const signal = opts?.signal;
274
+ await Promise.all(Object.entries(config).map(async ([name, cfg]) => {
275
+ if (!isEnabled(cfg)) {
276
+ status[name] = "disabled";
277
+ return;
278
+ }
279
+ const timeout = cfg.timeout ?? DEFAULT_TIMEOUT;
280
+ // Client created up front so an aborted/timed-out connect is still closed (no orphaned child).
281
+ const client = newMcpClient();
282
+ try {
283
+ await raceTimeout(connectTransport(client, cfg), timeout, signal);
284
+ const defs = await raceTimeout(paginateTools(client, timeout), timeout, signal);
285
+ toolsByServer[name] = defs.map((d) => ({
286
+ name: d.name,
287
+ description: d.description ?? "",
288
+ inputSchema: {
289
+ ...d.inputSchema,
290
+ type: "object",
291
+ properties: (d.inputSchema?.properties ?? {}),
292
+ },
293
+ }));
294
+ status[name] = "connected";
295
+ }
296
+ catch (e) {
297
+ status[name] = "failed";
298
+ if (signal?.aborted) {
299
+ await client.close().catch(() => { });
300
+ throw e;
301
+ }
302
+ console.warn(`[toolnexus] MCP server "${name}" failed: ${e instanceof Error ? e.message : String(e)}`);
303
+ }
304
+ finally {
305
+ await client.close().catch(() => { });
306
+ }
307
+ }));
308
+ return { tools: toolsByServer, status };
309
+ }
package/dist/serve.js CHANGED
@@ -50,7 +50,13 @@ export class FileTaskStore {
50
50
  }
51
51
  }
52
52
  async save(task) {
53
- fs.writeFileSync(this.file(task.id), JSON.stringify(task));
53
+ // Atomic write: a concurrent get() must never read a half-written file (that
54
+ // would surface as a spurious "Task not found" mid-poll). Write a temp file in
55
+ // the same dir, then rename over the target.
56
+ const target = this.file(task.id);
57
+ const tmp = `${target}.tmp`;
58
+ fs.writeFileSync(tmp, JSON.stringify(task));
59
+ fs.renameSync(tmp, target);
54
60
  }
55
61
  }
56
62
  /** Map a `store` selector to a concrete TaskStore. `"file:<dir>"` → FileTaskStore. */
package/dist/skill.d.ts CHANGED
@@ -10,6 +10,42 @@ export interface SkillInfo {
10
10
  description?: string;
11
11
  location: string;
12
12
  content: string;
13
+ /** Internal origin discriminator; "fs" ⇒ on-disk (default), "logical" ⇒ data. */
14
+ origin?: "fs" | "logical";
15
+ /** Logical resource list (data-sourced skills only). */
16
+ resources?: string[];
17
+ /** Logical base, e.g. skill://name/ (data-sourced skills only). */
18
+ base?: string;
19
+ }
20
+ /** A skill supplied directly as data, bypassing the filesystem (SPEC.md §3, S1). */
21
+ export interface SkillDef {
22
+ name: string;
23
+ description?: string;
24
+ content: string;
25
+ /** Optional logical resource names surfaced in the invoke-time <skill_files> block. */
26
+ resources?: string[];
27
+ /** Optional logical base; defaults to `skill://<name>/`. */
28
+ base?: string;
29
+ }
30
+ /** Why a candidate SKILL.md did not become a skill (SPEC.md §3, S3). */
31
+ export type SkillSkipReason = "missing-name" | "malformed-frontmatter" | "duplicate-name" | "unreadable";
32
+ export interface SkillSkip {
33
+ location: string;
34
+ reason: SkillSkipReason;
35
+ }
36
+ /** Result of a list-only validate pass (SPEC.md §3, S3). */
37
+ export interface SkillInventory {
38
+ skills: SkillInfo[];
39
+ skipped: SkillSkip[];
40
+ }
41
+ /** Options for loadSkills / listSkills (SPEC.md §3, S1/S2/S5). */
42
+ export interface LoadSkillsOptions {
43
+ dirs?: string | string[];
44
+ skills?: SkillDef[];
45
+ /** Per-agent allowlist keyed on skill name; same semantics as the MCP tools filter. */
46
+ filter?: Record<string, boolean>;
47
+ /** Sibling-file sample cap: 0 ⇒ default 10, n>0 ⇒ cap, -1 ⇒ omit <skill_files>. */
48
+ sampleLimit?: number;
13
49
  }
14
50
  export interface SkillSource {
15
51
  skills: Record<string, SkillInfo>;
@@ -17,5 +53,11 @@ export interface SkillSource {
17
53
  /** Markdown catalog for the system prompt (mirrors opencode Skill.fmt). */
18
54
  prompt(): string;
19
55
  }
20
- /** Discover skills under one or more roots and build the `skill` loader tool. */
21
- export declare function loadSkills(dirs: string | string[]): SkillSource;
56
+ /**
57
+ * Discover + validate skills from the same sources loadSkills accepts, returning
58
+ * parsed skills plus typed skip reasons — no toolkit wired (SPEC.md §3, S3). The
59
+ * inventory is UNFILTERED (it exists to author/validate the allowlist).
60
+ */
61
+ export declare function listSkills(input: string | string[] | LoadSkillsOptions): SkillInventory;
62
+ /** Discover skills (dirs and/or data) and build the `skill` loader tool. */
63
+ export declare function loadSkills(input: string | string[] | LoadSkillsOptions): SkillSource;