privateer-agent 0.1.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/LICENSE +21 -0
- package/README.md +474 -0
- package/bin/privateer.mjs +11 -0
- package/package.json +74 -0
- package/src/agents/loader.ts +49 -0
- package/src/auth/privateer.ts +393 -0
- package/src/commands/custom.ts +75 -0
- package/src/commands/registry.ts +499 -0
- package/src/components/AgentGroupView.tsx +104 -0
- package/src/components/App.tsx +1376 -0
- package/src/components/ApprovalPrompt.tsx +38 -0
- package/src/components/Banner.tsx +58 -0
- package/src/components/Markdown.tsx +183 -0
- package/src/components/ModeHint.tsx +40 -0
- package/src/components/ModelPicker.tsx +269 -0
- package/src/components/Onboarding.tsx +203 -0
- package/src/components/PlanConfirm.tsx +37 -0
- package/src/components/PrivateerLogin.tsx +109 -0
- package/src/components/PromptInput.tsx +602 -0
- package/src/components/RewindPicker.tsx +69 -0
- package/src/components/Root.tsx +95 -0
- package/src/components/SessionPicker.tsx +64 -0
- package/src/components/StatusBar.tsx +121 -0
- package/src/components/TodoPanel.tsx +36 -0
- package/src/components/ToolCallView.tsx +109 -0
- package/src/components/Transcript.tsx +203 -0
- package/src/components/figures.ts +13 -0
- package/src/components/promptModel.ts +73 -0
- package/src/components/spinnerVerbs.ts +46 -0
- package/src/components/theme.ts +55 -0
- package/src/components/types.ts +34 -0
- package/src/components/useTeeShield.ts +104 -0
- package/src/components/useTerminalWidth.ts +24 -0
- package/src/components/useZdrShield.ts +126 -0
- package/src/config/load.ts +115 -0
- package/src/config/paths.ts +61 -0
- package/src/config/schema.ts +94 -0
- package/src/context/outputStyles.ts +42 -0
- package/src/context/projectInfo.ts +59 -0
- package/src/context/systemPrompt.ts +167 -0
- package/src/engine/QueryEngine.ts +399 -0
- package/src/engine/errors.ts +197 -0
- package/src/engine/events.ts +74 -0
- package/src/engine/router.ts +165 -0
- package/src/hooks/engine.ts +155 -0
- package/src/main.tsx +167 -0
- package/src/mcp/client.ts +236 -0
- package/src/mcp/oauth.ts +245 -0
- package/src/memory/auto.ts +146 -0
- package/src/memory/checkpoints.ts +227 -0
- package/src/memory/store.ts +127 -0
- package/src/permissions/danger.ts +56 -0
- package/src/permissions/gate.ts +38 -0
- package/src/permissions/mode.ts +39 -0
- package/src/permissions/protected.ts +29 -0
- package/src/permissions/uiGate.ts +73 -0
- package/src/providers/attestation.ts +149 -0
- package/src/providers/capabilities.ts +104 -0
- package/src/providers/catalog.ts +66 -0
- package/src/providers/models.ts +183 -0
- package/src/providers/registry.ts +71 -0
- package/src/providers/resolve.ts +78 -0
- package/src/remote/relayClient.ts +283 -0
- package/src/session.ts +264 -0
- package/src/tools/bash.ts +98 -0
- package/src/tools/context.ts +114 -0
- package/src/tools/edit.ts +67 -0
- package/src/tools/exec.ts +60 -0
- package/src/tools/glob.ts +39 -0
- package/src/tools/grep.ts +86 -0
- package/src/tools/index.ts +69 -0
- package/src/tools/memory.ts +53 -0
- package/src/tools/processRegistry.ts +77 -0
- package/src/tools/read.ts +42 -0
- package/src/tools/saveAttachment.ts +53 -0
- package/src/tools/task.ts +52 -0
- package/src/tools/todo.ts +36 -0
- package/src/tools/todoStore.ts +31 -0
- package/src/tools/walk.ts +44 -0
- package/src/tools/web.ts +145 -0
- package/src/tools/write.ts +40 -0
- package/src/util/attachmentStore.ts +72 -0
- package/src/util/images.ts +343 -0
- package/src/util/limit.ts +32 -0
- package/src/util/redact.ts +44 -0
- package/src/version.ts +13 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Privateer account login for the agent CLI.
|
|
3
|
+
*
|
|
4
|
+
* Instead of a provider API key, the terminal logs into the user's Privateer
|
|
5
|
+
* account and runs inference billed to that account. Auth uses the device
|
|
6
|
+
* authorization grant (RFC 8628): the CLI shows a short user_code, the user
|
|
7
|
+
* approves it inside the already-logged-in Privateer mobile/web app, and the
|
|
8
|
+
* server mints a CLI-scoped session here. This works identically for email and
|
|
9
|
+
* wallet accounts — the wallet/password signing all happens in the app, never
|
|
10
|
+
* in the terminal.
|
|
11
|
+
*
|
|
12
|
+
* Framework-agnostic on purpose: the Ink UI drives `runDeviceLogin` and the
|
|
13
|
+
* provider factory uses `authedFetch`; nothing here imports React.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, rmSync } from "node:fs";
|
|
16
|
+
import { hostname, userInfo } from "node:os";
|
|
17
|
+
import { globalDir, credentialsPath } from "../config/paths.ts";
|
|
18
|
+
import { isAccountCapCode } from "../engine/errors.ts";
|
|
19
|
+
|
|
20
|
+
// Default Privateer API host. NOTE: this is still the legacy "helix" Render
|
|
21
|
+
// hostname the mobile/web client also points at (client/config/environment.ts);
|
|
22
|
+
// centralize/rename later. Override with PRIVATEER_SERVER_URL for dev/self-host.
|
|
23
|
+
export const DEFAULT_SERVER_URL = "https://helix-server-m1ac.onrender.com";
|
|
24
|
+
|
|
25
|
+
export interface PrivateerUser {
|
|
26
|
+
id: string;
|
|
27
|
+
email: string | null;
|
|
28
|
+
solanaPublicKey: string | null;
|
|
29
|
+
kekSource: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Credentials {
|
|
33
|
+
accessToken: string;
|
|
34
|
+
refreshToken: string;
|
|
35
|
+
user: PrivateerUser;
|
|
36
|
+
serverBaseUrl: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A server URL is safe only if it's https, OR http to a loopback host (dev).
|
|
40
|
+
// Anything else (plain http to a remote host) would send the account bearer
|
|
41
|
+
// token in cleartext and is rejected — a poisoned PRIVATEER_SERVER_URL must not
|
|
42
|
+
// be able to redirect/downgrade the connection and exfiltrate the token.
|
|
43
|
+
export function isSafeServerUrl(raw: string): boolean {
|
|
44
|
+
let u: URL;
|
|
45
|
+
try {
|
|
46
|
+
u = new URL(raw);
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
if (u.protocol === "https:") return true;
|
|
51
|
+
if (u.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "::1")) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Resolved base URL: env override > stored (from a prior login) > default.
|
|
58
|
+
// Both the override and the stored value are validated — never fall through to
|
|
59
|
+
// an insecure host that could capture the session token.
|
|
60
|
+
export function serverBaseUrl(): string {
|
|
61
|
+
const env = process.env.PRIVATEER_SERVER_URL?.replace(/\/$/, "");
|
|
62
|
+
if (env) {
|
|
63
|
+
if (!isSafeServerUrl(env)) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Refusing PRIVATEER_SERVER_URL=${env}: must be https:// (http allowed only for localhost). ` +
|
|
66
|
+
`This protects your account token from being sent over an insecure or attacker-controlled connection.`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return env;
|
|
70
|
+
}
|
|
71
|
+
const base = (loadCredentials()?.serverBaseUrl || DEFAULT_SERVER_URL).replace(/\/$/, "");
|
|
72
|
+
if (!isSafeServerUrl(base)) {
|
|
73
|
+
throw new Error(`Stored Privateer server URL is not https (${base}). Run /logout then /login again.`);
|
|
74
|
+
}
|
|
75
|
+
return base;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A friendly default label so a linked session is recognizable in the app's
|
|
79
|
+
// "Linked terminals" list, e.g. "patrick@MacBook-Pro".
|
|
80
|
+
export function defaultDeviceLabel(): string {
|
|
81
|
+
try {
|
|
82
|
+
return `${userInfo().username}@${hostname()}`;
|
|
83
|
+
} catch {
|
|
84
|
+
return "terminal";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Credential storage (0600, like saveGlobalConfig) ─────────────────────────
|
|
89
|
+
|
|
90
|
+
let _cache: Credentials | null | undefined;
|
|
91
|
+
|
|
92
|
+
// Per-terminal child session (see spawnChildSession). Held in memory ONLY — it
|
|
93
|
+
// is never written to the shared credentials file, so each running terminal
|
|
94
|
+
// rotates its own refresh token in isolation.
|
|
95
|
+
interface ChildSession { accessToken: string; refreshToken: string; }
|
|
96
|
+
let _child: ChildSession | null = null;
|
|
97
|
+
let _spawnInFlight: Promise<ChildSession> | null = null;
|
|
98
|
+
let _refreshInFlight: Promise<ChildSession> | null = null;
|
|
99
|
+
|
|
100
|
+
export function loadCredentials(): Credentials | null {
|
|
101
|
+
if (_cache !== undefined) return _cache;
|
|
102
|
+
const path = credentialsPath();
|
|
103
|
+
if (!existsSync(path)) return (_cache = null);
|
|
104
|
+
try {
|
|
105
|
+
_cache = JSON.parse(readFileSync(path, "utf8")) as Credentials;
|
|
106
|
+
} catch {
|
|
107
|
+
_cache = null;
|
|
108
|
+
}
|
|
109
|
+
return _cache;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function saveCredentials(creds: Credentials): void {
|
|
113
|
+
const dir = globalDir();
|
|
114
|
+
mkdirSync(dir, { recursive: true });
|
|
115
|
+
tryChmod(dir, 0o700);
|
|
116
|
+
const path = credentialsPath();
|
|
117
|
+
writeFileSync(path, JSON.stringify(creds, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
118
|
+
tryChmod(path, 0o600);
|
|
119
|
+
_cache = creds;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function clearCredentials(): void {
|
|
123
|
+
try {
|
|
124
|
+
rmSync(credentialsPath(), { force: true });
|
|
125
|
+
} catch {
|
|
126
|
+
/* nothing to remove */
|
|
127
|
+
}
|
|
128
|
+
_cache = null;
|
|
129
|
+
_child = null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function hasCredentials(): boolean {
|
|
133
|
+
return loadCredentials() !== null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function currentUser(): PrivateerUser | null {
|
|
137
|
+
return loadCredentials()?.user ?? null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function tryChmod(path: string, mode: number): void {
|
|
141
|
+
try {
|
|
142
|
+
chmodSync(path, mode);
|
|
143
|
+
} catch {
|
|
144
|
+
/* non-POSIX filesystem — best effort */
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Device authorization flow ────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
export interface DeviceCode {
|
|
151
|
+
device_code: string;
|
|
152
|
+
user_code: string;
|
|
153
|
+
verification_uri?: string;
|
|
154
|
+
verification_uri_complete?: string;
|
|
155
|
+
expires_in: number;
|
|
156
|
+
interval: number;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function postJson(base: string, path: string, body: unknown, init: RequestInit = {}): Promise<Response> {
|
|
160
|
+
// Spread init FIRST so method/headers/body below stay authoritative — otherwise
|
|
161
|
+
// a trailing `...init` clobbers the merged headers (dropping Content-Type when a
|
|
162
|
+
// caller passes its own headers, e.g. Authorization on /auth/session/spawn).
|
|
163
|
+
return fetch(`${base}${path}`, {
|
|
164
|
+
...init,
|
|
165
|
+
method: "POST",
|
|
166
|
+
headers: { "Content-Type": "application/json", ...((init.headers as Record<string, string>) || {}) },
|
|
167
|
+
body: JSON.stringify(body),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Step 1: ask the server for a device + user code the human will approve in-app.
|
|
172
|
+
export async function requestDeviceCode(deviceLabel = defaultDeviceLabel()): Promise<DeviceCode> {
|
|
173
|
+
const base = serverBaseUrl();
|
|
174
|
+
const res = await postJson(base, "/auth/device/code", { deviceLabel });
|
|
175
|
+
if (!res.ok) {
|
|
176
|
+
throw new Error(`Couldn't start login (${res.status}). Check your connection or PRIVATEER_SERVER_URL.`);
|
|
177
|
+
}
|
|
178
|
+
return (await res.json()) as DeviceCode;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export type PollState = "pending" | "slow_down";
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Step 2: poll until the user approves in the app. Resolves with the saved
|
|
185
|
+
* credentials, or rejects on denial/expiry. `onPoll` lets the UI show progress;
|
|
186
|
+
* `signal` cancels the wait.
|
|
187
|
+
*/
|
|
188
|
+
export async function pollForToken(
|
|
189
|
+
code: DeviceCode,
|
|
190
|
+
opts: { onPoll?: (state: PollState) => void; signal?: AbortSignal } = {},
|
|
191
|
+
): Promise<Credentials> {
|
|
192
|
+
const base = serverBaseUrl();
|
|
193
|
+
let interval = Math.max(1, code.interval || 5) * 1000;
|
|
194
|
+
const deadline = Date.now() + (code.expires_in || 600) * 1000;
|
|
195
|
+
|
|
196
|
+
while (Date.now() < deadline) {
|
|
197
|
+
if (opts.signal?.aborted) throw new Error("Login cancelled.");
|
|
198
|
+
await sleep(interval, opts.signal);
|
|
199
|
+
|
|
200
|
+
const res = await postJson(base, "/auth/device/token", { device_code: code.device_code });
|
|
201
|
+
|
|
202
|
+
if (res.ok) {
|
|
203
|
+
const data = (await res.json()) as Omit<Credentials, "serverBaseUrl">;
|
|
204
|
+
const creds: Credentials = { ...data, serverBaseUrl: base };
|
|
205
|
+
saveCredentials(creds);
|
|
206
|
+
return creds;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let err = "";
|
|
210
|
+
try {
|
|
211
|
+
err = ((await res.json()) as { error?: string }).error || "";
|
|
212
|
+
} catch {
|
|
213
|
+
/* non-JSON */
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (err === "authorization_pending") {
|
|
217
|
+
opts.onPoll?.("pending");
|
|
218
|
+
} else if (err === "slow_down") {
|
|
219
|
+
interval += 2000; // back off per RFC 8628
|
|
220
|
+
opts.onPoll?.("slow_down");
|
|
221
|
+
} else if (err === "access_denied") {
|
|
222
|
+
throw new Error("Login was denied in the app.");
|
|
223
|
+
} else if (err === "expired_token") {
|
|
224
|
+
throw new Error("This login code expired. Run /login again.");
|
|
225
|
+
} else {
|
|
226
|
+
throw new Error(`Login failed (${res.status}${err ? `: ${err}` : ""}).`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
throw new Error("This login code expired. Run /login again.");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* High-level orchestration: request a code, surface it via `onCode`, then wait
|
|
234
|
+
* for approval. Returns the logged-in user.
|
|
235
|
+
*/
|
|
236
|
+
export async function runDeviceLogin(opts: {
|
|
237
|
+
deviceLabel?: string;
|
|
238
|
+
onCode: (code: DeviceCode) => void;
|
|
239
|
+
onPoll?: (state: PollState) => void;
|
|
240
|
+
signal?: AbortSignal;
|
|
241
|
+
}): Promise<PrivateerUser> {
|
|
242
|
+
const code = await requestDeviceCode(opts.deviceLabel);
|
|
243
|
+
opts.onCode(code);
|
|
244
|
+
const creds = await pollForToken(code, { onPoll: opts.onPoll, signal: opts.signal });
|
|
245
|
+
return creds.user;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── Session token use + refresh ──────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Spawn THIS terminal's own session from the machine login (parent) refresh
|
|
252
|
+
* token. The parent token is only VALIDATED here — never rotated — so any number
|
|
253
|
+
* of terminals can spawn concurrently without colliding. The resulting child
|
|
254
|
+
* pair lives in memory only; the terminal then rotates its own refresh token in
|
|
255
|
+
* isolation, so two terminals never fight over one rotating token (which would
|
|
256
|
+
* trip the server's reuse-detection and revoke every session).
|
|
257
|
+
*/
|
|
258
|
+
async function spawnChildSession(): Promise<ChildSession> {
|
|
259
|
+
const parent = loadCredentials();
|
|
260
|
+
if (!parent) throw new Error("Not logged in to Privateer. Run /login.");
|
|
261
|
+
// Present the parent access token as a possession proof alongside the refresh
|
|
262
|
+
// token. The server allows this access token to be expired (the valid refresh
|
|
263
|
+
// token is the liveness proof) — so the parent file stays read-only and never
|
|
264
|
+
// needs rotating — but a refresh token WITHOUT a real signed access JWT can't
|
|
265
|
+
// spawn. Both are validated server-side against the same account.
|
|
266
|
+
const res = await postJson(serverBaseUrl(), "/auth/session/spawn", {
|
|
267
|
+
refreshToken: parent.refreshToken,
|
|
268
|
+
deviceLabel: defaultDeviceLabel(),
|
|
269
|
+
}, {
|
|
270
|
+
headers: { Authorization: `Bearer ${parent.accessToken}` },
|
|
271
|
+
});
|
|
272
|
+
if (!res.ok) {
|
|
273
|
+
// Parent refresh token invalid/expired → the machine login is gone.
|
|
274
|
+
if (res.status === 401) clearCredentials();
|
|
275
|
+
throw new Error("Your Privateer session expired. Run /login to sign in again.");
|
|
276
|
+
}
|
|
277
|
+
const { accessToken, refreshToken } = (await res.json()) as ChildSession;
|
|
278
|
+
_child = { accessToken, refreshToken };
|
|
279
|
+
return _child;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Ensure a child session exists, de-duping concurrent spawns within this process.
|
|
283
|
+
function ensureChildSession(): Promise<ChildSession> {
|
|
284
|
+
if (_child) return Promise.resolve(_child);
|
|
285
|
+
if (!_spawnInFlight) {
|
|
286
|
+
_spawnInFlight = spawnChildSession().finally(() => { _spawnInFlight = null; });
|
|
287
|
+
}
|
|
288
|
+
return _spawnInFlight;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Rotate this terminal's own refresh token; if it's gone, spawn a fresh child.
|
|
292
|
+
// Single-flighted so concurrent 401s don't double-rotate (which would reuse-trip
|
|
293
|
+
// the child's own token).
|
|
294
|
+
function refreshChildSession(): Promise<ChildSession> {
|
|
295
|
+
if (_refreshInFlight) return _refreshInFlight;
|
|
296
|
+
_refreshInFlight = (async (): Promise<ChildSession> => {
|
|
297
|
+
if (_child) {
|
|
298
|
+
const res = await postJson(serverBaseUrl(), "/auth/refresh", { refreshToken: _child.refreshToken });
|
|
299
|
+
if (res.ok) {
|
|
300
|
+
const { accessToken, refreshToken } = (await res.json()) as ChildSession;
|
|
301
|
+
_child = { accessToken, refreshToken };
|
|
302
|
+
return _child;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
_child = null; // child rotation failed (expired/reused) — get a new one
|
|
306
|
+
return spawnChildSession();
|
|
307
|
+
})().finally(() => { _refreshInFlight = null; });
|
|
308
|
+
return _refreshInFlight;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* fetch wrapper matching the global `fetch` signature, for use as the AI SDK
|
|
313
|
+
* provider's `fetch`. Authenticates with THIS terminal's child session and, on a
|
|
314
|
+
* 401, refreshes once and retries. The body is buffered so the retry can resend.
|
|
315
|
+
*/
|
|
316
|
+
export async function authedFetch(input: Parameters<typeof fetch>[0], init: RequestInit = {}): Promise<Response> {
|
|
317
|
+
const bodyBuf = init.body; // AI SDK passes a string body; safe to resend.
|
|
318
|
+
// Use a Headers object and `.set` (case-insensitive) so OUR bearer replaces any
|
|
319
|
+
// Authorization the caller already set. The AI SDK lowercases its headers and
|
|
320
|
+
// sends `authorization: Bearer <placeholder apiKey>`; a plain spread that adds an
|
|
321
|
+
// `Authorization` key would leave BOTH, which undici combines into
|
|
322
|
+
// "Bearer placeholder, Bearer <real>" → the server rejects it as Invalid token.
|
|
323
|
+
const withAuth = (token: string): RequestInit => {
|
|
324
|
+
const headers = new Headers(init.headers);
|
|
325
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
326
|
+
return { ...init, headers };
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
let child = await ensureChildSession();
|
|
330
|
+
let res = await fetch(input, withAuth(child.accessToken));
|
|
331
|
+
if (res.status === 401) {
|
|
332
|
+
child = await refreshChildSession();
|
|
333
|
+
res = await fetch(input, { ...withAuth(child.accessToken), body: bodyBuf });
|
|
334
|
+
}
|
|
335
|
+
return await defuseRetryableCap(res);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// A hard account cap (daily/monthly limit reached, balance exhausted) comes back
|
|
339
|
+
// as a 429, which the AI SDK treats as retryable — so it burns its whole retry
|
|
340
|
+
// budget waiting on a limit that won't clear, then surfaces a generic "Too Many
|
|
341
|
+
// Requests". Detect the cap by the backend's machine `code` and rewrite the status
|
|
342
|
+
// to 402 (Payment Required), which the SDK does NOT retry, while preserving the
|
|
343
|
+
// body and headers so describeError still shows the backend's own message. Only a
|
|
344
|
+
// 429 is inspected, and only its (small, non-streaming) error body is buffered;
|
|
345
|
+
// transient 429s without a cap code pass through untouched and stay retryable.
|
|
346
|
+
async function defuseRetryableCap(res: Response): Promise<Response> {
|
|
347
|
+
if (res.status !== 429) return res;
|
|
348
|
+
const body = await res.text();
|
|
349
|
+
let code: unknown;
|
|
350
|
+
try {
|
|
351
|
+
const parsed = JSON.parse(body) as { code?: unknown; error?: { code?: unknown } };
|
|
352
|
+
code = parsed.code ?? parsed.error?.code;
|
|
353
|
+
} catch {
|
|
354
|
+
/* non-JSON body — can't be a structured cap, leave it retryable */
|
|
355
|
+
}
|
|
356
|
+
const status = isAccountCapCode(typeof code === "string" ? code : undefined) ? 402 : 429;
|
|
357
|
+
return new Response(body, { status, statusText: res.statusText, headers: res.headers });
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Authenticated JSON request against the Privateer API (relative path). */
|
|
361
|
+
export async function apiRequest(path: string, init: RequestInit = {}): Promise<Response> {
|
|
362
|
+
const base = serverBaseUrl();
|
|
363
|
+
return authedFetch(`${base}${path}`, init);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ── Logout ───────────────────────────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Log out this terminal: revoke its session server-side (best effort) and wipe
|
|
370
|
+
* local credentials. Other devices/sessions are untouched.
|
|
371
|
+
*/
|
|
372
|
+
export async function logout(): Promise<void> {
|
|
373
|
+
try {
|
|
374
|
+
await apiRequest("/auth/logout", { method: "POST" });
|
|
375
|
+
} catch {
|
|
376
|
+
/* best effort — clear locally regardless */
|
|
377
|
+
}
|
|
378
|
+
clearCredentials();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
382
|
+
return new Promise((resolve, reject) => {
|
|
383
|
+
const t = setTimeout(resolve, ms);
|
|
384
|
+
signal?.addEventListener(
|
|
385
|
+
"abort",
|
|
386
|
+
() => {
|
|
387
|
+
clearTimeout(t);
|
|
388
|
+
reject(new Error("Login cancelled."));
|
|
389
|
+
},
|
|
390
|
+
{ once: true },
|
|
391
|
+
);
|
|
392
|
+
});
|
|
393
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { globalPaths, projectPaths } from "../config/paths.ts";
|
|
4
|
+
import { walkFiles } from "../tools/walk.ts";
|
|
5
|
+
|
|
6
|
+
// A user-authored slash command loaded from a markdown file under
|
|
7
|
+
// .privateer/commands/. The body is a prompt template; frontmatter is optional.
|
|
8
|
+
export interface CustomCommand {
|
|
9
|
+
name: string; // file path minus .md, separators → ":" (e.g. "git/pr.md" → "git:pr")
|
|
10
|
+
description: string;
|
|
11
|
+
argumentHint?: string;
|
|
12
|
+
allowedTools?: string[];
|
|
13
|
+
model?: string;
|
|
14
|
+
body: string;
|
|
15
|
+
scope: "project" | "user";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Minimal YAML-ish frontmatter: a leading `---` block of `key: value` lines.
|
|
19
|
+
export function parseFrontmatter(raw: string): { meta: Record<string, string>; body: string } {
|
|
20
|
+
const lines = raw.split("\n");
|
|
21
|
+
if (lines[0]?.trim() !== "---") return { meta: {}, body: raw };
|
|
22
|
+
const meta: Record<string, string> = {};
|
|
23
|
+
let i = 1;
|
|
24
|
+
for (; i < lines.length; i++) {
|
|
25
|
+
if (lines[i].trim() === "---") {
|
|
26
|
+
i++;
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
const m = lines[i].match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/);
|
|
30
|
+
if (m) meta[m[1].toLowerCase()] = m[2].trim();
|
|
31
|
+
}
|
|
32
|
+
return { meta, body: lines.slice(i).join("\n").replace(/^\n+/, "") };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function loadFromDir(dir: string, scope: "project" | "user"): CustomCommand[] {
|
|
36
|
+
if (!existsSync(dir)) return [];
|
|
37
|
+
const out: CustomCommand[] = [];
|
|
38
|
+
for (const rel of walkFiles(dir)) {
|
|
39
|
+
if (!rel.endsWith(".md")) continue;
|
|
40
|
+
const { meta, body } = parseFrontmatter(readFileSync(join(dir, rel), "utf8"));
|
|
41
|
+
out.push({
|
|
42
|
+
name: rel.replace(/\.md$/, "").split("/").join(":"),
|
|
43
|
+
description: meta.description ?? `custom ${scope} command`,
|
|
44
|
+
argumentHint: meta["argument-hint"],
|
|
45
|
+
allowedTools: meta["allowed-tools"]
|
|
46
|
+
?.split(",")
|
|
47
|
+
.map((s) => s.trim())
|
|
48
|
+
.filter(Boolean),
|
|
49
|
+
model: meta.model,
|
|
50
|
+
body: body.trim(),
|
|
51
|
+
scope,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Load custom commands from user (~/.privateer) then project (./.privateer);
|
|
58
|
+
// a project command overrides a user command of the same name.
|
|
59
|
+
export function loadCustomCommands(cwd: string = process.cwd()): CustomCommand[] {
|
|
60
|
+
const byName = new Map<string, CustomCommand>();
|
|
61
|
+
for (const c of loadFromDir(globalPaths().commands, "user")) byName.set(c.name, c);
|
|
62
|
+
for (const c of loadFromDir(projectPaths(cwd).commands, "project")) byName.set(c.name, c);
|
|
63
|
+
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Expand a command body against its arguments: $1..$9 are positional words,
|
|
67
|
+
// $ARGUMENTS and $@ are the full argument string.
|
|
68
|
+
export function expandCommand(cmd: CustomCommand, argString: string): string {
|
|
69
|
+
const args = argString.trim();
|
|
70
|
+
const parts = args.length ? args.split(/\s+/) : [];
|
|
71
|
+
return cmd.body
|
|
72
|
+
.replace(/\$([1-9])/g, (_, d: string) => parts[Number(d) - 1] ?? "")
|
|
73
|
+
.replace(/\$ARGUMENTS\b/g, args)
|
|
74
|
+
.replace(/\$@/g, args);
|
|
75
|
+
}
|