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.
Files changed (44) hide show
  1. package/README.md +86 -33
  2. package/package.json +1 -1
  3. package/src/auth/privateer.ts +71 -1
  4. package/src/commands/custom.ts +52 -4
  5. package/src/commands/registry.ts +124 -5
  6. package/src/components/App.tsx +268 -18
  7. package/src/components/ApprovalPrompt.tsx +15 -4
  8. package/src/components/Banner.tsx +21 -1
  9. package/src/components/ModelPicker.tsx +45 -12
  10. package/src/components/OptionPicker.tsx +134 -0
  11. package/src/components/Root.tsx +30 -9
  12. package/src/components/StatusBar.tsx +11 -1
  13. package/src/components/ToolCallView.tsx +4 -0
  14. package/src/components/Transcript.tsx +14 -7
  15. package/src/components/figures.ts +1 -0
  16. package/src/components/theme.ts +2 -0
  17. package/src/config/paths.ts +2 -0
  18. package/src/context/systemPrompt.ts +9 -0
  19. package/src/daemon/index.ts +322 -0
  20. package/src/daemon/ipc.ts +127 -0
  21. package/src/engine/errors.ts +10 -0
  22. package/src/main.tsx +43 -1
  23. package/src/mcp/client.ts +16 -1
  24. package/src/permissions/gate.ts +5 -0
  25. package/src/permissions/mode.ts +4 -0
  26. package/src/permissions/uiGate.ts +4 -3
  27. package/src/remote/relayClient.ts +161 -6
  28. package/src/routines/cron.ts +109 -0
  29. package/src/routines/delivery.ts +75 -0
  30. package/src/routines/schema.ts +65 -0
  31. package/src/routines/store.ts +205 -0
  32. package/src/routines/toolSelect.ts +48 -0
  33. package/src/routines/trigger.ts +41 -0
  34. package/src/session.ts +37 -12
  35. package/src/skills/installer.ts +222 -0
  36. package/src/skills/loader.ts +88 -0
  37. package/src/tools/askUser.ts +92 -0
  38. package/src/tools/context.ts +14 -0
  39. package/src/tools/index.ts +14 -0
  40. package/src/tools/routine.ts +110 -0
  41. package/src/tools/sendFileToClient.ts +55 -0
  42. package/src/tools/skill.ts +44 -0
  43. package/src/tools/worktree.ts +145 -0
  44. package/src/util/images.ts +35 -0
package/README.md CHANGED
@@ -12,15 +12,25 @@
12
12
  <a href="https://github.com/privateer-agent/privateer-agent/actions/workflows/ci.yml">
13
13
  <img src="https://github.com/privateer-agent/privateer-agent/actions/workflows/ci.yml/badge.svg" alt="CI" />
14
14
  </a>
15
+ <a href="https://www.npmjs.com/package/privateer-agent">
16
+ <img src="https://img.shields.io/npm/v/privateer-agent" alt="npm" />
17
+ </a>
15
18
  <img src="https://img.shields.io/badge/node-%E2%89%A520-brightgreen" alt="Node >= 20" />
16
19
  <img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License" />
17
20
  <img src="https://img.shields.io/badge/providers-OpenRouter%20·%20Anthropic%20·%20OpenAI%20·%20Ollama%20·%20NEAR%20AI-5b8def" alt="Providers" />
18
21
  <img src="https://img.shields.io/badge/built%20on-Vercel%20AI%20SDK-black" alt="Vercel AI SDK" />
19
22
  </p>
20
23
 
24
+ ```bash
25
+ curl -fsSL https://privateer.pro/install.sh | sh # installs the `privateer` command
26
+ npx privateer-agent # or run it instantly, nothing installed
27
+ ```
28
+
21
29
  Switch between **OpenRouter**, **Anthropic**, **OpenAI**, local **Ollama**, and **NEAR AI**
22
30
  (private, attestable inference) with one command. Built on the Vercel AI SDK, so tool-calling
23
31
  and streaming work identically across every provider — no model lock-in, no separate code paths.
32
+ MCP servers, Claude Code-compatible skills, scheduled routines, and approval from your phone
33
+ included.
24
34
 
25
35
  <p align="center">
26
36
  <img src="docs/screenshot.png" alt="Privateer running in the terminal" width="820" />
@@ -30,63 +40,61 @@ and streaming work identically across every provider — no model lock-in, no se
30
40
 
31
41
  - **No lock-in.** Point it at a frontier model today and a local Ollama model tomorrow —
