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.
- package/README.md +28 -2
- package/package.json +1 -1
- package/src/backend/claude-sdk/one-shot.ts +15 -2
- package/src/backend/codex/one-shot.ts +17 -2
- package/src/backend/kilo/one-shot.ts +4 -2
- package/src/backend/opencode/one-shot.ts +4 -2
- package/src/backend/remote-server/one-shot.ts +21 -2
- 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/agent-runtime/capabilities.ts +4 -1
- package/src/core/background/dream.ts +5 -3
- package/src/core/background/heartbeat/agent.ts +5 -3
- package/src/core/background/isolated-agent.ts +5 -4
- package/src/core/background/job-oneshot.ts +2 -2
- package/src/core/engine/gateway-actions/plugins.ts +36 -19
- package/src/core/engine/gateway.ts +24 -0
- package/src/core/plugin/entries.ts +105 -0
- package/src/core/plugin/loader.ts +14 -2
- package/src/core/plugin/manage.ts +99 -0
- package/src/core/plugin/types.ts +4 -0
- package/src/core/types.ts +12 -0
- package/src/frontend/native/extensions.ts +93 -0
- package/src/frontend/native/index.ts +13 -1
- package/src/frontend/native/protocol.ts +33 -0
- package/src/frontend/native/server.ts +37 -0
- package/src/frontend/native/settings.ts +7 -3
- package/src/storage/skill-store.ts +118 -5
- package/src/util/config.ts +7 -0
- package/src/util/paths.ts +3 -0
package/README.md
CHANGED
|
@@ -17,10 +17,10 @@ Multi-platform agentic AI harness. Runs on **Telegram**, **Discord**, **Microsof
|
|
|
17
17
|
| **Multi-frontend** | Telegram (Grammy + GramJS userbot), Discord (discord.js), Microsoft Teams (Bot Framework), Terminal with live tool visibility, and a **Desktop/Mobile app** (Flutter) over a local/remote bridge |
|
|
18
18
|
| **Pluggable backend** | Claude Agent SDK, Kilo, OpenCode, Codex, OpenAI Agents — selectable per-process via `backend` config. Streaming, model fallback, context-overflow recovery. |
|
|
19
19
|
| **MCP tools** | Messaging, media, history, search, web fetch, cron jobs, triggers, goals, stickers, file system, admin controls |
|
|
20
|
-
| **Plugins** | Hot-reloadable plugin system. Built-in: GitHub, MemPalace, Playwright, Brave Search
|
|
20
|
+
| **Plugins** | Hot-reloadable plugin system with `talon plugin install/enable/disable` (npm, git, or local sources). Built-in: GitHub, MemPalace, Playwright, Brave Search |
|
|
21
21
|
| **Background agents** | Heartbeat (hourly by default — advances goals, proactively messages when something matters) and Dream (memory consolidation + diary) |
|
|
22
22
|
| **Goals** | Persistent multi-day objectives the agent commits to in chat; every heartbeat run re-reads them, makes progress, and records what it did |
|
|
23
|
-
| **Skills** |
|
|
23
|
+
| **Skills** | SKILL.md workflow bundles the agent authors and reuses, with `talon skill install/enable/disable` (local folders, git, or `owner/repo` — the Anthropic skills ecosystem installs directly) |
|
|
24
24
|
| **Triggers** | Self-authored watcher scripts (bash/python/node) that wake the bot when conditions are met |
|
|
25
25
|
| **Task table** | Every unit of agent work — chat turns, heartbeat, dream, isolated cron/trigger jobs — registered live; `talon ps` / `talon kill` |
|
|
26
26
|
| **Event bus** | Typed internal pub-sub spine (task + turn lifecycle events); subsystems subscribe instead of importing each other; `talon events -f` |
|
|
@@ -169,6 +169,32 @@ The app provides multi-chat history, live streaming with reasoning + tool-call v
|
|
|
169
169
|
|
|
170
170
|
---
|
|
171
171
|
|
|
172
|
+
## Managing plugins & skills
|
|
173
|
+
|
|
174
|
+
Both stores are managed from the CLI; changes hot-reload into a running
|
|
175
|
+
daemon (plugins) or apply on the next session (skills):
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
# Plugins — npm specs, git repos, or local paths
|
|
179
|
+
talon plugin install @scope/my-talon-plugin # npm → module plugin
|
|
180
|
+
talon plugin install some-mcp-server --mcp # npm → standalone MCP server (npx)
|
|
181
|
+
talon plugin install owner/repo # git → module plugin
|
|
182
|
+
talon plugin list # built-ins + configured entries
|
|
183
|
+
talon plugin disable github # also toggles built-ins
|
|
184
|
+
talon plugin remove my-talon-plugin
|
|
185
|
+
|
|
186
|
+
# Skills — SKILL.md folders from local paths, git URLs, or owner/repo[/subpath]
|
|
187
|
+
talon skill install anthropics/skills/document-skills/pdf
|
|
188
|
+
talon skill install ./my-skill --force
|
|
189
|
+
talon skill list
|
|
190
|
+
talon skill disable pdf # hidden from the prompt index, still readable
|
|
191
|
+
talon skill remove pdf
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Module plugins install under `~/.talon/plugins/`; standalone MCP servers are
|
|
195
|
+
registered as `npx` entries in `config.json`. Disabling keeps the entry (or a
|
|
196
|
+
`.disabled` marker in the skill folder) so enabling restores it unchanged.
|
|
197
|
+
|
|
172
198
|
## Built-in Plugins
|
|
173
199
|
|
|
174
200
|
### GitHub
|
package/package.json
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { readdir, readFile } from "node:fs/promises";
|
|
15
15
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
16
16
|
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
17
|
-
import type { OneShotAgentParams } from "../../core/types.js";
|
|
17
|
+
import type { OneShotAgentParams, OneShotUsage } from "../../core/types.js";
|
|
18
18
|
import { log } from "../../util/log.js";
|
|
19
19
|
import { ALLOWED_TOOLS_BACKGROUND } from "../../core/constants.js";
|
|
20
20
|
import { buildMcpServers, buildPluginMcpServers } from "./options.js";
|
|
@@ -53,7 +53,7 @@ export function initClaudeOneShot(cfg: OneShotConfig): void {
|
|
|
53
53
|
|
|
54
54
|
export async function runOneShotAgent(
|
|
55
55
|
params: OneShotAgentParams,
|
|
56
|
-
): Promise<void> {
|
|
56
|
+
): Promise<OneShotUsage | void> {
|
|
57
57
|
const {
|
|
58
58
|
prompt,
|
|
59
59
|
systemPrompt,
|
|
@@ -86,9 +86,22 @@ export async function runOneShotAgent(
|
|
|
86
86
|
options: options as Parameters<typeof query>[0]["options"],
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
+
// The final `result` message carries the run's total token usage — the
|
|
90
|
+
// settlement figure the task table records.
|
|
91
|
+
let usage: OneShotUsage | undefined;
|
|
89
92
|
for await (const msg of qi) {
|
|
90
93
|
await formatAndAppendMessage(appendLog, msg);
|
|
94
|
+
if (msg.type === "result") {
|
|
95
|
+
const u = msg.usage;
|
|
96
|
+
usage = {
|
|
97
|
+
inputTokens: u.input_tokens ?? 0,
|
|
98
|
+
outputTokens: u.output_tokens ?? 0,
|
|
99
|
+
cacheRead: u.cache_read_input_tokens ?? 0,
|
|
100
|
+
cacheWrite: u.cache_creation_input_tokens ?? 0,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
91
103
|
}
|
|
104
|
+
return usage;
|
|
92
105
|
}
|
|
93
106
|
|
|
94
107
|
/**
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* fires — no orphan process handling needed.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import type { OneShotAgentParams } from "../../core/types.js";
|
|
20
|
+
import type { OneShotAgentParams, OneShotUsage } from "../../core/types.js";
|
|
21
21
|
import { log, logWarn } from "../../util/log.js";
|
|
22
22
|
import { appendBackendSuffix } from "../shared/index.js";
|
|
23
23
|
import { ensureCodex, getCodexAuthInfo } from "./init.js";
|
|
@@ -69,7 +69,7 @@ function resolveOneShotModel(requested: string): {
|
|
|
69
69
|
|
|
70
70
|
export async function runOneShotAgent(
|
|
71
71
|
params: OneShotAgentParams,
|
|
72
|
-
): Promise<void> {
|
|
72
|
+
): Promise<OneShotUsage | void> {
|
|
73
73
|
const {
|
|
74
74
|
prompt,
|
|
75
75
|
systemPrompt,
|
|
@@ -118,10 +118,25 @@ export async function runOneShotAgent(
|
|
|
118
118
|
signal: abortController.signal,
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
+
// `turn.completed.usage` is cumulative across the run — the last one
|
|
122
|
+
// seen is the settlement figure the task table records.
|
|
123
|
+
let usage: OneShotUsage | undefined;
|
|
121
124
|
for await (const event of events) {
|
|
122
125
|
if (abortController.signal.aborted) break;
|
|
123
126
|
await appendCodexEvent(appendLog, event);
|
|
127
|
+
if (event.type === "turn.completed") {
|
|
128
|
+
const u = (event as { usage?: Record<string, number> }).usage;
|
|
129
|
+
if (u) {
|
|
130
|
+
usage = {
|
|
131
|
+
inputTokens: u.input_tokens ?? 0,
|
|
132
|
+
outputTokens: u.output_tokens ?? 0,
|
|
133
|
+
cacheRead: u.cached_input_tokens ?? 0,
|
|
134
|
+
cacheWrite: 0, // Codex doesn't report cache writes
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
124
138
|
}
|
|
139
|
+
return usage;
|
|
125
140
|
} catch (err) {
|
|
126
141
|
if (
|
|
127
142
|
abortController.signal.aborted ||
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* suffix; everything else is the shared lifecycle.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type { OneShotAgentParams } from "../../core/types.js";
|
|
10
|
+
import type { OneShotAgentParams, OneShotUsage } from "../../core/types.js";
|
|
11
11
|
import { runRemoteOneShotAgent } from "../remote-server/one-shot.js";
|
|
12
12
|
import {
|
|
13
13
|
ensureServer,
|
|
@@ -21,7 +21,9 @@ import {
|
|
|
21
21
|
errMsg,
|
|
22
22
|
} from "./server.js";
|
|
23
23
|
|
|
24
|
-
export function runOneShotAgent(
|
|
24
|
+
export function runOneShotAgent(
|
|
25
|
+
params: OneShotAgentParams,
|
|
26
|
+
): Promise<OneShotUsage | void> {
|
|
25
27
|
return runRemoteOneShotAgent(
|
|
26
28
|
{
|
|
27
29
|
label: "Kilo",
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* suffix; everything else is the shared lifecycle.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type { OneShotAgentParams } from "../../core/types.js";
|
|
10
|
+
import type { OneShotAgentParams, OneShotUsage } from "../../core/types.js";
|
|
11
11
|
import { runRemoteOneShotAgent } from "../remote-server/one-shot.js";
|
|
12
12
|
import {
|
|
13
13
|
ensureServer,
|
|
@@ -21,7 +21,9 @@ import {
|
|
|
21
21
|
errMsg,
|
|
22
22
|
} from "./server.js";
|
|
23
23
|
|
|
24
|
-
export function runOneShotAgent(
|
|
24
|
+
export function runOneShotAgent(
|
|
25
|
+
params: OneShotAgentParams,
|
|
26
|
+
): Promise<OneShotUsage | void> {
|
|
25
27
|
return runRemoteOneShotAgent(
|
|
26
28
|
{
|
|
27
29
|
label: "OpenCode",
|
|
@@ -26,8 +26,12 @@
|
|
|
26
26
|
* model stops spending tokens once the timeout fires.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
-
import type { OneShotAgentParams } from "../../core/types.js";
|
|
29
|
+
import type { OneShotAgentParams, OneShotUsage } from "../../core/types.js";
|
|
30
30
|
import { logWarn } from "../../util/log.js";
|
|
31
|
+
import {
|
|
32
|
+
extractAssistantUsage,
|
|
33
|
+
type RemoteAssistantInfo,
|
|
34
|
+
} from "./session-helpers.js";
|
|
31
35
|
import { appendBackendSuffix } from "../shared/index.js";
|
|
32
36
|
|
|
33
37
|
// ── Client surface ──────────────────────────────────────────────────────────
|
|
@@ -86,7 +90,7 @@ export async function runRemoteOneShotAgent<
|
|
|
86
90
|
>(
|
|
87
91
|
bindings: RemoteOneShotBindings<TClient>,
|
|
88
92
|
params: OneShotAgentParams,
|
|
89
|
-
): Promise<void> {
|
|
93
|
+
): Promise<OneShotUsage | void> {
|
|
90
94
|
const {
|
|
91
95
|
prompt,
|
|
92
96
|
systemPrompt,
|
|
@@ -162,6 +166,21 @@ export async function runRemoteOneShotAgent<
|
|
|
162
166
|
for (const part of parts) {
|
|
163
167
|
await appendResponsePart(appendLog, part);
|
|
164
168
|
}
|
|
169
|
+
|
|
170
|
+
// The prompt response's assistant info carries the run's token usage —
|
|
171
|
+
// the settlement figure the task table records.
|
|
172
|
+
const info = data?.info as RemoteAssistantInfo | undefined;
|
|
173
|
+
if (info) {
|
|
174
|
+
const u = extractAssistantUsage(info);
|
|
175
|
+
if (u.inputTokens > 0 || u.outputTokens > 0) {
|
|
176
|
+
return {
|
|
177
|
+
inputTokens: u.inputTokens,
|
|
178
|
+
outputTokens: u.outputTokens,
|
|
179
|
+
cacheRead: u.cacheRead,
|
|
180
|
+
cacheWrite: u.cacheWrite,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
165
184
|
} finally {
|
|
166
185
|
abortController.signal.removeEventListener("abort", onAbort);
|
|
167
186
|
await bindings.disconnectChatMcpServer(oc, chatMcpServerName);
|
package/src/cli/daemon-api.ts
CHANGED
|
@@ -13,10 +13,11 @@ export async function fetchGateway(
|
|
|
13
13
|
port: number,
|
|
14
14
|
path: string,
|
|
15
15
|
init?: RequestInit,
|
|
16
|
+
timeoutMs: number = REQUEST_TIMEOUT_MS,
|
|
16
17
|
): Promise<unknown> {
|
|
17
18
|
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
|
18
19
|
...init,
|
|
19
|
-
signal: AbortSignal.timeout(
|
|
20
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
20
21
|
});
|
|
21
22
|
if (!response.ok) throw new Error(`gateway answered ${response.status}`);
|
|
22
23
|
return response.json();
|
package/src/cli/index.ts
CHANGED
|
@@ -33,6 +33,8 @@ import { daemonStart, daemonStop, daemonRestart } from "./daemon.js";
|
|
|
33
33
|
import { showTasks, killTask } from "./tasks.js";
|
|
34
34
|
import { showEvents } from "./events.js";
|
|
35
35
|
import { showLs, showCat } from "./fs.js";
|
|
36
|
+
import { runPluginCommand } from "./plugin.js";
|
|
37
|
+
import { runSkillCommand } from "./skill.js";
|
|
36
38
|
import { mainMenu } from "./menu.js";
|
|
37
39
|
|
|
38
40
|
export * from "./context.js";
|
|
@@ -63,6 +65,8 @@ const CLI_COMMANDS = [
|
|
|
63
65
|
"events",
|
|
64
66
|
"ls",
|
|
65
67
|
"cat",
|
|
68
|
+
"plugin",
|
|
69
|
+
"skill",
|
|
66
70
|
];
|
|
67
71
|
|
|
68
72
|
/** Route a `talon <command>` invocation. Called by the entry point. */
|
|
@@ -128,6 +132,12 @@ export async function runCli(): Promise<void> {
|
|
|
128
132
|
case "cat":
|
|
129
133
|
await showCat(process.argv[3]);
|
|
130
134
|
break;
|
|
135
|
+
case "plugin":
|
|
136
|
+
await runPluginCommand(process.argv.slice(3));
|
|
137
|
+
break;
|
|
138
|
+
case "skill":
|
|
139
|
+
await runSkillCommand(process.argv.slice(3));
|
|
140
|
+
break;
|
|
131
141
|
case "--version":
|
|
132
142
|
case "-v": {
|
|
133
143
|
console.log(pkg.version);
|
|
@@ -158,6 +168,12 @@ export async function runCli(): Promise<void> {
|
|
|
158
168
|
console.log(
|
|
159
169
|
` ${pc.cyan("cat")} Read a talon:// file (cat proc/events)`,
|
|
160
170
|
);
|
|
171
|
+
console.log(
|
|
172
|
+
` ${pc.cyan("plugin")} Manage plugins (install/enable/disable)`,
|
|
173
|
+
);
|
|
174
|
+
console.log(
|
|
175
|
+
` ${pc.cyan("skill")} Manage skills (install/enable/disable)`,
|
|
176
|
+
);
|
|
161
177
|
console.log(` ${pc.cyan("config")} View/edit configuration`);
|
|
162
178
|
console.log(` ${pc.cyan("logs")} Tail log file`);
|
|
163
179
|
console.log(` ${pc.cyan("doctor")} Validate environment`);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install-source resolution shared by `talon plugin install` and
|
|
3
|
+
* `talon skill install`.
|
|
4
|
+
*
|
|
5
|
+
* One grammar for both commands, checked in order:
|
|
6
|
+
*
|
|
7
|
+
* 1. an existing local path → { kind: "local" }
|
|
8
|
+
* 2. a git URL (scheme, `git@`, or `.git`) → { kind: "git" }
|
|
9
|
+
* 3. `owner/repo[/subpath]` shorthand → { kind: "git" } on github.com
|
|
10
|
+
* 4. anything else → { kind: "other" } — the caller
|
|
11
|
+
* decides (plugins treat it as an npm spec, skills reject it)
|
|
12
|
+
*
|
|
13
|
+
* Cloning always uses `--depth=1` (installs never need history) and spawns
|
|
14
|
+
* `git`/`npm` via cross-spawn, which resolves the `.cmd`/`.exe` shims on
|
|
15
|
+
* Windows — never assume a POSIX shell here.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import crossSpawn from "cross-spawn";
|
|
19
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join, resolve } from "node:path";
|
|
22
|
+
|
|
23
|
+
export type ResolvedSource =
|
|
24
|
+
| { kind: "local"; dir: string }
|
|
25
|
+
| { kind: "git"; url: string; subpath?: string }
|
|
26
|
+
| { kind: "other"; raw: string };
|
|
27
|
+
|
|
28
|
+
const GIT_URL_RE = /^(https?|git|ssh):\/\//;
|
|
29
|
+
/** `owner/repo` or `owner/repo/sub/path` — never an npm scope (`@…`). */
|
|
30
|
+
const GITHUB_SHORTHAND_RE =
|
|
31
|
+
/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)((?:\/[^\s/]+)*)$/;
|
|
32
|
+
|
|
33
|
+
export function resolveSource(raw: string): ResolvedSource {
|
|
34
|
+
const trimmed = raw.trim();
|
|
35
|
+
if (existsSync(resolve(trimmed))) {
|
|
36
|
+
return { kind: "local", dir: resolve(trimmed) };
|
|
37
|
+
}
|
|
38
|
+
if (
|
|
39
|
+
GIT_URL_RE.test(trimmed) ||
|
|
40
|
+
trimmed.startsWith("git@") ||
|
|
41
|
+
trimmed.endsWith(".git")
|
|
42
|
+
) {
|
|
43
|
+
return { kind: "git", url: trimmed };
|
|
44
|
+
}
|
|
45
|
+
if (!trimmed.startsWith("@")) {
|
|
46
|
+
const match = GITHUB_SHORTHAND_RE.exec(trimmed);
|
|
47
|
+
if (match) {
|
|
48
|
+
const [, owner, repo, rest] = match;
|
|
49
|
+
return {
|
|
50
|
+
kind: "git",
|
|
51
|
+
url: `https://github.com/${owner}/${repo}.git`,
|
|
52
|
+
...(rest ? { subpath: rest.slice(1) } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { kind: "other", raw: trimmed };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type CommandOutcome = { ok: true } | { ok: false; error: string };
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Run a tool from PATH synchronously. `inherit` streams output to the
|
|
63
|
+
* terminal (npm installs); otherwise stderr is captured for the error.
|
|
64
|
+
*/
|
|
65
|
+
export function runTool(
|
|
66
|
+
tool: string,
|
|
67
|
+
args: string[],
|
|
68
|
+
options: { cwd?: string; inherit?: boolean } = {},
|
|
69
|
+
): CommandOutcome {
|
|
70
|
+
const result = crossSpawn.sync(tool, args, {
|
|
71
|
+
cwd: options.cwd,
|
|
72
|
+
stdio: options.inherit ? "inherit" : ["ignore", "ignore", "pipe"],
|
|
73
|
+
});
|
|
74
|
+
if (result.error) {
|
|
75
|
+
const missing = (result.error as NodeJS.ErrnoException).code === "ENOENT";
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
error: missing
|
|
79
|
+
? `${tool} is not installed or not on PATH`
|
|
80
|
+
: result.error.message,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (result.status !== 0) {
|
|
84
|
+
const stderr = result.stderr?.toString().trim();
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
error: stderr || `${tool} ${args[0]} exited with ${result.status}`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return { ok: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type CloneOutcome =
|
|
94
|
+
{ ok: true; dir: string; cleanup: () => void } | { ok: false; error: string };
|
|
95
|
+
|
|
96
|
+
/** Shallow-clone into a fresh temp directory. Caller must run `cleanup`. */
|
|
97
|
+
export function cloneShallow(url: string): CloneOutcome {
|
|
98
|
+
const dir = mkdtempSync(join(tmpdir(), "talon-install-"));
|
|
99
|
+
const cleanup = () => rmSync(dir, { recursive: true, force: true });
|
|
100
|
+
const outcome = runTool("git", ["clone", "--depth=1", url, dir]);
|
|
101
|
+
if (!outcome.ok) {
|
|
102
|
+
cleanup();
|
|
103
|
+
return { ok: false, error: `Clone failed: ${outcome.error}` };
|
|
104
|
+
}
|
|
105
|
+
return { ok: true, dir, cleanup };
|
|
106
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers over the config `plugins` array for the `talon plugin`
|
|
3
|
+
* command group. The CLI edits config.json as JSON — entries here are the
|
|
4
|
+
* on-disk shape (`core/plugin/types.ts` PluginEntry), not loaded plugins.
|
|
5
|
+
* No filesystem or process access — everything is testable data-in/data-out.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** On-disk plugin entry — path-based module or standalone MCP server. */
|
|
9
|
+
export type PluginEntryJson = {
|
|
10
|
+
path?: string;
|
|
11
|
+
config?: Record<string, unknown>;
|
|
12
|
+
name?: string;
|
|
13
|
+
command?: string;
|
|
14
|
+
args?: string[];
|
|
15
|
+
env?: Record<string, string>;
|
|
16
|
+
enabled?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Built-in plugins toggled by their own config sections (`github.enabled`,
|
|
21
|
+
* …) rather than `plugins[]` entries — see core/plugin/builtins.ts.
|
|
22
|
+
*/
|
|
23
|
+
export const BUILTIN_PLUGINS = [
|
|
24
|
+
"github",
|
|
25
|
+
"mempalace",
|
|
26
|
+
"mem0",
|
|
27
|
+
"playwright",
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
export function isBuiltinPlugin(name: string): boolean {
|
|
31
|
+
return (BUILTIN_PLUGINS as readonly string[]).includes(name);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Split a config path on either separator — Windows configs carry `\`. */
|
|
35
|
+
function pathSegments(path: string): string[] {
|
|
36
|
+
return path.split(/[\\/]+/).filter(Boolean);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Display name for an entry: the MCP name, or for path entries the package
|
|
41
|
+
* name — segments after the last `node_modules` (preserving `@scope/pkg`),
|
|
42
|
+
* else the folder basename.
|
|
43
|
+
*/
|
|
44
|
+
export function entryDisplayName(entry: PluginEntryJson): string {
|
|
45
|
+
if (entry.name) return entry.name;
|
|
46
|
+
if (!entry.path) return "(invalid entry)";
|
|
47
|
+
const segments = pathSegments(entry.path);
|
|
48
|
+
const nm = segments.lastIndexOf("node_modules");
|
|
49
|
+
if (nm >= 0 && nm < segments.length - 1) {
|
|
50
|
+
return segments.slice(nm + 1).join("/");
|
|
51
|
+
}
|
|
52
|
+
return segments[segments.length - 1] ?? entry.path;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Does `token` identify this entry? Display name, basename, or full path. */
|
|
56
|
+
export function entryMatches(entry: PluginEntryJson, token: string): boolean {
|
|
57
|
+
if (entryDisplayName(entry) === token) return true;
|
|
58
|
+
if (!entry.path) return false;
|
|
59
|
+
const segments = pathSegments(entry.path);
|
|
60
|
+
return entry.path === token || segments[segments.length - 1] === token;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function findEntryIndex(
|
|
64
|
+
entries: PluginEntryJson[],
|
|
65
|
+
token: string,
|
|
66
|
+
): number {
|
|
67
|
+
return entries.findIndex((entry) => entryMatches(entry, token));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Index of an entry with the same identity as `candidate` (same module
|
|
72
|
+
* path or same MCP name) — the duplicate an install must replace.
|
|
73
|
+
*/
|
|
74
|
+
export function findConflictIndex(
|
|
75
|
+
entries: PluginEntryJson[],
|
|
76
|
+
candidate: PluginEntryJson,
|
|
77
|
+
): number {
|
|
78
|
+
return entries.findIndex((entry) =>
|
|
79
|
+
candidate.path !== undefined
|
|
80
|
+
? entry.path === candidate.path
|
|
81
|
+
: entry.name !== undefined && entry.name === candidate.name,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Copy of `entry` with the enabled state applied. Enabled is the default,
|
|
87
|
+
* so enabling removes the key rather than writing `enabled: true`.
|
|
88
|
+
*/
|
|
89
|
+
export function withEnabled(
|
|
90
|
+
entry: PluginEntryJson,
|
|
91
|
+
enabled: boolean,
|
|
92
|
+
): PluginEntryJson {
|
|
93
|
+
const { enabled: _drop, ...rest } = entry;
|
|
94
|
+
return enabled ? rest : { ...rest, enabled: false };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Package name of an npm spec, version stripped: `pkg@1.2` → `pkg`,
|
|
99
|
+
* `@scope/pkg@^1` → `@scope/pkg`. A leading `@` is the scope, not a version.
|
|
100
|
+
*/
|
|
101
|
+
export function npmSpecName(spec: string): string {
|
|
102
|
+
const at = spec.lastIndexOf("@");
|
|
103
|
+
return at > 0 ? spec.slice(0, at) : spec;
|
|
104
|
+
}
|