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,165 @@
|
|
|
1
|
+
import type { LanguageModel, ModelMessage } from "ai";
|
|
2
|
+
import type { Modality } from "../util/images.ts";
|
|
3
|
+
|
|
4
|
+
export type { Modality };
|
|
5
|
+
|
|
6
|
+
// The named routes the engine can switch between per turn. `default` is always
|
|
7
|
+
// present (it's the session's configured model); the rest are optional specialized
|
|
8
|
+
// targets resolved from the `router` config block.
|
|
9
|
+
export type RouteName = "default" | "vision" | "document" | "audio" | "video" | "long" | "fast";
|
|
10
|
+
|
|
11
|
+
// Routes that exist to satisfy a specific input modality, in the order we prefer them
|
|
12
|
+
// when several could cover a turn's requirements.
|
|
13
|
+
const MODALITY_ROUTES: { name: RouteName; modality: Modality }[] = [
|
|
14
|
+
{ name: "vision", modality: "image" },
|
|
15
|
+
{ name: "document", modality: "document" },
|
|
16
|
+
{ name: "audio", modality: "audio" },
|
|
17
|
+
{ name: "video", modality: "video" },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
// A resolved, ready-to-stream model plus the per-model knobs that travel with it
|
|
21
|
+
// (Anthropic caching/thinking depend on the model family, so they're per-route) and
|
|
22
|
+
// the set of input modalities it accepts (so the router can satisfy a turn's needs).
|
|
23
|
+
export interface Route {
|
|
24
|
+
spec: string; // "provider:model"
|
|
25
|
+
model: LanguageModel;
|
|
26
|
+
cacheControl: boolean;
|
|
27
|
+
thinkingBudget?: number;
|
|
28
|
+
label: string; // short display name for UI notices (the model id tail)
|
|
29
|
+
supports: Set<Modality>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RouteSet {
|
|
33
|
+
default: Route;
|
|
34
|
+
vision?: Route;
|
|
35
|
+
document?: Route;
|
|
36
|
+
audio?: Route;
|
|
37
|
+
video?: Route;
|
|
38
|
+
long?: Route;
|
|
39
|
+
fast?: Route;
|
|
40
|
+
longThreshold: number; // estimated tokens above which `long` applies
|
|
41
|
+
fastMaxChars: number; // prompt length at/below which `fast` applies
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// What the current turn looks like, used to pick a route.
|
|
45
|
+
export interface RouteSignals {
|
|
46
|
+
modalities: Set<Modality>; // input modalities anywhere in the conversation (sticky)
|
|
47
|
+
estTokens: number; // estimated tokens of the whole conversation
|
|
48
|
+
promptChars: number; // length of the new user prompt
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface RouteSelection {
|
|
52
|
+
name: RouteName;
|
|
53
|
+
route: Route;
|
|
54
|
+
reason?: string; // short human reason, for the UI notice
|
|
55
|
+
missing?: Modality[]; // modalities the chosen route can't satisfy (warn)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function covers(route: Route | undefined, required: Set<Modality>): route is Route {
|
|
59
|
+
if (!route) return false;
|
|
60
|
+
for (const m of required) if (!route.supports.has(m)) return false;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function missingFrom(route: Route, required: Set<Modality>): Modality[] {
|
|
65
|
+
return [...required].filter((m) => !route.supports.has(m));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Candidate routes to consider for an attachment-bearing turn, in preference order:
|
|
69
|
+
// the dedicated modality routes first, then long/fast/default as fallbacks (they may
|
|
70
|
+
// happen to be multimodal). Deduped by identity.
|
|
71
|
+
function candidateRoutes(routes: RouteSet): Route[] {
|
|
72
|
+
const ordered = [
|
|
73
|
+
...MODALITY_ROUTES.map((r) => routes[r.name]),
|
|
74
|
+
routes.long,
|
|
75
|
+
routes.fast,
|
|
76
|
+
routes.default,
|
|
77
|
+
].filter((r): r is Route => Boolean(r));
|
|
78
|
+
return [...new Set(ordered)];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Pick the model for this turn.
|
|
82
|
+
// • If the turn requires input modalities, choose the highest-preference route whose
|
|
83
|
+
// `supports` covers the whole union (capability is a hard requirement). If none
|
|
84
|
+
// fully covers, fall back to the route covering the most, flagging what's missing.
|
|
85
|
+
// • Otherwise apply the soft preferences: long (large context) > fast (short prompt)
|
|
86
|
+
// > default.
|
|
87
|
+
export function selectRoute(routes: RouteSet, s: RouteSignals): RouteSelection {
|
|
88
|
+
if (s.modalities.size > 0) {
|
|
89
|
+
const candidates = candidateRoutes(routes);
|
|
90
|
+
const full = candidates.find((r) => covers(r, s.modalities));
|
|
91
|
+
if (full) {
|
|
92
|
+
const name = routeNameOf(routes, full);
|
|
93
|
+
return { name, route: full, reason: reasonFor(s.modalities) };
|
|
94
|
+
}
|
|
95
|
+
// No single route covers everything — pick the one that covers the most and warn.
|
|
96
|
+
let best = routes.default;
|
|
97
|
+
let bestMissing = missingFrom(best, s.modalities);
|
|
98
|
+
for (const r of candidates) {
|
|
99
|
+
const miss = missingFrom(r, s.modalities);
|
|
100
|
+
if (miss.length < bestMissing.length) {
|
|
101
|
+
best = r;
|
|
102
|
+
bestMissing = miss;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
name: routeNameOf(routes, best),
|
|
107
|
+
route: best,
|
|
108
|
+
reason: reasonFor(s.modalities),
|
|
109
|
+
missing: bestMissing,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (routes.long && s.estTokens > routes.longThreshold) {
|
|
113
|
+
return { name: "long", route: routes.long, reason: "long context" };
|
|
114
|
+
}
|
|
115
|
+
if (routes.fast && s.promptChars <= routes.fastMaxChars) {
|
|
116
|
+
return { name: "fast", route: routes.fast, reason: "short prompt" };
|
|
117
|
+
}
|
|
118
|
+
return { name: "default", route: routes.default };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function reasonFor(modalities: Set<Modality>): string {
|
|
122
|
+
return `${[...modalities].join("+")} input`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Recover the route name for a resolved Route (for the UI/event). Falls back to
|
|
126
|
+
// "default" if it isn't one of the named slots.
|
|
127
|
+
function routeNameOf(routes: RouteSet, route: Route): RouteName {
|
|
128
|
+
const names: RouteName[] = ["vision", "document", "audio", "video", "long", "fast"];
|
|
129
|
+
for (const n of names) if (routes[n] === route) return n;
|
|
130
|
+
return "default";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const MEDIA_MODALITY: { test: RegExp; modality: Modality }[] = [
|
|
134
|
+
{ test: /^image\//, modality: "image" },
|
|
135
|
+
{ test: /^audio\//, modality: "audio" },
|
|
136
|
+
{ test: /^video\//, modality: "video" },
|
|
137
|
+
{ test: /^application\/pdf$/, modality: "document" },
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
function modalityOfMediaType(mediaType: string): Modality | null {
|
|
141
|
+
return MEDIA_MODALITY.find((m) => m.test.test(mediaType))?.modality ?? null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The input modalities present anywhere in the conversation. Keyed off the whole
|
|
145
|
+
// history (not just the new message) so that once an attachment is in context,
|
|
146
|
+
// follow-up turns stay on a model that can accept it rather than replaying it to one
|
|
147
|
+
// that can't.
|
|
148
|
+
export function requiredModalities(messages: ModelMessage[]): Set<Modality> {
|
|
149
|
+
const out = new Set<Modality>();
|
|
150
|
+
for (const m of messages) {
|
|
151
|
+
const content = m.content;
|
|
152
|
+
if (!Array.isArray(content)) continue;
|
|
153
|
+
for (const part of content) {
|
|
154
|
+
if (!part || typeof part !== "object" || !("type" in part)) continue;
|
|
155
|
+
const t = (part as { type: string }).type;
|
|
156
|
+
if (t === "image") out.add("image");
|
|
157
|
+
else if (t === "file") {
|
|
158
|
+
const mt = (part as { mediaType?: string }).mediaType;
|
|
159
|
+
const mod = mt ? modalityOfMediaType(mt) : null;
|
|
160
|
+
if (mod) out.add(mod);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { ToolSet } from "ai";
|
|
2
|
+
import { exec } from "../tools/exec.ts";
|
|
3
|
+
|
|
4
|
+
// A single hook: a shell command, optionally gated by a regex matched against the
|
|
5
|
+
// tool name (tool events only). Loaded from settings under the `hooks` key.
|
|
6
|
+
export interface HookDef {
|
|
7
|
+
matcher?: string;
|
|
8
|
+
command: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type HookEvent = "PreToolUse" | "PostToolUse" | "UserPromptSubmit" | "Stop";
|
|
12
|
+
export type HooksConfig = Partial<Record<HookEvent, HookDef[]>>;
|
|
13
|
+
|
|
14
|
+
// Aggregated result of firing an event's hooks.
|
|
15
|
+
export interface HookOutcome {
|
|
16
|
+
block?: boolean; // PreToolUse / UserPromptSubmit may veto
|
|
17
|
+
reason?: string;
|
|
18
|
+
additionalContext?: string; // UserPromptSubmit may inject extra context
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const HOOK_TIMEOUT_MS = 30_000;
|
|
22
|
+
const KNOWN_EVENTS: HookEvent[] = ["PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop"];
|
|
23
|
+
|
|
24
|
+
// Parse the loosely-typed `hooks` section of settings into a validated HooksConfig.
|
|
25
|
+
export function loadHooks(raw: unknown): HooksConfig {
|
|
26
|
+
if (typeof raw !== "object" || raw === null) return {};
|
|
27
|
+
const out: HooksConfig = {};
|
|
28
|
+
for (const event of KNOWN_EVENTS) {
|
|
29
|
+
const entries = (raw as Record<string, unknown>)[event];
|
|
30
|
+
if (!Array.isArray(entries)) continue;
|
|
31
|
+
const defs: HookDef[] = [];
|
|
32
|
+
for (const e of entries) {
|
|
33
|
+
if (e && typeof e === "object" && typeof (e as any).command === "string") {
|
|
34
|
+
defs.push({ matcher: (e as any).matcher, command: (e as any).command });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (defs.length) out[event] = defs;
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function matches(def: HookDef, toolName?: string): boolean {
|
|
43
|
+
if (!def.matcher) return true;
|
|
44
|
+
if (toolName === undefined) return true;
|
|
45
|
+
try {
|
|
46
|
+
return new RegExp(def.matcher).test(toolName);
|
|
47
|
+
} catch {
|
|
48
|
+
return false; // invalid regex → don't match
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Fire one hook with a JSON payload on stdin; interpret its result. A hook blocks by
|
|
53
|
+
// exiting with code 2 (reason from stderr) or printing JSON with decision "block"/"deny".
|
|
54
|
+
// It may inject context via { "additionalContext": "..." }.
|
|
55
|
+
async function fireHook(def: HookDef, payload: unknown, cwd: string): Promise<HookOutcome> {
|
|
56
|
+
const res = await exec(def.command, [], {
|
|
57
|
+
cwd,
|
|
58
|
+
timeoutMs: HOOK_TIMEOUT_MS,
|
|
59
|
+
shell: true,
|
|
60
|
+
input: JSON.stringify(payload),
|
|
61
|
+
});
|
|
62
|
+
if (res.code === 2) return { block: true, reason: res.stderr.trim() || "blocked by hook" };
|
|
63
|
+
const stdout = res.stdout.trim();
|
|
64
|
+
if (stdout.startsWith("{")) {
|
|
65
|
+
try {
|
|
66
|
+
const out = JSON.parse(stdout) as Record<string, unknown>;
|
|
67
|
+
const decision = (out.decision ?? out.permissionDecision) as string | undefined;
|
|
68
|
+
return {
|
|
69
|
+
block: decision === "block" || decision === "deny",
|
|
70
|
+
reason: (out.reason ?? out.permissionDecisionReason) as string | undefined,
|
|
71
|
+
additionalContext: out.additionalContext as string | undefined,
|
|
72
|
+
};
|
|
73
|
+
} catch {
|
|
74
|
+
/* not JSON → no control signal */
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return {};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Runs the configured hooks for each lifecycle event.
|
|
81
|
+
export class HookRunner {
|
|
82
|
+
constructor(
|
|
83
|
+
private readonly hooks: HooksConfig,
|
|
84
|
+
private readonly cwd: string,
|
|
85
|
+
) {}
|
|
86
|
+
|
|
87
|
+
has(event: HookEvent): boolean {
|
|
88
|
+
return (this.hooks[event]?.length ?? 0) > 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
config(): HooksConfig {
|
|
92
|
+
return this.hooks;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Fire tool-event hooks (Pre/PostToolUse). The first hook to block wins.
|
|
96
|
+
async tool(
|
|
97
|
+
event: "PreToolUse" | "PostToolUse",
|
|
98
|
+
toolName: string,
|
|
99
|
+
payload: { input: unknown; output?: unknown },
|
|
100
|
+
): Promise<HookOutcome> {
|
|
101
|
+
const defs = (this.hooks[event] ?? []).filter((d) => matches(d, toolName));
|
|
102
|
+
let merged: HookOutcome = {};
|
|
103
|
+
for (const def of defs) {
|
|
104
|
+
const outcome = await fireHook(def, { event, tool: toolName, ...payload, cwd: this.cwd }, this.cwd);
|
|
105
|
+
if (outcome.block) return outcome;
|
|
106
|
+
merged = { ...merged, ...outcome };
|
|
107
|
+
}
|
|
108
|
+
return merged;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Fire UserPromptSubmit hooks; a block vetoes the turn, additionalContext is merged.
|
|
112
|
+
async prompt(text: string): Promise<HookOutcome> {
|
|
113
|
+
const defs = this.hooks.UserPromptSubmit ?? [];
|
|
114
|
+
let context = "";
|
|
115
|
+
for (const def of defs) {
|
|
116
|
+
const outcome = await fireHook(def, { event: "UserPromptSubmit", prompt: text, cwd: this.cwd }, this.cwd);
|
|
117
|
+
if (outcome.block) return outcome;
|
|
118
|
+
if (outcome.additionalContext) context += (context ? "\n" : "") + outcome.additionalContext;
|
|
119
|
+
}
|
|
120
|
+
return context ? { additionalContext: context } : {};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Fire Stop hooks (turn finished); fire-and-report, no control flow.
|
|
124
|
+
async stop(): Promise<void> {
|
|
125
|
+
for (const def of this.hooks.Stop ?? []) {
|
|
126
|
+
await fireHook(def, { event: "Stop", cwd: this.cwd }, this.cwd);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Wrap each tool's execute with Pre/PostToolUse hooks. A blocking PreToolUse hook
|
|
132
|
+
// short-circuits the call, returning the reason so the model can adapt. No-op when no
|
|
133
|
+
// tool hooks are configured.
|
|
134
|
+
export function wrapToolsWithHooks(tools: ToolSet, runner: HookRunner): ToolSet {
|
|
135
|
+
if (!runner.has("PreToolUse") && !runner.has("PostToolUse")) return tools;
|
|
136
|
+
const wrapped: ToolSet = {};
|
|
137
|
+
for (const [name, t] of Object.entries(tools)) {
|
|
138
|
+
const orig = (t as any).execute;
|
|
139
|
+
if (typeof orig !== "function") {
|
|
140
|
+
wrapped[name] = t;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
wrapped[name] = {
|
|
144
|
+
...(t as any),
|
|
145
|
+
execute: async (input: unknown, options: unknown) => {
|
|
146
|
+
const pre = await runner.tool("PreToolUse", name, { input });
|
|
147
|
+
if (pre.block) return `Blocked by PreToolUse hook: ${pre.reason ?? "no reason given"}`;
|
|
148
|
+
const output = await orig(input, options);
|
|
149
|
+
await runner.tool("PostToolUse", name, { input, output });
|
|
150
|
+
return output;
|
|
151
|
+
},
|
|
152
|
+
} as ToolSet[string];
|
|
153
|
+
}
|
|
154
|
+
return wrapped;
|
|
155
|
+
}
|
package/src/main.tsx
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render } from "ink";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { Root } from "./components/Root.tsx";
|
|
5
|
+
import { NAME, VERSION, DESCRIPTION } from "./version.ts";
|
|
6
|
+
import { loadConfig } from "./config/load.ts";
|
|
7
|
+
import { createSession } from "./session.ts";
|
|
8
|
+
import { loadLatest, loadSession } from "./memory/store.ts";
|
|
9
|
+
import { configuredProviders } from "./providers/resolve.ts";
|
|
10
|
+
import { describeError } from "./engine/errors.ts";
|
|
11
|
+
|
|
12
|
+
// Set while Ink owns the screen. A stray unhandled rejection while the TUI is up
|
|
13
|
+
// must NOT reach stdout/stderr — Node's default printer dumps the whole error
|
|
14
|
+
// object (the request body included) unredacted and scrambles the render. The
|
|
15
|
+
// underlying error has already surfaced cleanly via the engine's `error` event,
|
|
16
|
+
// so here we just swallow the duplicate.
|
|
17
|
+
let tuiActive = false;
|
|
18
|
+
|
|
19
|
+
process.on("unhandledRejection", (reason) => {
|
|
20
|
+
if (tuiActive) return;
|
|
21
|
+
const d = describeError(reason);
|
|
22
|
+
process.stderr.write(`\nError: ${d.message}${d.hint ? `\n${d.hint}` : ""}\n`);
|
|
23
|
+
process.exitCode = 1;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
interface CliOptions {
|
|
27
|
+
print?: boolean;
|
|
28
|
+
model?: string;
|
|
29
|
+
cwd?: string;
|
|
30
|
+
dangerouslySkipPermissions?: boolean;
|
|
31
|
+
// Commander treats `--no-quarter` as a negatable boolean: `quarter` is true by
|
|
32
|
+
// default and becomes false when the flag is passed.
|
|
33
|
+
quarter?: boolean;
|
|
34
|
+
continue?: boolean;
|
|
35
|
+
resume?: string;
|
|
36
|
+
onboard?: boolean;
|
|
37
|
+
// Commander negatable boolean: `confine` is true by default, false with --no-confine.
|
|
38
|
+
confine?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const DEFAULT_MODEL = "anthropic:claude-opus-4-8";
|
|
42
|
+
|
|
43
|
+
async function main() {
|
|
44
|
+
const program = new Command();
|
|
45
|
+
|
|
46
|
+
program
|
|
47
|
+
.name(NAME)
|
|
48
|
+
.description(DESCRIPTION)
|
|
49
|
+
.version(VERSION, "-v, --version")
|
|
50
|
+
.argument("[prompt...]", "prompt to send (with -p, runs headless)")
|
|
51
|
+
.option("-p, --print", "print mode: run headless and write the answer to stdout")
|
|
52
|
+
.option("-m, --model <provider:model>", "model to use, e.g. openrouter:anthropic/claude-opus-4.8")
|
|
53
|
+
.option("-C, --cwd <dir>", "working directory")
|
|
54
|
+
.option("--no-confine", "let the agent read/edit outside the working directory without prompting")
|
|
55
|
+
.option("--dangerously-skip-permissions", "auto-approve all tool actions (bypass mode)")
|
|
56
|
+
.option("--no-quarter", "auto-approve all tool actions, taking no prisoners (bypass mode)")
|
|
57
|
+
.option("-c, --continue", "resume the most recent session in this directory")
|
|
58
|
+
.option("-r, --resume <id>", "resume a specific session by id (printed on exit)")
|
|
59
|
+
.option("--onboard", "run the provider/key setup flow")
|
|
60
|
+
.action(async (promptParts: string[], options: CliOptions) => {
|
|
61
|
+
try {
|
|
62
|
+
if (options.cwd) process.chdir(options.cwd);
|
|
63
|
+
const config = loadConfig();
|
|
64
|
+
if (options.dangerouslySkipPermissions || options.quarter === false)
|
|
65
|
+
config.permissionMode = "bypass";
|
|
66
|
+
// --no-confine turns off the working-directory boundary for this run.
|
|
67
|
+
if (options.confine === false) config.confineToCwd = false;
|
|
68
|
+
// --resume <id> loads a specific session (the id printed on a prior exit);
|
|
69
|
+
// --continue loads the most recent one.
|
|
70
|
+
const resume = options.resume
|
|
71
|
+
? loadSession(process.cwd(), options.resume)
|
|
72
|
+
: options.continue
|
|
73
|
+
? loadLatest(process.cwd())
|
|
74
|
+
: null;
|
|
75
|
+
if (options.resume && !resume) {
|
|
76
|
+
process.stderr.write(`No session "${options.resume}" found in this directory.\n`);
|
|
77
|
+
}
|
|
78
|
+
const modelSpec = options.model ?? resume?.modelSpec ?? config.defaultModel ?? DEFAULT_MODEL;
|
|
79
|
+
|
|
80
|
+
if (options.print) {
|
|
81
|
+
await runPrint(modelSpec, promptParts.join(" ").trim(), config.confineToCwd);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// First-run onboarding: no provider has credentials yet (and we're not
|
|
86
|
+
// resuming). Also forceable with --onboard.
|
|
87
|
+
const noProviderReady = !configuredProviders(config).some((p) => p.ready);
|
|
88
|
+
const startInOnboarding = Boolean(options.onboard) || (!resume && noProviderReady);
|
|
89
|
+
|
|
90
|
+
// Interactive TUI.
|
|
91
|
+
tuiActive = true;
|
|
92
|
+
const { waitUntilExit } = render(
|
|
93
|
+
<Root
|
|
94
|
+
config={config}
|
|
95
|
+
modelSpec={modelSpec}
|
|
96
|
+
cwd={process.cwd()}
|
|
97
|
+
resume={resume}
|
|
98
|
+
startInOnboarding={startInOnboarding}
|
|
99
|
+
/>,
|
|
100
|
+
);
|
|
101
|
+
await waitUntilExit();
|
|
102
|
+
tuiActive = false;
|
|
103
|
+
|
|
104
|
+
// On exit, print a hash that resumes this conversation later (à la Claude
|
|
105
|
+
// Code). The latest persisted session carries the id used this run; it only
|
|
106
|
+
// exists once at least one turn has been saved.
|
|
107
|
+
const last = loadLatest(process.cwd());
|
|
108
|
+
if (last && last.messages.length > 0) {
|
|
109
|
+
process.stdout.write(`\nResume this session: ${NAME} --resume ${last.id}\n`);
|
|
110
|
+
}
|
|
111
|
+
} catch (err) {
|
|
112
|
+
// Configuration/resolution errors are expected and user-facing — print them
|
|
113
|
+
// cleanly without a stack trace.
|
|
114
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
115
|
+
process.exitCode = 1;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await program.parseAsync(process.argv);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Headless one-shot: stream the answer to stdout, surfacing tool activity on stderr.
|
|
123
|
+
async function runPrint(modelSpec: string, prompt: string, confineToCwd: boolean) {
|
|
124
|
+
if (!prompt) {
|
|
125
|
+
process.stderr.write("No prompt provided.\n");
|
|
126
|
+
process.exitCode = 1;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const session = createSession({ config: loadConfig(), modelSpec, cwd: process.cwd(), confineToCwd });
|
|
130
|
+
for await (const ev of session.engine.send(prompt)) {
|
|
131
|
+
switch (ev.type) {
|
|
132
|
+
case "text":
|
|
133
|
+
process.stdout.write(ev.text);
|
|
134
|
+
break;
|
|
135
|
+
case "tool-call":
|
|
136
|
+
process.stderr.write(`\n· ${ev.name} ${JSON.stringify(ev.input)}\n`);
|
|
137
|
+
break;
|
|
138
|
+
case "tool-error":
|
|
139
|
+
process.stderr.write(`\n! ${ev.name}: ${ev.error}\n`);
|
|
140
|
+
break;
|
|
141
|
+
case "routed":
|
|
142
|
+
process.stderr.write(
|
|
143
|
+
ev.missing && ev.missing.length > 0
|
|
144
|
+
? `\n⚠ no model configured for ${ev.missing.join("/")} input\n`
|
|
145
|
+
: `\n↪ ${ev.label}${ev.reason ? ` · ${ev.reason}` : ""}\n`,
|
|
146
|
+
);
|
|
147
|
+
break;
|
|
148
|
+
case "error":
|
|
149
|
+
process.stderr.write(`\nError: ${ev.error}${ev.hint ? `\n${ev.hint}` : ""}\n`);
|
|
150
|
+
process.exitCode = 1;
|
|
151
|
+
break;
|
|
152
|
+
case "finish":
|
|
153
|
+
process.stdout.write(
|
|
154
|
+
`\n\n[${modelSpec} · ${ev.usage.totalTokens} tokens · ${ev.finishReason}]\n`,
|
|
155
|
+
);
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
main().catch((err) => {
|
|
162
|
+
// Redact + summarize rather than dumping the raw error object, which may carry
|
|
163
|
+
// request bodies or key material in provider-error fields.
|
|
164
|
+
const d = describeError(err);
|
|
165
|
+
process.stderr.write(`Error: ${d.message}${d.hint ? `\n${d.hint}` : ""}\n`);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
});
|