32
42
  `/model` swaps mid-session. Your config, commands, and agents come along for the ride.
43
+ - **No API key required.** Bring your own key (BYOK) from any supported provider, run
44
+ keyless against a local Ollama — or `/login` to bill a Privateer account instead.
33
45
  - **The agent UX you already know.** Plan mode, checkpoint/rewind, a modal prompt, slash
34
46
  commands, sub-agents, and project memory — but vendor-neutral.
35
47
  - **Genuinely extensible.** MCP servers, lifecycle hooks, custom commands, output styles,
36
- and sub-agents are all just files under `.privateer/`. No plugins to compile.
48
+ sub-agents, and skills are all just files under `.privateer/`. No plugins to compile.
37
49
  - **Zero binary deps.** The file/search/shell tools are pure Node — nothing to install
38
50
  beyond `node`.
39
51
 
40
52
  ## Highlights
41
53
 
54
+ - **MCP servers** (local stdio + remote HTTP/SSE, with interactive OAuth), lifecycle
55
+ **hooks**, and **custom sub-agents**
56
+ - **Claude Code-compatible skills** — published Agent Skills drop in unchanged; install
57
+ from GitHub with `/skills install owner/repo`
58
+ - **Scheduled routines** — a daemon runs approved tasks unattended, cron or one-off
59
+ - **Approve it from your phone** — link the terminal to the Privateer app with
60
+ `/remote-access` (off by default) and Allow/Deny every action remotely
61
+ - **Zero-Data-Retention surfacing** for OpenRouter: a status-bar shield colors the selected
62
+ model's retention posture, and `/zdr` pins routing to zero-retention endpoints
63
+ - **Private, verifiable inference** via NEAR AI: every model runs in a TEE, a `⛉ TEE` status
64
+ shield reflects the live attestation, and `/verify` fetches the attestation report (validate
65
+ the raw quote chains with the NEAR Cloud Verifier for full cryptographic proof)
66
+ - **Plan mode** (read-only → present a plan → approve), **checkpoint/rewind** of
67
+ conversation and files
42
68
  - A modal prompt with `/` command and `@` file autocomplete, `!` shell passthrough,
43
69
  `#` memory append, input history, optional **vim** mode, and **ctrl-r** history search
44
70
  - Layered `settings.json` (user → project → local → managed), **custom slash commands**
45
71
  and **output styles** as markdown files
46
- - **Plan mode** (read-only → present a plan → approve), **checkpoint/rewind** of
47
- conversation and files
48
- - Extensible: **MCP servers** (local stdio + remote HTTP/SSE, with interactive OAuth),
49
- lifecycle **hooks**, and **custom sub-agents**
50
72
  - Background shells, bounded parallel sub-agents, thinking display, structured compaction,
51
73
  and image attachment for vision-capable models
52
- - **Zero-Data-Retention surfacing** for OpenRouter: a status-bar shield colors the selected
53
- model's retention posture, and `/zdr` pins routing to zero-retention endpoints
54
- - **Private, verifiable inference** via NEAR AI: every model runs in a TEE, a `⛉ TEE` status
55
- shield reflects the live attestation, and `/verify` fetches the attestation report (validate
56
- the raw quote chains with the NEAR Cloud Verifier for full cryptographic proof)
57
74
 
58
75
  ## Quickstart
59
76
 
60
77
  ```bash
61
- npx privateer-agent # zero-install, runs the latest
62
- ```
63
-
64
- Or install the `privateer` command on your PATH:
65
-
66
- ```bash
67
- npm install -g privateer-agent # then just run: privateer
68
- # or, the one-liner installer (checks Node, then installs):
69
- curl -fsSL https://privateer.pro/install.sh | sh
70
- ```
71
-
72
- ```bash
73
- export OPENROUTER_API_KEY=sk-or-... # one provider is enough — or run /login
74
- privateer # launches the interactive TUI
78
+ curl -fsSL https://privateer.pro/install.sh | sh # or: npm install -g privateer-agent
79
+ export OPENROUTER_API_KEY=sk-or-... # one provider is enough — or skip and run /login
80
+ privateer # launches the interactive TUI
75
81
  ```
76
82
 
77
83
  First run walks you through picking a provider and default model. From there, just type.
84
+ (No install at all: `npx privateer-agent`.)
78
85
 
79
86
  ## Contents
80
87
 
