privateer-agent 0.3.6 → 0.4.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.
Files changed (41) hide show
  1. package/bin/privateer-daemon.mjs +30 -0
  2. package/bin/privateer-subagent.mjs +68 -0
  3. package/bin/privateer-tui +19 -0
  4. package/extensions/privateer-brand.ts +61 -20
  5. package/extensions/privateer-gate.ts +290 -3
  6. package/package.json +4 -1
  7. package/src/auth/privateer.ts +45 -6
  8. package/src/channels/bridge.ts +293 -0
  9. package/src/channels/discord.ts +210 -0
  10. package/src/channels/run.ts +383 -0
  11. package/src/channels/slack.ts +176 -0
  12. package/src/channels/status.ts +54 -0
  13. package/src/channels/telegram.ts +139 -0
  14. package/src/channels/types.ts +36 -0
  15. package/src/channels/whatsapp.ts +178 -0
  16. package/src/cli/chat.ts +389 -30
  17. package/src/cli/daemonCli.ts +67 -0
  18. package/src/crypto/accountTrust.ts +113 -0
  19. package/src/crypto/accountVerify.ts +138 -0
  20. package/src/crypto/terminalKey.ts +95 -0
  21. package/src/crypto/terminalUnseal.ts +62 -0
  22. package/src/daemon/index.ts +511 -46
  23. package/src/daemon/service.ts +232 -0
  24. package/src/ext/permissionGate.ts +38 -0
  25. package/src/permissions/classify.ts +49 -5
  26. package/src/remote/channelsControl.ts +192 -0
  27. package/src/remote/controlAuth.ts +67 -0
  28. package/src/remote/extensionsControl.ts +140 -0
  29. package/src/remote/liveTaskSession.ts +218 -0
  30. package/src/remote/relayClient.ts +512 -1
  31. package/src/remote/remoteBridge.ts +172 -0
  32. package/src/remote/routinesControl.ts +216 -0
  33. package/src/remote/skillsControl.ts +205 -0
  34. package/src/remote/subagentChannel.ts +261 -0
  35. package/src/remote/subagentRelay.ts +126 -0
  36. package/src/remote/workflowsControl.ts +132 -0
  37. package/src/routines/store.ts +5 -1
  38. package/src/workflows/expr.ts +4 -0
  39. package/src/workflows/runner.ts +8 -0
  40. package/src/workflows/schema.ts +5 -0
  41. package/src/workflows/store.ts +108 -0
@@ -23,6 +23,42 @@ export interface RelayLike {
23
23
  isConnected(): boolean;
24
24
  sendNoQuarter(on: boolean): void;
25
25
  sendFile(file: { name: string; mediaType: string; base64: string; size: number }): Promise<{ ok: boolean; reason?: string }>;
26
+ sendNotice(text: string): void;
27
+ sendCommands(commands: { name: string; description?: string }[]): void;
28
+ requestSelect(id: string, req: SelectRequest): void;
29
+ requestInput(id: string, req: InputRequest): void;
30
+ sendExtensions(payload: ExtensionsPayload): void;
31
+ sendSkills(payload: SkillsPayload): void;
32
+ }
33
+
34
+ // The installed-extensions snapshot relayed to the app's extensions manager.
35
+ export interface ExtensionsPayload {
36
+ installed: { source: string; scope: string; filtered?: boolean; installed?: boolean }[];
37
+ busy?: boolean;
38
+ message?: string;
39
+ needsRestart?: boolean;
40
+ }
41
+
42
+ // The skills snapshot relayed to the app's skills manager.
43
+ export interface SkillsPayload {
44
+ items: { name: string; description: string; source: string; editable: boolean; disabled: boolean }[];
45
+ busy?: boolean;
46
+ message?: string;
47
+ needsRestart?: boolean;
48
+ }
49
+
50
+ // A CLI-initiated selection prompt relayed to the app (e.g. pick a model).
51
+ export interface SelectRequest {
52
+ title: string;
53
+ options: { value: string; label: string; hint?: string }[];
54
+ current?: string;
55
+ }
56
+
57
+ // A CLI-initiated free-form text prompt relayed to the app (e.g. a skill asking
58
+ // for a value that isn't a fixed choice).
59
+ export interface InputRequest {
60
+ title: string;
61
+ placeholder?: string;
26
62
  }
27
63
 
