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
|
@@ -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
|
+
}
|
package/src/core/plugin/types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
186
|
-
|
|
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(
|
|
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(
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/util/config.ts
CHANGED
|
@@ -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 ──────────────────────────────────────────────────────────────────
|