81
- - [Requirements](#requirements) · [Install](#install) · [Configure a provider](#configure-a-provider) · [Model routing](#model-routing) · [Data retention (ZDR)](#data-retention-zdr) · [Private inference (NEAR AI)](#private-inference-near-ai) · [Usage](#usage)
88
+ - [Requirements](#requirements) · [Install](#install) · [Configure a provider](#configure-a-provider) · [Model routing](#model-routing) · [Data retention (ZDR)](#data-retention-zdr) · [Private inference (NEAR AI)](#private-inference-near-ai) · [Privateer account](#privateer-account-billed-inference--what-it-sees) · [Usage](#usage)
82
89
  - [The prompt](#the-prompt) · [Slash commands](#slash-commands) · [Tools](#tools)
83
90
  - [Customize & extend](#customize--extend) · [Permission modes](#permission-modes) · [Project context](#project-context)
84
- - [Develop](#develop) · [Caveats](#caveats) · [Docs](#docs) · [License](#license)
91
+ - [How it compares](#how-it-compares) · [Develop](#develop) · [Caveats](#caveats) · [Docs](#docs) · [License](#license)
85
92
 
86
93
  ## Requirements
87
94
 
88
- - Node.js 20
89
- - An API key for at least one provider (or a local Ollama install)
95
+ - macOS or Linux
96
+ - Node.js 20 (pure Node, zero binary dependencies)
97
+ - An API key for at least one provider — or a local Ollama install, or a Privateer account (`/login`)
90
98
 
91
99
  ## Install
92
100
 
@@ -352,14 +360,19 @@ Built-ins (plus any custom commands you add):
352
360
  | Command | |
353
361
  |---|---|
354
362
  | `/help` `/doctor` `/config` | help, diagnostics, resolved settings layers |
355
- | `/model [spec]` `/provider` `/login` | choose a model, list providers, re-run onboarding |
363
+ | `/model [spec]` `/provider` `/keys` | choose a model, list providers, manage API keys |
364
+ | `/login` `/logout` | sign a Privateer account in/out (see [Privateer account](#privateer-account-billed-inference--what-it-sees)) |
365
+ | `/remote-access [on\|off\|status]` | link this terminal to the Privateer app for phone approval (off by default) |
356
366
  | `/permissions [mode]` `/cost` `/context` | permission mode, token usage, context window |
357
- | `/init` `/memory` | write/show `PRIVATEER.md` |
367
+ | `/init` `/memory` `/todo` | write/show `PRIVATEER.md`; show the task list |
358
368
  | `/agents` `/mcp [logout]` `/hooks` | inspect sub-agents; MCP status / clear OAuth; hooks |
369
+ | `/skills [list\|info\|install\|remove]` | manage skills (see [Customize & extend](#customize--extend)) |
370
+ | `/routine [list\|pause\|resume\|rm\|run]` | manage scheduled routines |
359
371
  | `/output-style [name]` `/vim` `/verbose` | persona, modal editing, full tool output |
360
372
  | `/zdr` | toggle OpenRouter zero-data-retention enforcement (see [Data retention](#data-retention-zdr)) |
361
373
  | `/verify` | fetch the NEAR AI TEE attestation for the current model (see [Private inference](#private-inference-near-ai)) |
362
374
  | `/rewind` `/compact` `/clear` `/export` | restore a checkpoint, compact, clear, save transcript |
375
+ | `/resume` `/sessions` | pick up an earlier session in this directory |
363
376
  | `/exit` | quit |
364
377
 
365
378
  - `/model` — open a picker of each provider's live models (or `/model provider:id` to set one directly).
@@ -392,6 +405,14 @@ Everything below is optional and lives under `.privateer/` (project) or `~/.priv
392
405
  Switch with `/output-style <name>` (or `default`).
393
406
  - **Sub-agents** — `.privateer/agents/<name>.md` with frontmatter (`description`, `tools`,
394
407
  `model`). Invoke via the `task` tool's `subagent_type`; `/agents` lists them.
408
+ - **Skills** — `.privateer/skills/<name>/SKILL.md` (frontmatter `name`/`description` +
409
+ instruction body, plus any bundled `scripts/`/`references/` files). The format is
410
+ Claude Code-compatible, so published Agent Skills drop in unchanged. The agent sees a
411
+ catalog of names and descriptions and loads a skill's full instructions on demand via
412
+ the `skill` tool; `/skill-name` invokes one explicitly. Manage with `/skills`
413
+ (`list`, `info <name>`, `install <owner/repo[/path]> [--project] [--all] [--force]`,
414
+ `remove <name>`) — install fetches from GitHub with a shallow clone and never executes
415
+ anything it downloads.
395
416
  - **Hooks** — a `hooks` section in `settings.json` runs shell commands on `PreToolUse`,
396
417
  `PostToolUse`, `UserPromptSubmit`, and `Stop`. A hook blocks by exiting `2` or printing
397
418
  `{"decision":"block"}`; `UserPromptSubmit` can inject `additionalContext`. `/hooks` lists them.
@@ -414,6 +435,13 @@ Everything below is optional and lives under `.privateer/` (project) or `~/.priv
414
435
  }
415
436
  }
416
437
  ```
438
+ - **Routines** — saved tasks the scheduler daemon (`privateer daemon --detach`) runs
439
+ unattended, recurring (cron) or one-off. Ask the agent ("summarize world news every
440
+ morning") and approve; manage with `/routine` (list/pause/resume/rm/run). Runs use a safe
441
+ read/web toolset by default; a routine's `tools` list can also grant specific MCP tools
442
+ (`server__tool` or `server__*`) — flagged at approval, since they then run with no one
443
+ watching. See the [Sheet → WhatsApp recipe](docs/recipes/sheet-to-whatsapp.md) for a
444
+ full business automation built this way.
417
445
  - **Status line** — set `statusLine` to a shell command; it receives session JSON on stdin
418
446
  and its stdout becomes the status line.
419
447
 
@@ -422,18 +450,42 @@ Everything below is optional and lives under `.privateer/` (project) or `~/.priv
422
450
  | Mode | Behavior |
423
451
  |---|---|
424
452
  | `default` | prompt before edits and shell commands |
425
- | `acceptEdits` | auto-approve file edits; still prompt for other shell commands (the default) |
453
+ | `acceptEdits` | auto-approve file edits; still prompt for shell commands |
426
454
  | `bypass` | no prompts (also `--dangerously-skip-permissions` or `--no-quarter`) |
427
455
  | `plan` | read-only; the agent presents a plan, then you approve to leave plan mode |
428
456
 
429
- At an approval prompt: **y** allow once · **a** always · **n** deny. In plan mode, after the
430
- agent presents its plan: **a** approve and exit plan mode · **k** keep planning.
457
+ Out of the box the mode is **`acceptEdits`** (naming is a nod to convention: the mode
458
+ called `default` prompts on everything, but isn't the shipped default). Edits are still
459
+ checkpointed, so `/rewind` undoes them; prefer prompting on every edit? Run
460
+ `/permissions default` once — it persists. At an approval prompt: **y** allow once ·
461
+ **a** always · **n** deny. In plan mode, after the agent presents its plan: **a** approve
462
+ and exit plan mode · **k** keep planning.
431
463
 
432
464
  ## Project context
433
465
 
434
466
  Create a `PRIVATEER.md` in your repo (via `/init`) to give the agent standing
435
467
  context — conventions, architecture notes, anything it should always know.
436
468
 
469
+ ## How it compares
470
+
471
+ There are excellent terminal coding agents already. What this one does differently:
472
+
473
+ - **Provider-agnostic by construction, not adaptation.** One agent loop over the Vercel AI
474
+ SDK; OpenRouter, Anthropic, OpenAI, local Ollama, and NEAR AI are interchangeable at
475
+ `/model` time, including mid-session. No vendor's models are privileged.
476
+ - **Retention posture is a UI element.** ZDR status is visible before you send and
477
+ enforceable per request (`/zdr`); TEE inference is attestable (`/verify`). Most tools
478
+ leave this to the provider's terms-of-service page.
479
+ - **Phone approval.** `/remote-access` relays each proposed action to the Privateer app
480
+ for Allow/Deny while execution stays on your machine — useful for long agent runs you
481
+ want to supervise from anywhere.
482
+ - **Interop over ecosystem.** Skills use the Agent Skills format, so Claude Code skills
483
+ drop in unchanged; MCP covers tools. The goal is to reuse what exists, not grow a
484
+ parallel plugin world.
485
+
486
+ And, honestly, what it doesn't have: the maturity of the incumbents. It's a young
487
+ codebase — see [Caveats](#caveats) for the sharp edges we know about.
488
+
437
489
  ## Develop
438
490
 
439
491
  ```bash
@@ -467,6 +519,7 @@ deliberately simplified for now:
467
519
  ## Docs
468
520
 
469
521
  - [Architecture](docs/ARCHITECTURE.md) — how the provider layer, agent loop, tools, and permissions fit together
522
+ - [Recipe: Sheet → WhatsApp](docs/recipes/sheet-to-whatsapp.md) — an unattended routine + MCP servers messaging new spreadsheet rows
470
523
  - [Brand assets](brand/README.md) — the logo and icon set
471
524
 
472
525
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Privateer — a provider-agnostic terminal coding agent. Bring your own model (OpenRouter, Anthropic, OpenAI, Ollama, NEAR AI).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -145,6 +145,32 @@ function tryChmod(path: string, mode: number): void {
145
145
  }
146
146
  }
147
147
 
148
+ // ── Session-expiry notification ──────────────────────────────────────────────
149
+ // Fired when the machine login is invalidated server-side: the parent refresh
150
+ // token's TTL lapsed (14 days for email logins, 60 for wallet — each session
151
+ // spawn slides it forward, so this means the machine sat unused that long), or
152
+ // it was revoked (from the app's Linked Devices, or by reuse detection).
153
+ // Without a listener the credentials are wiped silently and Privateer just
154
+ // stops working; the UI subscribes to announce the sign-out prominently.
155
+
156
+ type SessionExpiredListener = () => void;
157
+ const _expiredListeners = new Set<SessionExpiredListener>();
158
+
159
+ export function onSessionExpired(listener: SessionExpiredListener): () => void {
160
+ _expiredListeners.add(listener);
161
+ return () => _expiredListeners.delete(listener);
162
+ }
163
+
164
+ function notifySessionExpired(): void {
165
+ for (const listener of _expiredListeners) {
166
+ try {
167
+ listener();
168
+ } catch {
169
+ /* a failing listener must not break the auth path */
170
+ }
171
+ }
172
+ }
173
+
148
174
  // ── Device authorization flow ────────────────────────────────────────────────
149
175
 
150
176
  export interface DeviceCode {
@@ -271,7 +297,10 @@ async function spawnChildSession(): Promise<ChildSession> {
271
297
  });
272
298
  if (!res.ok) {
273
299
  // Parent refresh token invalid/expired → the machine login is gone.
274
- if (res.status === 401) clearCredentials();
300
+ if (res.status === 401) {
301
+ clearCredentials();
302
+ notifySessionExpired();
303
+ }
275
304
  throw new Error("Your Privateer session expired. Run /login to sign in again.");
276
305
  }
277
306
  const { accessToken, refreshToken } = (await res.json()) as ChildSession;
@@ -279,6 +308,22 @@ async function spawnChildSession(): Promise<ChildSession> {
279
308
  return _child;
280
309
  }
281
310
 
311
+ /**
312
+ * Eagerly spawn this terminal's child session at startup so an expired machine
313
+ * login is announced (via onSessionExpired) at launch rather than surfacing as
314
+ * an inference error on the first prompt of the day. Best effort: transient
315
+ * network failures stay silent here — the first real request retries the spawn
316
+ * and reports through the normal error path.
317
+ */
318
+ export async function warmSession(): Promise<void> {
319
+ if (!hasCredentials()) return;
320
+ try {
321
+ await ensureChildSession();
322
+ } catch {
323
+ /* expiry is announced via onSessionExpired; other failures retry on use */
324
+ }
325
+ }
326
+
282
327
  // Ensure a child session exists, de-duping concurrent spawns within this process.
283
328
  function ensureChildSession(): Promise<ChildSession> {
284
329
  if (_child) return Promise.resolve(_child);
@@ -363,6 +408,31 @@ export async function apiRequest(path: string, init: RequestInit = {}): Promise<
363
408
  return authedFetch(`${base}${path}`, init);
364
409
  }
365
410
 
411
+ /**
412
+ * Best-effort revoke of THIS terminal's child session on exit, so the terminal
413
+ * disappears from the app's Linked Devices list immediately instead of
414
+ * lingering until its access-token rows expire (24h server-side).
415
+ *
416
+ * Deliberately NOT authedFetch: that would spawn/refresh a session just to kill
417
+ * it. If no child was ever spawned (e.g. BYO-key run), there's nothing to do.
418
+ * Bounded by a short timeout — exit must never hang on a slow network — and all
419
+ * failures are swallowed; the server's TTL remains the fallback.
420
+ */
421
+ export async function revokeChildSession(timeoutMs = 1500): Promise<void> {
422
+ const child = _child;
423
+ if (!child) return;
424
+ _child = null; // never reuse a session we've asked the server to revoke
425
+ try {
426
+ await fetch(`${serverBaseUrl()}/auth/session/current`, {
427
+ method: "DELETE",
428
+ headers: { Authorization: `Bearer ${child.accessToken}` },
429
+ signal: AbortSignal.timeout(timeoutMs),
430
+ });
431
+ } catch {
432
+ /* best effort — the session expires server-side regardless */
433
+ }
434
+ }
435
+
366
436
  // ── Logout ───────────────────────────────────────────────────────────────────
367
437
 
368
438
  /**
@@ -15,23 +15,71 @@ export interface CustomCommand {
15
15
  scope: "project" | "user";
16
16
  }
17
17
 
18
- // Minimal YAML-ish frontmatter: a leading `---` block of `key: value` lines.
18
+ // Minimal YAML-ish frontmatter: a leading `---` block of `key: value` lines. Values
19
+ // may be quoted, continue onto indented lines, or use block scalars (`key: |` / `key: >`)
20
+ // — enough for real-world command/agent/skill files without a YAML dependency.
19
21
  export function parseFrontmatter(raw: string): { meta: Record<string, string>; body: string } {
20
22
  const lines = raw.split("\n");
21
23
  if (lines[0]?.trim() !== "---") return { meta: {}, body: raw };
22
24
  const meta: Record<string, string> = {};
23
25
  let i = 1;
26
+ let lastKey: string | undefined;
24
27
  for (; i < lines.length; i++) {
25
- if (lines[i].trim() === "---") {
28
+ const line = lines[i];
29
+ if (line.trim() === "---") {
26
30
  i++;
27
31
  break;
28
32
  }
29
- const m = lines[i].match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/);
30
- if (m) meta[m[1].toLowerCase()] = m[2].trim();
33
+ const m = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/);
34
+ if (m) {
35
+ const key = m[1].toLowerCase();
36
+ const val = m[2].trim();
37
+ const block = /^([|>])[+-]?$/.exec(val);
38
+ if (block) {
39
+ // Block scalar: consume the following blank or more-indented lines. Literal
40
+ // (|) keeps line breaks; folded (>) joins lines with spaces.
41
+ const parts: string[] = [];
42
+ let indent = 0;
43
+ while (i + 1 < lines.length) {
44
+ const next = lines[i + 1];
45
+ const blank = next.trim() === "";
46
+ if (!blank && !/^[ \t]/.test(next)) break;
47
+ i++;
48
+ if (blank) {
49
+ parts.push("");
50
+ continue;
51
+ }
52
+ const lead = next.match(/^[ \t]*/)![0].length;
53
+ if (!indent) indent = lead;
54
+ parts.push(next.slice(Math.min(indent, lead)));
55
+ }
56
+ meta[key] =
57
+ block[1] === "|"
58
+ ? parts.join("\n").replace(/\s+$/, "")
59
+ : parts
60
+ .map((p) => p.trim())
61
+ .filter(Boolean)
62
+ .join(" ");
63
+ } else {
64
+ meta[key] = unquote(val);
65
+ }
66
+ lastKey = key;
67
+ continue;
68
+ }
69
+ // An indented non-empty line after `key: value` continues that value.
70
+ if (lastKey && /^[ \t]/.test(line) && line.trim() !== "") {
71
+ meta[lastKey] = `${meta[lastKey]} ${line.trim()}`.trim();
72
+ }
31
73
  }
32
74
  return { meta, body: lines.slice(i).join("\n").replace(/^\n+/, "") };
33
75
  }
34
76
 
77
+ // Strip one pair of matching surrounding quotes from a scalar value.
78
+ function unquote(v: string): string {
79
+ const m = /^"(.*)"$/.exec(v) ?? /^'(.*)'$/.exec(v);
80
+ return m ? m[1] : v;
81
+ }
82
+
35
83
  function loadFromDir(dir: string, scope: "project" | "user"): CustomCommand[] {
36
84
  if (!existsSync(dir)) return [];
37
85
  const out: CustomCommand[] = [];
@@ -6,6 +6,7 @@ import { configLayers } from "../config/load.ts";
6
6
  import { expandCommand, type CustomCommand } from "./custom.ts";
7
7
  import { loadOutputStyles } from "../context/outputStyles.ts";
8
8
  import { loadAgents } from "../agents/loader.ts";
9
+ import { loadSkills, type SkillDefinition } from "../skills/loader.ts";
9
10
  import { loadHooks } from "../hooks/engine.ts";
10
11
  import { configuredProviders, parseModelSpec } from "../providers/resolve.ts";
11
12
  import { currentUser, serverBaseUrl } from "../auth/privateer.ts";
@@ -56,7 +57,12 @@ export type CommandResult =
56
57
  // Sign out of the Privateer account on this terminal.
57
58
  | { type: "privateerLogout" }
58
59
  // Toggle remote access (let the Privateer app drive this terminal). on=true/false; null=show status.
59
- | { type: "remoteAccess"; on: boolean | null };
60
+ | { type: "remoteAccess"; on: boolean | null }
61
+ // Manage routines via the daemon (resolved in the App over IPC).
62
+ // action defaults to "list"; arg is the routine name/id for the targeted actions.
63
+ | { type: "routine"; action: "list" | "pause" | "resume" | "remove" | "run"; arg?: string }
64
+ // Install or remove an agent skill (async fetch/fs work, resolved by the App).
65
+ | { type: "skillOp"; op: "install" | "remove"; arg: string; project?: boolean; all?: boolean; force?: boolean };
60
66
 
61
67
  export interface CommandContext {
62
68
  config: Config;
@@ -69,6 +75,7 @@ export interface CommandContext {
69
75
  cwd: string;
70
76
  todos: TodoItem[];
71
77
  customCommands?: CustomCommand[];
78
+ skills?: SkillDefinition[];
72
79
  }
73
80
 
74
81
  interface CommandDef {
@@ -86,9 +93,16 @@ const COMMANDS: CommandDef[] = [
86
93
  const custom = (ctx.customCommands ?? [])
87
94
  .map((c) => ` /${c.name.padEnd(11)} ${c.description}`)
88
95
  .join("\n");
96
+ const skills = (ctx.skills ?? [])
97
+ .map((s) => ` /${s.name.padEnd(11)} ${s.description.split("\n")[0]}`)
98
+ .join("\n");
89
99
  return {
90
100
  type: "notice",
91
- text: "Commands:\n" + builtin + (custom ? "\n\nCustom commands:\n" + custom : ""),
101
+ text:
102
+ "Commands:\n" +
103
+ builtin +
104
+ (custom ? "\n\nCustom commands:\n" + custom : "") +
105
+ (skills ? "\n\nSkills:\n" + skills : ""),
92
106
  };
93
107
  },
94
108
  },
@@ -332,6 +346,28 @@ const COMMANDS: CommandDef[] = [
332
346
  return { type: "mcp" };
333
347
  },
334
348
  },
349
+ {
350
+ name: "routine",
351
+ summary: "routines: list, or `pause|resume|rm|run <name>`",
352
+ run: (args) => {
353
+ const [sub, ...rest] = args.trim().split(/\s+/).filter(Boolean);
354
+ const arg = rest.join(" ") || undefined;
355
+ switch (sub) {
356
+ case "pause":
357
+ return { type: "routine", action: "pause", arg };
358
+ case "resume":
359
+ return { type: "routine", action: "resume", arg };
360
+ case "rm":
361
+ case "remove":
362
+ case "delete":
363
+ return { type: "routine", action: "remove", arg };
364
+ case "run":
365
+ return { type: "routine", action: "run", arg };
366
+ default:
367
+ return { type: "routine", action: "list" };
368
+ }
369
+ },
370
+ },
335
371
  {
336
372
  name: "hooks",
337
373
  summary: "list configured lifecycle hooks",
@@ -371,6 +407,70 @@ const COMMANDS: CommandDef[] = [
371
407
  return { type: "notice", text: `Sub-agents:\n${lines.join("\n")}` };
372
408
  },
373
409
  },
410
+ {
411
+ name: "skills",
412
+ summary: "list agent skills, or install/remove one (install <src> | info | remove <name>)",
413
+ run: (args, ctx) => {
414
+ const parts = args.trim().split(/\s+/).filter(Boolean);
415
+ const flags = new Set(parts.filter((p) => p.startsWith("--")));
416
+ const words = parts.filter((p) => !p.startsWith("--"));
417
+ const sub = words[0] ?? "list";
418
+ const project = flags.has("--project");
419
+ switch (sub) {
420
+ case "list": {
421
+ const { skills, warnings } = loadSkills(ctx.cwd);
422
+ if (skills.length === 0 && warnings.length === 0) {
423
+ return {
424
+ type: "notice",
425
+ text:
426
+ "No skills installed. Add SKILL.md directories under .privateer/skills/, " +
427
+ "or install one with /skills install <owner/repo[/path]>.",
428
+ };
429
+ }
430
+ const lines = skills.map(
431
+ (s) => ` ${s.name} (${s.scope})\n ${s.description.split("\n")[0]}`,
432
+ );
433
+ const warn = warnings.length ? `\n\nWarnings:\n${warnings.map((w) => ` ${w}`).join("\n")}` : "";
434
+ return { type: "notice", text: `Skills:\n${lines.join("\n")}${warn}` };
435
+ }
436
+ case "info": {
437
+ const name = words[1];
438
+ if (!name) return { type: "notice", tone: "error", text: "Usage: /skills info <name>" };
439
+ const skill = loadSkills(ctx.cwd).skills.find((s) => s.name === name);
440
+ if (!skill) return { type: "notice", tone: "error", text: `No skill "${name}". See /skills.` };
441
+ const head = skill.body.split("\n").slice(0, 30).join("\n");
442
+ const fields = [
443
+ `name: ${skill.name} (${skill.scope})`,
444
+ `dir: ${skill.dir}`,
445
+ skill.model ? `model: ${skill.model}` : null,
446
+ skill.allowedTools?.length ? `allowed-tools: ${skill.allowedTools.join(", ")}` : null,
447
+ `description: ${skill.description}`,
448
+ ].filter(Boolean);
449
+ return { type: "notice", text: `${fields.join("\n")}\n\n${head}` };
450
+ }
451
+ case "install": {
452
+ if (!words[1]) {
453
+ return {
454
+ type: "notice",
455
+ tone: "error",
456
+ text: "Usage: /skills install <owner/repo[/path] | github url> [--project] [--all] [--force]",
457
+ };
458
+ }
459
+ return { type: "skillOp", op: "install", arg: words[1], project, all: flags.has("--all"), force: flags.has("--force") };
460
+ }
461
+ case "remove": {
462
+ if (!words[1]) return { type: "notice", tone: "error", text: "Usage: /skills remove <name> [--project]" };
463
+ return { type: "skillOp", op: "remove", arg: words[1], project };
464
+ }
465
+ default:
466
+ return {
467
+ type: "notice",
468
+ tone: "error",
469
+ text: `Unknown subcommand "${sub}". Use /skills [list | info <name> | install <src> | remove <name>].`,
470
+ };
471
+ }
472
+ },
473
+ },
374
474
  {
375
475
  name: "compact",
376
476
  summary: "summarize older history to free up context",
@@ -480,9 +580,16 @@ export const COMMAND_LIST: { name: string; summary: string }[] = COMMANDS.map((c
480
580
  summary: c.summary,
481
581
  }));
482
582
 
483
- // Built-ins plus any custom commands, for autocomplete.
484
- export function commandList(custom: CustomCommand[] = []): { name: string; summary: string }[] {
485
- return [...COMMAND_LIST, ...custom.map((c) => ({ name: c.name, summary: c.description }))];
583
+ // Built-ins plus any custom commands and skills, for autocomplete.
584
+ export function commandList(
585
+ custom: CustomCommand[] = [],
586
+ skills: SkillDefinition[] = [],
587
+ ): { name: string; summary: string }[] {
588
+ return [
589
+ ...COMMAND_LIST,
590
+ ...custom.map((c) => ({ name: c.name, summary: c.description })),
591
+ ...skills.map((s) => ({ name: s.name, summary: `skill — ${s.description.split("\n")[0]}` })),
592
+ ];
486
593
  }
487
594
 
488
595
  // Parse and run a "/command args" line. Returns null if not a slash command.
@@ -495,5 +602,17 @@ export function runCommand(raw: string, ctx: CommandContext): CommandResult | nu
495
602
  if (cmd) return cmd.run(args, ctx);
496
603
  const custom = ctx.customCommands?.find((c) => c.name === name);
497
604
  if (custom) return { type: "runPrompt", text: expandCommand(custom, args) };
605
+ // A skill name invokes the skill via the tool (not by inlining the body): the
606
+ // tool's execute is what unlocks reads of the skill's bundled files.
607
+ const skill = ctx.skills?.find((s) => s.name === name);
608
+ if (skill) {
609
+ const req = args.trim();
610
+ return {
611
+ type: "runPrompt",
612
+ text:
613
+ `Load the "${skill.name}" skill with the skill tool and follow its instructions` +
614
+ (req ? ` for this request: ${req}` : "."),
615
+ };
616
+ }
498
617
  return { type: "notice", tone: "error", text: `Unknown command "/${name}". Try /help.` };
499
618
  }