talon-agent 1.50.0 → 1.51.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/README.md +28 -2
- package/package.json +1 -1
- package/src/cli/daemon-api.ts +2 -1
- package/src/cli/index.ts +16 -0
- package/src/cli/install-sources.ts +106 -0
- package/src/cli/plugin-entries.ts +104 -0
- package/src/cli/plugin.ts +494 -0
- package/src/cli/skill.ts +252 -0
- package/src/core/engine/gateway-actions/plugins.ts +36 -19
- package/src/core/engine/gateway.ts +24 -0
- package/src/core/plugin/loader.ts +14 -2
- package/src/core/plugin/types.ts +4 -0
- package/src/storage/skill-store.ts +118 -5
- package/src/util/config.ts +7 -0
- package/src/util/paths.ts +3 -0
|
@@ -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 "./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
|
+
}
|