28
64
  export interface RemoteAttachment {
@@ -37,6 +73,26 @@ export interface RemoteBridgeConfig {
37
73
  onPrompt: (text: string, attachments: RemoteAttachment[]) => void;
38
74
  onInterrupt?: () => void;
39
75
  onTerminate?: () => void;
76
+ // The account signed this terminal out server-side (revoked from the app). The
77
+ // owner should tear down the login and stop the relay — see RelayCallbacks.onRevoked.
78
+ onRevoked?: () => void;
79
+ // A slash command arrived from the app composer (e.g. "/model provider/id").
80
+ // Route it to the same command dispatcher the local REPL uses.
81
+ onCommand?: (text: string) => void;
82
+ // The app's extensions manager opened — the owner should push the installed list.
83
+ onExtensionsList?: () => void;
84
+ // The app asked to install / remove a Pi extension by source spec. `sig`+`ts`
85
+ // authenticate the mutation with the account key (H2) — installing a package is code
86
+ // execution, so the owner verifies before acting (authorizeControl).
87
+ onExtensionsAdd?: (source: string, sig?: string, ts?: number) => void;
88
+ onExtensionsRemove?: (source: string, sig?: string, ts?: number) => void;
89
+ // The app's skills manager opened — the owner should push the skills list.
90
+ onSkillsList?: () => void;
91
+ // The app asked to create/overwrite, delete, or toggle a user skill. Signed (H2) —
92
+ // a skill is an auto-invoked system-prompt instruction, so mutations are verified.
93
+ onSkillCreate?: (skill: { name: string; description: string; instructions: string }, sig?: string, ts?: number) => void;
94
+ onSkillDelete?: (name: string, sig?: string, ts?: number) => void;
95
+ onSkillSetEnabled?: (name: string, enabled: boolean, sig?: string, ts?: number) => void;
40
96
  // A controller (re)attached — the owner should push a transcript snapshot.
41
97
  onControllerAttached?: () => void;
42
98
  onStatus?: (text: string) => void;
@@ -50,6 +106,8 @@ export class RemoteBridge {
50
106
  private remote = false;
51
107
  private noQuarter = false;
52
108
  private readonly pending = new Map<string, (d: AskOutcome) => void>();
109
+ private readonly pendingSelects = new Map<string, (v: string | null) => void>();
110
+ private readonly pendingInputs = new Map<string, (v: string | null) => void>();
53
111
  private pendingAttachments: RemoteAttachment[] = [];
54
112
 
55
113
  constructor(private readonly cfg: RemoteBridgeConfig) {}
@@ -72,10 +130,54 @@ export class RemoteBridge {
72
130
  },
73
131
  onInterrupt: () => this.cfg.onInterrupt?.(),
74
132
  onTerminate: () => this.cfg.onTerminate?.(),
133
+ onRevoked: () => this.cfg.onRevoked?.(),
134
+ onCommand: (text) => this.cfg.onCommand?.(text),
135
+ onExtensionsList: () => this.cfg.onExtensionsList?.(),
136
+ onExtensionsAdd: (source, sig, ts) => this.cfg.onExtensionsAdd?.(source, sig, ts),
137
+ onExtensionsRemove: (source, sig, ts) => this.cfg.onExtensionsRemove?.(source, sig, ts),
138
+ onSkillsList: () => this.cfg.onSkillsList?.(),
139
+ onSkillCreate: (skill, sig, ts) => this.cfg.onSkillCreate?.(skill, sig, ts),
140
+ onSkillDelete: (name, sig, ts) => this.cfg.onSkillDelete?.(name, sig, ts),
141
+ onSkillSetEnabled: (name, enabled, sig, ts) => this.cfg.onSkillSetEnabled?.(name, enabled, sig, ts),
142
+ // Routines are owned by the daemon, not an interactive session, so its own relay
143
+ // (not this bridge) handles routines_*. These no-ops just satisfy Required — an
144
+ // interactive terminal never surfaces the routines manager in the app.
145
+ onRoutinesList: () => {},
146
+ onRoutinesSave: () => {},
147
+ onRoutinesDelete: () => {},
148
+ onRoutinesSetEnabled: () => {},
149
+ onRoutinesRun: () => {},
150
+ // Ad-hoc task spawns are daemon-owned too (they run on / are stood up by the daemon,
151
+ // not an interactive session), so its own relay handles task_submit/task_spawn. These
152
+ // no-ops just satisfy Required — an interactive terminal never receives them.
153
+ onTaskSubmit: () => {},
154
+ onTaskSpawn: () => {},
155
+ // Channels, like routines, are owned by the daemon (its channels/run.ts config),
156
+ // not an interactive session — the daemon's own relay handles channels_*. These
157
+ // no-ops just satisfy Required; an interactive terminal never surfaces channels.
158
+ onChannelsList: () => {},
159
+ onChannelsSave: () => {},
160
+ onChannelsRemove: () => {},
161
+ // Workflows, like routines/channels, are daemon-owned — the daemon's own relay handles
162
+ // workflows_*. These no-ops just satisfy Required; an interactive terminal never
163
+ // surfaces workflows.
164
+ onWorkflowsList: () => {},
165
+ onWorkflowsGet: () => {},
166
+ onWorkflowsSave: () => {},
167
+ onWorkflowsRemove: () => {},
168
+ onWorkflowsRun: () => {},
75
169
  onApprovalResponse: (id, decision) => {
76
170
  const resolve = this.pending.get(id);
77
171
  if (resolve) resolve(decision);
78
172
  },
173
+ onSelectResponse: (id, value) => {
174
+ const resolve = this.pendingSelects.get(id);
175
+ if (resolve) resolve(value);
176
+ },
177
+ onInputResponse: (id, value) => {
178
+ const resolve = this.pendingInputs.get(id);
179
+ if (resolve) resolve(value);
180
+ },
79
181
  onNoQuarter: (on) => {
80
182
  this.noQuarter = on;
81
183
  this.relay?.sendNoQuarter(on); // echo the ack back so the app's toggle syncs
@@ -120,6 +222,70 @@ export class RemoteBridge {
120
222
  });
121
223
  };
122
224
 
225
+ // Surface a one-line notice in the app's feed (command feedback).
226
+ sendNotice(text: string): void {
227
+ this.relay?.sendNotice(text);
228
+ }
229
+
230
+ // Advertise the terminal's available commands to the app (on attach).
231
+ sendCommands(commands: { name: string; description?: string }[]): void {
232
+ this.relay?.sendCommands(commands);
233
+ }
234
+
235
+ // Push the installed-extensions snapshot to the app's extensions manager.
236
+ sendExtensions(payload: ExtensionsPayload): void {
237
+ this.relay?.sendExtensions(payload);
238
+ }
239
+
240
+ // Push the skills snapshot to the app's skills manager.
241
+ sendSkills(payload: SkillsPayload): void {
242
+ this.relay?.sendSkills(payload);
243
+ }
244
+
245
+ // A CLI-initiated selection prompt: relay the options to the app and await its
246
+ // choice. Fail closed (null) if no controller, on abort, or on disconnect — the
247
+ // same posture as remoteAsk. Callers get the chosen `value` or null.
248
+ selectRemote = (req: SelectRequest, signal?: AbortSignal): Promise<string | null> => {
249
+ if (!this.relay || !this.relay.isConnected()) return Promise.resolve(null);
250
+ const id = randomUUID();
251
+ return new Promise<string | null>((resolve) => {
252
+ const onAbort = () => settle(null);
253
+ const settle = (v: string | null) => {
254
+ this.pendingSelects.delete(id);
255
+ signal?.removeEventListener("abort", onAbort);
256
+ resolve(v);
257
+ };
258
+ this.pendingSelects.set(id, settle);
259
+ if (signal) {
260
+ if (signal.aborted) return onAbort();
261
+ signal.addEventListener("abort", onAbort, { once: true });
262
+ }
263
+ this.relay!.requestSelect(id, req);
264
+ });
265
+ };
266
+
267
+ // A CLI-initiated free-form text prompt: relay it to the app and await the typed
268
+ // line. Same fail-closed posture as selectRemote — null if no controller, on
269
+ // abort, or on disconnect. Callers get the submitted string or null.
270
+ inputRemote = (req: InputRequest, signal?: AbortSignal): Promise<string | null> => {
271
+ if (!this.relay || !this.relay.isConnected()) return Promise.resolve(null);
272
+ const id = randomUUID();
273
+ return new Promise<string | null>((resolve) => {
274
+ const onAbort = () => settle(null);
275
+ const settle = (v: string | null) => {
276
+ this.pendingInputs.delete(id);
277
+ signal?.removeEventListener("abort", onAbort);
278
+ resolve(v);
279
+ };
280
+ this.pendingInputs.set(id, settle);
281
+ if (signal) {
282
+ if (signal.aborted) return onAbort();
283
+ signal.addEventListener("abort", onAbort, { once: true });
284
+ }
285
+ this.relay!.requestInput(id, req);
286
+ });
287
+ };
288
+
123
289
  // ── turn lifecycle + event forwarding ───────────────────────────────────────
124
290
 
125
291
  // Mark the end of a turn so the next (possibly local) turn isn't treated as
@@ -148,5 +314,11 @@ export class RemoteBridge {
148
314
  private rejectAllPending(): void {
149
315
  for (const resolve of this.pending.values()) resolve("deny");
150
316
  this.pending.clear();
317
+ // A relayed selection prompt whose controller vanished resolves to "no choice".
318
+ for (const resolve of this.pendingSelects.values()) resolve(null);
319
+ this.pendingSelects.clear();
320
+ // Same for a relayed text prompt: a gone controller resolves to "no input".
321
+ for (const resolve of this.pendingInputs.values()) resolve(null);
322
+ this.pendingInputs.clear();
151
323
  }
152
324
  }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Routine management for the app.
3
+ *
4
+ * A UI-agnostic wrapper over the routines store so the app (over the relay) can
5
+ * see the daemon's saved routines and create / edit / delete / pause / run them —
6
+ * the sibling of extensionsControl.ts and skillsControl.ts, but for scheduled
7
+ * tasks rather than Pi packages/skills.
8
+ *
9
+ * Unlike those two, routines are owned by the DAEMON (not an interactive Pi
10
+ * session): they live in routines.json (see routines/store.ts) and fire from the
11
+ * resident scheduler. So this control is wired into the daemon's own relay
12
+ * connection (the "Privateer Routines" terminal), not the REPL/TUI. Running a
13
+ * routine now is the one action that needs the daemon itself, so it's injected as
14
+ * `runNow` rather than reaching back into the store.
15
+ *
16
+ * Framework-agnostic: nothing here imports React or the relay. The caller owns the
17
+ * frame plumbing and the run seam.
18
+ */
19
+ import {
20
+ loadRoutines,
21
+ upsertRoutine,
22
+ removeRoutine,
23
+ findRoutine,
24
+ } from "../routines/store.ts";
25
+ import {
26
+ DELIVERY_CHANNELS,
27
+ webhookName,
28
+ newRoutineId,
29
+ type Routine,
30
+ } from "../routines/schema.ts";
31
+ import { triggerError, computeNextRun } from "../routines/trigger.ts";
32
+
33
+ // One routine as surfaced to the app. This is the full Routine shape (the user's
34
+ // own config shown back to the user's own app), so an edit can round-trip every
35
+ // field. NON-secret by nature — the prompt/cwd/model are what the user authored.
36
+ export interface RemoteRoutine {
37
+ id: string;
38
+ name: string;
39
+ cron?: string;
40
+ at?: string;
41
+ prompt: string;
42
+ cwd: string;
43
+ model?: string;
44
+ delivery: string[];
45
+ tools?: string[];
46
+ enabled: boolean;
47
+ lastRun?: string;
48
+ lastStatus?: "ok" | "error";
49
+ lastError?: string;
50
+ nextRun?: string;
51
+ }
52
+
53
+ // An app-submitted create/edit. `id` present → edit that routine (bookkeeping
54
+ // fields are preserved); absent → create a new one. Everything else mirrors the
55
+ // create_routine tool's parameters.
56
+ export interface RoutineDraft {
57
+ id?: string;
58
+ name?: string;
59
+ cron?: string;
60
+ at?: string;
61
+ prompt?: string;
62
+ cwd?: string;
63
+ model?: string;
64
+ delivery?: string[];
65
+ tools?: string[];
66
+ }
67
+
68
+ export interface RoutinesControl {
69
+ // All saved routines (enabled + paused), most-recently-scheduled first.
70
+ list(): RemoteRoutine[];
71
+ // Create (no id) or edit (id) a routine. Validates the trigger + delivery and
72
+ // schedules nextRun. On an edit, the existing run bookkeeping is preserved.
73
+ save(draft: RoutineDraft): { ok: boolean; message?: string };
74
+ // Remove a routine by id or name. ok:false when nothing matched.
75
+ remove(idOrName: string): { ok: boolean; message?: string };
76
+ // Pause/resume a routine. Resuming reschedules nextRun; pausing clears it.
77
+ setEnabled(idOrName: string, enabled: boolean): { ok: boolean; message?: string };
78
+ // Run a routine now (fire-and-forget on the daemon). ok:false when not found.
79
+ run(idOrName: string): { ok: boolean; message?: string };
80
+ }
81
+
82
+ const KNOWN_CHANNELS = new Set<string>(DELIVERY_CHANNELS);
83
+
84
+ function cleanStrList(v: unknown): string[] | undefined {
85
+ if (!Array.isArray(v)) return undefined;
86
+ const out = v.map((x) => String(x ?? "").trim()).filter(Boolean);
87
+ return out.length > 0 ? out : undefined;
88
+ }
89
+
90
+ // Validate a delivery list: each entry is a known channel or "webhook:<name>".
91
+ // (Webhook existence is checked against config by an injected `webhookExists`, so
92
+ // the routine can't silently reference an undeclared endpoint.)
93
+ function deliveryError(delivery: string[], webhookExists: (name: string) => boolean): string | null {
94
+ for (const entry of delivery) {
95
+ const hook = webhookName(entry);
96
+ if (hook === null) {
97
+ if (!KNOWN_CHANNELS.has(entry)) return `unknown delivery channel "${entry}"`;
98
+ } else if (!webhookExists(hook)) {
99
+ return `webhook "${hook}" is not configured on this machine`;
100
+ }
101
+ }
102
+ return null;
103
+ }
104
+
105
+ function toRemote(r: Routine): RemoteRoutine {
106
+ return {
107
+ id: r.id,
108
+ name: r.name,
109
+ cron: r.cron,
110
+ at: r.at,
111
+ prompt: r.prompt,
112
+ cwd: r.cwd,
113
+ model: r.model,
114
+ delivery: r.delivery,
115
+ tools: r.tools,
116
+ enabled: r.enabled,
117
+ lastRun: r.lastRun,
118
+ lastStatus: r.lastStatus,
119
+ lastError: r.lastError,
120
+ nextRun: r.nextRun,
121
+ };
122
+ }
123
+
124
+ export function makeRoutinesControl(opts: {
125
+ // Working directory for a new routine when the draft omits `cwd` (the daemon's).
126
+ defaultCwd: () => string;
127
+ // Is a webhook name declared in config? Guards "webhook:<name>" delivery entries.
128
+ webhookExists?: (name: string) => boolean;
129
+ // Fire a routine now — injected by the daemon (its runRoutine). Absent → run is
130
+ // reported unavailable rather than silently dropped.
131
+ runNow?: (routine: Routine) => void;
132
+ }): RoutinesControl {
133
+ const webhookExists = opts.webhookExists ?? (() => false);
134
+
135
+ return {
136
+ list(): RemoteRoutine[] {
137
+ // Order by soonest next run, then paused ones (no nextRun) last — a stable,
138
+ // useful order for the app without it having to sort.
139
+ return loadRoutines()
140
+ .map(toRemote)
141
+ .sort((a, b) => {
142
+ const ta = a.nextRun ? Date.parse(a.nextRun) : Infinity;
143
+ const tb = b.nextRun ? Date.parse(b.nextRun) : Infinity;
144
+ return ta - tb;
145
+ });
146
+ },
147
+
148
+ save(draft: RoutineDraft): { ok: boolean; message?: string } {
149
+ const name = (draft?.name ?? "").trim();
150
+ const prompt = (draft?.prompt ?? "").trim();
151
+ const cron = draft?.cron?.trim() || undefined;
152
+ const at = draft?.at?.trim() || undefined;
153
+ if (!name) return { ok: false, message: "A name is required." };
154
+ if (!prompt) return { ok: false, message: "A prompt is required." };
155
+
156
+ const trigErr = triggerError({ cron, at });
157
+ if (trigErr) return { ok: false, message: trigErr };
158
+
159
+ const delivery = cleanStrList(draft?.delivery) ?? ["file"];
160
+ const delErr = deliveryError(delivery, webhookExists);
161
+ if (delErr) return { ok: false, message: delErr };
162
+
163
+ const existing = draft?.id ? findRoutine(loadRoutines(), draft.id) : undefined;
164
+ // A rename must not collide with a *different* routine's name.
165
+ const clash = loadRoutines().find((r) => r.name.toLowerCase() === name.toLowerCase() && r.id !== existing?.id);
166
+ if (clash) return { ok: false, message: `A routine named "${name}" already exists.` };
167
+
168
+ const cwd = (draft?.cwd ?? "").trim() || existing?.cwd || opts.defaultCwd();
169
+ const model = (draft?.model ?? "").trim() || undefined;
170
+ const tools = cleanStrList(draft?.tools);
171
+ const nextRun = computeNextRun({ cron, at })?.toISOString();
172
+
173
+ const routine: Routine = {
174
+ // Preserve id + run bookkeeping on edit; mint a fresh id on create.
175
+ id: existing?.id ?? newRoutineId(),
176
+ name,
177
+ cron,
178
+ at,
179
+ prompt,
180
+ cwd,
181
+ model,
182
+ delivery: delivery as Routine["delivery"],
183
+ tools,
184
+ // Keep the enabled state on edit; new routines start enabled.
185
+ enabled: existing?.enabled ?? true,
186
+ lastRun: existing?.lastRun,
187
+ lastStatus: existing?.lastStatus,
188
+ lastError: existing?.lastError,
189
+ nextRun,
190
+ };
191
+ upsertRoutine(routine);
192
+ return { ok: true, message: existing ? `Updated "${name}".` : `Created "${name}".` };
193
+ },
194
+
195
+ remove(idOrName: string): { ok: boolean; message?: string } {
196
+ const removed = removeRoutine((idOrName ?? "").trim());
197
+ return removed ? { ok: true, message: `Removed "${removed.name}".` } : { ok: false, message: "Not found." };
198
+ },
199
+
200
+ setEnabled(idOrName: string, enabled: boolean): { ok: boolean; message?: string } {
201
+ const r = findRoutine(loadRoutines(), (idOrName ?? "").trim());
202
+ if (!r) return { ok: false, message: "Not found." };
203
+ const nextRun = enabled ? computeNextRun(r)?.toISOString() : undefined;
204
+ upsertRoutine({ ...r, enabled, nextRun });
205
+ return { ok: true, message: `${enabled ? "Resumed" : "Paused"} "${r.name}".` };
206
+ },
207
+
208
+ run(idOrName: string): { ok: boolean; message?: string } {
209
+ const r = findRoutine(loadRoutines(), (idOrName ?? "").trim());
210
+ if (!r) return { ok: false, message: "Not found." };
211
+ if (!opts.runNow) return { ok: false, message: "The scheduler can't run this right now." };
212
+ opts.runNow(r);
213
+ return { ok: true, message: `Running "${r.name}" now.` };
214
+ },
215
+ };
216
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Skill management for linked terminals.
3
+ *
4
+ * A UI-agnostic wrapper over Pi's skills loader so the app (over the relay) can
5
+ * see which skills THIS terminal has, and create / edit / delete / toggle the
6
+ * user's OWN ones — the sibling of extensionsControl.ts (which does the same for
7
+ * Pi extensions). Both the dev REPL (src/cli/chat.ts) and the shipped TUI
8
+ * (extensions/privateer-gate.ts) build one of these and route the skills_* relay
9
+ * frames through it.
10
+ *
11
+ * A skill is a directory holding a SKILL.md (YAML frontmatter `name` +
12
+ * `description`, then a markdown instructions body); Pi auto-loads it into the
13
+ * system prompt and exposes it as `/skill:name`. See pi.dev/docs/latest/skills.
14
+ *
15
+ * Only the user's OWN skills — the ones under <agentDir>/skills/ — are editable.
16
+ * Skills that come from packages or the project tree surface read-only
17
+ * (editable:false); we never rewrite or delete files we didn't author.
18
+ *
19
+ * Framework-agnostic: nothing here imports React or the relay. The caller owns the
20
+ * frame plumbing and hands us a SettingsManager (the REPL reuses the session's;
21
+ * the TUI creates a fresh one — both read the same ~/.privateer/agent/settings.json).
22
+ */
23
+ import { promises as fs } from "node:fs";
24
+ import * as path from "node:path";
25
+ import { loadSkills } from "@earendil-works/pi-coding-agent";
26
+ import type { SettingsManager } from "@earendil-works/pi-coding-agent";
27
+
28
+ // One skill as surfaced to the app. NON-PII: a name + description + a coarse
29
+ // `source` label ("user"/"project"/a package name) — no absolute paths. `editable`
30
+ // is true only for the user's own skills under <agentDir>/skills/, which we may
31
+ // rewrite/delete. `disabled` mirrors the SKILL.md `disable-model-invocation` flag.
32
+ export interface RemoteSkill {
33
+ name: string;
34
+ description: string;
35
+ source: string;
36
+ editable: boolean;
37
+ disabled: boolean;
38
+ }
39
+
40
+ export interface SkillDraft {
41
+ name: string;
42
+ description: string;
43
+ instructions: string;
44
+ }
45
+
46
+ export interface SkillsControl {
47
+ // All discovered skills (user + project + package), user ones flagged editable.
48
+ listSkills(): RemoteSkill[];
49
+ // Create or overwrite a user skill at <agentDir>/skills/<name>/SKILL.md.
50
+ createSkill(draft: SkillDraft): Promise<{ ok: boolean; message?: string }>;
51
+ // Delete a user skill's directory. Refuses non-editable (package/project) skills.
52
+ deleteSkill(name: string): Promise<{ ok: boolean; message?: string }>;
53
+ // Flip a user skill's `disable-model-invocation` frontmatter (editable only).
54
+ setEnabled(name: string, enabled: boolean): Promise<{ ok: boolean; message?: string }>;
55
+ }
56
+
57
+ // A skill name per the Agent Skills standard: lowercase a-z0-9-, ≤64 chars, no
58
+ // leading/trailing/consecutive hyphens. Doubles as the directory name, so this also
59
+ // keeps it filesystem-safe (no slashes, dots, traversal).
60
+ const NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
61
+
62
+ function validName(name: string): boolean {
63
+ return typeof name === "string" && name.length > 0 && name.length <= 64 && NAME_RE.test(name);
64
+ }
65
+
66
+ // Serialize a SKILL.md: YAML frontmatter (name, description, and the invocation
67
+ // flag only when disabled) + the instructions body. Values are single-line and
68
+ // name-validated, so a plain quoted scalar is safe without a YAML dependency.
69
+ function renderSkillMd(draft: SkillDraft, disabled: boolean): string {
70
+ const q = (s: string) => JSON.stringify(String(s ?? "")); // JSON string ⊂ YAML flow scalar
71
+ const lines = ["---", `name: ${q(draft.name)}`, `description: ${q(draft.description)}`];
72
+ if (disabled) lines.push("disable-model-invocation: true");
73
+ lines.push("---", "");
74
+ const body = (draft.instructions ?? "").replace(/\r\n/g, "\n").trimEnd();
75
+ return lines.join("\n") + (body ? body + "\n" : "");
76
+ }
77
+
78
+ export function makeSkillsControl(opts: {
79
+ cwd: string;
80
+ agentDir: string;
81
+ settingsManager: SettingsManager;
82
+ }): SkillsControl {
83
+ // The user's own global skills live here; only these are editable.
84
+ const userSkillsDir = path.join(opts.agentDir, "skills");
85
+
86
+ // Is this loaded skill one of ours (under <agentDir>/skills/)? Compared on the
87
+ // skill's baseDir/filePath so a symlinked or package skill of the same name can't
88
+ // masquerade as editable.
89
+ function isEditable(baseDir: string | undefined, filePath: string): boolean {
90
+ const p = path.resolve(baseDir || path.dirname(filePath));
91
+ const root = path.resolve(userSkillsDir);
92
+ return p === root || p.startsWith(root + path.sep);
93
+ }
94
+
95
+ function load(): RemoteSkill[] {
96
+ let skillPaths: string[] = [];
97
+ try {
98
+ skillPaths = opts.settingsManager.getSkillPaths() ?? [];
99
+ } catch {
100
+ skillPaths = [];
101
+ }
102
+ let result;
103
+ try {
104
+ result = loadSkills({ cwd: opts.cwd, agentDir: opts.agentDir, skillPaths, includeDefaults: true });
105
+ } catch {
106
+ return [];
107
+ }
108
+ return result.skills.map((sk) => ({
109
+ name: sk.name,
110
+ description: sk.description,
111
+ source: sk.sourceInfo?.scope === "project" ? "project" : sk.sourceInfo?.source || "user",
112
+ editable: isEditable(sk.baseDir, sk.filePath),
113
+ disabled: !!sk.disableModelInvocation,
114
+ }));
115
+ }
116
+
117
+ // The user skill file to rewrite/delete for `name`. Only ever inside userSkillsDir.
118
+ function skillDir(name: string): string {
119
+ return path.join(userSkillsDir, name);
120
+ }
121
+
122
+ return {
123
+ listSkills(): RemoteSkill[] {
124
+ return load();
125
+ },
126
+
127
+ async createSkill(draft: SkillDraft): Promise<{ ok: boolean; message?: string }> {
128
+ const name = (draft?.name ?? "").trim();
129
+ const description = (draft?.description ?? "").trim();
130
+ if (!validName(name)) {
131
+ return { ok: false, message: "Name must be lowercase letters, numbers and single hyphens (max 64)." };
132
+ }
133
+ if (!description) return { ok: false, message: "A description is required." };
134
+ // Refuse to shadow a non-editable skill (package/project) that owns this name.
135
+ const clash = load().find((s) => s.name === name && !s.editable);
136
+ if (clash) return { ok: false, message: `A ${clash.source} skill named "${name}" already exists.` };
137
+ // Preserve the disabled state on edit; a brand-new skill defaults to enabled.
138
+ const existing = load().find((s) => s.name === name && s.editable);
139
+ const disabled = existing?.disabled ?? false;
140
+ try {
141
+ const dir = skillDir(name);
142
+ await fs.mkdir(dir, { recursive: true });
143
+ await fs.writeFile(path.join(dir, "SKILL.md"), renderSkillMd({ name, description, instructions: draft.instructions }, disabled), "utf8");
144
+ return { ok: true };
145
+ } catch (e) {
146
+ return { ok: false, message: (e as Error).message };
147
+ }
148
+ },
149
+
150
+ async deleteSkill(name: string): Promise<{ ok: boolean; message?: string }> {
151
+ const n = (name ?? "").trim();
152
+ if (!validName(n)) return { ok: false, message: "Unknown skill." };
153
+ const skill = load().find((s) => s.name === n);
154
+ if (!skill) return { ok: false, message: "Not found." };
155
+ if (!skill.editable) return { ok: false, message: "That skill is read-only." };
156
+ try {
157
+ await fs.rm(skillDir(n), { recursive: true, force: true });
158
+ return { ok: true };
159
+ } catch (e) {
160
+ return { ok: false, message: (e as Error).message };
161
+ }
162
+ },
163
+
164
+ async setEnabled(name: string, enabled: boolean): Promise<{ ok: boolean; message?: string }> {
165
+ const n = (name ?? "").trim();
166
+ if (!validName(n)) return { ok: false, message: "Unknown skill." };
167
+ const skill = load().find((s) => s.name === n);
168
+ if (!skill) return { ok: false, message: "Not found." };
169
+ if (!skill.editable) return { ok: false, message: "That skill is read-only." };
170
+ try {
171
+ const file = path.join(skillDir(n), "SKILL.md");
172
+ const raw = await fs.readFile(file, "utf8");
173
+ const rewritten = toggleFrontmatterFlag(raw, !enabled);
174
+ await fs.writeFile(file, rewritten, "utf8");
175
+ return { ok: true };
176
+ } catch (e) {
177
+ return { ok: false, message: (e as Error).message };
178
+ }
179
+ },
180
+ };
181
+ }
182
+
183
+ // Set or clear `disable-model-invocation` in an existing SKILL.md's frontmatter,
184
+ // preserving the rest of the file byte-for-byte. Rewrites only the flag line.
185
+ function toggleFrontmatterFlag(raw: string, disabled: boolean): string {
186
+ const nl = raw.includes("\r\n") ? "\r\n" : "\n";
187
+ const lines = raw.split(/\r?\n/);
188
+ if (lines[0]?.trim() !== "---") {
189
+ // No frontmatter — synthesize a minimal one so the flag lands somewhere valid.
190
+ return `---${nl}disable-model-invocation: ${disabled}${nl}---${nl}${raw}`;
191
+ }
192
+ let end = -1;
193
+ for (let i = 1; i < lines.length; i++) {
194
+ if (lines[i].trim() === "---") { end = i; break; }
195
+ }
196
+ if (end === -1) return raw; // malformed; leave untouched
197
+ const flagIdx = lines.findIndex((l, i) => i > 0 && i < end && /^\s*disable-model-invocation\s*:/.test(l));
198
+ if (disabled) {
199
+ if (flagIdx === -1) lines.splice(end, 0, "disable-model-invocation: true");
200
+ else lines[flagIdx] = "disable-model-invocation: true";
201
+ } else if (flagIdx !== -1) {
202
+ lines.splice(flagIdx, 1);
203
+ }
204
+ return lines.join(nl);
205
+ }