talon-agent 1.50.0 → 1.52.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.
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Plugin management over the live config — list and toggle, outcomes as
3
+ * data. The client bridge's plugin endpoints run through here in-process;
4
+ * the CLI edits config.json out-of-process with the same `entries.ts`
5
+ * helpers and converges through `POST /plugins/reload`. Rendering (and
6
+ * persisting/hot-applying, which are surface concerns) stay with the
7
+ * caller: a toggle returns the exact partial document to merge into
8
+ * talon.json so the surface owns its own write path.
9
+ */
10
+
11
+ import type { TalonConfig } from "../../util/config.js";
12
+ import { isPathPlugin, type PluginEntry } from "./types.js";
13
+ import {
14
+ BUILTIN_PLUGINS,
15
+ entryDisplayName,
16
+ findEntryIndex,
17
+ isBuiltinPlugin,
18
+ withEnabled,
19
+ } from "./entries.js";
20
+
21
+ /** One plugin as every management surface lists it. */
22
+ export type PluginItem = {
23
+ readonly name: string;
24
+ readonly kind: "builtin" | "module" | "mcp";
25
+ readonly enabled: boolean;
26
+ /** Where it comes from: a config section, module path, or MCP command. */
27
+ readonly source: string;
28
+ };
29
+
30
+ export type PluginToggleOutcome =
31
+ | {
32
+ readonly ok: true;
33
+ readonly name: string;
34
+ /** Partial talon.json document the surface must persist. */
35
+ readonly persist: Record<string, unknown>;
36
+ }
37
+ | { readonly ok: false; readonly error: string };
38
+
39
+ function builtinSection(
40
+ config: TalonConfig,
41
+ name: string,
42
+ ): { enabled?: boolean } | undefined {
43
+ return (config as unknown as Record<string, { enabled?: boolean }>)[name];
44
+ }
45
+
46
+ /** Every plugin: built-ins first, then configured entries, config order. */
47
+ export function listPluginItems(config: TalonConfig): PluginItem[] {
48
+ const builtins: PluginItem[] = BUILTIN_PLUGINS.map((name) => ({
49
+ name,
50
+ kind: "builtin" as const,
51
+ enabled: builtinSection(config, name)?.enabled === true,
52
+ source: `config.${name}`,
53
+ }));
54
+ const configured: PluginItem[] = (config.plugins ?? []).map((entry) => ({
55
+ name: entryDisplayName(entry),
56
+ kind: isPathPlugin(entry) ? ("module" as const) : ("mcp" as const),
57
+ enabled: entry.enabled !== false,
58
+ source: isPathPlugin(entry)
59
+ ? entry.path
60
+ : `${entry.command}${entry.args ? ` ${entry.args.join(" ")}` : ""}`,
61
+ }));
62
+ return [...builtins, ...configured];
63
+ }
64
+
65
+ /**
66
+ * Toggle a plugin on the LIVE config object (so in-process readers see the
67
+ * change immediately) and report what to persist. Hot-applying (plugin
68
+ * reload, prompt rebuild) is the caller's follow-up — this function only
69
+ * decides and mutates state.
70
+ */
71
+ export function setPluginEnabled(
72
+ config: TalonConfig,
73
+ rawName: string,
74
+ enabled: boolean,
75
+ ): PluginToggleOutcome {
76
+ const name = rawName.trim();
77
+ if (!name) return { ok: false, error: "A plugin name is required." };
78
+
79
+ if (isBuiltinPlugin(name)) {
80
+ const section = { ...(builtinSection(config, name) ?? {}), enabled };
81
+ (config as unknown as Record<string, unknown>)[name] = section;
82
+ return { ok: true, name, persist: { [name]: section } };
83
+ }
84
+
85
+ const list: PluginEntry[] = config.plugins ?? [];
86
+ const index = findEntryIndex(list, name);
87
+ if (index < 0) {
88
+ return { ok: false, error: `No plugin named "${name}".` };
89
+ }
90
+ // withEnabled only adds/drops the `enabled` key, so the entry keeps its
91
+ // (validated) format — safe to narrow back from the JSON helper shape.
92
+ list[index] = withEnabled(list[index]!, enabled) as PluginEntry;
93
+ config.plugins = list;
94
+ return {
95
+ ok: true,
96
+ name: entryDisplayName(list[index]!),
97
+ persist: { plugins: list },
98
+ };
99
+ }
@@ -8,6 +8,8 @@ import type { ActionResult } from "../types.js";
8
8
  export interface PluginPathEntry {
9
9
  path: string;
10
10
  config?: Record<string, unknown>;
11
+ /** `false` keeps the entry in config but skips loading it. Default true. */
12
+ enabled?: boolean;
11
13
  }
