privateer-agent 0.1.1 → 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 +222 -16
- package/src/components/ApprovalPrompt.tsx +15 -4
- package/src/components/Banner.tsx +3 -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/ToolCallView.tsx +4 -0
- package/src/components/Transcript.tsx +14 -7
- 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 +76 -4
- 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 +22 -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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
89
|
-
-
|
|
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` `/
|
|
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
|
|
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
|
-
|
|
430
|
-
|
|
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
package/src/auth/privateer.ts
CHANGED
|
@@ -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)
|
|
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
|
/**
|
package/src/commands/custom.ts
CHANGED
|
@@ -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
|
-
|
|
28
|
+
const line = lines[i];
|
|
29
|
+
if (line.trim() === "---") {
|
|
26
30
|
i++;
|
|
27
31
|
break;
|
|
28
32
|
}
|
|
29
|
-
const m =
|
|
30
|
-
if (m)
|
|
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[] = [];
|
package/src/commands/registry.ts
CHANGED
|
@@ -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:
|
|
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(
|
|
485
|
-
|
|
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
|
}
|