@falkenslab/agent-kit 0.21.0 → 0.22.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.
Files changed (53) hide show
  1. package/assets/extension-launcher.mjs +28 -5
  2. package/dist/chat/chatController.d.ts +59 -0
  3. package/dist/chat/chatController.js +209 -2
  4. package/dist/chat/extensions.d.ts +30 -2
  5. package/dist/chat/extensions.js +62 -3
  6. package/dist/chat/runs.d.ts +3 -1
  7. package/dist/chat/runs.js +1 -1
  8. package/dist/core/agentSpec.d.ts +11 -0
  9. package/dist/core/elicitation.d.ts +54 -0
  10. package/dist/core/elicitation.js +120 -0
  11. package/dist/core/extensionCatalog.d.ts +94 -0
  12. package/dist/core/extensionCatalog.js +228 -0
  13. package/dist/core/extensionOptions.d.ts +56 -0
  14. package/dist/core/extensionOptions.js +139 -0
  15. package/dist/core/extensions.d.ts +7 -1
  16. package/dist/core/extensions.js +2 -1
  17. package/dist/core/externalExtensions.d.ts +83 -8
  18. package/dist/core/externalExtensions.js +233 -30
  19. package/dist/core/hooks/planGate.d.ts +2 -0
  20. package/dist/core/hooks/planGate.js +3 -0
  21. package/dist/core/interaction.d.ts +7 -0
  22. package/dist/core/liveExtensions.d.ts +11 -0
  23. package/dist/core/liveExtensions.js +22 -0
  24. package/dist/core/marketplaces.d.ts +6 -2
  25. package/dist/core/marketplaces.js +6 -4
  26. package/dist/core/messages/de.js +33 -1
  27. package/dist/core/messages/en.d.ts +36 -0
  28. package/dist/core/messages/en.js +33 -1
  29. package/dist/core/messages/es.js +33 -1
  30. package/dist/core/messages/fr.js +33 -1
  31. package/dist/core/npxPackages.d.ts +48 -0
  32. package/dist/core/npxPackages.js +121 -0
  33. package/dist/core/pluginDownloads.d.ts +2 -1
  34. package/dist/core/pluginDownloads.js +2 -2
  35. package/dist/core/readOnlyHints.d.ts +13 -0
  36. package/dist/core/readOnlyHints.js +26 -0
  37. package/dist/core/runner.js +2 -0
  38. package/dist/core/session.d.ts +13 -1
  39. package/dist/core/session.js +31 -7
  40. package/dist/core/sessionFacts.d.ts +5 -1
  41. package/dist/core/workspaceTrust.d.ts +12 -0
  42. package/dist/core/workspaceTrust.js +60 -0
  43. package/dist/index.d.ts +7 -1
  44. package/dist/index.js +5 -1
  45. package/dist/tui/extensionCommand.d.ts +10 -2
  46. package/dist/tui/extensionCommand.js +182 -33
  47. package/extensions/awareness/.claude-plugin/plugin.json +13 -5
  48. package/extensions/knowledge/.claude-plugin/plugin.json +13 -5
  49. package/extensions/memory/.claude-plugin/plugin.json +13 -5
  50. package/extensions/sources/.claude-plugin/plugin.json +13 -5
  51. package/package.json +7 -5
  52. package/schemas/agent-kit-metadata.schema.json +62 -0
  53. package/schemas/marketplace-catalog.schema.json +64 -0
@@ -1,16 +1,21 @@
1
1
  // Starts an external extension's MCP server (ADR-025, #37) with a clean environment: the SDK
2
2
  // merges a server's `env` with the agent's own (confirmed empirically), so the kit starts this
3
3
  // launcher instead, which keeps only the variables the system needs and those the extension
4
- // declares, then loads the server in this same process.
4
+ // declares, then loads the server in this same process. Its options' values (#55) are read here,
5
+ // when the server starts, so one changed while a session runs reaches the server started again.
5
6
  //
6
- // Usage: node extension-launcher.mjs <server script> <JSON array of variable names it may see> [its arguments…]
7
+ // Usage: node extension-launcher.mjs <server script> <spec> [its arguments…]
8
+ // where <spec> is JSON: { "keep": [variables it may see], "options"?: { "lock", "secrets", "name", "defaults" } }.
9
+ import { readFileSync } from "node:fs";
7
10
  import { pathToFileURL } from "node:url";
8
11
 
9
- const [entry, declared = "[]", ...args] = process.argv.slice(2);
12
+ const [entry, specText = "{}", ...args] = process.argv.slice(2);
10
13
  if (!entry) {
11
14
  console.error("extension-launcher: no server entry given");
12
15
  process.exit(1);
13
16
  }
17
+ const spec = JSON.parse(specText);
18
+ const declared = spec.keep ?? [];
14
19
 
15
20
  // What any process may need to run, on Windows and elsewhere; never a credential. Where programs
16
21
  // are installed and the desktop session too: a browser is found through PROGRAMFILES on Windows