12
14
 
13
15
  /** Standalone MCP server entry (command + args, not a loadable module). */
@@ -16,6 +18,8 @@ export interface PluginMcpEntry {
16
18
  command: string;
17
19
  args?: string[];
18
20
  env?: Record<string, string>;
21
+ /** `false` keeps the entry in config but skips registering it. Default true. */
22
+ enabled?: boolean;
19
23
  }
20
24
 
21
25
  /** Configuration entry for a plugin in config.json. */
package/src/core/types.ts CHANGED
@@ -120,6 +120,18 @@ export interface ModelPickerResult {
120
120
  * fire-and-forget agent invocation: spawn an agent with a prompt, let it run
121
121
  * tools, log everything to a file, return when it finishes (or when aborted).
122
122
  */
123
+ /**
124
+ * Token usage a one-shot run reports at settlement, when the backend's SDK
125
+ * surfaces it. Shape-compatible with the task table's `TaskUsage`, so the
126
+ * background callers can hand it straight to `TaskHandle.succeed`.
127
+ */
128
+ export type OneShotUsage = {
129
+ inputTokens: number;
130
+ outputTokens: number;
131
+ cacheRead: number;
132
+ cacheWrite: number;
133
+ };
134
+
123
135
  export type OneShotAgentParams = {
124
136
  /** The user prompt the agent should execute. */
125
137
  prompt: string;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Extensions over the bridge — plugins and skills, list + toggle.
3
+ *
4
+ * This is what backs the companion app's Plugins and Skills settings
5
+ * sub-menus. Listing is a pure read (core/plugin/manage.ts for plugins,
6
+ * the skill store for skills); a toggle mutates the live state, persists
7
+ * it, and hot-applies so the change is real on the very next turn:
8
+ *
9
+ * - plugin toggle → merge into talon.json + full plugin reload (same
10
+ * path as `POST /plugins/reload`), so tools appear/disappear live;
11
+ * - skill toggle → `.disabled` marker via the skill store + system
12
+ * prompt rebuild (skills render into the prompt; no plugin reload
13
+ * needed).
14
+ *
15
+ * Toggles hide capability from the model's default view — they never
16
+ * restrict it. That contract lives in the stores; nothing here adds
17
+ * gating on top.
18
+ */
19
+
20
+ import type { Backend } from "../../core/agent-runtime/capabilities.js";
21
+ import { performPluginReload } from "../../core/engine/gateway-actions/plugins.js";
22
+ import { getPluginPromptAdditions } from "../../core/plugin/index.js";
23
+ import { listPluginItems, setPluginEnabled } from "../../core/plugin/manage.js";
24
+ import { clearSystemPromptSnapshots } from "../../backend/shared/system-prompt.js";
25
+ import { listSkills, setSkillEnabled } from "../../storage/skill-store.js";
26
+ import { rebuildSystemPrompt, type TalonConfig } from "../../util/config.js";
27
+ import { log } from "../../util/log.js";
28
+ import type { PluginItem, SkillItem, ToggleResult } from "./protocol.js";
29
+ import { persistConfigPatch } from "./settings.js";
30
+
31
+ export function pluginItems(config: TalonConfig): PluginItem[] {
32
+ return listPluginItems(config);
33
+ }
34
+
35
+ export function skillItems(): SkillItem[] {
36
+ return listSkills().map(({ name, description, enabled }) => ({
37
+ name,
38
+ description,
39
+ enabled,
40
+ }));
41
+ }
42
+
43
+ /**
44
+ * Toggle a plugin: mutate the live config, persist the change, then
45
+ * hot-reload plugins (rebuilds the system prompt and drops per-session
46
+ * prompt snapshots on the way). A reload failure after a successful save
47
+ * is reported as an error — the state is on disk, but the caller must
48
+ * know the daemon hasn't applied it.
49
+ */
50
+ export async function togglePlugin(
51
+ config: TalonConfig,
52
+ backend: Backend | null,
53
+ name: string,
54
+ enabled: boolean,
55
+ ): Promise<ToggleResult> {
56
+ const outcome = setPluginEnabled(config, name, enabled);
57
+ if (!outcome.ok) return { ok: false, error: outcome.error };
58
+ persistConfigPatch(outcome.persist);
59
+ log(
60
+ "native",
61
+ `Plugin ${outcome.name} ${enabled ? "enabled" : "disabled"} via bridge`,
62
+ );
63
+ try {
64
+ await performPluginReload(backend);
65
+ } catch (err) {
66
+ return {
67
+ ok: false,
68
+ error: `Saved, but the hot reload failed: ${err instanceof Error ? err.message : String(err)} — restart Talon to apply.`,
69
+ };
70
+ }
71
+ return { ok: true };
72
+ }
73
+
74
+ /**
75
+ * Toggle a skill: the store drops/creates the `.disabled` marker, then the
76
+ * system prompt is rebuilt so the skill index reflects the change on every
77
+ * chat's next turn. No plugin reload — skills don't carry tools.
78
+ */
79
+ export function toggleSkill(
80
+ config: TalonConfig,
81
+ backend: Backend | null,
82
+ name: string,
83
+ enabled: boolean,
84
+ ): ToggleResult {
85
+ if (!setSkillEnabled(name, enabled)) {
86
+ return { ok: false, error: `No skill named "${name}".` };
87
+ }
88
+ log("native", `Skill ${name} ${enabled ? "enabled" : "disabled"} via bridge`);
89
+ rebuildSystemPrompt(config, getPluginPromptAdditions());
90
+ backend?.control?.updateSystemPrompt?.(config.systemPrompt);
91
+ clearSystemPromptSnapshots();
92
+ return { ok: true };
93
+ }
@@ -52,6 +52,12 @@ import {
52
52
  import { resetSession } from "../../storage/sessions.js";
53
53
  import { resetPulseCheckpoint } from "../../core/background/pulse.js";
54
54
  import { configSnapshot, applyConfigUpdate } from "./settings.js";
55
+ import {
56
+ pluginItems,
57
+ skillItems,
58
+ togglePlugin,
59
+ toggleSkill,
60
+ } from "./extensions.js";
55
61
  import { resolveModel } from "../../core/models/catalog.js";
56
62
  import {
57
63
  getBackendForChat,
@@ -743,7 +749,7 @@ export function createNativeFrontend(
743
749
  return {
744
750
  app: "talon-bridge",
745
751
  protocol: BRIDGE_PROTOCOL_VERSION,
746
- capabilities: ["mesh", "mesh-commands"],
752
+ capabilities: ["mesh", "mesh-commands", "plugins-skills"],
747
753
  botName,
748
754
  backend: config.backend,
749
755
  model: resolveModel(config.model)?.displayName ?? config.model,
@@ -1168,6 +1174,12 @@ export function createNativeFrontend(
1168
1174
  broadcast({ kind: "status", status: status() });
1169
1175
  return snap;
1170
1176
  },
1177
+ listPlugins: () => pluginItems(config),
1178
+ setPluginEnabled: (name, enabled) =>
1179
+ togglePlugin(config, getPooledBackend(config.backend), name, enabled),
1180
+ listSkills: () => skillItems(),
1181
+ setSkillEnabled: (name, enabled) =>
1182
+ toggleSkill(config, getPooledBackend(config.backend), name, enabled),
1171
1183
  control,
1172
1184
  logs: ({ lines, minLevel, component }) =>
1173
1185
  readLogEntries(files.log, { limit: lines, minLevel, component }),
@@ -197,6 +197,39 @@ export type BackendOption = {
197
197
  label: string;
198
198
  };
199
199
 
200
+ /**
201
+ * One installed plugin, as `GET /plugins` lists it. Toggled via
202
+ * `POST /plugins/toggle` (`{ name, enabled }` → `ToggleResult`). Additive
203
+ * in v1 behind the `plugins-skills` capability — older clients never call
204
+ * these endpoints.
205
+ */
206
+ export type PluginItem = {
207
+ name: string;
208
+ /** Built-in (config-section) plugin, module plugin, or MCP server. */
209
+ kind: "builtin" | "module" | "mcp";
210
+ enabled: boolean;
211
+ /** Where it comes from: a config section, module path, or MCP command. */
212
+ source: string;
213
+ };
214
+
215
+ /**
216
+ * One installed skill, as `GET /skills` lists it. Toggled via
217
+ * `POST /skills/toggle` — a disabled skill drops out of the model's
218
+ * prompt index but stays installed.
219
+ */
220
+ export type SkillItem = {
221
+ name: string;
222
+ description: string;
223
+ enabled: boolean;
224
+ };
225
+
226
+ /**
227
+ * Application-level outcome of a toggle, always carried in an HTTP 200
228
+ * body (mirrors `/backend`: the client renders ok/error, HTTP-level
229
+ * failures stay transport errors).
230
+ */
231
+ export type ToggleResult = { ok: boolean; error?: string };
232
+
200
233
  // Mesh device shapes are canonical in core (the mesh is daemon-wide state,
201
234
  // readable from every frontend); re-exported here so bridge clients keep
202
235
  // depending on the protocol module alone.
@@ -40,7 +40,10 @@ import {
40
40
  type LogEntry,
41
41
  type LogLevel,
42
42
  type ModelOption,
43
+ type PluginItem,
43
44
  type SearchResult,
45
+ type SkillItem,
46
+ type ToggleResult,
44
47
  } from "./protocol.js";
45
48
  import type { ConfigSnapshot } from "./settings.js";
46
49
 
@@ -100,6 +103,14 @@ export type BridgeServerHandlers = {
100
103
  getConfig(): ConfigSnapshot;
101
104
  /** Change daemon settings; returns the fresh snapshot. */
102
105
  setConfig(update: Record<string, unknown>): ConfigSnapshot;
106
+ /** Installed plugins (built-ins + configured entries) with state. */
107
+ listPlugins(): PluginItem[];
108
+ /** Enable/disable a plugin; persists + hot-reloads. */
109
+ setPluginEnabled(name: string, enabled: boolean): Promise<ToggleResult>;
110
+ /** Installed skills with state. */
111
+ listSkills(): SkillItem[];
112
+ /** Enable/disable a skill; rebuilds the prompt index. */
113
+ setSkillEnabled(name: string, enabled: boolean): ToggleResult;
103
114
  /** Fire a daemon-level control action (e.g. "restart", "dream"). */
104
115
  control(action: string): Promise<{ ok: boolean; message: string }>;
105
116
  /** Newest daemon log entries (for the client's log viewer). */
@@ -552,6 +563,32 @@ export class BridgeServer {
552
563
  });
553
564
  }
554
565
 
566
+ if (method === "GET" && path === "/plugins")
567
+ return this.json(res, 200, { plugins: this.handlers.listPlugins() });
568
+
569
+ if (method === "POST" && path === "/plugins/toggle") {
570
+ const body = await this.readJson(req);
571
+ // Always 200: ok/error is an application result the client renders,
572
+ // not an HTTP-level failure (mirrors /backend).
573
+ const result = await this.handlers.setPluginEnabled(
574
+ asString(body.name) ?? "",
575
+ body.enabled === true,
576
+ );
577
+ return this.json(res, 200, result);
578
+ }
579
+
580
+ if (method === "GET" && path === "/skills")
581
+ return this.json(res, 200, { skills: this.handlers.listSkills() });
582
+
583
+ if (method === "POST" && path === "/skills/toggle") {
584
+ const body = await this.readJson(req);
585
+ const result = this.handlers.setSkillEnabled(
586
+ asString(body.name) ?? "",
587
+ body.enabled === true,
588
+ );
589
+ return this.json(res, 200, result);
590
+ }
591
+
555
592
  if (method === "GET" && path === "/config")
556
593
  return this.json(res, 200, this.handlers.getConfig());
557
594
 
@@ -175,15 +175,19 @@ export function applyConfigUpdate(
175
175
  }
176
176
 
177
177
  if (Object.keys(persist).length > 0) {
178
- persistToFile(persist);
178
+ persistConfigPatch(persist);
179
179
  log("native", `Config updated: ${Object.keys(persist).join(", ")}`);
180
180
  }
181
181
 
182
182
  return configSnapshot(config);
183
183
  }
184
184
 
185
- /** Merge a partial update into talon.json on disk, preserving everything else. */
186
- function persistToFile(update: Record<string, unknown>): void {
185
+ /**
186
+ * Merge a partial update into talon.json on disk, preserving everything
187
+ * else. Shared by every bridge surface that persists config changes
188
+ * (settings sync here, plugin toggles in extensions.ts).
189
+ */
190
+ export function persistConfigPatch(update: Record<string, unknown>): void {
187
191
  const file = pathFiles.config;
188
192
  let current: Record<string, unknown> = {};
189
193
  try {
@@ -15,12 +15,14 @@
15
15
  */
16
16
 
17
17
  import {
18
+ cpSync,
18
19
  existsSync,
19
20
  mkdirSync,
20
21
  readFileSync,
21
22
  readdirSync,
22
23
  rmSync,
23
24
  statSync,
25
+ unlinkSync,
24
26
  writeFileSync,
25
27
  } from "node:fs";
26
28
  import { basename, resolve } from "node:path";
@@ -33,6 +35,12 @@ export type Skill = {
33
35
  body: string;
34
36
  path: string;
35
37
  updatedAt: number;
38
+ /**
39
+ * Disabled skills (a `.disabled` marker file in the folder) drop out of
40
+ * the prompt index and `find_skills`, but stay listable and readable —
41
+ * the toggle hides, it never restricts.
42
+ */
43
+ enabled: boolean;
36
44
  /** Relative filenames bundled in the skill folder (excludes SKILL.md). */
37
45
  resources: string[];
38
46
  /** Frontmatter keys beyond name/description, preserved on read. */
@@ -46,6 +54,12 @@ export type SkillSearchResult = {
46
54
  };
47
55
 
48
56
  const SKILL_FILE = "SKILL.md";
57
+ /**
58
+ * Marker file for `talon skill disable`. Lives beside SKILL.md rather than
59
+ * in frontmatter because `save_skill` rewrites SKILL.md wholesale — a
60
+ * sibling file survives every update, and is visible in the filesystem.
61
+ */
62
+ const DISABLED_FILE = ".disabled";
49
63
  const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
50
64
  const DESCRIPTION_MAX_CHARS = 300;
51
65
  const BODY_MAX_BYTES = 128 * 1024;
@@ -109,7 +123,10 @@ function serializeSkill(input: {
109
123
  return ["---", yaml, "---", "", input.body.trimEnd(), ""].join("\n");
110
124
  }
111
125
 
112
- function parseSkill(path: string, raw: string): Omit<Skill, "resources"> {
126
+ function parseSkill(
127
+ path: string,
128
+ raw: string,
129
+ ): Omit<Skill, "resources" | "enabled"> {
113
130
  const fallbackName = basename(resolve(path, ".."));
114
131
  if (!raw.startsWith("---\n")) {
115
132
  return {
@@ -174,7 +191,12 @@ function parseSkill(path: string, raw: string): Omit<Skill, "resources"> {
174
191
  function listResources(dir: string): string[] {
175
192
  try {
176
193
  return readdirSync(dir, { withFileTypes: true })
177
- .filter((entry) => entry.isFile() && entry.name !== SKILL_FILE)
194
+ .filter(
195
+ (entry) =>
196
+ entry.isFile() &&
197
+ entry.name !== SKILL_FILE &&
198
+ entry.name !== DISABLED_FILE,
199
+ )
178
200
  .map((entry) => entry.name)
179
201
  .sort((a, b) => a.localeCompare(b));
180
202
  } catch {
@@ -207,7 +229,11 @@ export function readSkill(name: string): Skill | undefined {
207
229
  if (!existsSync(path)) return undefined;
208
230
  const raw = readFileSync(path, "utf-8");
209
231
  const parsed = parseSkill(path, raw);
210
- const skill: Skill = { ...parsed, resources: listResources(dir) };
232
+ const skill: Skill = {
233
+ ...parsed,
234
+ enabled: !existsSync(resolve(dir, DISABLED_FILE)),
235
+ resources: listResources(dir),
236
+ };
211
237
  try {
212
238
  skill.updatedAt = Math.floor(statSync(path).mtimeMs);
213
239
  } catch {
@@ -271,6 +297,7 @@ export function searchSkills(
271
297
  const tokens = tokenizeQuery(query);
272
298
  if (tokens.length === 0) return [];
273
299
  return listSkills()
300
+ .filter((skill) => skill.enabled)
274
301
  .map((skill) => ({
275
302
  skill,
276
303
  score: scoreSkill(skill, tokens),
@@ -283,6 +310,90 @@ export function searchSkills(
283
310
  .slice(0, Math.max(1, Math.min(50, limit)));
284
311
  }
285
312
 
313
+ /**
314
+ * Toggle a skill's enabled state via the `.disabled` marker. Idempotent.
315
+ * Returns false when the skill doesn't exist (or the name is invalid).
316
+ */
317
+ export function setSkillEnabled(name: string, enabled: boolean): boolean {
318
+ if (validateSkillName(name)) return false;
319
+ if (!existsSync(skillFilePath(name))) return false;
320
+ const marker = resolve(skillDir(name), DISABLED_FILE);
321
+ if (enabled) {
322
+ if (existsSync(marker)) unlinkSync(marker);
323
+ } else {
324
+ writeFileSync(marker, "", { encoding: "utf-8", mode: 0o600 });
325
+ }
326
+ return true;
327
+ }
328
+
329
+ export type SkillInstallResult =
330
+ { ok: true; skill: Skill } | { ok: false; error: string };
331
+
332
+ /**
333
+ * Install a skill from a folder containing SKILL.md (plus any bundled
334
+ * resources) — the store half of `talon skill install`. The target folder
335
+ * name is the frontmatter `name`, so a checkout's directory name never
336
+ * leaks into the store. Refuses to overwrite an existing skill unless
337
+ * `force`, in which case the old folder (including its `.disabled` marker)
338
+ * is replaced wholesale.
339
+ */
340
+ export function installSkillFromDir(
341
+ sourceDir: string,
342
+ options: { force?: boolean } = {},
343
+ ): SkillInstallResult {
344
+ const sourceFile = resolve(sourceDir, SKILL_FILE);
345
+ if (!existsSync(sourceFile)) {
346
+ return { ok: false, error: `No ${SKILL_FILE} in ${sourceDir}` };
347
+ }
348
+
349
+ let raw: string;
350
+ try {
351
+ raw = readFileSync(sourceFile, "utf-8");
352
+ } catch (err) {
353
+ return {
354
+ ok: false,
355
+ error: `Could not read ${sourceFile}: ${err instanceof Error ? err.message : err}`,
356
+ };
357
+ }
358
+
359
+ const parsed = parseSkill(sourceFile, raw);
360
+ const invalid =
361
+ validateSkillName(parsed.name) ??
362
+ validateSkillDescription(parsed.description) ??
363
+ validateSkillBody(parsed.body);
364
+ if (invalid) return { ok: false, error: `Invalid skill: ${invalid}` };
365
+
366
+ const target = skillDir(parsed.name);
367
+ // Installing the store's own folder onto itself would delete the source
368
+ // before the copy (rmSync + cpSync) — refuse rather than lose the skill.
369
+ if (resolve(sourceDir) === target) {
370
+ return {
371
+ ok: false,
372
+ error: `"${parsed.name}" is already the installed copy (${target})`,
373
+ };
374
+ }
375
+ if (existsSync(target)) {
376
+ if (!options.force) {
377
+ return {
378
+ ok: false,
379
+ error: `Skill "${parsed.name}" already exists (use --force to replace)`,
380
+ };
381
+ }
382
+ rmSync(target, { recursive: true, force: true });
383
+ }
384
+
385
+ mkdirSync(skillsDir(), { recursive: true });
386
+ cpSync(sourceDir, target, { recursive: true });
387
+ const skill = readSkill(parsed.name);
388
+ if (!skill) {
389
+ return {
390
+ ok: false,
391
+ error: `Install of "${parsed.name}" failed to read back`,
392
+ };
393
+ }
394
+ return { ok: true, skill };
395
+ }
396
+
286
397
  export function deleteSkill(name: string): boolean {
287
398
  // Reject names that fail validation (e.g. "../escape") before they
288
399
  // are turned into a filesystem path — guards against path traversal.
@@ -297,7 +408,8 @@ export function formatSkill(skill: Skill): string {
297
408
  const updated = skill.updatedAt
298
409
  ? new Date(skill.updatedAt).toISOString().slice(0, 10)
299
410
  : "unknown";
300
- return `- ${skill.name} (updated ${updated}) ${skill.description}`;
411
+ const state = skill.enabled ? "" : " [disabled]";
412
+ return `- ${skill.name} (updated ${updated})${state} — ${skill.description}`;
301
413
  }
302
414
 
303
415
  export function formatSkillSearchResult(result: SkillSearchResult): string {
@@ -307,7 +419,8 @@ export function formatSkillSearchResult(result: SkillSearchResult): string {
307
419
  }
308
420
 
309
421
  export function renderSkillsPrompt(): string {
310
- const skills = listSkills();
422
+ // Disabled skills stay out of the index; `list_skills` still shows them.
423
+ const skills = listSkills().filter((skill) => skill.enabled);
311
424
  if (skills.length === 0) return "";
312
425
  const visible = skills.slice(0, PROMPT_LIST_LIMIT);
313
426
  const hidden = skills.length - visible.length;
@@ -34,6 +34,7 @@ const pluginPathSchema = z
34
34
  .object({
35
35
  path: z.string(),
36
36
  config: z.record(z.string(), z.unknown()).optional(),
37
+ enabled: z.boolean().optional(),
37
38
  })
38
39
  .strict();
39
40
 
@@ -44,6 +45,7 @@ const pluginMcpSchema = z
44
45
  command: z.string(),
45
46
  args: z.array(z.string()).optional(),
46
47
  env: z.record(z.string(), z.string()).optional(),
48
+ enabled: z.boolean().optional(),
47
49
  })
48
50
  .strict();
49
51
 
@@ -55,6 +57,11 @@ const pluginEntrySchema = z
55
57
  command: z.string().optional(),
56
58
  args: z.array(z.string()).optional(),
57
59
  env: z.record(z.string(), z.string()).optional(),
60
+ /**
61
+ * `false` keeps the entry in config but skips loading it — the state
62
+ * behind `talon plugin enable/disable`. Valid on both entry formats.
63
+ */
64
+ enabled: z.boolean().optional(),
58
65
  })
59
66
  .strict()
60
67
  .superRefine((value, ctx) => {
package/src/util/paths.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * Layout:
8
8
  * ~/.talon/
9
9
  * config.json Main configuration
10
+ * plugins/ CLI-installed plugins (npm prefix / git clones)
10
11
  * data/ Internal state
11
12
  * talon.db SQLite — all structured state (sessions,
12
13
  * history, settings, media, goals, scripts,
@@ -71,6 +72,8 @@ export const dirs = {
71
72
  skills: resolve(TALON_ROOT, "workspace", "skills"),
72
73
  /** Key material (bridge TLS identity, release keys): ~/.talon/keys/ */
73
74
  keys: resolve(TALON_ROOT, "keys"),
75
+ /** CLI-installed plugins (`talon plugin install`): ~/.talon/plugins/ */
76
+ plugins: resolve(TALON_ROOT, "plugins"),
74
77
  } as const;
75
78
 
76
79
  // ── Files ──────────────────────────────────────────────────────────────────