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,494 @@
1
+ /**
2
+ * `talon plugin` — install / list / enable / disable / remove plugins.
3
+ *
4
+ * Plugins live in two places, and this command manages both:
5
+ * - built-ins toggled by their own config sections (`github.enabled`, …);
6
+ * - the `plugins` array: path-based modules and standalone MCP servers.
7
+ *
8
+ * Every mutation edits config.json, then asks a running daemon to hot-reload
9
+ * via `POST /plugins/reload`; when the daemon is down the change simply
10
+ * applies on the next start.
11
+ *
12
+ * Install sources (see cli/install-sources.ts for the shared grammar):
13
+ * local path and git checkouts become module entries under ~/.talon/plugins;
14
+ * an npm spec installs there too, or registers an `npx` MCP entry with
15
+ * `--mcp`. Windows-safe throughout — tools are spawned via cross-spawn.
16
+ */
17
+
18
+ import pc from "picocolors";
19
+ import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs";
20
+ import { basename, isAbsolute, join, relative, resolve } from "node:path";
21
+ import { dirs } from "../util/paths.js";
22
+ import { ENTRY_CANDIDATES } from "../core/plugin/loader.js";
23
+ import { findRunningInstance } from "../core/daemon/discovery.js";
24
+ import { fetchGateway } from "./daemon-api.js";
25
+ import { loadConfig, saveConfig, type Config } from "./config.js";
26
+ import {
27
+ cloneShallow,
28
+ resolveSource,
29
+ runTool,
30
+ type ResolvedSource,
31
+ } from "./install-sources.js";
32
+ import {
33
+ BUILTIN_PLUGINS,
34
+ entryDisplayName,
35
+ findConflictIndex,
36
+ findEntryIndex,
37
+ isBuiltinPlugin,
38
+ npmSpecName,
39
+ withEnabled,
40
+ type PluginEntryJson,
41
+ } from "../core/plugin/entries.js";
42
+
43
+ /** Plugin init can legitimately take ~30s (MemPalace) — outlive it. */
44
+ const RELOAD_TIMEOUT_MS = 60_000;
45
+
46
+ const USAGE = [
47
+ ` Usage: ${pc.cyan("talon plugin <command>")}`,
48
+ "",
49
+ " Commands:",
50
+ ` ${pc.cyan("list")} Show built-ins and configured plugins`,
51
+ ` ${pc.cyan("install <source>")} Add a plugin (local path, git URL,`,
52
+ " owner/repo, or npm spec)",
53
+ ` ${pc.cyan("enable <name>")} Enable a plugin`,
54
+ ` ${pc.cyan("disable <name>")} Disable a plugin (kept in config)`,
55
+ ` ${pc.cyan("remove <name>")} Remove a plugin entry (and its install)`,
56
+ "",
57
+ " Install flags:",
58
+ ` ${pc.cyan("--mcp")} Register an npm spec as a standalone`,
59
+ " MCP server (npx) instead of a module",
60
+ ` ${pc.cyan("--name <name>")} Override the derived plugin name`,
61
+ ` ${pc.cyan("--force")} Replace an existing install/entry`,
62
+ "",
63
+ ].join("\n");
64
+
65
+ function entries(config: Config): PluginEntryJson[] {
66
+ if (!Array.isArray(config.plugins)) config.plugins = [];
67
+ return config.plugins as PluginEntryJson[];
68
+ }
69
+
70
+ function builtinSection(
71
+ config: Config,
72
+ name: string,
73
+ ): Record<string, unknown> | undefined {
74
+ const section = (config as unknown as Record<string, unknown>)[name];
75
+ return section && typeof section === "object"
76
+ ? (section as Record<string, unknown>)
77
+ : undefined;
78
+ }
79
+
80
+ function ok(message: string): void {
81
+ console.log(` ${pc.green("●")} ${message}`);
82
+ }
83
+
84
+ function fail(message: string): void {
85
+ console.log(` ${pc.red("✖")} ${message}`);
86
+ }
87
+
88
+ /**
89
+ * Ask a running daemon to hot-reload. Daemon down is not an error — the
90
+ * config change applies on next start.
91
+ */
92
+ async function requestReload(): Promise<void> {
93
+ const instance = await findRunningInstance();
94
+ if (!instance?.port) {
95
+ console.log(
96
+ ` ${pc.dim("Talon is not running — the change applies on next start.")}`,
97
+ );
98
+ return;
99
+ }
100
+ try {
101
+ const body = (await fetchGateway(
102
+ instance.port,
103
+ "/plugins/reload",
104
+ { method: "POST" },
105
+ RELOAD_TIMEOUT_MS,
106
+ )) as { ok?: boolean; loaded?: string[]; error?: string };
107
+ if (body.ok) {
108
+ const loaded = body.loaded ?? [];
109
+ ok(
110
+ `Daemon reloaded plugins (${loaded.length} loaded${
111
+ loaded.length > 0 ? `: ${loaded.join(", ")}` : ""
112
+ }).`,
113
+ );
114
+ } else {
115
+ fail(`Daemon reload failed: ${body.error ?? "unknown error"}`);
116
+ }
117
+ } catch (err) {
118
+ fail(
119
+ `Could not reach the daemon to reload: ${err instanceof Error ? err.message : err}`,
120
+ );
121
+ }
122
+ }
123
+
124
+ function hasModuleEntryPoint(dir: string): boolean {
125
+ return ENTRY_CANDIDATES.some((candidate) =>
126
+ existsSync(resolve(dir, candidate)),
127
+ );
128
+ }
129
+
130
+ /**
131
+ * Is `path` inside the managed ~/.talon/plugins tree? Cross-drive Windows
132
+ * paths make `relative()` return an absolute path, hence the isAbsolute
133
+ * guard alongside the usual `..` escape check.
134
+ */
135
+ function isManagedPath(path: string): boolean {
136
+ const rel = relative(dirs.plugins, resolve(path));
137
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
138
+ }
139
+
140
+ // ── list ────────────────────────────────────────────────────────────────────
141
+
142
+ type Row = { name: string; kind: string; state: string; source: string };
143
+
144
+ function listRows(config: Config): Row[] {
145
+ const builtins: Row[] = BUILTIN_PLUGINS.map((name) => ({
146
+ name,
147
+ kind: "built-in",
148
+ state:
149
+ builtinSection(config, name)?.enabled === true ? "enabled" : "disabled",
150
+ source: `config.${name}`,
151
+ }));
152
+ const configured: Row[] = entries(config).map((entry) => ({
153
+ name: entryDisplayName(entry),
154
+ kind: entry.path !== undefined ? "module" : "mcp",
155
+ state: entry.enabled === false ? "disabled" : "enabled",
156
+ source:
157
+ entry.path ??
158
+ `${entry.command ?? "?"}${entry.args ? ` ${entry.args.join(" ")}` : ""}`,
159
+ }));
160
+ return [...builtins, ...configured];
161
+ }
162
+
163
+ function cmdList(): void {
164
+ const rows = listRows(loadConfig());
165
+ const header = ["NAME", "KIND", "STATE", "SOURCE"];
166
+ const cells = rows.map((r) => [r.name, r.kind, r.state, r.source]);
167
+ const widths = header.map((h, col) =>
168
+ Math.max(h.length, ...cells.map((r) => r[col]!.length)),
169
+ );
170
+ const pad = (row: string[]) =>
171
+ row.map((cell, col) => cell.padEnd(widths[col]!));
172
+
173
+ console.log(` ${pc.dim(pad(header).join(" "))}`);
174
+ rows.forEach((row, i) => {
175
+ const padded = pad(cells[i]!);
176
+ padded[2] =
177
+ (row.state === "enabled" ? pc.green(row.state) : pc.dim(row.state)) +
178
+ " ".repeat(widths[2]! - row.state.length);
179
+ console.log(` ${padded.join(" ")}`);
180
+ });
181
+ console.log();
182
+ }
183
+
184
+ // ── install ─────────────────────────────────────────────────────────────────
185
+
186
+ type InstallFlags = { mcp: boolean; force: boolean; name?: string };
187
+
188
+ function parseInstallArgs(
189
+ args: string[],
190
+ ): { source: string; flags: InstallFlags } | null {
191
+ const flags: InstallFlags = { mcp: false, force: false };
192
+ let source: string | undefined;
193
+ for (let i = 0; i < args.length; i++) {
194
+ const arg = args[i]!;
195
+ if (arg === "--mcp") flags.mcp = true;
196
+ else if (arg === "--force") flags.force = true;
197
+ else if (arg === "--name") flags.name = args[++i];
198
+ else if (!arg.startsWith("-") && source === undefined) source = arg;
199
+ else return null;
200
+ }
201
+ if (!source || (flags.name !== undefined && !flags.name)) return null;
202
+ return { source, flags };
203
+ }
204
+
205
+ type EntryOutcome =
206
+ { ok: true; entry: PluginEntryJson } | { ok: false; error: string };
207
+
208
+ function installFromLocalDir(dir: string): EntryOutcome {
209
+ if (existsSync(resolve(dir, "SKILL.md"))) {
210
+ return {
211
+ ok: false,
212
+ error: `${dir} looks like a skill — use ${pc.cyan("talon skill install")}`,
213
+ };
214
+ }
215
+ if (!hasModuleEntryPoint(dir)) {
216
+ return {
217
+ ok: false,
218
+ error: `No plugin entry point in ${dir} (expected one of: ${ENTRY_CANDIDATES.join(", ")})`,
219
+ };
220
+ }
221
+ return { ok: true, entry: { path: dir } };
222
+ }
223
+
224
+ /**
225
+ * Clone (or copy a clone subpath) into ~/.talon/plugins/<name>. The
226
+ * checkout is staged and validated (deps installed, entry point present)
227
+ * entirely inside the temp clone; an existing install is only replaced
228
+ * once the staged copy is known-good, so a failed install never destroys
229
+ * a working one.
230
+ */
231
+ function installFromGit(
232
+ source: Extract<ResolvedSource, { kind: "git" }>,
233
+ flags: InstallFlags,
234
+ ): EntryOutcome {
235
+ const derived = source.subpath
236
+ ? basename(source.subpath)
237
+ : basename(source.url, ".git");
238
+ const name = flags.name ?? derived;
239
+ const target = join(dirs.plugins, name);
240
+ if (existsSync(target) && !flags.force) {
241
+ return {
242
+ ok: false,
243
+ error: `${target} already exists (use --force to replace)`,
244
+ };
245
+ }
246
+
247
+ const clone = cloneShallow(source.url);
248
+ if (!clone.ok) return { ok: false, error: clone.error };
249
+ try {
250
+ const stage = source.subpath
251
+ ? resolve(clone.dir, source.subpath)
252
+ : clone.dir;
253
+ if (!existsSync(stage)) {
254
+ return {
255
+ ok: false,
256
+ error: `Path "${source.subpath}" not found in ${source.url}`,
257
+ };
258
+ }
259
+ // An install is a snapshot, not a checkout — updates go through
260
+ // `install --force`, so the clone's history has no business here.
261
+ rmSync(join(stage, ".git"), { recursive: true, force: true });
262
+
263
+ if (existsSync(join(stage, "package.json"))) {
264
+ console.log(` ${pc.dim("Installing dependencies…")}`);
265
+ const deps = runTool("npm", ["install", "--omit=dev"], {
266
+ cwd: stage,
267
+ inherit: true,
268
+ });
269
+ if (!deps.ok) {
270
+ return { ok: false, error: `Dependency install failed: ${deps.error}` };
271
+ }
272
+ }
273
+ if (!hasModuleEntryPoint(stage)) {
274
+ return {
275
+ ok: false,
276
+ error: `No plugin entry point in the checkout (expected one of: ${ENTRY_CANDIDATES.join(", ")})`,
277
+ };
278
+ }
279
+
280
+ mkdirSync(dirs.plugins, { recursive: true });
281
+ rmSync(target, { recursive: true, force: true });
282
+ cpSync(stage, target, { recursive: true });
283
+ return { ok: true, entry: { path: target } };
284
+ } finally {
285
+ clone.cleanup();
286
+ }
287
+ }
288
+
289
+ function installFromNpm(spec: string, flags: InstallFlags): EntryOutcome {
290
+ if (flags.mcp) {
291
+ const pkg = npmSpecName(spec);
292
+ const name = flags.name ?? pkg.split("/").pop()!;
293
+ // npx resolves the package at daemon start; the mcp-launcher wraps the
294
+ // spawn, so `.cmd` shims work on Windows.
295
+ return {
296
+ ok: true,
297
+ entry: { name, command: "npx", args: ["-y", spec] },
298
+ };
299
+ }
300
+
301
+ mkdirSync(dirs.plugins, { recursive: true });
302
+ console.log(` ${pc.dim(`Installing ${spec} from npm…`)}`);
303
+ const install = runTool("npm", ["install", "--prefix", dirs.plugins, spec], {
304
+ inherit: true,
305
+ });
306
+ if (!install.ok) return { ok: false, error: install.error };
307
+
308
+ const pkg = npmSpecName(spec);
309
+ const moduleDir = join(dirs.plugins, "node_modules", ...pkg.split("/"));
310
+ if (!hasModuleEntryPoint(moduleDir)) {
311
+ runTool("npm", ["uninstall", "--prefix", dirs.plugins, pkg]);
312
+ return {
313
+ ok: false,
314
+ error:
315
+ `${pkg} has no Talon plugin entry point — if it is a standalone ` +
316
+ `MCP server, install it with ${pc.cyan(`talon plugin install ${spec} --mcp`)}`,
317
+ };
318
+ }
319
+ return { ok: true, entry: { path: moduleDir } };
320
+ }
321
+
322
+ async function cmdInstall(args: string[]): Promise<void> {
323
+ const parsed = parseInstallArgs(args);
324
+ if (!parsed) {
325
+ console.log(USAGE);
326
+ process.exitCode = 1;
327
+ return;
328
+ }
329
+ const { source, flags } = parsed;
330
+
331
+ const resolved = resolveSource(source);
332
+ let outcome: EntryOutcome;
333
+ switch (resolved.kind) {
334
+ case "local":
335
+ outcome = installFromLocalDir(resolved.dir);
336
+ break;
337
+ case "git":
338
+ outcome = installFromGit(resolved, flags);
339
+ break;
340
+ case "other":
341
+ outcome = installFromNpm(resolved.raw, flags);
342
+ break;
343
+ }
344
+ if (!outcome.ok) {
345
+ fail(outcome.error);
346
+ process.exitCode = 1;
347
+ return;
348
+ }
349
+
350
+ const config = loadConfig();
351
+ const list = entries(config);
352
+ const conflict = findConflictIndex(list, outcome.entry);
353
+ if (conflict >= 0) {
354
+ if (!flags.force) {
355
+ fail(
356
+ `"${entryDisplayName(outcome.entry)}" is already configured (use --force to replace).`,
357
+ );
358
+ process.exitCode = 1;
359
+ return;
360
+ }
361
+ list[conflict] = outcome.entry;
362
+ } else {
363
+ list.push(outcome.entry);
364
+ }
365
+ saveConfig(config);
366
+
367
+ const name = entryDisplayName(outcome.entry);
368
+ ok(
369
+ outcome.entry.path !== undefined
370
+ ? `Installed module plugin ${pc.bold(name)} (${outcome.entry.path}).`
371
+ : `Registered MCP server ${pc.bold(name)} (${outcome.entry.command} ${outcome.entry.args?.join(" ") ?? ""}).`,
372
+ );
373
+ await requestReload();
374
+ console.log();
375
+ }
376
+
377
+ // ── enable / disable ────────────────────────────────────────────────────────
378
+
379
+ async function cmdSetEnabled(
380
+ name: string | undefined,
381
+ enabled: boolean,
382
+ ): Promise<void> {
383
+ const verb = enabled ? "enable" : "disable";
384
+ if (!name) {
385
+ fail(`Usage: ${pc.cyan(`talon plugin ${verb} <name>`)}`);
386
+ process.exitCode = 1;
387
+ return;
388
+ }
389
+
390
+ const config = loadConfig();
391
+ if (isBuiltinPlugin(name)) {
392
+ const section = builtinSection(config, name) ?? {};
393
+ (config as unknown as Record<string, unknown>)[name] = {
394
+ ...section,
395
+ enabled,
396
+ };
397
+ saveConfig(config);
398
+ ok(`Built-in ${pc.bold(name)} ${verb}d.`);
399
+ if (enabled && Object.keys(section).length === 0) {
400
+ console.log(
401
+ ` ${pc.dim(`${name} may need additional config — see the README's plugin section.`)}`,
402
+ );
403
+ }
404
+ } else {
405
+ const list = entries(config);
406
+ const index = findEntryIndex(list, name);
407
+ if (index < 0) {
408
+ fail(`No plugin named "${name}" — see ${pc.cyan("talon plugin list")}.`);
409
+ process.exitCode = 1;
410
+ return;
411
+ }
412
+ list[index] = withEnabled(list[index]!, enabled);
413
+ saveConfig(config);
414
+ ok(`Plugin ${pc.bold(entryDisplayName(list[index]!))} ${verb}d.`);
415
+ }
416
+ await requestReload();
417
+ console.log();
418
+ }
419
+
420
+ // ── remove ──────────────────────────────────────────────────────────────────
421
+
422
+ async function cmdRemove(name: string | undefined): Promise<void> {
423
+ if (!name) {
424
+ fail(`Usage: ${pc.cyan("talon plugin remove <name>")}`);
425
+ process.exitCode = 1;
426
+ return;
427
+ }
428
+ if (isBuiltinPlugin(name)) {
429
+ fail(
430
+ `${name} is built-in — use ${pc.cyan(`talon plugin disable ${name}`)} instead.`,
431
+ );
432
+ process.exitCode = 1;
433
+ return;
434
+ }
435
+
436
+ const config = loadConfig();
437
+ const list = entries(config);
438
+ const index = findEntryIndex(list, name);
439
+ if (index < 0) {
440
+ fail(`No plugin named "${name}" — see ${pc.cyan("talon plugin list")}.`);
441
+ process.exitCode = 1;
442
+ return;
443
+ }
444
+ const [removed] = list.splice(index, 1);
445
+ saveConfig(config);
446
+
447
+ // Clean up installs this CLI owns (~/.talon/plugins); never touch
448
+ // user-managed paths elsewhere on disk.
449
+ const path = removed!.path;
450
+ if (path && isManagedPath(path)) {
451
+ if (path.split(/[\\/]/).includes("node_modules")) {
452
+ runTool("npm", [
453
+ "uninstall",
454
+ "--prefix",
455
+ dirs.plugins,
456
+ entryDisplayName(removed!),
457
+ ]);
458
+ } else {
459
+ rmSync(path, { recursive: true, force: true });
460
+ }
461
+ }
462
+
463
+ ok(`Removed plugin ${pc.bold(entryDisplayName(removed!))}.`);
464
+ await requestReload();
465
+ console.log();
466
+ }
467
+
468
+ // ── dispatch ────────────────────────────────────────────────────────────────
469
+
470
+ export async function runPluginCommand(args: string[]): Promise<void> {
471
+ console.log();
472
+ const [subcommand, ...rest] = args;
473
+ switch (subcommand) {
474
+ case "list":
475
+ case undefined:
476
+ cmdList();
477
+ break;
478
+ case "install":
479
+ await cmdInstall(rest);
480
+ break;
481
+ case "enable":
482
+ await cmdSetEnabled(rest[0], true);
483
+ break;
484
+ case "disable":
485
+ await cmdSetEnabled(rest[0], false);
486
+ break;
487
+ case "remove":
488
+ await cmdRemove(rest[0]);
489
+ break;
490
+ default:
491
+ console.log(USAGE);
492
+ process.exitCode = 1;
493
+ }
494
+ }