privateer-agent 0.2.1 → 0.3.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 +135 -443
- package/bin/privateer-tui +86 -0
- package/bin/privateer.mjs +17 -6
- package/bin/pv +28 -0
- package/package.json +26 -23
- package/src/auth/privateer.ts +57 -0
- package/src/boot.ts +43 -0
- package/src/bridge/engineAdapter.ts +182 -0
- package/src/cli/chat.ts +243 -0
- package/src/config/paths.ts +24 -46
- package/src/config/permissionMode.ts +5 -0
- package/src/crypto/outboxSeal.ts +61 -0
- package/src/daemon/index.ts +204 -121
- package/src/daemon/ipc.ts +1 -1
- package/src/engine/errors.ts +85 -45
- package/src/engine/router.ts +11 -165
- package/src/ext/permissionGate.ts +216 -0
- package/src/main.ts +32 -0
- package/src/permissions/classify.ts +172 -0
- package/src/permissions/gate.ts +11 -14
- package/src/permissions/mode.ts +6 -2
- package/src/permissions/{uiGate.ts → modeGate.ts} +18 -3
- package/src/providers/account.ts +170 -0
- package/src/providers/catalog.ts +73 -61
- package/src/providers/genModelsJson.ts +97 -0
- package/src/remote/relayClient.ts +15 -0
- package/src/remote/remoteBridge.ts +152 -0
- package/src/routines/delivery.ts +118 -9
- package/src/routines/schema.ts +24 -5
- package/src/routines/store.ts +44 -1
- package/src/routines/toolSelect.ts +3 -1
- package/src/session.ts +81 -273
- package/src/tools/routine.ts +120 -101
- package/src/tools/saveAttachment.ts +39 -42
- package/src/tools/sendFile.ts +75 -0
- package/src/util/attachmentStore.ts +18 -35
- package/src/util/redact.ts +33 -3
- package/LICENSE +0 -21
- package/src/agents/loader.ts +0 -49
- package/src/commands/custom.ts +0 -123
- package/src/commands/registry.ts +0 -618
- package/src/components/AgentGroupView.tsx +0 -104
- package/src/components/App.tsx +0 -1626
- package/src/components/ApprovalPrompt.tsx +0 -49
- package/src/components/Banner.tsx +0 -78
- package/src/components/Markdown.tsx +0 -183
- package/src/components/ModeHint.tsx +0 -40
- package/src/components/ModelPicker.tsx +0 -302
- package/src/components/Onboarding.tsx +0 -203
- package/src/components/OptionPicker.tsx +0 -134
- package/src/components/PlanConfirm.tsx +0 -37
- package/src/components/PrivateerLogin.tsx +0 -109
- package/src/components/PromptInput.tsx +0 -602
- package/src/components/RewindPicker.tsx +0 -69
- package/src/components/Root.tsx +0 -116
- package/src/components/SessionPicker.tsx +0 -64
- package/src/components/StatusBar.tsx +0 -131
- package/src/components/TodoPanel.tsx +0 -36
- package/src/components/ToolCallView.tsx +0 -113
- package/src/components/Transcript.tsx +0 -210
- package/src/components/figures.ts +0 -14
- package/src/components/promptModel.ts +0 -73
- package/src/components/spinnerVerbs.ts +0 -46
- package/src/components/theme.ts +0 -57
- package/src/components/types.ts +0 -34
- package/src/components/useTeeShield.ts +0 -104
- package/src/components/useTerminalWidth.ts +0 -24
- package/src/components/useZdrShield.ts +0 -126
- package/src/config/load.ts +0 -115
- package/src/config/schema.ts +0 -94
- package/src/context/outputStyles.ts +0 -42
- package/src/context/projectInfo.ts +0 -59
- package/src/context/systemPrompt.ts +0 -176
- package/src/engine/QueryEngine.ts +0 -399
- package/src/hooks/engine.ts +0 -155
- package/src/main.tsx +0 -209
- package/src/mcp/client.ts +0 -251
- package/src/mcp/oauth.ts +0 -245
- package/src/memory/auto.ts +0 -146
- package/src/memory/checkpoints.ts +0 -227
- package/src/memory/store.ts +0 -127
- package/src/providers/attestation.ts +0 -149
- package/src/providers/capabilities.ts +0 -104
- package/src/providers/models.ts +0 -183
- package/src/providers/registry.ts +0 -71
- package/src/providers/resolve.ts +0 -78
- package/src/skills/installer.ts +0 -222
- package/src/skills/loader.ts +0 -88
- package/src/tools/askUser.ts +0 -92
- package/src/tools/bash.ts +0 -98
- package/src/tools/context.ts +0 -128
- package/src/tools/edit.ts +0 -67
- package/src/tools/exec.ts +0 -60
- package/src/tools/glob.ts +0 -39
- package/src/tools/grep.ts +0 -86
- package/src/tools/index.ts +0 -83
- package/src/tools/memory.ts +0 -53
- package/src/tools/processRegistry.ts +0 -77
- package/src/tools/read.ts +0 -42
- package/src/tools/sendFileToClient.ts +0 -55
- package/src/tools/skill.ts +0 -44
- package/src/tools/task.ts +0 -52
- package/src/tools/todo.ts +0 -36
- package/src/tools/todoStore.ts +0 -31
- package/src/tools/walk.ts +0 -44
- package/src/tools/web.ts +0 -145
- package/src/tools/worktree.ts +0 -145
- package/src/tools/write.ts +0 -40
- package/src/util/images.ts +0 -378
- package/src/util/limit.ts +0 -32
- package/src/version.ts +0 -13
package/src/providers/resolve.ts
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
import type { LanguageModel } from "ai";
|
|
2
|
-
import type { Config } from "../config/schema.ts";
|
|
3
|
-
import { KNOWN_PROVIDERS, type ProviderName } from "../config/schema.ts";
|
|
4
|
-
import { buildModel, providerRequiresKey } from "./registry.ts";
|
|
5
|
-
import { hasCredentials } from "../auth/privateer.ts";
|
|
6
|
-
|
|
7
|
-
// Privateer's readiness is session-based, not key-based: ready iff logged in.
|
|
8
|
-
function providerReady(name: ProviderName, cfg: { apiKey?: string }): boolean {
|
|
9
|
-
if (name === "privateer") return hasCredentials();
|
|
10
|
-
return providerRequiresKey(name) ? Boolean(cfg.apiKey) : true;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface ResolvedModel {
|
|
14
|
-
spec: string; // original "provider:model" string
|
|
15
|
-
provider: ProviderName;
|
|
16
|
-
modelId: string;
|
|
17
|
-
model: LanguageModel;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function isKnownProvider(name: string): name is ProviderName {
|
|
21
|
-
return (KNOWN_PROVIDERS as readonly string[]).includes(name);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// Parse a "provider:model" spec. The model id itself may contain ":" or "/"
|
|
25
|
-
// (e.g. "openrouter:anthropic/claude-opus-4.8"), so only the first ":" splits.
|
|
26
|
-
export function parseModelSpec(spec: string): { provider: string; modelId: string } {
|
|
27
|
-
const idx = spec.indexOf(":");
|
|
28
|
-
if (idx === -1) {
|
|
29
|
-
throw new Error(
|
|
30
|
-
`Invalid model "${spec}". Use "provider:model", e.g. openrouter:anthropic/claude-opus-4.8`,
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
return { provider: spec.slice(0, idx).trim(), modelId: spec.slice(idx + 1).trim() };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// A Privateer account model is served over one of two privacy channels: NEAR's
|
|
37
|
-
// confidential-compute TEE (model ids prefixed "near/", cryptographically
|
|
38
|
-
// attestable) or the account's zero-data-retention OpenRouter proxy (every other
|
|
39
|
-
// id, pinned to ZDR endpoints server-side). The picker and the status-bar shield
|
|
40
|
-
// both surface this so the active privacy channel is always visible.
|
|
41
|
-
export type PrivateerChannel = "tee" | "zdr";
|
|
42
|
-
|
|
43
|
-
export function privateerChannel(modelId: string): PrivateerChannel {
|
|
44
|
-
return modelId.startsWith("near/") ? "tee" : "zdr";
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// Turn a model spec + config into a ready-to-use AI SDK model, validating that the
|
|
48
|
-
// provider is known and configured. Construction does not hit the network.
|
|
49
|
-
export function resolveModel(spec: string, config: Config): ResolvedModel {
|
|
50
|
-
const { provider, modelId } = parseModelSpec(spec);
|
|
51
|
-
|
|
52
|
-
if (!isKnownProvider(provider)) {
|
|
53
|
-
throw new Error(
|
|
54
|
-
`Unknown provider "${provider}". Known: ${KNOWN_PROVIDERS.join(", ")}.`,
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
if (!modelId) throw new Error(`Missing model id in "${spec}".`);
|
|
58
|
-
|
|
59
|
-
const cfg = config.providers[provider] ?? {};
|
|
60
|
-
if (provider === "privateer" && !hasCredentials()) {
|
|
61
|
-
throw new Error(`Not signed in to your Privateer account. Run /login first.`);
|
|
62
|
-
}
|
|
63
|
-
if (provider !== "privateer" && providerRequiresKey(provider) && !cfg.apiKey) {
|
|
64
|
-
throw new Error(
|
|
65
|
-
`No API key for "${provider}". Set ${provider.toUpperCase()}_API_KEY or add it to ~/.privateer/config.json.`,
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return { spec, provider, modelId, model: buildModel(provider, cfg, modelId) };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Which providers currently have working credentials — used by /doctor and provider listing.
|
|
73
|
-
export function configuredProviders(config: Config): { name: ProviderName; ready: boolean }[] {
|
|
74
|
-
return KNOWN_PROVIDERS.map((name) => {
|
|
75
|
-
const cfg = config.providers[name] ?? {};
|
|
76
|
-
return { name, ready: providerReady(name, cfg) };
|
|
77
|
-
});
|
|
78
|
-
}
|
package/src/skills/installer.ts
DELETED
|
@@ -1,222 +0,0 @@
|
|
|
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
|
-
}
|
package/src/skills/loader.ts
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
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
|
-
}
|
package/src/tools/askUser.ts
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import { tool } from "ai";
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
import type { ToolContext } from "./context.ts";
|
|
4
|
-
|
|
5
|
-
// A single selectable approach presented to the user.
|
|
6
|
-
export interface UserChoiceOption {
|
|
7
|
-
label: string;
|
|
8
|
-
description?: string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
// A decision the model surfaces to the user via the `ask_user` tool.
|
|
12
|
-
export interface UserQuestion {
|
|
13
|
-
question: string;
|
|
14
|
-
options: UserChoiceOption[];
|
|
15
|
-
multiSelect: boolean;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// The user's answer: chosen option index(es), free-form custom text, or a dismissal.
|
|
19
|
-
export type UserAnswer =
|
|
20
|
-
| { kind: "selected"; indices: number[] }
|
|
21
|
-
| { kind: "custom"; text: string }
|
|
22
|
-
| { kind: "dismissed" };
|
|
23
|
-
|
|
24
|
-
// Bridge supplied by the interactive session: surface a question to the TUI and
|
|
25
|
-
// resolve with the user's choice. Absent in non-interactive contexts (sub-agents,
|
|
26
|
-
// remote-driven turns, headless runs), where the tool reports it couldn't ask.
|
|
27
|
-
export type UserAsker = (q: UserQuestion) => Promise<UserAnswer>;
|
|
28
|
-
|
|
29
|
-
// The `ask_user` tool: when the right implementation direction is genuinely
|
|
30
|
-
// ambiguous, the model puts a small menu of approaches to the user and blocks until
|
|
31
|
-
// they pick one (or write their own answer). No filesystem mutation, so it isn't
|
|
32
|
-
// gated — but it does pause the turn on the human, exactly like the approval prompt.
|
|
33
|
-
export function askUserTool(ctx: ToolContext) {
|
|
34
|
-
return tool({
|
|
35
|
-
description:
|
|
36
|
-
"Ask the user to choose between competing implementation directions when the right " +
|
|
37
|
-
"approach is genuinely ambiguous and the choice materially changes the work (architecture, " +
|
|
38
|
-
"a library, a data model, the scope of a change). Present 2–4 concrete options, " +
|
|
39
|
-
"most-recommended first, each with its trade-offs; the user picks one (or writes their own " +
|
|
40
|
-
"answer). Prefer this over silently guessing on a consequential fork. Do NOT use it for " +
|
|
41
|
-
"trivial choices you can reasonably make yourself, or to request permission for an action — " +
|
|
42
|
-
"the approval gate handles permissions.",
|
|
43
|
-
inputSchema: z.object({
|
|
44
|
-
question: z.string().describe("The decision to put to the user, phrased as a question."),
|
|
45
|
-
options: z
|
|
46
|
-
.array(
|
|
47
|
-
z.object({
|
|
48
|
-
label: z
|
|
49
|
-
.string()
|
|
50
|
-
.describe("Short name for this approach, e.g. 'Server-side rendering'."),
|
|
51
|
-
description: z
|
|
52
|
-
.string()
|
|
53
|
-
.optional()
|
|
54
|
-
.describe("One or two sentences on what it entails and its trade-offs."),
|
|
55
|
-
}),
|
|
56
|
-
)
|
|
57
|
-
.min(2)
|
|
58
|
-
.max(4)
|
|
59
|
-
.describe("The competing approaches, most-recommended first."),
|
|
60
|
-
multiSelect: z
|
|
61
|
-
.boolean()
|
|
62
|
-
.optional()
|
|
63
|
-
.describe("Allow the user to choose more than one option (default false)."),
|
|
64
|
-
}),
|
|
65
|
-
execute: async ({ question, options, multiSelect }) => {
|
|
66
|
-
if (!ctx.askUser) {
|
|
67
|
-
return (
|
|
68
|
-
"Cannot ask the user interactively in this context. Pick the best option using your " +
|
|
69
|
-
"own judgment, then state which you chose and why before proceeding."
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
const answer = await ctx.askUser({ question, options, multiSelect: multiSelect ?? false });
|
|
73
|
-
if (answer.kind === "custom") {
|
|
74
|
-
return `The user answered: ${answer.text}`;
|
|
75
|
-
}
|
|
76
|
-
if (answer.kind === "dismissed") {
|
|
77
|
-
return (
|
|
78
|
-
"The user dismissed the question without choosing. Either restate the choice briefly in " +
|
|
79
|
-
"plain prose, or proceed with the best option and say which you picked."
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
const chosen = answer.indices.map((i) => options[i]).filter(Boolean);
|
|
83
|
-
if (chosen.length === 0) {
|
|
84
|
-
return "The user made no selection. Proceed with the best option and state which you chose.";
|
|
85
|
-
}
|
|
86
|
-
const rendered = chosen
|
|
87
|
-
.map((o) => (o.description ? `"${o.label}" (${o.description})` : `"${o.label}"`))
|
|
88
|
-
.join(", ");
|
|
89
|
-
return `The user chose: ${rendered}. Proceed with this direction.`;
|
|
90
|
-
},
|
|
91
|
-
});
|
|
92
|
-
}
|
package/src/tools/bash.ts
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
import { tool } from "ai";
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
import type { ToolContext } from "./context.ts";
|
|
4
|
-
import { exec } from "./exec.ts";
|
|
5
|
-
import { PermissionDeniedError } from "../permissions/gate.ts";
|
|
6
|
-
|
|
7
|
-
const DEFAULT_TIMEOUT = 120_000;
|
|
8
|
-
const MAX_TIMEOUT = 600_000;
|
|
9
|
-
// Cap how much command output enters the conversation. Unbounded output (a big
|
|
10
|
-
// `git diff`, a verbose build log) is otherwise re-sent on every subsequent step of
|
|
11
|
-
// the agentic loop, ballooning token usage. Keep the head and tail — both ends carry
|
|
12
|
-
// the most signal (the command's start and its final status/errors).
|
|
13
|
-
const MAX_OUTPUT_CHARS = 30_000;
|
|
14
|
-
const HEAD_CHARS = 20_000;
|
|
15
|
-
|
|
16
|
-
function clampOutput(text: string): string {
|
|
17
|
-
if (text.length <= MAX_OUTPUT_CHARS) return text;
|
|
18
|
-
const head = text.slice(0, HEAD_CHARS);
|
|
19
|
-
const tail = text.slice(text.length - (MAX_OUTPUT_CHARS - HEAD_CHARS));
|
|
20
|
-
const omitted = text.length - MAX_OUTPUT_CHARS;
|
|
21
|
-
return `${head}\n… (${omitted} chars of output truncated) …\n${tail}`;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function bashTool(ctx: ToolContext) {
|
|
25
|
-
return tool({
|
|
26
|
-
description:
|
|
27
|
-
"Run a shell command in the working directory and return its output. " +
|
|
28
|
-
"Use for builds, tests, git, and other CLI tasks. Avoid long-running/interactive commands.",
|
|
29
|
-
inputSchema: z.object({
|
|
30
|
-
command: z.string().describe("The shell command to run."),
|
|
31
|
-
timeout: z.number().int().positive().optional().describe("Timeout in ms (max 600000)."),
|
|
32
|
-
run_in_background: z
|
|
33
|
-
.boolean()
|
|
34
|
-
.optional()
|
|
35
|
-
.describe("Run detached and return immediately; poll with bash_output, stop with kill_shell."),
|
|
36
|
-
}),
|
|
37
|
-
execute: async ({ command, timeout, run_in_background }) => {
|
|
38
|
-
const decision = await ctx.gate.request({
|
|
39
|
-
tool: "bash",
|
|
40
|
-
kind: "bash",
|
|
41
|
-
title: run_in_background ? "Run command (background)" : "Run command",
|
|
42
|
-
detail: command,
|
|
43
|
-
});
|
|
44
|
-
if (decision === "deny") throw new PermissionDeniedError("bash");
|
|
45
|
-
|
|
46
|
-
if (run_in_background) {
|
|
47
|
-
if (!ctx.processes) return "Background execution is not available in this context.";
|
|
48
|
-
const id = ctx.processes.spawn(command, ctx.cwd);
|
|
49
|
-
return `Started in background as ${id}. Read output with bash_output(bash_id="${id}"); stop with kill_shell(bash_id="${id}").`;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const timeoutMs = Math.min(timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT);
|
|
53
|
-
const { stdout, stderr, code, timedOut } = await exec(command, [], {
|
|
54
|
-
cwd: ctx.cwd,
|
|
55
|
-
timeoutMs,
|
|
56
|
-
shell: true,
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
const parts: string[] = [];
|
|
60
|
-
if (stdout.trim()) parts.push(clampOutput(stdout.trimEnd()));
|
|
61
|
-
if (stderr.trim()) parts.push(`[stderr]\n${clampOutput(stderr.trimEnd())}`);
|
|
62
|
-
if (timedOut) parts.push(`[timed out after ${timeoutMs}ms]`);
|
|
63
|
-
parts.push(`[exit code ${code ?? "null"}]`);
|
|
64
|
-
return parts.join("\n");
|
|
65
|
-
},
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function bashOutputTool(ctx: ToolContext) {
|
|
70
|
-
return tool({
|
|
71
|
-
description:
|
|
72
|
-
"Read new output from a background shell started with bash run_in_background. Returns only " +
|
|
73
|
-
"output produced since the previous read, plus the shell's status.",
|
|
74
|
-
inputSchema: z.object({
|
|
75
|
-
bash_id: z.string().describe("The background shell id (e.g. bash_1)."),
|
|
76
|
-
}),
|
|
77
|
-
execute: async ({ bash_id }) => {
|
|
78
|
-
if (!ctx.processes) return "Background processes are not available in this context.";
|
|
79
|
-
const r = ctx.processes.read(bash_id);
|
|
80
|
-
if (!r) return `No background shell "${bash_id}".`;
|
|
81
|
-
const head = `[${r.status}${r.status === "exited" ? `, exit ${r.code ?? "null"}` : ""}]`;
|
|
82
|
-
return r.output ? `${head}\n${r.output.trimEnd()}` : `${head} (no new output)`;
|
|
83
|
-
},
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export function killShellTool(ctx: ToolContext) {
|
|
88
|
-
return tool({
|
|
89
|
-
description: "Stop a background shell started with bash run_in_background.",
|
|
90
|
-
inputSchema: z.object({
|
|
91
|
-
bash_id: z.string().describe("The background shell id to stop."),
|
|
92
|
-
}),
|
|
93
|
-
execute: async ({ bash_id }) => {
|
|
94
|
-
if (!ctx.processes) return "Background processes are not available in this context.";
|
|
95
|
-
return ctx.processes.kill(bash_id) ? `Stopped ${bash_id}.` : `No background shell "${bash_id}".`;
|
|
96
|
-
},
|
|
97
|
-
});
|
|
98
|
-
}
|