privateer-agent 0.1.0 → 0.2.1
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 +86 -33
- package/package.json +1 -1
- package/src/auth/privateer.ts +71 -1
- package/src/commands/custom.ts +52 -4
- package/src/commands/registry.ts +124 -5
- package/src/components/App.tsx +268 -18
- package/src/components/ApprovalPrompt.tsx +15 -4
- package/src/components/Banner.tsx +21 -1
- package/src/components/ModelPicker.tsx +45 -12
- package/src/components/OptionPicker.tsx +134 -0
- package/src/components/Root.tsx +30 -9
- package/src/components/StatusBar.tsx +11 -1
- package/src/components/ToolCallView.tsx +4 -0
- package/src/components/Transcript.tsx +14 -7
- package/src/components/figures.ts +1 -0
- package/src/components/theme.ts +2 -0
- package/src/config/paths.ts +2 -0
- package/src/context/systemPrompt.ts +9 -0
- package/src/daemon/index.ts +322 -0
- package/src/daemon/ipc.ts +127 -0
- package/src/engine/errors.ts +10 -0
- package/src/main.tsx +43 -1
- package/src/mcp/client.ts +16 -1
- package/src/permissions/gate.ts +5 -0
- package/src/permissions/mode.ts +4 -0
- package/src/permissions/uiGate.ts +4 -3
- package/src/remote/relayClient.ts +161 -6
- package/src/routines/cron.ts +109 -0
- package/src/routines/delivery.ts +75 -0
- package/src/routines/schema.ts +65 -0
- package/src/routines/store.ts +205 -0
- package/src/routines/toolSelect.ts +48 -0
- package/src/routines/trigger.ts +41 -0
- package/src/session.ts +37 -12
- package/src/skills/installer.ts +222 -0
- package/src/skills/loader.ts +88 -0
- package/src/tools/askUser.ts +92 -0
- package/src/tools/context.ts +14 -0
- package/src/tools/index.ts +14 -0
- package/src/tools/routine.ts +110 -0
- package/src/tools/sendFileToClient.ts +55 -0
- package/src/tools/skill.ts +44 -0
- package/src/tools/worktree.ts +145 -0
- package/src/util/images.ts +35 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { globalDir } from "../config/load.ts";
|
|
5
|
+
import { Routine, RoutineFile } from "./schema.ts";
|
|
6
|
+
|
|
7
|
+
// routines.json lives alongside config.json in the global dir. It can carry the
|
|
8
|
+
// prompt text and (for email delivery) recipient hints, so it is written owner-only
|
|
9
|
+
// (0600) inside the owner-only global dir, mirroring saveGlobalConfig.
|
|
10
|
+
export function routinesFilePath(): string {
|
|
11
|
+
return join(globalDir(), "routines.json");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Per-routine output directory (dated result files + latest.md).
|
|
15
|
+
export function routineOutputDir(name: string): string {
|
|
16
|
+
return join(globalDir(), "routines", slug(name));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function slug(name: string): string {
|
|
20
|
+
return name.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "routine";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// A stable relay terminal id for the daemon, persisted so it reappears as the same
|
|
24
|
+
// "Privateer Routines" terminal in the app across restarts (rather than a fresh
|
|
25
|
+
// random terminal each boot). Random on first use so it stays unique per install —
|
|
26
|
+
// the relay routes on this id with no user namespacing, so a shared constant could
|
|
27
|
+
// collide across accounts. Matches the server's isValidTermId (`[A-Za-z0-9_-]{8,64}`).
|
|
28
|
+
export function routineRelayId(): string {
|
|
29
|
+
const path = join(globalDir(), "routines", "relay-id");
|
|
30
|
+
if (existsSync(path)) {
|
|
31
|
+
const existing = readFileSync(path, "utf8").trim();
|
|
32
|
+
if (/^[A-Za-z0-9_-]{8,64}$/.test(existing)) return existing;
|
|
33
|
+
}
|
|
34
|
+
const id = `routines-${randomUUID().replace(/-/g, "")}`;
|
|
35
|
+
const dir = join(globalDir(), "routines");
|
|
36
|
+
mkdirSync(dir, { recursive: true });
|
|
37
|
+
writeFileSync(path, id + "\n", { encoding: "utf8", mode: 0o600 });
|
|
38
|
+
tryChmod(path, 0o600);
|
|
39
|
+
return id;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function tryChmod(path: string, mode: number): void {
|
|
43
|
+
try {
|
|
44
|
+
chmodSync(path, mode);
|
|
45
|
+
} catch {
|
|
46
|
+
/* non-POSIX filesystem or insufficient perms — nothing we can do */
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function loadRoutines(): Routine[] {
|
|
51
|
+
const path = routinesFilePath();
|
|
52
|
+
if (!existsSync(path)) return [];
|
|
53
|
+
try {
|
|
54
|
+
return RoutineFile.parse(JSON.parse(readFileSync(path, "utf8"))).routines;
|
|
55
|
+
} catch {
|
|
56
|
+
// A corrupt or hand-edited file shouldn't crash the daemon; treat as empty.
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function saveRoutines(routines: Routine[]): void {
|
|
62
|
+
const dir = globalDir();
|
|
63
|
+
mkdirSync(dir, { recursive: true });
|
|
64
|
+
tryChmod(dir, 0o700);
|
|
65
|
+
const payload: RoutineFile = { routines };
|
|
66
|
+
writeFileSync(routinesFilePath(), JSON.stringify(payload, null, 2) + "\n", {
|
|
67
|
+
encoding: "utf8",
|
|
68
|
+
mode: 0o600,
|
|
69
|
+
});
|
|
70
|
+
tryChmod(routinesFilePath(), 0o600);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Look up by id first, then by (case-insensitive) name for CLI convenience.
|
|
74
|
+
export function findRoutine(routines: Routine[], idOrName: string): Routine | undefined {
|
|
75
|
+
const needle = idOrName.trim().toLowerCase();
|
|
76
|
+
return (
|
|
77
|
+
routines.find((r) => r.id === idOrName) ??
|
|
78
|
+
routines.find((r) => r.name.toLowerCase() === needle)
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Insert or replace a routine (matched by id), persisting the whole file.
|
|
83
|
+
export function upsertRoutine(routine: Routine): Routine[] {
|
|
84
|
+
const routines = loadRoutines();
|
|
85
|
+
const i = routines.findIndex((r) => r.id === routine.id);
|
|
86
|
+
if (i >= 0) routines[i] = routine;
|
|
87
|
+
else routines.push(routine);
|
|
88
|
+
saveRoutines(routines);
|
|
89
|
+
return routines;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Remove a routine by id or name. Returns the removed routine, or null if absent.
|
|
93
|
+
export function removeRoutine(idOrName: string): Routine | null {
|
|
94
|
+
const routines = loadRoutines();
|
|
95
|
+
const target = findRoutine(routines, idOrName);
|
|
96
|
+
if (!target) return null;
|
|
97
|
+
saveRoutines(routines.filter((r) => r.id !== target.id));
|
|
98
|
+
return target;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Write a run's result to the routine's output dir: a dated file plus latest.md.
|
|
102
|
+
// Returns the absolute path of latest.md.
|
|
103
|
+
export function writeRoutineOutput(name: string, content: string): string {
|
|
104
|
+
const dir = routineOutputDir(name);
|
|
105
|
+
mkdirSync(dir, { recursive: true });
|
|
106
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
107
|
+
writeFileSync(join(dir, `${stamp}.md`), content, "utf8");
|
|
108
|
+
const latest = join(dir, "latest.md");
|
|
109
|
+
writeFileSync(latest, content, "utf8");
|
|
110
|
+
return latest;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// A pending routine result queued for the next interactive session ("notice"
|
|
114
|
+
// delivery). The TUI drains these on startup so results surface even when no
|
|
115
|
+
// terminal was attached at fire time.
|
|
116
|
+
export interface RoutineNotice {
|
|
117
|
+
routine: string;
|
|
118
|
+
at: string; // ISO timestamp
|
|
119
|
+
status: "ok" | "error";
|
|
120
|
+
preview: string; // short single-line summary
|
|
121
|
+
path?: string; // latest.md, when file delivery also ran
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function noticesPath(): string {
|
|
125
|
+
return join(globalDir(), "routines", "notices.json");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function loadNotices(): RoutineNotice[] {
|
|
129
|
+
const path = noticesPath();
|
|
130
|
+
if (!existsSync(path)) return [];
|
|
131
|
+
try {
|
|
132
|
+
const data = JSON.parse(readFileSync(path, "utf8"));
|
|
133
|
+
return Array.isArray(data) ? (data as RoutineNotice[]) : [];
|
|
134
|
+
} catch {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function addNotice(notice: RoutineNotice): void {
|
|
140
|
+
const dir = join(globalDir(), "routines");
|
|
141
|
+
mkdirSync(dir, { recursive: true });
|
|
142
|
+
const notices = loadNotices();
|
|
143
|
+
notices.push(notice);
|
|
144
|
+
// Keep the queue bounded so an offline stretch can't grow it without limit.
|
|
145
|
+
const trimmed = notices.slice(-50);
|
|
146
|
+
writeFileSync(noticesPath(), JSON.stringify(trimmed, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
147
|
+
tryChmod(noticesPath(), 0o600);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Read and clear the notice queue (called by the TUI on startup).
|
|
151
|
+
export function drainNotices(): RoutineNotice[] {
|
|
152
|
+
const notices = loadNotices();
|
|
153
|
+
if (notices.length === 0) return [];
|
|
154
|
+
try {
|
|
155
|
+
writeFileSync(noticesPath(), "[]\n", { encoding: "utf8", mode: 0o600 });
|
|
156
|
+
} catch {
|
|
157
|
+
/* best-effort clear */
|
|
158
|
+
}
|
|
159
|
+
return notices;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// A relay result produced while no controller was attached, held until the app
|
|
163
|
+
// next connects. Persisted (not just in-memory) so it survives a daemon restart.
|
|
164
|
+
export interface PendingRelay {
|
|
165
|
+
routine: string;
|
|
166
|
+
at: string; // ISO timestamp
|
|
167
|
+
content: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function pendingRelayPath(): string {
|
|
171
|
+
return join(globalDir(), "routines", "pending-relay.json");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function loadPendingRelay(): PendingRelay[] {
|
|
175
|
+
const path = pendingRelayPath();
|
|
176
|
+
if (!existsSync(path)) return [];
|
|
177
|
+
try {
|
|
178
|
+
const data = JSON.parse(readFileSync(path, "utf8"));
|
|
179
|
+
return Array.isArray(data) ? (data as PendingRelay[]) : [];
|
|
180
|
+
} catch {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function addPendingRelay(entry: PendingRelay): void {
|
|
186
|
+
const dir = join(globalDir(), "routines");
|
|
187
|
+
mkdirSync(dir, { recursive: true });
|
|
188
|
+
const queue = loadPendingRelay();
|
|
189
|
+
queue.push(entry);
|
|
190
|
+
const trimmed = queue.slice(-50); // bound the backlog
|
|
191
|
+
writeFileSync(pendingRelayPath(), JSON.stringify(trimmed, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
192
|
+
tryChmod(pendingRelayPath(), 0o600);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Read and clear the pending-relay queue (called when a controller attaches).
|
|
196
|
+
export function drainPendingRelay(): PendingRelay[] {
|
|
197
|
+
const queue = loadPendingRelay();
|
|
198
|
+
if (queue.length === 0) return [];
|
|
199
|
+
try {
|
|
200
|
+
writeFileSync(pendingRelayPath(), "[]\n", { encoding: "utf8", mode: 0o600 });
|
|
201
|
+
} catch {
|
|
202
|
+
/* best-effort clear */
|
|
203
|
+
}
|
|
204
|
+
return queue;
|
|
205
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ToolSet } from "ai";
|
|
2
|
+
|
|
3
|
+
// A routine's `tools` field mixes builtin tool names with MCP selectors. MCP tools
|
|
4
|
+
// are namespaced "<server>__<tool>" (see adaptMcpTools), and no builtin name contains
|
|
5
|
+
// "__", so the separator is unambiguous: entries with "__" are MCP selectors — an
|
|
6
|
+
// exact tool name or a per-server wildcard "<server>__*" — everything else is a
|
|
7
|
+
// builtin allowlist entry.
|
|
8
|
+
|
|
9
|
+
export interface RoutineToolSplit {
|
|
10
|
+
// Builtin tool names (read, glob, ...). Empty → caller falls back to the safe set.
|
|
11
|
+
builtin: string[];
|
|
12
|
+
// MCP selectors: "<server>__<tool>" exact, or "<server>__*" for a whole server.
|
|
13
|
+
mcp: string[];
|
|
14
|
+
// Unique server prefixes from `mcp`, i.e. which servers need connecting at all.
|
|
15
|
+
servers: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function splitRoutineTools(tools?: string[]): RoutineToolSplit {
|
|
19
|
+
const builtin: string[] = [];
|
|
20
|
+
const mcp: string[] = [];
|
|
21
|
+
const servers = new Set<string>();
|
|
22
|
+
for (const t of tools ?? []) {
|
|
23
|
+
const sep = t.indexOf("__");
|
|
24
|
+
if (sep > 0) {
|
|
25
|
+
mcp.push(t);
|
|
26
|
+
servers.add(t.slice(0, sep));
|
|
27
|
+
} else {
|
|
28
|
+
builtin.push(t);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { builtin, mcp, servers: [...servers] };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Does a namespaced MCP tool name match a selector? Exact match, or "<server>__*"
|
|
35
|
+
// matching any tool on that server.
|
|
36
|
+
export function matchesSelector(name: string, selector: string): boolean {
|
|
37
|
+
if (selector.endsWith("__*")) return name.startsWith(selector.slice(0, -1));
|
|
38
|
+
return name === selector;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Narrow a connected MCP toolset to the selected tools. Least privilege matters here:
|
|
42
|
+
// routine runs use the auto-approve gate, so anything left in this set fires without
|
|
43
|
+
// a human in the loop.
|
|
44
|
+
export function filterMcpTools(tools: ToolSet, selectors: string[]): ToolSet {
|
|
45
|
+
return Object.fromEntries(
|
|
46
|
+
Object.entries(tools).filter(([name]) => selectors.some((s) => matchesSelector(name, s))),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { nextRun as cronNext, cronError } from "./cron.ts";
|
|
2
|
+
import { isRecurring, type Routine } from "./schema.ts";
|
|
3
|
+
|
|
4
|
+
// A routine's trigger: a recurring `cron` expression or a one-off `at` datetime.
|
|
5
|
+
type Trigger = Pick<Routine, "cron" | "at">;
|
|
6
|
+
|
|
7
|
+
// Validate the trigger, returning an error message or null. Enforces "exactly one"
|
|
8
|
+
// and that the chosen form parses.
|
|
9
|
+
export function triggerError(t: Trigger): string | null {
|
|
10
|
+
const hasCron = Boolean(t.cron);
|
|
11
|
+
const hasAt = Boolean(t.at);
|
|
12
|
+
if (hasCron === hasAt) return "set exactly one of `cron` (recurring) or `at` (one-off)";
|
|
13
|
+
if (hasCron) return cronError(t.cron!);
|
|
14
|
+
return Number.isNaN(Date.parse(t.at!)) ? `invalid datetime "${t.at}"` : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The fire time to store as `nextRun`. For cron: the next match strictly after
|
|
18
|
+
// `from`. For a one-off: the fixed `at` time as-is (even if already past, so a
|
|
19
|
+
// missed one-off still fires once when the daemon comes back). Null if unparseable.
|
|
20
|
+
export function computeNextRun(t: Trigger, from: Date = new Date()): Date | null {
|
|
21
|
+
if (t.cron) return cronNext(t.cron, from);
|
|
22
|
+
if (t.at) {
|
|
23
|
+
const d = new Date(t.at);
|
|
24
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// After a run, what to persist. Recurring routines reschedule; one-offs disable
|
|
30
|
+
// themselves (they've now fired).
|
|
31
|
+
export function advanceAfterRun(routine: Routine, from: Date = new Date()): Partial<Routine> {
|
|
32
|
+
if (isRecurring(routine)) return { nextRun: computeNextRun(routine, from)?.toISOString() };
|
|
33
|
+
return { enabled: false, nextRun: undefined };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Short human description of when a routine fires, for /routine listings.
|
|
37
|
+
export function describeTrigger(t: Trigger): string {
|
|
38
|
+
if (t.cron) return t.cron;
|
|
39
|
+
if (t.at) return `once @ ${new Date(t.at).toLocaleString()}`;
|
|
40
|
+
return "(no trigger)";
|
|
41
|
+
}
|
package/src/session.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { findOutputStyle } from "./context/outputStyles.ts";
|
|
|
9
9
|
import { QueryEngine } from "./engine/QueryEngine.ts";
|
|
10
10
|
import { autoApproveGate, type PermissionGate } from "./permissions/gate.ts";
|
|
11
11
|
import type { SubAgentRunner } from "./tools/context.ts";
|
|
12
|
+
import type { UserAsker } from "./tools/askUser.ts";
|
|
12
13
|
import { TodoStore } from "./tools/todoStore.ts";
|
|
13
14
|
import type { CheckpointStore } from "./memory/checkpoints.ts";
|
|
14
15
|
import type { ProcessRegistry } from "./tools/processRegistry.ts";
|
|
@@ -34,6 +35,10 @@ export interface SessionOptions {
|
|
|
34
35
|
checkpoints?: CheckpointStore;
|
|
35
36
|
// Extra tools merged into the toolset (e.g. tools exposed by MCP servers).
|
|
36
37
|
extraTools?: ToolSet;
|
|
38
|
+
// When set, restrict the built-in toolset to these tool names (MCP `extraTools`
|
|
39
|
+
// are always kept). Used for unattended runs (scheduled routines) that must not
|
|
40
|
+
// have write/bash/edit auto-approved with no human to gate them.
|
|
41
|
+
allowedTools?: string[];
|
|
37
42
|
// Background-shell registry for bash run_in_background / bash_output / kill_shell.
|
|
38
43
|
processes?: ProcessRegistry;
|
|
39
44
|
// Session attachment store, so dragged/pasted file bytes can be saved via the
|
|
@@ -42,6 +47,17 @@ export interface SessionOptions {
|
|
|
42
47
|
// Reports each finished `task` sub-agent's run metrics (tool uses + tokens) by
|
|
43
48
|
// tool-call id, so the TUI can render the grouped agents view. Best-effort.
|
|
44
49
|
onSubAgentMetrics?: (toolCallId: string, m: { toolUses: number; tokens: number }) => void;
|
|
50
|
+
// Surfaces an `ask_user` question to the live TUI and resolves with the choice.
|
|
51
|
+
// Omitted outside the interactive app, where ask_user reports it couldn't ask.
|
|
52
|
+
askUser?: UserAsker;
|
|
53
|
+
// Streams a file to the connected remote controller (Privateer app), for the
|
|
54
|
+
// send_file_to_client tool. Omitted when remote access isn't available.
|
|
55
|
+
sendFileToController?: (file: {
|
|
56
|
+
name: string;
|
|
57
|
+
mediaType: string;
|
|
58
|
+
base64: string;
|
|
59
|
+
size: number;
|
|
60
|
+
}) => Promise<{ ok: boolean; reason?: string }>;
|
|
45
61
|
}
|
|
46
62
|
|
|
47
63
|
export interface Session {
|
|
@@ -114,20 +130,29 @@ export function createSession(opts: SessionOptions): Session {
|
|
|
114
130
|
});
|
|
115
131
|
|
|
116
132
|
const hooks = new HookRunner(loadHooks((opts.config as Record<string, unknown>).hooks), opts.cwd);
|
|
133
|
+
let builtinTools = createTools({
|
|
134
|
+
cwd: opts.cwd,
|
|
135
|
+
gate,
|
|
136
|
+
confineToCwd,
|
|
137
|
+
allowedOutsideRoots,
|
|
138
|
+
todos,
|
|
139
|
+
runSubAgent,
|
|
140
|
+
onSubAgentMetrics: opts.onSubAgentMetrics,
|
|
141
|
+
recordMutation: opts.checkpoints ? (abs) => opts.checkpoints!.recordMutation(abs) : undefined,
|
|
142
|
+
processes: opts.processes,
|
|
143
|
+
attachments,
|
|
144
|
+
askUser: opts.askUser,
|
|
145
|
+
sendFileToController: opts.sendFileToController,
|
|
146
|
+
});
|
|
147
|
+
if (opts.allowedTools) {
|
|
148
|
+
const allow = new Set(opts.allowedTools);
|
|
149
|
+
builtinTools = Object.fromEntries(
|
|
150
|
+
Object.entries(builtinTools).filter(([name]) => allow.has(name)),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
117
153
|
const tools = wrapToolsWithHooks(
|
|
118
154
|
{
|
|
119
|
-
...
|
|
120
|
-
cwd: opts.cwd,
|
|
121
|
-
gate,
|
|
122
|
-
confineToCwd,
|
|
123
|
-
allowedOutsideRoots,
|
|
124
|
-
todos,
|
|
125
|
-
runSubAgent,
|
|
126
|
-
onSubAgentMetrics: opts.onSubAgentMetrics,
|
|
127
|
-
recordMutation: opts.checkpoints ? (abs) => opts.checkpoints!.recordMutation(abs) : undefined,
|
|
128
|
-
processes: opts.processes,
|
|
129
|
-
attachments,
|
|
130
|
-
}),
|
|
155
|
+
...builtinTools,
|
|
131
156
|
...(opts.extraTools ?? {}),
|
|
132
157
|
},
|
|
133
158
|
hooks,
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
copyFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
mkdtempSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join, resolve, sep } from "node:path";
|
|
14
|
+
import { promisify } from "node:util";
|
|
15
|
+
import { globalPaths, projectPaths } from "../config/paths.ts";
|
|
16
|
+
import { parseFrontmatter } from "../commands/custom.ts";
|
|
17
|
+
import { walkFiles } from "../tools/walk.ts";
|
|
18
|
+
import { isInsideDir } from "../tools/context.ts";
|
|
19
|
+
import { SKILL_NAME_RE, loadSkills } from "./loader.ts";
|
|
20
|
+
|
|
21
|
+
// Installs skills from GitHub into .privateer/skills/. Security posture: nothing
|
|
22
|
+
// from a fetched repo is ever executed at install time (no hooks, no scripts) —
|
|
23
|
+
// files are only copied. Symlinks are dropped (they could alias paths outside the
|
|
24
|
+
// skill), per-skill size/count caps bound the copy, and skill names are validated
|
|
25
|
+
// before choosing a target directory. A skill's scripts only ever run later via
|
|
26
|
+
// the bash tool, under the normal permission gate — the same trust model as any
|
|
27
|
+
// file the model reads.
|
|
28
|
+
|
|
29
|
+
const execFileAsync = promisify(execFile);
|
|
30
|
+
|
|
31
|
+
const MAX_FILES = 100;
|
|
32
|
+
const MAX_BYTES = 20 * 1024 * 1024;
|
|
33
|
+
|
|
34
|
+
export interface SkillSource {
|
|
35
|
+
repoUrl: string;
|
|
36
|
+
ref?: string;
|
|
37
|
+
subpath?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Accepts "owner/repo", "owner/repo/path/to/skill", "https://github.com/owner/repo[.git]",
|
|
41
|
+
// and "https://github.com/owner/repo/tree/<ref>/<path>".
|
|
42
|
+
export function parseSkillSource(src: string): SkillSource {
|
|
43
|
+
const s = src.trim().replace(/\/+$/, "");
|
|
44
|
+
const reject = () =>
|
|
45
|
+
new Error(
|
|
46
|
+
`Unrecognized skill source "${src}". Use owner/repo, owner/repo/path, or a github.com URL.`,
|
|
47
|
+
);
|
|
48
|
+
let m = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/tree\/([^/]+)(?:\/(.+))?)?$/.exec(s);
|
|
49
|
+
let owner: string, repo: string, ref: string | undefined, subpath: string | undefined;
|
|
50
|
+
if (m) {
|
|
51
|
+
[, owner, repo, ref, subpath] = m;
|
|
52
|
+
} else if (/^[\w.-]+\/[\w.-]+(\/.*)?$/.test(s) && !s.includes(":")) {
|
|
53
|
+
const parts = s.split("/");
|
|
54
|
+
[owner, repo] = parts;
|
|
55
|
+
subpath = parts.length > 2 ? parts.slice(2).join("/") : undefined;
|
|
56
|
+
} else {
|
|
57
|
+
throw reject();
|
|
58
|
+
}
|
|
59
|
+
if (subpath?.split("/").some((p) => p === ".." || p === "")) throw reject();
|
|
60
|
+
return { repoUrl: `https://github.com/${owner}/${repo}.git`, ref, subpath };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Find installable skills (directories containing a valid SKILL.md) under a local
|
|
64
|
+
// directory. With a subpath that itself holds a SKILL.md, that single skill is
|
|
65
|
+
// returned; otherwise the tree is scanned. Pure fs — no network.
|
|
66
|
+
export function discoverSkills(
|
|
67
|
+
rootDir: string,
|
|
68
|
+
subpath?: string,
|
|
69
|
+
): { found: { name: string; dir: string }[]; invalid: string[] } {
|
|
70
|
+
const base = subpath ? join(rootDir, subpath) : rootDir;
|
|
71
|
+
if (!existsSync(base)) throw new Error(`Path "${subpath ?? "."}" not found in the repository.`);
|
|
72
|
+
const candidates: string[] = [];
|
|
73
|
+
if (existsSync(join(base, "SKILL.md"))) {
|
|
74
|
+
candidates.push(base);
|
|
75
|
+
} else {
|
|
76
|
+
for (const rel of walkFiles(base)) {
|
|
77
|
+
if (!rel.endsWith("/SKILL.md")) continue;
|
|
78
|
+
candidates.push(join(base, rel.slice(0, -"/SKILL.md".length)));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const found: { name: string; dir: string }[] = [];
|
|
82
|
+
const invalid: string[] = [];
|
|
83
|
+
for (const dir of candidates) {
|
|
84
|
+
const { meta } = parseFrontmatter(readFileSync(join(dir, "SKILL.md"), "utf8"));
|
|
85
|
+
const name = (meta.name || dir.split(sep).pop() || "").toLowerCase();
|
|
86
|
+
if (!SKILL_NAME_RE.test(name) || !meta.description) {
|
|
87
|
+
invalid.push(dir === base ? name || "(unnamed)" : name);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
found.push({ name, dir });
|
|
91
|
+
}
|
|
92
|
+
return { found, invalid };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Copy one skill directory into the target, skipping symlinks and enforcing the
|
|
96
|
+
// per-skill caps. Cleans up the target on any failure.
|
|
97
|
+
export function copySkillDir(srcDir: string, destDir: string): void {
|
|
98
|
+
let files = 0;
|
|
99
|
+
let bytes = 0;
|
|
100
|
+
const copy = (from: string, to: string) => {
|
|
101
|
+
mkdirSync(to, { recursive: true });
|
|
102
|
+
for (const entry of readdirSync(from, { withFileTypes: true })) {
|
|
103
|
+
const src = join(from, entry.name);
|
|
104
|
+
const st = lstatSync(src);
|
|
105
|
+
if (st.isSymbolicLink()) continue;
|
|
106
|
+
if (st.isDirectory()) {
|
|
107
|
+
copy(src, join(to, entry.name));
|
|
108
|
+
} else if (st.isFile()) {
|
|
109
|
+
files += 1;
|
|
110
|
+
bytes += st.size;
|
|
111
|
+
if (files > MAX_FILES || bytes > MAX_BYTES) {
|
|
112
|
+
throw new Error(`Skill exceeds limits (${MAX_FILES} files / ${MAX_BYTES / 1024 / 1024} MB).`);
|
|
113
|
+
}
|
|
114
|
+
copyFileSync(src, join(to, entry.name));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
try {
|
|
119
|
+
copy(srcDir, destDir);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
rmSync(destDir, { recursive: true, force: true });
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface InstallOptions {
|
|
127
|
+
scope: "project" | "user";
|
|
128
|
+
all?: boolean;
|
|
129
|
+
force?: boolean;
|
|
130
|
+
cwd?: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Install skills discovered under a local directory (the post-clone half, factored
|
|
134
|
+
// out so tests need neither git nor network).
|
|
135
|
+
export function installFromDir(
|
|
136
|
+
localDir: string,
|
|
137
|
+
subpath: string | undefined,
|
|
138
|
+
opts: InstallOptions,
|
|
139
|
+
): { name: string; dir: string }[] {
|
|
140
|
+
const { found, invalid } = discoverSkills(localDir, subpath);
|
|
141
|
+
if (found.length === 0) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`No installable skills found${invalid.length ? ` (invalid: ${invalid.join(", ")})` : ""}.`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (found.length > 1 && !opts.all) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`Found ${found.length} skills: ${found.map((f) => f.name).join(", ")}. ` +
|
|
149
|
+
`Install one by path (install <src>/<skill-path>) or pass --all.`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const root =
|
|
153
|
+
opts.scope === "project" ? projectPaths(opts.cwd ?? process.cwd()).skills : globalPaths().skills;
|
|
154
|
+
const installed: { name: string; dir: string }[] = [];
|
|
155
|
+
for (const skill of found) {
|
|
156
|
+
const target = join(root, skill.name);
|
|
157
|
+
if (existsSync(target)) {
|
|
158
|
+
if (!opts.force) throw new Error(`Skill "${skill.name}" already exists at ${target}. Use --force to replace it.`);
|
|
159
|
+
rmSync(target, { recursive: true, force: true });
|
|
160
|
+
}
|
|
161
|
+
copySkillDir(skill.dir, target);
|
|
162
|
+
installed.push({ name: skill.name, dir: target });
|
|
163
|
+
}
|
|
164
|
+
return installed;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Fetch a source from GitHub (shallow clone — no archive extraction, no execution)
|
|
168
|
+
// and install the skills it contains.
|
|
169
|
+
export async function installSkills(
|
|
170
|
+
src: string,
|
|
171
|
+
opts: InstallOptions,
|
|
172
|
+
): Promise<{ name: string; dir: string }[]> {
|
|
173
|
+
const { repoUrl, ref, subpath } = parseSkillSource(src);
|
|
174
|
+
const tmp = mkdtempSync(join(tmpdir(), "privateer-skill-"));
|
|
175
|
+
try {
|
|
176
|
+
const args = ["clone", "--depth", "1", ...(ref ? ["--branch", ref] : []), repoUrl, tmp];
|
|
177
|
+
await execFileAsync("git", args, { timeout: 120_000 }).catch((err) => {
|
|
178
|
+
throw new Error(`git clone failed for ${repoUrl}: ${err instanceof Error ? err.message : String(err)}`);
|
|
179
|
+
});
|
|
180
|
+
return installFromDir(tmp, subpath, opts);
|
|
181
|
+
} finally {
|
|
182
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Remove an installed skill by name. Without an explicit scope, project is tried
|
|
187
|
+
// first (mirroring lookup precedence). Resolves by directory name first — the
|
|
188
|
+
// merged loader hides a user-scope skill shadowed by a project one — and falls
|
|
189
|
+
// back to the loader for skills whose frontmatter name differs from their dir.
|
|
190
|
+
export function removeSkill(
|
|
191
|
+
name: string,
|
|
192
|
+
opts: { scope?: "project" | "user"; cwd?: string },
|
|
193
|
+
): { dir: string } {
|
|
194
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
195
|
+
const roots =
|
|
196
|
+
opts.scope === "project"
|
|
197
|
+
? [projectPaths(cwd).skills]
|
|
198
|
+
: opts.scope === "user"
|
|
199
|
+
? [globalPaths().skills]
|
|
200
|
+
: [projectPaths(cwd).skills, globalPaths().skills];
|
|
201
|
+
// The name rule (no separators, no dots) also rules out path traversal here.
|
|
202
|
+
if (SKILL_NAME_RE.test(name)) {
|
|
203
|
+
for (const root of roots) {
|
|
204
|
+
const dir = join(root, name);
|
|
205
|
+
if (existsSync(dir)) {
|
|
206
|
+
rmSync(dir, { recursive: true, force: true });
|
|
207
|
+
return { dir };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const skill = loadSkills(cwd).skills.find(
|
|
212
|
+
(s) => s.name === name && (!opts.scope || s.scope === opts.scope),
|
|
213
|
+
);
|
|
214
|
+
if (skill) {
|
|
215
|
+
const root = skill.scope === "project" ? projectPaths(cwd).skills : globalPaths().skills;
|
|
216
|
+
if (isInsideDir(root, resolve(skill.dir))) {
|
|
217
|
+
rmSync(skill.dir, { recursive: true, force: true });
|
|
218
|
+
return { dir: skill.dir };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
throw new Error(`No installed skill "${name}"${opts.scope ? ` in ${opts.scope} scope` : ""}.`);
|
|
222
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { globalPaths, projectPaths } from "../config/paths.ts";
|
|
4
|
+
import { parseFrontmatter } from "../commands/custom.ts";
|
|
5
|
+
|
|
6
|
+
// An agent skill: a directory under .privateer/skills/ containing SKILL.md
|
|
7
|
+
// (frontmatter + instruction body) plus optional bundled files (scripts/,
|
|
8
|
+
// references/, ...). Format-compatible with Claude Code skills, so published
|
|
9
|
+
// skills work when dropped in unchanged. The model sees only name+description
|
|
10
|
+
// (the catalog in the `skill` tool); the body is loaded on demand.
|
|
11
|
+
export interface SkillDefinition {
|
|
12
|
+
name: string; // frontmatter `name`, else the directory name
|
|
13
|
+
description: string; // required — a skill without one is skipped
|
|
14
|
+
allowedTools?: string[]; // frontmatter `allowed-tools` — parsed, advisory in v1
|
|
15
|
+
model?: string; // parsed, unused in v1 (skills run inline in the main loop)
|
|
16
|
+
body: string; // SKILL.md body — the instructions
|
|
17
|
+
dir: string; // absolute skill directory, base path for bundled files
|
|
18
|
+
scope: "project" | "user";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Lowercase alphanumeric + hyphens, ≤64 chars (the Claude Code skill-name rule).
|
|
22
|
+
export const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
23
|
+
|
|
24
|
+
function loadFromDir(
|
|
25
|
+
root: string,
|
|
26
|
+
scope: "project" | "user",
|
|
27
|
+
): { skills: SkillDefinition[]; warnings: string[] } {
|
|
28
|
+
const skills: SkillDefinition[] = [];
|
|
29
|
+
const warnings: string[] = [];
|
|
30
|
+
if (!existsSync(root)) return { skills, warnings };
|
|
31
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
32
|
+
if (!entry.isDirectory()) continue;
|
|
33
|
+
const dir = join(root, entry.name);
|
|
34
|
+
const file = join(dir, "SKILL.md");
|
|
35
|
+
if (!existsSync(file)) continue;
|
|
36
|
+
const { meta, body } = parseFrontmatter(readFileSync(file, "utf8"));
|
|
37
|
+
const name = (meta.name || entry.name).toLowerCase();
|
|
38
|
+
if (!SKILL_NAME_RE.test(name)) {
|
|
39
|
+
warnings.push(`${scope} skill "${entry.name}": invalid name "${name}" — skipped`);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (!meta.description) {
|
|
43
|
+
warnings.push(`${scope} skill "${name}": missing description — skipped`);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (meta.name && meta.name.toLowerCase() !== entry.name.toLowerCase()) {
|
|
47
|
+
warnings.push(`${scope} skill "${name}": directory is named "${entry.name}"`);
|
|
48
|
+
}
|
|
49
|
+
skills.push({
|
|
50
|
+
name,
|
|
51
|
+
description: meta.description,
|
|
52
|
+
allowedTools: meta["allowed-tools"]
|
|
53
|
+
?.split(",")
|
|
54
|
+
.map((s) => s.trim())
|
|
55
|
+
.filter(Boolean),
|
|
56
|
+
model: meta.model,
|
|
57
|
+
body: body.trim(),
|
|
58
|
+
dir,
|
|
59
|
+
scope,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return { skills, warnings };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// User (~/.privateer/skills) then project (./.privateer/skills); a project skill
|
|
66
|
+
// overrides a user skill of the same name.
|
|
67
|
+
export function loadSkills(cwd: string = process.cwd()): {
|
|
68
|
+
skills: SkillDefinition[];
|
|
69
|
+
warnings: string[];
|
|
70
|
+
} {
|
|
71
|
+
const byName = new Map<string, SkillDefinition>();
|
|
72
|
+
const warnings: string[] = [];
|
|
73
|
+
for (const scoped of [
|
|
74
|
+
loadFromDir(globalPaths().skills, "user"),
|
|
75
|
+
loadFromDir(projectPaths(cwd).skills, "project"),
|
|
76
|
+
]) {
|
|
77
|
+
for (const s of scoped.skills) byName.set(s.name, s);
|
|
78
|
+
warnings.push(...scoped.warnings);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
skills: [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
|
82
|
+
warnings,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function findSkill(name: string, cwd: string = process.cwd()): SkillDefinition | undefined {
|
|
87
|
+
return loadSkills(cwd).skills.find((s) => s.name === name);
|
|
88
|
+
}
|