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.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +474 -0
  3. package/bin/privateer.mjs +11 -0
  4. package/package.json +74 -0
  5. package/src/agents/loader.ts +49 -0
  6. package/src/auth/privateer.ts +393 -0
  7. package/src/commands/custom.ts +75 -0
  8. package/src/commands/registry.ts +499 -0
  9. package/src/components/AgentGroupView.tsx +104 -0
  10. package/src/components/App.tsx +1376 -0
  11. package/src/components/ApprovalPrompt.tsx +38 -0
  12. package/src/components/Banner.tsx +58 -0
  13. package/src/components/Markdown.tsx +183 -0
  14. package/src/components/ModeHint.tsx +40 -0
  15. package/src/components/ModelPicker.tsx +269 -0
  16. package/src/components/Onboarding.tsx +203 -0
  17. package/src/components/PlanConfirm.tsx +37 -0
  18. package/src/components/PrivateerLogin.tsx +109 -0
  19. package/src/components/PromptInput.tsx +602 -0
  20. package/src/components/RewindPicker.tsx +69 -0
  21. package/src/components/Root.tsx +95 -0
  22. package/src/components/SessionPicker.tsx +64 -0
  23. package/src/components/StatusBar.tsx +121 -0
  24. package/src/components/TodoPanel.tsx +36 -0
  25. package/src/components/ToolCallView.tsx +109 -0
  26. package/src/components/Transcript.tsx +203 -0
  27. package/src/components/figures.ts +13 -0
  28. package/src/components/promptModel.ts +73 -0
  29. package/src/components/spinnerVerbs.ts +46 -0
  30. package/src/components/theme.ts +55 -0
  31. package/src/components/types.ts +34 -0
  32. package/src/components/useTeeShield.ts +104 -0
  33. package/src/components/useTerminalWidth.ts +24 -0
  34. package/src/components/useZdrShield.ts +126 -0
  35. package/src/config/load.ts +115 -0
  36. package/src/config/paths.ts +61 -0
  37. package/src/config/schema.ts +94 -0
  38. package/src/context/outputStyles.ts +42 -0
  39. package/src/context/projectInfo.ts +59 -0
  40. package/src/context/systemPrompt.ts +167 -0
  41. package/src/engine/QueryEngine.ts +399 -0
  42. package/src/engine/errors.ts +197 -0
  43. package/src/engine/events.ts +74 -0
  44. package/src/engine/router.ts +165 -0
  45. package/src/hooks/engine.ts +155 -0
  46. package/src/main.tsx +167 -0
  47. package/src/mcp/client.ts +236 -0
  48. package/src/mcp/oauth.ts +245 -0
  49. package/src/memory/auto.ts +146 -0
  50. package/src/memory/checkpoints.ts +227 -0
  51. package/src/memory/store.ts +127 -0
  52. package/src/permissions/danger.ts +56 -0
  53. package/src/permissions/gate.ts +38 -0
  54. package/src/permissions/mode.ts +39 -0
  55. package/src/permissions/protected.ts +29 -0
  56. package/src/permissions/uiGate.ts +73 -0
  57. package/src/providers/attestation.ts +149 -0
  58. package/src/providers/capabilities.ts +104 -0
  59. package/src/providers/catalog.ts +66 -0
  60. package/src/providers/models.ts +183 -0
  61. package/src/providers/registry.ts +71 -0
  62. package/src/providers/resolve.ts +78 -0
  63. package/src/remote/relayClient.ts +283 -0
  64. package/src/session.ts +264 -0
  65. package/src/tools/bash.ts +98 -0
  66. package/src/tools/context.ts +114 -0
  67. package/src/tools/edit.ts +67 -0
  68. package/src/tools/exec.ts +60 -0
  69. package/src/tools/glob.ts +39 -0
  70. package/src/tools/grep.ts +86 -0
  71. package/src/tools/index.ts +69 -0
  72. package/src/tools/memory.ts +53 -0
  73. package/src/tools/processRegistry.ts +77 -0
  74. package/src/tools/read.ts +42 -0
  75. package/src/tools/saveAttachment.ts +53 -0
  76. package/src/tools/task.ts +52 -0
  77. package/src/tools/todo.ts +36 -0
  78. package/src/tools/todoStore.ts +31 -0
  79. package/src/tools/walk.ts +44 -0
  80. package/src/tools/web.ts +145 -0
  81. package/src/tools/write.ts +40 -0
  82. package/src/util/attachmentStore.ts +72 -0
  83. package/src/util/images.ts +343 -0
  84. package/src/util/limit.ts +32 -0
  85. package/src/util/redact.ts +44 -0
  86. package/src/version.ts +13 -0