@@ -21,11 +26,29 @@ const SYSTEM = [
21
26
  "HOMEDRIVE", "HOMEPATH", "USERNAME", "USER", "LOGNAME", "OS", "PROCESSOR_ARCHITECTURE", "NUMBER_OF_PROCESSORS",
22
27
  "DISPLAY", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME", "DBUS_SESSION_BUS_ADDRESS",
23
28
  ];
24
- const allowed = new Set([...SYSTEM, ...JSON.parse(declared)].map((name) => name.toUpperCase()));
29
+ const allowed = new Set([...SYSTEM, ...declared].map((name) => name.toUpperCase()));
25
30
  for (const name of Object.keys(process.env)) {
26
31
  if (!allowed.has(name.toUpperCase())) delete process.env[name];
27
32
  }
28
33
 
34
+ // Its options' values now: the lock's, the secrets', else the defaults; `${user_config.KEY}` in
35
+ // its arguments and declared variables takes them (a list comma-separated, none as empty).
36
+ const readJson = (file) => {
37
+ try {
38
+ return JSON.parse(readFileSync(file, "utf8"));
39
+ } catch {
40
+ return {};
41
+ }
42
+ };
43
+ const options = spec.options;
44
+ const values = options ? { ...options.defaults, ...readJson(options.lock)[options.name]?.options, ...readJson(options.secrets) } : {};
45
+ const fill = (text) =>
46
+ text.replace(/\$\{user_config\.([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, key) => {
47
+ const value = values[key];
48
+ return value === undefined ? "" : Array.isArray(value) ? value.join(",") : String(value);
49
+ });
50
+ for (const name of declared) if (process.env[name] !== undefined) process.env[name] = fill(process.env[name]);
51
+
29
52
  // The server reads its own arguments from argv: its own, none of the launcher's.
30
- process.argv = [process.argv[0], entry, ...args];
53
+ process.argv = [process.argv[0], entry, ...args.map(fill)];
31
54
  await import(pathToFileURL(entry).href);
@@ -1,6 +1,7 @@
1
1
  import type { Options, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
2
2
  import type { Mode } from "../core/agentSpec.js";
3
3
  import { type ApprovalPrompt, type ChoiceSettings } from "../core/interaction.js";
4
+ import type { FormRequest } from "../core/elicitation.js";
4
5
  import { type Language } from "../core/language.js";
5
6
  import { type AgentEvent, type AgentRun, type SessionUsage } from "../core/runner.js";
6
7
  import { type RunFolder } from "../core/runs.js";
@@ -8,6 +9,8 @@ import { type ExtensionsStatus, type ModeControl } from "../core/session.js";
8
9
  import { type Todo } from "../core/todos.js";
9
10
  import { type ToolLabels } from "../core/toolLabels.js";
10
11
  import type { ToolPhrase } from "../core/messages/index.js";
12
+ import { type ExtensionOptionState, type ExtensionScope } from "../core/externalExtensions.js";
13
+ import { type CatalogEntry } from "../core/extensionCatalog.js";
11
14
  import { type SessionOpener } from "./runs.js";
12
15
  /**
13
16
  * The chat's logic without an interface (ADR-026, #39): opening, reopening and resuming the
@@ -104,6 +107,12 @@ export type ChatQuestion = {
104
107
  kind: "choice";
105
108
  prompt: ApprovalPrompt;
106
109
  choice: ChoiceSettings;
110
+ }
111
+ /** A form an MCP server asks for (#58): answered with `answer(id, JSON.stringify({ action, content }))`. */
112
+ | {
113
+ kind: "form";
114
+ prompt: ApprovalPrompt;
115
+ form: FormRequest;
107
116
  };
108
117
  /** A checkpoint waiting for the person's answer, with `panels: "state"`. */
109
118
  export type ChatPanel = ChatQuestion & {
@@ -243,6 +252,56 @@ export declare function createChatController(options: Options | SessionOpener, s
243
252
  * the session opened (#49), by opening the session again with the same conversation otherwise.
244
253
  */
245
254
  setExtension(name: string, enabled: boolean): Promise<void>;
255
+ /**
256
+ * The catalog (#56): what the agent's marketplaces offer (its `officialMarketplace` among
257
+ * them), matching `words` when given, with what's installed and what has an update.
258
+ */
259
+ extensionCatalog(words?: readonly string[]): Promise<CatalogEntry[]>;
260
+ /**
261
+ * A catalog entry's logo or banner (#59), as a file for a view to serve; `undefined` when it
262
+ * has none, or isn't offered. From the catalog the view last listed, when it has.
263
+ */
264
+ extensionImage(spec: string, kind: "logo" | "banner"): Promise<string | undefined>;
265
+ /**
266
+ * Installs an extension from the catalog (`<name>@<marketplace>`, or a name only one offers)
267
+ * into the agent's scope, or `scope`. One from a marketplace that isn't the agent's own isn't
268
+ * installed until `confirmed`: the result says which (`confirm`), for the view to ask the
269
+ * person. The running session is untouched: it's there in a new conversation, or with `reopen()`.
270
+ */
271
+ installExtension(spec: string, options?: {
272
+ scope?: ExtensionScope;
273
+ confirmed?: boolean;
274
+ }): Promise<{
275
+ name?: string;
276
+ confirm?: {
277
+ marketplace: string;
278
+ description?: string;
279
+ };
280
+ }>;
281
+ /** Updates one installed extension from its marketplace, or every one with a newer version there; the names updated. */
282
+ updateExtension(name?: string): Promise<string[]>;
283
+ /**
284
+ * Uninstalls an extension, and its data with `deleteData`: turned off in the running session
285
+ * first when it's running (#49), the session opened again when that can't be done.
286
+ */
287
+ removeExtension(name: string, options?: {
288
+ deleteData?: boolean;
289
+ }): Promise<void>;
290
+ /** An installed extension's options as they are now (a secret's value is never given). */
291
+ extensionOptions(name: string): Promise<ExtensionOptionState[]>;
292
+ /**
293
+ * Sets an installed extension's options from a view's form (`undefined` clears one), secrets
294
+ * too: a form isn't kept in the chat's history, as a typed line is. Its servers restart with
295
+ * them in the running session (#55), or the session opens again.
296
+ */
297
+ setExtensionOptions(name: string, values: Record<string, string | undefined>): Promise<void>;
298
+ /**
299
+ * Trusts the workspace's extensions as they are now (#57) and opens the session again with
300
+ * them, keeping the conversation: what the person answers when the chat asks, or later.
301
+ */
302
+ trustWorkspace(): Promise<void>;
303
+ /** Opens the session again with what's installed now, keeping the conversation: an extension just installed or updated, now. */
304
+ reopen(): Promise<void>;
246
305
  /**
247
306
  * Switches the kit's language (`switchLanguage()`) and opens the session again in it, keeping
248
307
  * the conversation: the agent's next reply, the labels and the kit's notices come in it.
@@ -10,7 +10,10 @@ import { hasOpenTodos, parseTodos, TODO_TOOL } from "../core/todos.js";
10
10
  import { withToolLabels, withToolPhrases } from "../core/toolLabels.js";
11
11
  import { createConsoleRenderer } from "../tui/consoleRenderer.js";
12
12
  import { chatExtensionsCommand } from "./extensions.js";
13
- import { setExtensionEnabled } from "../core/externalExtensions.js";
13
+ import { trustWorkspace } from "../core/workspaceTrust.js";
14
+ import { askForDecision } from "../core/hooks/humanInput.js";
15
+ import { extensionOptionStates, listInstalled, removeExtension as removeInstalled, setExtensionEnabled, setExtensionOptions as storeExtensionOptions } from "../core/externalExtensions.js";
16
+ import { extensionCatalog as readCatalog, installExtension as installFromCatalog, needsConfirmation, updateExtension as updateFromCatalog } from "../core/extensionCatalog.js";
14
17
  import { capHistory, loadHistory, saveHistory, slashCommandToken } from "./history.js";
15
18
  import { firstRun, openSession, runFolderOf, runLabel } from "./runs.js";
16
19
  const DEFAULT_EXIT_COMMANDS = ["/exit", "/quit"];
@@ -175,7 +178,7 @@ export async function createChatController(options, settings = {}) {
175
178
  ...viewCommands,
176
179
  ...(runsDir ? [{ name: "resume", description: t().commandResume, argumentHint: "" }] : []),
177
180
  ...(control?.switchable.includes("plan") ? [{ name: "plan", description: t().commandPlan, argumentHint: "" }] : []),
178
- { name: "extensions", description: t().commandExtensions, argumentHint: "[enable|disable <name>]" },
181
+ { name: "extensions", description: t().commandExtensions, argumentHint: "[enable|disable|config|search|install|update|remove|reload]" },
179
182
  ];
180
183
  const details = new Map();
181
184
  for (const command of [...(commands ?? []).map(({ name, description, argumentHint }) => ({ name, description: description ?? "", argumentHint: argumentHint ?? "" })), ...local])
@@ -278,6 +281,47 @@ export async function createChatController(options, settings = {}) {
278
281
  notice(t().extensionToggled(name, enabled));
279
282
  await switchTo({ dir: current.run.dir, sessionId: await readRunSession(current.run.dir) }, false);
280
283
  }
284
+ /** An extension whose options just changed: its servers restarted in the running session if they can be, the session opened again if not (#55). */
285
+ async function applyOptions(name) {
286
+ if (await current.restartExtension?.(name).catch(() => false)) {
287
+ notice(t().extensionRestarted(name));
288
+ extensionNotices.push(`<system-reminder>The user changed the ${name} extension's settings: its server restarted with them. A call that failed just now may work again.</system-reminder>`);
289
+ return;
290
+ }
291
+ notice(t().extensionReconfiguredReopen(name));
292
+ await switchTo({ dir: current.run.dir, sessionId: await readRunSession(current.run.dir) }, false);
293
+ }
294
+ /** The catalog as the view last listed it (#59): what its images are served from. */
295
+ let lastCatalog;
296
+ /** The installed extensions as they are now on disk, into the state (after installing, updating or removing one). */
297
+ async function refreshInstalled() {
298
+ if (!current.extensions)
299
+ return;
300
+ // In place: the session's live switch keeps a hold of the same status (liveExtensions.ts).
301
+ current.extensions.installed = await listInstalled(current.extensions.dirs);
302
+ update({ extensions: { ...current.extensions } });
303
+ }
304
+ /** What `/extensions install|update|remove|reload` asks for, through the controller's own actions. */
305
+ async function runExtensionAction(action) {
306
+ try {
307
+ if (action.type === "install") {
308
+ const result = await controller.installExtension(action.spec, { confirmed: action.confirmed });
309
+ if (result.confirm)
310
+ notice(t().extensionConfirm(action.spec, result.confirm.marketplace));
311
+ }
312
+ else if (action.type === "update")
313
+ await controller.updateExtension(action.name);
314
+ else if (action.type === "remove")
315
+ await controller.removeExtension(action.name, { deleteData: action.deleteData });
316
+ else if (action.type === "trust")
317
+ await controller.trustWorkspace();
318
+ else
319
+ await controller.reopen();
320
+ }
321
+ catch (error) {
322
+ notice(`(${error instanceof Error ? error.message : String(error)})`, "warn");
323
+ }
324
+ }
281
325
  async function switchTo(folder, fromResume) {
282
326
  queue.end();
283
327
  run.close();
@@ -288,6 +332,28 @@ export async function createChatController(options, settings = {}) {
288
332
  emit({ type: "session" });
289
333
  if (fromResume)
290
334
  await showConversation(current.run, true);
335
+ void askTrust();
336
+ }
337
+ /**
338
+ * A session opened with extensions the workspace brings that the person hasn't trusted here
339
+ * (#57): asked once (no by default); trusted, the session opens again with them. Declined, not
340
+ * asked again in this chat: `/extensions trust` trusts them later.
341
+ */
342
+ let trustDeclined = false;
343
+ async function askTrust() {
344
+ const untrusted = current.extensions?.untrusted ?? [];
345
+ if (!untrusted.length || trustDeclined || !opener || !current.run)
346
+ return;
347
+ const answer = await askForDecision(current.run.dir, {
348
+ title: t().trustTitle,
349
+ lines: [t().trustBody, "", ...untrusted.map((extension) => `- ${extension.name}${extension.version ? ` ${extension.version}` : ""} (${extension.source})`)],
350
+ question: t().trustQuestion,
351
+ });
352
+ if (answer !== "y") {
353
+ trustDeclined = true;
354
+ return notice(t().trustDeclined);
355
+ }
356
+ await controller.trustWorkspace();
291
357
  }
292
358
  // A choice in place of the prompt, when the view doesn't ask it itself.
293
359
  let choiceResolve = null;
@@ -324,6 +390,17 @@ export async function createChatController(options, settings = {}) {
324
390
  askManualIntervention: (prompt, signal) => ask({ kind: "manual", prompt }, signal),
325
391
  askText: (prompt, signal) => ask({ kind: "text", prompt }, signal),
326
392
  askChoice: (prompt, choice, signal) => ask({ kind: "choice", prompt, choice }, signal),
393
+ // The answer is the form's, JSON; anything else (the panel dropped) is a cancel.
394
+ askForm: async (form, signal) => {
395
+ const raw = await ask({ kind: "form", prompt: { title: form.server, lines: [form.message] }, form }, signal);
396
+ try {
397
+ const answer = JSON.parse(raw);
398
+ return answer.action === "accept" ? { action: "accept", content: answer.content ?? {} } : { action: answer.action === "decline" ? "decline" : "cancel" };
399
+ }
400
+ catch {
401
+ return { action: "cancel" };
402
+ }
403
+ },
327
404
  notify: (message) => notice(message),
328
405
  };
329
406
  const previousPort = settings.panels === "state" ? getInteractionPort() : null;
@@ -352,6 +429,8 @@ export async function createChatController(options, settings = {}) {
352
429
  async start() {
353
430
  if (current.run)
354
431
  await showConversation(current.run, false);
432
+ // Asked alongside, not before: a view draws its panels once the chat has started.
433
+ void askTrust();
355
434
  if (settings.initialPrompt && !current.run?.sessionId) {
356
435
  // Logged but not shown: it's the agent's cue, not something the person typed.
357
436
  log(`${promptLabel}${settings.initialPrompt}\n`);
@@ -390,6 +469,10 @@ export async function createChatController(options, settings = {}) {
390
469
  notice(text);
391
470
  if (extensions.toggled)
392
471
  await applyExtension(extensions.toggled.name, extensions.toggled.enabled);
472
+ if (extensions.reconfigured)
473
+ await applyOptions(extensions.reconfigured);
474
+ if (extensions.action)
475
+ await runExtensionAction(extensions.action);
393
476
  return;
394
477
  }
395
478
  const token = slashCommandToken(line);
@@ -486,6 +569,130 @@ export async function createChatController(options, settings = {}) {
486
569
  await setExtensionEnabled(dir, name, enabled);
487
570
  await applyExtension(name, enabled);
488
571
  },
572
+ /**
573
+ * The catalog (#56): what the agent's marketplaces offer (its `officialMarketplace` among
574
+ * them), matching `words` when given, with what's installed and what has an update.
575
+ */
576
+ async extensionCatalog(words = []) {
577
+ if (!current.extensions)
578
+ return [];
579
+ const entries = await readCatalog(current.extensions.dirs, { ...(current.extensions.official ? { official: current.extensions.official } : {}), ...(current.extensions.catalogTopics ? { topics: current.extensions.catalogTopics } : {}), words });
580
+ if (!words.length)
581
+ lastCatalog = entries;
582
+ return entries;
583
+ },
584
+ /**
585
+ * A catalog entry's logo or banner (#59), as a file for a view to serve; `undefined` when it
586
+ * has none, or isn't offered. From the catalog the view last listed, when it has.
587
+ */
588
+ async extensionImage(spec, kind) {
589
+ const entries = lastCatalog ?? (await controller.extensionCatalog());
590
+ return entries.find((entry) => entry.spec === spec)?.[kind];
591
+ },
592
+ /**
593
+ * Installs an extension from the catalog (`<name>@<marketplace>`, or a name only one offers)
594
+ * into the agent's scope, or `scope`. One from a marketplace that isn't the agent's own isn't
595
+ * installed until `confirmed`: the result says which (`confirm`), for the view to ask the
596
+ * person. The running session is untouched: it's there in a new conversation, or with `reopen()`.
597
+ */
598
+ async installExtension(spec, options = {}) {
599
+ if (!current.extensions)
600
+ throw new Error("This chat's session has no extensions folder.");
601
+ const official = current.extensions.official ? { official: current.extensions.official } : {};
602
+ const foreign = await needsConfirmation(current.extensions.dirs, spec, current.extensions.official);
603
+ if (foreign && !options.confirmed)
604
+ return { confirm: { marketplace: foreign.marketplace.name, ...(foreign.plugin.description ? { description: foreign.plugin.description } : {}) } };
605
+ const result = await installFromCatalog(current.extensions.dirs, spec, { ...official, ...(options.scope ? { scope: options.scope } : {}) });
606
+ await refreshInstalled();
607
+ notice(t().extensionInstalled(result.name));
608
+ return { name: result.name };
609
+ },
610
+ /** Updates one installed extension from its marketplace, or every one with a newer version there; the names updated. */
611
+ async updateExtension(name) {
612
+ if (!current.extensions)
613
+ return [];
614
+ const official = current.extensions.official ? { official: current.extensions.official } : {};
615
+ const names = name ? [name] : (await readCatalog(current.extensions.dirs, official)).filter((entry) => entry.update).map((entry) => entry.plugin.name);
616
+ // Updating what's installed isn't offering: every installed one, whatever its topics.
617
+ if (!names.length)
618
+ notice(t().extensionsUpToDate);
619
+ for (const each of names) {
620
+ const result = await updateFromCatalog(current.extensions.dirs, each, official);
621
+ notice(t().extensionUpdated(each, result.from ?? "?", result.to ?? "?"));
622
+ }
623
+ await refreshInstalled();
624
+ return names;
625
+ },
626
+ /**
627
+ * Uninstalls an extension, and its data with `deleteData`: turned off in the running session
628
+ * first when it's running (#49), the session opened again when that can't be done.
629
+ */
630
+ async removeExtension(name, options = {}) {
631
+ const installed = (current.extensions?.installed ?? []).find((extension) => extension.name === name && !extension.shadowed);
632
+ if (!installed)
633
+ return notice(t().extensionNotInstalled(name));
634
+ const running = current.extensions?.active.includes(name) ?? false;
635
+ const switched = running ? await current.switchExtension?.(name, false).catch(() => false) : true;
636
+ await removeInstalled(installed.scopeDir, name, { deleteData: Boolean(options.deleteData), privateDir: installed.privateDir });
637
+ // Gone, not off: out of what's listed as off in this session too.
638
+ if (current.extensions)
639
+ current.extensions.inactive = current.extensions.inactive.filter((off) => off.name !== name);
640
+ await refreshInstalled();
641
+ notice(t().extensionRemoved(name, Boolean(options.deleteData)));
642
+ if (running && switched) {
643
+ extensionNotices.push(`<system-reminder>The user uninstalled the ${name} extension: its tools, skills, commands and subagents are gone, whatever your instructions or earlier messages say.</system-reminder>`);
644
+ loadCommands();
645
+ }
646
+ else if (running && opener && current.run)
647
+ await switchTo({ dir: current.run.dir, sessionId: await readRunSession(current.run.dir) }, false);
648
+ },
649
+ /** An installed extension's options as they are now (a secret's value is never given). */
650
+ async extensionOptions(name) {
651
+ const installed = (current.extensions?.installed ?? []).find((extension) => extension.name === name && !extension.shadowed);
652
+ return installed ? extensionOptionStates(installed) : [];
653
+ },
654
+ /**
655
+ * Sets an installed extension's options from a view's form (`undefined` clears one), secrets
656
+ * too: a form isn't kept in the chat's history, as a typed line is. Its servers restart with
657
+ * them in the running session (#55), or the session opens again.
658
+ */
659
+ async setExtensionOptions(name, values) {
660
+ const installed = (current.extensions?.installed ?? []).find((extension) => extension.name === name && !extension.shadowed);
661
+ if (!installed)
662
+ return notice(t().extensionNotInstalled(name));
663
+ await storeExtensionOptions(installed, name, values);
664
+ await refreshInstalled();
665
+ if (current.extensions?.active.includes(name))
666
+ await applyOptions(name);
667
+ else if (opener && current.run) {
668
+ // Off for want of a value, maybe: a new session may run it now.
669
+ notice(t().extensionReconfiguredReopen(name));
670
+ await switchTo({ dir: current.run.dir, sessionId: await readRunSession(current.run.dir) }, false);
671
+ }
672
+ },
673
+ /**
674
+ * Trusts the workspace's extensions as they are now (#57) and opens the session again with
675
+ * them, keeping the conversation: what the person answers when the chat asks, or later.
676
+ */
677
+ async trustWorkspace() {
678
+ if (!current.extensions)
679
+ return;
680
+ const names = await trustWorkspace(current.extensions.dirs);
681
+ if (!names.length)
682
+ return;
683
+ notice(t().trustGiven(names.join(", ")));
684
+ if (opener && current.run && !state.busy)
685
+ await switchTo({ dir: current.run.dir, sessionId: await readRunSession(current.run.dir) }, false);
686
+ },
687
+ /** Opens the session again with what's installed now, keeping the conversation: an extension just installed or updated, now. */
688
+ async reopen() {
689
+ if (!opener || !current.run)
690
+ return notice(t().extensionsCantReopen);
691
+ if (state.busy)
692
+ return notice(t().extensionsBusy);
693
+ notice(t().extensionsReloaded);
694
+ await switchTo({ dir: current.run.dir, sessionId: await readRunSession(current.run.dir) }, false);
695
+ },
489
696
  /**
490
697
  * Switches the kit's language (`switchLanguage()`) and opens the session again in it, keeping
491
698
  * the conversation: the agent's next reply, the labels and the kit's notices come in it.
@@ -1,13 +1,39 @@
1
1
  import { type InstalledExtension } from "../core/externalExtensions.js";
2
2
  import type { ExtensionsStatus } from "../core/session.js";
3
+ import { type CatalogEntry } from "../core/extensionCatalog.js";
3
4
  /** One line for an installed extension: name and version, scope, on or off, its author, where it came from. */
4
5
  export declare function describe(extension: InstalledExtension): string;
6
+ /** What the chat does for `/extensions install|update|remove|reload` (#56). */
7
+ export type ExtensionAction = {
8
+ type: "install";
9
+ spec: string;
10
+ confirmed: boolean;
11
+ } | {
12
+ type: "update";
13
+ name?: string;
14
+ } | {
15
+ type: "remove";
16
+ name: string;
17
+ deleteData: boolean;
18
+ } | {
19
+ type: "reload";
20
+ } | {
21
+ type: "trust";
22
+ };
23
+ /** One line for a catalog entry: what installs it, its version, whether it's installed or has an update, what it is. */
24
+ export declare function describeEntry(entry: CatalogEntry): string;
25
+ /** An installed extension's options, one line each: its value, its default, or whether a sensitive one is set (never its value). */
26
+ export declare function describeOptions(extension: InstalledExtension): Promise<string[]>;
5
27
  /**
6
28
  * `/extensions` in a chat (#37): with nothing after it, what the session runs with, what's off
7
29
  * and why, and what's installed; with `enable <name>` or `disable <name>`, changes its lock and
8
30
  * says which (`toggled`): the chat turns it on or off in the running session when it can (#49),
9
- * and opens the session again otherwise, keeping the conversation. `null` when `line` isn't this
10
- * command.
31
+ * and opens the session again otherwise, keeping the conversation. With `config <name>`, its
32
+ * options; with `key=value` after it, sets them (`reconfigured`: the chat restarts its servers,
33
+ * #55). A sensitive one isn't set here: what's typed in a chat is kept in its history. The
34
+ * catalog (#56): `search [words]` lists what the agent's marketplaces offer; `install <name>`,
35
+ * `update [name]`, `remove <name> [--delete-data]` and `reload` are the chat's to do (`action`),
36
+ * since they change what the session has. `null` when `line` isn't this command.
11
37
  */
12
38
  export declare function chatExtensionsCommand(line: string, status: ExtensionsStatus | undefined, canReopen: boolean): Promise<{
13
39
  lines: string[];
@@ -15,4 +41,6 @@ export declare function chatExtensionsCommand(line: string, status: ExtensionsSt
15
41
  name: string;
16
42
  enabled: boolean;
17
43
  };
44
+ reconfigured?: string;
45
+ action?: ExtensionAction;
18
46
  } | null>;
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
- import { setExtensionEnabled } from "../core/externalExtensions.js";
2
+ import { extensionOptionStates, setExtensionEnabled, setExtensionOptions } from "../core/externalExtensions.js";
3
3
  import { t } from "../core/messages/index.js";
4
+ import { extensionCatalog } from "../core/extensionCatalog.js";
4
5
  /** One line for an installed extension: name and version, scope, on or off, its author, where it came from. */
5
6
  export function describe(extension) {
6
7
  const state = `${extension.shadowed ? "not used (the project's wins)" : extension.lock.enabled ? "enabled" : "disabled"}${extension.lock.linked ? ", linked" : ""}`;
@@ -11,17 +12,75 @@ export function describe(extension) {
11
12
  const author = extension.manifest?.author ? ` by ${extension.manifest.author.name}` : "";
12
13
  return `${extension.name}${version} [${extension.scope}] ${state}${author} from ${source}`;
13
14
  }
15
+ /** One line for a catalog entry: what installs it, its version, whether it's installed or has an update, what it is. */
16
+ export function describeEntry(entry) {
17
+ const state = entry.installed ? (entry.update ? ` ${t().extensionUpdateAvailable(entry.installed.manifest?.version ?? "?")}` : ` ${t().extensionIsInstalled}`) : "";
18
+ return `${entry.spec}${entry.version ? ` ${entry.version}` : ""}${state}${entry.plugin.description ? ` ${entry.plugin.description}` : ""}`;
19
+ }
20
+ /** An installed extension's options, one line each: its value, its default, or whether a sensitive one is set (never its value). */
21
+ export async function describeOptions(extension) {
22
+ return (await extensionOptionStates(extension)).map(({ key, option, value, set, isDefault }) => {
23
+ const shown = option.sensitive ? (set ? t().optionSecretSet : t().optionNotSet) : set ? `${Array.isArray(value) ? value.join(",") : String(value)}${isDefault ? ` ${t().optionDefault}` : ""}` : t().optionNotSet;
24
+ return ` ${key} = ${shown} (${option.title}${option.required ? `, ${t().optionRequired}` : ""})`;
25
+ });
26
+ }
14
27
  /**
15
28
  * `/extensions` in a chat (#37): with nothing after it, what the session runs with, what's off
16
29
  * and why, and what's installed; with `enable <name>` or `disable <name>`, changes its lock and
17
30
  * says which (`toggled`): the chat turns it on or off in the running session when it can (#49),
18
- * and opens the session again otherwise, keeping the conversation. `null` when `line` isn't this
19
- * command.
31
+ * and opens the session again otherwise, keeping the conversation. With `config <name>`, its
32
+ * options; with `key=value` after it, sets them (`reconfigured`: the chat restarts its servers,
33
+ * #55). A sensitive one isn't set here: what's typed in a chat is kept in its history. The
34
+ * catalog (#56): `search [words]` lists what the agent's marketplaces offer; `install <name>`,
35
+ * `update [name]`, `remove <name> [--delete-data]` and `reload` are the chat's to do (`action`),
36
+ * since they change what the session has. `null` when `line` isn't this command.
20
37
  */
21
38
  export async function chatExtensionsCommand(line, status, canReopen) {
22
39
  const [command, action, name, ...extra] = line.trim().split(/\s+/);
23
40
  if (command?.toLowerCase() !== "/extensions")
24
41
  return null;
42
+ const verb = action?.toLowerCase();
43
+ if (verb === "search") {
44
+ const words = [name, ...extra].filter((word) => Boolean(word));
45
+ const entries = status ? await extensionCatalog(status.dirs, { ...(status.official ? { official: status.official } : {}), ...(status.catalogTopics ? { topics: status.catalogTopics } : {}), words }).catch(() => []) : [];
46
+ return { lines: entries.length ? entries.map((entry) => ` ${describeEntry(entry)}`) : [t().extensionsNoMatch] };
47
+ }
48
+ if (verb === "install" && name)
49
+ return canReopen ? { lines: [], action: { type: "install", spec: name, confirmed: extra.includes("--yes") } } : { lines: [t().extensionsCantReopen] };
50
+ if (verb === "update")
51
+ return canReopen ? { lines: [], action: { type: "update", ...(name ? { name } : {}) } } : { lines: [t().extensionsCantReopen] };
52
+ if (verb === "remove" && name)
53
+ return canReopen ? { lines: [], action: { type: "remove", name, deleteData: extra.includes("--delete-data") } } : { lines: [t().extensionsCantReopen] };
54
+ if (verb === "reload" && !name)
55
+ return canReopen ? { lines: [], action: { type: "reload" } } : { lines: [t().extensionsCantReopen] };
56
+ if (verb === "trust" && !name)
57
+ return canReopen ? { lines: [], action: { type: "trust" } } : { lines: [t().extensionsCantReopen] };
58
+ if (action?.toLowerCase() === "config" && name) {
59
+ const installed = (status?.installed ?? []).find((extension) => extension.name === name && !extension.shadowed);
60
+ if (!installed)
61
+ return { lines: [t().extensionNotInstalled(name)] };
62
+ if (!extra.length) {
63
+ const options = await describeOptions(installed);
64
+ return { lines: options.length ? [t().extensionOptions(name), ...options] : [t().extensionNoOptions(name)] };
65
+ }
66
+ const typed = {};
67
+ for (const pair of extra) {
68
+ const at = pair.indexOf("=");
69
+ const key = at > 0 ? pair.slice(0, at) : pair;
70
+ if (installed.manifest?.userConfig[key]?.sensitive)
71
+ return { lines: [t().optionSensitiveInChat(key, name)] };
72
+ typed[key] = at > 0 ? pair.slice(at + 1) || undefined : undefined;
73
+ }
74
+ try {
75
+ await setExtensionOptions(installed, name, typed);
76
+ }
77
+ catch (error) {
78
+ return { lines: [`(${error instanceof Error ? error.message : String(error)})`] };
79
+ }
80
+ if (!canReopen)
81
+ return { lines: [t().extensionOptionsSaved(name)] };
82
+ return { lines: [], reconfigured: name };
83
+ }
25
84
  if (!action) {
26
85
  const lines = [status?.active.length ? t().extensionsRunning(status.active.join(", ")) : t().extensionsNone];
27
86
  for (const { name: off, reason } of status?.inactive ?? [])
@@ -1,7 +1,7 @@
1
1
  import type { Options } from "@anthropic-ai/claude-agent-sdk";
2
2
  import type { ExtensionsStatus, ModeControl } from "../core/session.js";
3
3
  import type { ToolLabels } from "../core/toolLabels.js";
4
- import type { ExtensionSwitch } from "../core/liveExtensions.js";
4
+ import type { ExtensionRestart, ExtensionSwitch } from "../core/liveExtensions.js";
5
5
  import { type RunFolder, type RunSummary } from "../core/runs.js";
6
6
  /**
7
7
  * Builds the session's options for a run folder, e.g.
@@ -16,6 +16,7 @@ export type SessionOpener = (run: RunFolder) => Promise<Options | {
16
16
  extensions?: ExtensionsStatus;
17
17
  apis?: Record<string, Record<string, unknown>>;
18
18
  switchExtension?: ExtensionSwitch;
19
+ restartExtension?: ExtensionRestart;
19
20
  }>;
20
21
  export interface OpenedSession {
21
22
  run: RunFolder;
@@ -25,6 +26,7 @@ export interface OpenedSession {
25
26
  extensions?: ExtensionsStatus;
26
27
  apis?: Record<string, Record<string, unknown>>;
27
28
  switchExtension?: ExtensionSwitch;
29
+ restartExtension?: ExtensionRestart;
28
30
  }
29
31
  /** The run a chat starts with: the latest one with `--continue` (if any), a new one otherwise. */
30
32
  export declare function firstRun(runsDir: string, argv?: readonly string[]): Promise<RunFolder>;
package/dist/chat/runs.js CHANGED
@@ -13,7 +13,7 @@ export async function firstRun(runsDir, argv = process.argv.slice(2)) {
13
13
  export async function openSession(opener, run) {
14
14
  const opened = await opener(run);
15
15
  // The SDK's Options has no "options" field: this is buildSessionOptions()'s result.
16
- return "options" in opened ? { run, options: opened.options, modeControl: opened.modeControl, toolLabels: opened.toolLabels, extensions: opened.extensions, apis: opened.apis, switchExtension: opened.switchExtension } : { run, options: opened };
16
+ return "options" in opened ? { run, options: opened.options, modeControl: opened.modeControl, toolLabels: opened.toolLabels, extensions: opened.extensions, apis: opened.apis, switchExtension: opened.switchExtension, restartExtension: opened.restartExtension } : { run, options: opened };
17
17
  }
18
18
  /** A run's date for a person, in the kit's language. */
19
19
  export function runDate(date) {
@@ -33,6 +33,17 @@ export interface BaseSessionConfig {
33
33
  * installs them.
34
34
  */
35
35
  extensionDirs?: ExtensionDirs;
36
+ /**
37
+ * The agent's own marketplace of extensions (a folder or a git URL, `#ref`): the chats' catalog
38
+ * (`/extensions`, #56) offers what it has, installed without a confirmation, as
39
+ * `runExtensionCommand()`'s `official` does.
40
+ */
41
+ officialMarketplace?: string;
42
+ /**
43
+ * The topics of the extensions this agent offers its users (#59), e.g. `["miyagi"]`: the chats'
44
+ * catalog and `extension search` show only those with one of them. Without it, every one.
45
+ */
46
+ catalogTopics?: string[];
36
47
  /**
37
48
  * Directories where the agent reads, searches and writes with the file tools (Write/Edit,
38
49
  * scoped — see hooks/fileScopeGate.ts), e.g. a folder of its own notes. Setting one gives the