@@ -0,0 +1,73 @@
1
+ // Pure logic behind the prompt input: mode detection and autocomplete matching.
2
+ // Kept free of Ink so it can be unit-tested directly.
3
+
4
+ export type InputMode = "bash" | "memory" | "command" | "prompt";
5
+
6
+ // A leading "/" only starts a slash command when the first word looks like a
7
+ // command name — no path separators or dots. This lets absolute file paths like
8
+ // "/Users/me/shot.png" be typed as a normal prompt (and attached as an image)
9
+ // instead of being mistaken for a command. A bare "/" still opens the menu.
10
+ export function isSlashCommand(value: string): boolean {
11
+ if (value[0] !== "/") return false;
12
+ const firstWord = value.slice(1).split(/\s/, 1)[0];
13
+ // Command names are strictly alphanumeric (with - and _); anything else in the
14
+ // first word — a "/", ".", "\", etc. — means it's a path, not a command.
15
+ return firstWord.length === 0 || /^[A-Za-z0-9_-]+$/.test(firstWord);
16
+ }
17
+
18
+ // What the leading character of the buffer means. `!` shells out, `#` appends to
19
+ // project memory, `/` runs a slash command, anything else is a model prompt.
20
+ export function detectMode(value: string): InputMode {
21
+ if (value[0] === "!") return "bash";
22
+ if (value[0] === "#") return "memory";
23
+ if (isSlashCommand(value)) return "command";
24
+ return "prompt";
25
+ }
26
+
27
+ // The command-name fragment being typed, when the cursor sits within the first
28
+ // word of a leading "/command". Null once the user moves on to typing arguments.
29
+ export function slashQuery(value: string, cursor: number): string | null {
30
+ if (!isSlashCommand(value)) return null;
31
+ const firstSpace = value.indexOf(" ");
32
+ const nameEnd = firstSpace === -1 ? value.length : firstSpace;
33
+ if (cursor > nameEnd) return null;
34
+ return value.slice(1, nameEnd);
35
+ }
36
+
37
+ // The @-mention token under the cursor: the '@' index plus the text typed after
38
+ // it. Requires the '@' to start a word (so emails like a@b don't trigger it).
39
+ export function mentionAt(value: string, cursor: number): { start: number; query: string } | null {
40
+ let i = cursor;
41
+ while (i > 0 && !/\s/.test(value[i - 1])) i--;
42
+ if (value[i] !== "@") return null;
43
+ return { start: i, query: value.slice(i + 1, cursor) };
44
+ }
45
+
46
+ export interface CommandItem {
47
+ name: string;
48
+ summary: string;
49
+ }
50
+
51
+ export function filterCommands(all: CommandItem[], query: string): CommandItem[] {
52
+ const q = query.toLowerCase();
53
+ return all.filter((c) => c.name.toLowerCase().startsWith(q));
54
+ }
55
+
56
+ // Substring file match, ranked: basename hits beat path hits, earlier and shorter
57
+ // paths win. Keeps the menu small and relevant on large trees.
58
+ export function filterFiles(all: string[], query: string, limit: number): string[] {
59
+ const q = query.toLowerCase();
60
+ if (!q) return all.slice(0, limit);
61
+ const scored: { f: string; score: number }[] = [];
62
+ for (const f of all) {
63
+ const lower = f.toLowerCase();
64
+ const idx = lower.indexOf(q);
65
+ if (idx === -1) continue;
66
+ const base = lower.slice(lower.lastIndexOf("/") + 1);
67
+ const baseIdx = base.indexOf(q);
68
+ const score = (baseIdx === -1 ? 1000 : baseIdx) + idx * 0.01 + f.length * 0.001;
69
+ scored.push({ f, score });
70
+ }
71
+ scored.sort((a, b) => a.score - b.score);
72
+ return scored.slice(0, limit).map((s) => s.f);
73
+ }
@@ -0,0 +1,46 @@
1
+ // A set of gerund "thinking" verbs shown beside the spinner while a turn runs.
2
+ // One is chosen at random per turn.
3
+ const VERBS = [
4
+ "Cogitating",
5
+ "Pondering",
6
+ "Brewing",
7
+ "Conjuring",
8
+ "Forging",
9
+ "Wrangling",
10
+ "Synthesizing",
11
+ "Percolating",
12
+ "Ruminating",
13
+ "Noodling",
14
+ "Computing",
15
+ "Crafting",
16
+ "Composing",
17
+ "Deliberating",
18
+ "Tinkering",
19
+ "Hatching",
20
+ "Incubating",
21
+ "Marinating",
22
+ "Musing",
23
+ "Orchestrating",
24
+ "Puzzling",
25
+ "Scheming",
26
+ "Simmering",
27
+ "Spelunking",
28
+ "Stewing",
29
+ "Summoning",
30
+ "Unfurling",
31
+ "Whirring",
32
+ "Working",
33
+ "Wibbling",
34
+ "Manifesting",
35
+ "Channeling",
36
+ "Concocting",
37
+ "Crunching",
38
+ "Divining",
39
+ "Finagling",
40
+ "Mulling",
41
+ "Plotting",
42
+ "Reticulating",
43
+ "Transmuting",
44
+ ];
45
+
46
+ export const randomVerb = (): string => VERBS[Math.floor(Math.random() * VERBS.length)];
@@ -0,0 +1,55 @@
1
+ import type { PermissionMode } from "../config/schema.ts";
2
+ import type { ZdrPosture } from "../providers/models.ts";
3
+
4
+ // Single source of truth for TUI color. Privateer's navy/white identity drives the
5
+ // accent: it marks bullets, the prompt prefix, headings, and the active permission
6
+ // mode. Text is the terminal default (white on dark); metadata is dimmed gray.
7
+ //
8
+ // Note on navy: a true navy (#1e3a5f) is too dark to read on dark terminals, so the
9
+ // accent is a brighter navy/indigo. It's one knob — tune it here and the whole UI
10
+ // follows.
11
+ export const theme = {
12
+ accent: "#5c7cfa", // Privateer navy/indigo — the single accent hue
13
+ accentDim: "#3b5b8c",
14
+ text: undefined as string | undefined, // terminal default
15
+ dim: "gray",
16
+ success: "green",
17
+ error: "red",
18
+ warning: "yellow",
19
+ diffAdded: "green",
20
+ diffRemoved: "red",
21
+ } as const;
22
+
23
+ // Permission-mode accent colors (moved here from StatusBar so color lives in one place).
24
+ export const MODE_COLOR: Record<PermissionMode, string> = {
25
+ default: theme.warning,
26
+ acceptEdits: theme.success,
27
+ bypass: theme.error,
28
+ plan: theme.accent,
29
+ };
30
+
31
+ // OpenRouter ZDR posture colors — green enforced, yellow available, red retained.
32
+ // Shared by the status-bar shield and the per-model badges in the picker.
33
+ export const POSTURE_COLOR: Record<ZdrPosture, string> = {
34
+ green: theme.success,
35
+ yellow: theme.warning,
36
+ red: theme.error,
37
+ };
38
+
39
+ // Capitalized tool display names: read → Read, web_fetch → WebFetch.
40
+ const TOOL_DISPLAY: Record<string, string> = {
41
+ read: "Read",
42
+ write: "Write",
43
+ edit: "Edit",
44
+ glob: "Glob",
45
+ grep: "Grep",
46
+ bash: "Bash",
47
+ bash_output: "BashOutput",
48
+ kill_shell: "KillShell",
49
+ todo: "TodoWrite",
50
+ task: "Task",
51
+ web_fetch: "WebFetch",
52
+ web_search: "WebSearch",
53
+ };
54
+
55
+ export const toolDisplayName = (name: string): string => TOOL_DISPLAY[name] ?? name;
@@ -0,0 +1,34 @@
1
+ // One rendered line/block in the conversation transcript.
2
+ export type ToolStatus = "running" | "done" | "error";
3
+
4
+ export interface ToolEntry {
5
+ kind: "tool";
6
+ id: string;
7
+ name: string;
8
+ input: unknown;
9
+ status: ToolStatus;
10
+ output?: string;
11
+ error?: string;
12
+ // Set only on `task` calls: the sub-agent's short description, its type (undefined =
13
+ // the default read-only explorer), and run metrics filled in when it finishes. Drives
14
+ // the grouped "N agents finished" rendering.
15
+ agent?: { description: string; subagentType?: string; toolUses?: number; tokens?: number };
16
+ }
17
+
18
+ export type Entry =
19
+ | { kind: "user"; text: string }
20
+ | { kind: "assistant"; text: string }
21
+ | { kind: "thinking"; text: string }
22
+ | ToolEntry
23
+ // `hint` is an optional actionable line rendered dim beneath the notice
24
+ // (used by error notices to suggest a next step).
25
+ | { kind: "notice"; text: string; tone?: "info" | "error"; hint?: string };
26
+
27
+ // A render-time row: either a single transcript entry, or a run of two-or-more
28
+ // concurrent `task` sub-agents collapsed into one grouped block. Grouping happens at
29
+ // render time (see groupRows) so the underlying transcript stays a flat entry list.
30
+ export interface AgentGroupRow {
31
+ kind: "agent-group";
32
+ agents: ToolEntry[];
33
+ }
34
+ export type Row = Entry | AgentGroupRow;
@@ -0,0 +1,104 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { Config } from "../config/schema.ts";
3
+ import { parseModelSpec, privateerChannel } from "../providers/resolve.ts";
4
+ import {
5
+ fetchAttestation,
6
+ fetchAttestationViaServer,
7
+ teePosture,
8
+ type Attestation,
9
+ type TeePosture,
10
+ } from "../providers/attestation.ts";
11
+
12
+ // What the status-bar shield should render for a TEE attestation. Distinct from
13
+ // posture so a dim "needs a key / unknown" affordance never implies a verdict.
14
+ export type TeeState =
15
+ | { kind: "hidden" } // not a TEE-backed model — no badge
16
+ | { kind: "no-key" } // NEAR AI (BYO key) selected but no API key to attest with
17
+ | { kind: "loading" } // fetching the attestation report
18
+ | { kind: "error" } // network / timeout / HTTP failure
19
+ | { kind: "ready"; posture: TeePosture; attestation: Attestation };
20
+
21
+ // Attestations are per-model (each model has its own enclave + signing key), so we
22
+ // cache the in-flight/resolved promise per (apiKey, baseURL, modelId) and reuse it
23
+ // for the session. Concurrent consumers dedupe to one fetch; a rejection is evicted
24
+ // so a later selection can retry rather than being stuck in error forever.
25
+ const cache = new Map<string, Promise<Attestation>>();
26
+
27
+ function loadAttestation(apiKey: string, baseURL: string | undefined, modelId: string): Promise<Attestation> {
28
+ const key = `${apiKey}::${baseURL ?? ""}::${modelId}`;
29
+ let pending = cache.get(key);
30
+ if (!pending) {
31
+ pending = fetchAttestation({ apiKey, baseURL }, modelId).catch((err) => {
32
+ cache.delete(key);
33
+ throw err;
34
+ });
35
+ cache.set(key, pending);
36
+ }
37
+ return pending;
38
+ }
39
+
40
+ // Account-billed `privateer:near/*` models attest through the Privateer server
41
+ // proxy (the NEAR key stays server-side), so they cache by model id alone.
42
+ const serverCache = new Map<string, Promise<Attestation>>();
43
+
44
+ function loadServerAttestation(modelId: string): Promise<Attestation> {
45
+ let pending = serverCache.get(modelId);
46
+ if (!pending) {
47
+ pending = fetchAttestationViaServer(modelId).catch((err) => {
48
+ serverCache.delete(modelId);
49
+ throw err;
50
+ });
51
+ serverCache.set(modelId, pending);
52
+ }
53
+ return pending;
54
+ }
55
+
56
+ // Resolve the TEE shield state for the currently selected model. Two paths trigger
57
+ // a fetch: BYO `nearai:*` models (direct gateway, needs a key) and account-billed
58
+ // `privateer:near/*` models (server proxy, uses the logged-in session). Everything
59
+ // else returns "hidden" before touching the network.
60
+ export function useTeeShield(modelSpec: string, config: Config): TeeState {
61
+ let provider = "";
62
+ let modelId = "";
63
+ try {
64
+ ({ provider, modelId } = parseModelSpec(modelSpec));
65
+ } catch {
66
+ provider = "";
67
+ modelId = "";
68
+ }
69
+ const cfg = config.providers.nearai ?? {};
70
+ const isNearai = provider === "nearai";
71
+ const isPrivateerTee = provider === "privateer" && privateerChannel(modelId) === "tee";
72
+ const apiKey = isNearai ? cfg.apiKey : undefined;
73
+ const baseURL = cfg.baseURL;
74
+
75
+ const [state, setState] = useState<TeeState>({ kind: "hidden" });
76
+
77
+ useEffect(() => {
78
+ if (!isNearai && !isPrivateerTee) {
79
+ setState({ kind: "hidden" });
80
+ return;
81
+ }
82
+ if (isNearai && !apiKey) {
83
+ setState({ kind: "no-key" });
84
+ return;
85
+ }
86
+ let ignore = false;
87
+ setState({ kind: "loading" });
88
+ const pending = isPrivateerTee
89
+ ? loadServerAttestation(modelId)
90
+ : loadAttestation(apiKey!, baseURL, modelId);
91
+ pending
92
+ .then((attestation) => {
93
+ if (!ignore) setState({ kind: "ready", posture: teePosture(attestation), attestation });
94
+ })
95
+ .catch(() => {
96
+ if (!ignore) setState({ kind: "error" });
97
+ });
98
+ return () => {
99
+ ignore = true;
100
+ };
101
+ }, [isNearai, isPrivateerTee, apiKey, baseURL, modelId]);
102
+
103
+ return state;
104
+ }
@@ -0,0 +1,24 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useStdout } from "ink";
3
+
4
+ // Current terminal column count, re-read on every resize.
5
+ //
6
+ // The dynamic region below <Static> is erased and redrawn on each render. Ink
7
+ // counts the previous frame by its newlines, so any line that soft-wraps — or
8
+ // that the terminal reflows when dragged narrower — desyncs the erase and leaves
9
+ // stale copies in the scrollback (the classic stack of duplicated status bars).
10
+ // Subscribing to resize lets those lines re-truncate to the new width and stay a
11
+ // single row, which keeps Ink's erase count correct.
12
+ export function useTerminalWidth(): number {
13
+ const { stdout } = useStdout();
14
+ const [cols, setCols] = useState(stdout?.columns ?? 80);
15
+ useEffect(() => {
16
+ if (!stdout) return;
17
+ const onResize = () => setCols(stdout.columns ?? 80);
18
+ stdout.on("resize", onResize);
19
+ return () => {
20
+ stdout.off("resize", onResize);
21
+ };
22
+ }, [stdout]);
23
+ return cols;
24
+ }
@@ -0,0 +1,126 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { Config, ProviderName } from "../config/schema.ts";
3
+ import { parseModelSpec, privateerChannel } from "../providers/resolve.ts";
4
+ import { fetchZdrAccount, zdrPosture, type ZdrAccountData, type ZdrPosture } from "../providers/models.ts";
5
+
6
+ // What the status-bar shield should render. Distinct from posture so we can show a
7
+ // dim "needs a key / unknown" affordance without ever implying a colored verdict.
8
+ export type ZdrState =
9
+ | { kind: "hidden" } // not a ZDR-backed model — no badge
10
+ | { kind: "no-key" } // OpenRouter selected but no API key to query with
11
+ | { kind: "loading" } // fetching the account snapshot
12
+ | { kind: "error" } // network / timeout / HTTP failure
13
+ | { kind: "ready"; posture: ZdrPosture };
14
+
15
+ // The ZDR snapshot (Z, U, enforcement) is global per account, not per model, so we
16
+ // fetch it once per (apiKey, baseURL) and reuse it for the whole session — switching
17
+ // models re-evaluates synchronously. The cache holds the in-flight/resolved promise so
18
+ // concurrent consumers dedupe to a single fetch pair; a rejection is evicted so a later
19
+ // selection can retry rather than being stuck in error forever.
20
+ const cache = new Map<string, Promise<ZdrAccountData>>();
21
+
22
+ function loadAccount(apiKey: string, baseURL: string | undefined): Promise<ZdrAccountData> {
23
+ const key = `${apiKey}::${baseURL ?? ""}`;
24
+ let pending = cache.get(key);
25
+ if (!pending) {
26
+ pending = fetchZdrAccount({ apiKey, baseURL }).catch((err) => {
27
+ cache.delete(key);
28
+ throw err;
29
+ });
30
+ cache.set(key, pending);
31
+ }
32
+ return pending;
33
+ }
34
+
35
+ // Resolve the ZDR shield state for the currently selected model. Only OpenRouter
36
+ // models trigger a fetch; everything else returns "hidden" before touching the network.
37
+ export function useZdrShield(modelSpec: string, config: Config): ZdrState {
38
+ let provider = "";
39
+ let modelId = "";
40
+ try {
41
+ ({ provider, modelId } = parseModelSpec(modelSpec));
42
+ } catch {
43
+ provider = "";
44
+ modelId = "";
45
+ }
46
+ const cfg = config.providers.openrouter ?? {};
47
+ const isOpenRouter = provider === "openrouter";
48
+ // Account-billed Privateer models that aren't NEAR/TEE route through the server's
49
+ // ZDR-pinned OpenRouter proxy — zero retention is guaranteed server-side.
50
+ const isPrivateerZdr = provider === "privateer" && privateerChannel(modelId) === "zdr";
51
+ const apiKey = isOpenRouter ? cfg.apiKey : undefined;
52
+ const baseURL = cfg.baseURL;
53
+ const enforced = Boolean(cfg.enforceZdr);
54
+
55
+ const [state, setState] = useState<ZdrState>({ kind: "hidden" });
56
+
57
+ useEffect(() => {
58
+ if (isPrivateerZdr) {
59
+ // The proxy always pins ZDR endpoints, so the posture is green without a
60
+ // client-side account query (there's no OpenRouter key on this side).
61
+ setState({ kind: "ready", posture: "green" });
62
+ return;
63
+ }
64
+ if (!isOpenRouter) {
65
+ setState({ kind: "hidden" });
66
+ return;
67
+ }
68
+ if (!apiKey) {
69
+ setState({ kind: "no-key" });
70
+ return;
71
+ }
72
+ let ignore = false;
73
+ setState({ kind: "loading" });
74
+ loadAccount(apiKey, baseURL)
75
+ .then((acct) => {
76
+ if (!ignore) setState({ kind: "ready", posture: zdrPosture(modelId, acct, enforced) });
77
+ })
78
+ .catch(() => {
79
+ if (!ignore) setState({ kind: "error" });
80
+ });
81
+ return () => {
82
+ ignore = true;
83
+ };
84
+ }, [isOpenRouter, isPrivateerZdr, apiKey, baseURL, modelId, enforced]);
85
+
86
+ return state;
87
+ }
88
+
89
+ // The account snapshot itself, for callers that score many models at once (the
90
+ // OpenRouter model picker renders a per-row badge). Same fetch + cache as the
91
+ // shield, but it hands back the raw ZdrAccountData so the consumer can call
92
+ // zdrPosture(id, account) for each model synchronously, without a fetch per row.
93
+ export type ZdrAccountState =
94
+ | { kind: "idle" } // not OpenRouter, or no key — no badges
95
+ | { kind: "loading" }
96
+ | { kind: "error" }
97
+ | { kind: "ready"; account: ZdrAccountData };
98
+
99
+ export function useZdrAccount(provider: ProviderName | string, config: Config): ZdrAccountState {
100
+ const cfg = config.providers.openrouter ?? {};
101
+ const apiKey = provider === "openrouter" ? cfg.apiKey : undefined;
102
+ const baseURL = cfg.baseURL;
103
+
104
+ const [state, setState] = useState<ZdrAccountState>({ kind: "idle" });
105
+
106
+ useEffect(() => {
107
+ if (provider !== "openrouter" || !apiKey) {
108
+ setState({ kind: "idle" });
109
+ return;
110
+ }
111
+ let ignore = false;
112
+ setState({ kind: "loading" });
113
+ loadAccount(apiKey, baseURL)
114
+ .then((account) => {
115
+ if (!ignore) setState({ kind: "ready", account });
116
+ })
117
+ .catch(() => {
118
+ if (!ignore) setState({ kind: "error" });
119
+ });
120
+ return () => {
121
+ ignore = true;
122
+ };
123
+ }, [provider, apiKey, baseURL]);
124
+
125
+ return state;
126
+ }
@@ -0,0 +1,115 @@
1
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs";
2
+ import { Config, type Config as ConfigT, type ProviderName } from "./schema.ts";
3
+ import { globalPaths, projectPaths, managedSettingsPath } from "./paths.ts";
4
+
5
+ // Back-compat re-exports: existing callers import these from here.
6
+ export { globalDir } from "./paths.ts";
7
+ export function globalConfigPath(): string {
8
+ return globalPaths().config;
9
+ }
10
+ export function projectConfigPath(): string {
11
+ return projectPaths().config;
12
+ }
13
+
14
+ function readJsonIfExists(path: string): unknown {
15
+ if (!existsSync(path)) return undefined;
16
+ try {
17
+ return JSON.parse(readFileSync(path, "utf8"));
18
+ } catch (err) {
19
+ throw new Error(`Failed to parse config at ${path}: ${(err as Error).message}`);
20
+ }
21
+ }
22
+
23
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
24
+ return typeof v === "object" && v !== null && !Array.isArray(v);
25
+ }
26
+
27
+ // Recursively merge raw config layers: objects merge per-key, everything else
28
+ // (scalars, arrays) is replaced by the higher-precedence layer.
29
+ function deepMerge(base: unknown, over: unknown): unknown {
30
+ if (over === undefined) return base;
31
+ if (!isPlainObject(base) || !isPlainObject(over)) return over;
32
+ const out: Record<string, unknown> = { ...base };
33
+ for (const [k, v] of Object.entries(over)) {
34
+ if (v === undefined) continue;
35
+ out[k] = k in out ? deepMerge(out[k], v) : v;
36
+ }
37
+ return out;
38
+ }
39
+
40
+ export interface ConfigLayer {
41
+ label: string;
42
+ path: string;
43
+ present: boolean;
44
+ }
45
+
46
+ // The precedence chain, ordered low → high. Each scope contributes its
47
+ // config.json (credentials + prefs) then its settings file(s) on top; managed
48
+ // enterprise settings, if present, win over everything.
49
+ function layerSpecs(): { label: string; path: string }[] {
50
+ const g = globalPaths();
51
+ const p = projectPaths();
52
+ const specs = [
53
+ { label: "user config", path: g.config },
54
+ { label: "user settings", path: g.settings },
55
+ { label: "project config", path: p.config },
56
+ { label: "project settings", path: p.settings },
57
+ { label: "project settings (local)", path: p.settingsLocal },
58
+ ];
59
+ const managed = managedSettingsPath();
60
+ if (managed) specs.push({ label: "managed", path: managed });
61
+ return specs;
62
+ }
63
+
64
+ // Resolved layer presence, for `/doctor` and `/config`.
65
+ export function configLayers(): ConfigLayer[] {
66
+ return layerSpecs().map(({ label, path }) => ({ label, path, present: existsSync(path) }));
67
+ }
68
+
69
+ // Environment fallbacks for provider credentials. Applied only when config omits them.
70
+ function applyEnv(cfg: ConfigT): ConfigT {
71
+ const p = cfg.providers;
72
+ const set = (name: ProviderName, key: "apiKey" | "baseURL", val?: string) => {
73
+ if (!val) return;
74
+ p[name] = { ...(p[name] ?? {}), [key]: (p[name] as any)?.[key] ?? val };
75
+ };
76
+ set("openrouter", "apiKey", process.env.OPENROUTER_API_KEY);
77
+ set("anthropic", "apiKey", process.env.ANTHROPIC_API_KEY);
78
+ set("openai", "apiKey", process.env.OPENAI_API_KEY);
79
+ set("ollama", "baseURL", process.env.OLLAMA_BASE_URL);
80
+ set("nearai", "apiKey", process.env.NEAR_AI_API_KEY ?? process.env.NEARAI_API_KEY);
81
+ return cfg;
82
+ }
83
+
84
+ export function loadConfig(): ConfigT {
85
+ // Merge raw layers first (so per-layer files stay partial), then parse once so
86
+ // schema defaults are applied to the resolved object rather than each layer.
87
+ let raw: unknown = {};
88
+ for (const { path } of layerSpecs()) {
89
+ raw = deepMerge(raw, readJsonIfExists(path));
90
+ }
91
+ const cfg = Config.parse(raw ?? {});
92
+ return applyEnv(cfg);
93
+ }
94
+
95
+ // Persist the global config (used by /model, /provider, /permissions to remember
96
+ // choices). The file holds provider API keys, so it is written owner-only (0600)
97
+ // inside an owner-only directory (0700). `chmod` is best-effort: it's a no-op on
98
+ // filesystems/platforms (e.g. Windows) that don't honour POSIX modes.
99
+ export function saveGlobalConfig(cfg: ConfigT): void {
100
+ const g = globalPaths();
101
+ mkdirSync(g.dir, { recursive: true });
102
+ tryChmod(g.dir, 0o700);
103
+ // `mode` on writeFileSync only applies when creating the file, so chmod after
104
+ // to also tighten a pre-existing, looser config.
105
+ writeFileSync(g.config, JSON.stringify(cfg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
106
+ tryChmod(g.config, 0o600);
107
+ }
108
+
109
+ function tryChmod(path: string, mode: number): void {
110
+ try {
111
+ chmodSync(path, mode);
112
+ } catch {
113
+ /* non-POSIX filesystem or insufficient perms — nothing we can do */
114
+ }
115
+ }
@@ -0,0 +1,61 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ // Global config/data dir. Overridable via PRIVATEER_HOME (portability + tests).
5
+ // Computed lazily so the env var can be set before first use.
6
+ export function globalDir(): string {
7
+ return process.env.PRIVATEER_HOME ?? join(homedir(), ".privateer");
8
+ }
9
+
10
+ // Project-scoped config dir (./.privateer).
11
+ export function projectDir(cwd: string = process.cwd()): string {
12
+ return join(cwd, ".privateer");
13
+ }
14
+
15
+ // Privateer-account session credentials (JWT access/refresh + user). Kept in a
16
+ // SEPARATE file from config.json: these are session tokens, not BYO provider
17
+ // keys, and have a different lifecycle (rotated on refresh, cleared on logout).
18
+ export function credentialsPath(): string {
19
+ return join(globalDir(), "credentials.json");
20
+ }
21
+
22
+ // Optional enterprise-managed settings file (highest precedence). Opt-in via env
23
+ // so it stays out of the way for individuals and tests.
24
+ export function managedSettingsPath(): string | undefined {
25
+ return process.env.PRIVATEER_MANAGED_SETTINGS || undefined;
26
+ }
27
+
28
+ // The standard file/dir layout under a scope's .privateer directory. Later
29
+ // milestones (commands, agents, mcp, output styles) resolve their roots here so
30
+ // the convention lives in one place.
31
+ export interface ScopePaths {
32
+ dir: string;
33
+ config: string; // config.json — credentials + prefs (existing single file)
34
+ settings: string; // settings.json — layered settings
35
+ settingsLocal: string; // settings.local.json — gitignored local overrides
36
+ commands: string; // commands/ — custom slash commands (M2)
37
+ agents: string; // agents/ — custom subagents (M4)
38
+ outputStyles: string; // output-styles/ — persona prompts (M2)
39
+ mcp: string; // mcp.json — MCP server declarations (M4)
40
+ }
41
+
42
+ function scopePaths(dir: string): ScopePaths {
43
+ return {
44
+ dir,
45
+ config: join(dir, "config.json"),
46
+ settings: join(dir, "settings.json"),
47
+ settingsLocal: join(dir, "settings.local.json"),
48
+ commands: join(dir, "commands"),
49
+ agents: join(dir, "agents"),
50
+ outputStyles: join(dir, "output-styles"),
51
+ mcp: join(dir, "mcp.json"),
52
+ };
53
+ }
54
+
55
+ export function globalPaths(): ScopePaths {
56
+ return scopePaths(globalDir());
57
+ }
58
+
59
+ export function projectPaths(cwd: string = process.cwd()): ScopePaths {
60
+ return scopePaths(projectDir(cwd));
61
+ }