@jameslovespancakes/pi-plus 1.0.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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +190 -0
  3. package/config/pi-plus.example.json +60 -0
  4. package/config/skills/model-routing/SKILL.md +86 -0
  5. package/images/board_demo.png +0 -0
  6. package/images/pi-plus.svg +10 -0
  7. package/images/pi-plus_demo.png +0 -0
  8. package/images/provider_demo.png +0 -0
  9. package/images/remote_demo.png +0 -0
  10. package/images/usage_demo.png +0 -0
  11. package/package.json +67 -0
  12. package/server/board-server.mjs +641 -0
  13. package/server/package.json +17 -0
  14. package/src/core/accounts/registry.ts +93 -0
  15. package/src/core/anthropic/client-identity.ts +241 -0
  16. package/src/core/anthropic/models.ts +69 -0
  17. package/src/core/anthropic/oauth.ts +208 -0
  18. package/src/core/anthropic/quota.ts +253 -0
  19. package/src/core/anthropic/routing.ts +168 -0
  20. package/src/core/anthropic/store.ts +225 -0
  21. package/src/core/anthropic/vendor/README.md +36 -0
  22. package/src/core/anthropic/vendor/xxhash-wasm.LICENSE.md +25 -0
  23. package/src/core/anthropic/vendor/xxhash-wasm.js +2 -0
  24. package/src/core/anthropic/xxhash64.ts +33 -0
  25. package/src/core/catalog/quality.ts +314 -0
  26. package/src/core/codex/oauth.ts +129 -0
  27. package/src/core/codex/quota.ts +88 -0
  28. package/src/core/codex/store.ts +97 -0
  29. package/src/core/config.ts +169 -0
  30. package/src/core/env.ts +58 -0
  31. package/src/core/exec/process.ts +146 -0
  32. package/src/core/exec/ssh-config.ts +157 -0
  33. package/src/core/oauth/pkce.ts +88 -0
  34. package/src/core/policy/policy.ts +183 -0
  35. package/src/core/quota/pool.ts +64 -0
  36. package/src/core/quota/usage-source.ts +289 -0
  37. package/src/core/store.ts +43 -0
  38. package/src/domains/agents/board-setup.ts +409 -0
  39. package/src/domains/agents/index.ts +462 -0
  40. package/src/domains/models/catalog-tool.ts +361 -0
  41. package/src/domains/models/index.ts +14 -0
  42. package/src/domains/models/policy-gate.ts +169 -0
  43. package/src/domains/models/provider-picker.ts +208 -0
  44. package/src/domains/remote/config-path.ts +41 -0
  45. package/src/domains/remote/index.ts +866 -0
  46. package/src/domains/remote/setup.ts +425 -0
  47. package/src/domains/setup/index.ts +220 -0
  48. package/src/domains/subscriptions/accounts-picker.ts +178 -0
  49. package/src/domains/subscriptions/accounts.ts +242 -0
  50. package/src/domains/subscriptions/footer.ts +182 -0
  51. package/src/domains/subscriptions/index.ts +42 -0
  52. package/src/domains/subscriptions/provider.ts +219 -0
  53. package/src/domains/subscriptions/providers/anthropic.ts +149 -0
  54. package/src/domains/subscriptions/providers/codex.ts +148 -0
  55. package/src/domains/subscriptions/routing.ts +72 -0
  56. package/src/services/usage-service.ts +186 -0
  57. package/src/ui/format.ts +73 -0
  58. package/src/ui/usage-bars.ts +154 -0
  59. package/src/vendor/anthropic.ts +109 -0
@@ -0,0 +1,97 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import type { QuotaSnapshot } from "../anthropic/store.ts";
5
+
6
+ /**
7
+ * Codex account storage.
8
+ *
9
+ * Deliberately a separate file from the Anthropic store rather than a shared
10
+ * one. The two providers issue different credential shapes (Codex carries an
11
+ * `accountId` that must accompany every request) and the Anthropic file is
12
+ * byte-compatible with an older format we still want to be able to downgrade
13
+ * to. Sharing one file would couple those constraints together for no gain.
14
+ */
15
+
16
+ export interface CodexAccount {
17
+ id: string;
18
+ label?: string;
19
+ enabled?: boolean;
20
+ access?: string;
21
+ refresh?: string;
22
+ expires?: number;
23
+ /** ChatGPT account id; sent as `chatgpt-account-id` on every request. */
24
+ accountId?: string;
25
+ /** From the token claims, e.g. "pro" or "plus". Display only. */
26
+ plan?: string;
27
+ addedAt?: number;
28
+ lastRefreshedAt?: number;
29
+ quota?: QuotaSnapshot;
30
+ }
31
+
32
+ export interface CodexStorage {
33
+ accounts: CodexAccount[];
34
+ routing?: { mode?: string };
35
+ }
36
+
37
+ export const MAIN_ACCOUNT_ID = "main";
38
+
39
+ function storePath(): string {
40
+ return join(homedir(), ".pi", "agent", "codex-accounts.json");
41
+ }
42
+
43
+ export function loadCodexAccounts(path = storePath()): CodexStorage {
44
+ try {
45
+ const raw = JSON.parse(readFileSync(path, "utf8"));
46
+ const accounts = Array.isArray(raw?.accounts) ? raw.accounts : [];
47
+ return { accounts, routing: raw?.routing };
48
+ } catch {
49
+ return { accounts: [] };
50
+ }
51
+ }
52
+
53
+ export function saveCodexAccounts(storage: CodexStorage, path = storePath()): void {
54
+ mkdirSync(join(homedir(), ".pi", "agent"), { recursive: true });
55
+ // 0600: these are live OAuth credentials.
56
+ writeFileSync(path, JSON.stringify(storage, null, 2) + "\n", { mode: 0o600 });
57
+ }
58
+
59
+ /** Inserts or replaces one account, leaving the others untouched. */
60
+ export function saveCodexAccount(account: CodexAccount, path = storePath()): void {
61
+ const storage = loadCodexAccounts(path);
62
+ const index = storage.accounts.findIndex((a) => a.id === account.id);
63
+ if (index >= 0) storage.accounts[index] = { ...storage.accounts[index], ...account };
64
+ else storage.accounts.push(account);
65
+ saveCodexAccounts(storage, path);
66
+ }
67
+
68
+ export function removeCodexAccount(id: string, path = storePath()): boolean {
69
+ const storage = loadCodexAccounts(path);
70
+ const before = storage.accounts.length;
71
+ storage.accounts = storage.accounts.filter((a) => a.id !== id);
72
+ if (storage.accounts.length === before) return false;
73
+ saveCodexAccounts(storage, path);
74
+ return true;
75
+ }
76
+
77
+ export const isUsableCodex = (a: CodexAccount): boolean => a.enabled !== false && !!a.access;
78
+
79
+ /**
80
+ * Reads plan and account id out of an access token.
81
+ *
82
+ * Codex tokens are JWTs carrying `chatgpt_plan_type` and `chatgpt_account_id`,
83
+ * so a new account needs no extra API call to identify itself.
84
+ */
85
+ export function claimsOf(accessToken: string | undefined): { accountId?: string; plan?: string } {
86
+ if (!accessToken) return {};
87
+ try {
88
+ const payload = JSON.parse(Buffer.from(accessToken.split(".")[1] ?? "", "base64url").toString());
89
+ const auth = payload?.["https://api.openai.com/auth"] ?? {};
90
+ return {
91
+ accountId: typeof auth.chatgpt_account_id === "string" ? auth.chatgpt_account_id : undefined,
92
+ plan: typeof auth.chatgpt_plan_type === "string" ? auth.chatgpt_plan_type : undefined,
93
+ };
94
+ } catch {
95
+ return {};
96
+ }
97
+ }
@@ -0,0 +1,169 @@
1
+ import { existsSync } from "node:fs";
2
+ import { agentPath, readJson, writeJson } from "./store.ts";
3
+
4
+ /**
5
+ * One file for everything pi-plus owns: `~/.pi/agent/pi-plus.json`.
6
+ *
7
+ * ```json
8
+ * {
9
+ * "env": { "ARTIFICIAL_ANALYSIS_API_KEY": "aa_…", "AGENT_BOARD_URL": "…" },
10
+ * "policy": { "autoApprove": [], "requireApproval": [], "deny": [] },
11
+ * "remote": { "injectStatus": true, "defaults": {}, "workers": [] }
12
+ * }
13
+ * ```
14
+ *
15
+ * Pi's own `settings.json` and `models.json` are deliberately NOT absorbed:
16
+ * pi reads those from fixed paths and folding them in here would break it.
17
+ *
18
+ * Every legacy file is migrated on first read and left on disk, so downgrading
19
+ * keeps working.
20
+ */
21
+
22
+ export const CONFIG_FILE = "pi-plus.json";
23
+
24
+ export interface PolicySection {
25
+ autoApprove: string[];
26
+ requireApproval: string[];
27
+ deny: string[];
28
+ }
29
+
30
+ export interface RemoteSection {
31
+ injectStatus?: boolean;
32
+ defaults?: Record<string, unknown>;
33
+ workers: Record<string, unknown>[];
34
+ }
35
+
36
+ export interface PiPlusConfig {
37
+ env: Record<string, string>;
38
+ policy: PolicySection;
39
+ remote: RemoteSection;
40
+ }
41
+
42
+ const DEFAULTS: PiPlusConfig = {
43
+ env: {},
44
+ policy: {
45
+ autoApprove: ["anthropic/*", "openai-codex/*"],
46
+ requireApproval: ["openrouter/*", "google/*", "openai/*", "xai/*"],
47
+ deny: [],
48
+ },
49
+ remote: { injectStatus: true, workers: [] },
50
+ };
51
+
52
+ /** Legacy file -> section, applied only when that section is still absent. */
53
+ const MIGRATIONS: { file: string; apply: (raw: any, into: PiPlusConfig) => boolean }[] = [
54
+ {
55
+ file: "pi-plus.env.json",
56
+ apply: (raw, into) => {
57
+ if (Object.keys(into.env).length > 0 || !raw || typeof raw !== "object") return false;
58
+ for (const [key, value] of Object.entries(raw)) {
59
+ if (typeof value === "string" && value.trim()) into.env[key] = value.trim();
60
+ }
61
+ return true;
62
+ },
63
+ },
64
+ {
65
+ file: "model-quality-key.json",
66
+ apply: (raw, into) => {
67
+ if (into.env.ARTIFICIAL_ANALYSIS_API_KEY || typeof raw?.artificialAnalysis !== "string") return false;
68
+ into.env.ARTIFICIAL_ANALYSIS_API_KEY = raw.artificialAnalysis;
69
+ return true;
70
+ },
71
+ },
72
+ {
73
+ file: "agent-board.json",
74
+ apply: (raw, into) => {
75
+ if (into.env.AGENT_BOARD_URL || typeof raw?.url !== "string" || typeof raw?.token !== "string") return false;
76
+ into.env.AGENT_BOARD_URL = raw.url;
77
+ into.env.AGENT_BOARD_TOKEN = raw.token;
78
+ if (typeof raw.adminName === "string") into.env.AGENT_BOARD_NAME ??= raw.adminName;
79
+ return true;
80
+ },
81
+ },
82
+ {
83
+ file: "model-policy.json",
84
+ apply: (raw, into) => {
85
+ if (!raw || typeof raw !== "object" || !Array.isArray(raw.requireApproval)) return false;
86
+ into.policy = {
87
+ autoApprove: Array.isArray(raw.autoApprove) ? raw.autoApprove : DEFAULTS.policy.autoApprove,
88
+ requireApproval: raw.requireApproval,
89
+ deny: Array.isArray(raw.deny) ? raw.deny : [],
90
+ };
91
+ return true;
92
+ },
93
+ },
94
+ {
95
+ file: "remote.json",
96
+ apply: (raw, into) => {
97
+ if (into.remote.workers.length > 0 || !Array.isArray(raw?.workers)) return false;
98
+ into.remote = { injectStatus: raw.injectStatus !== false, defaults: raw.defaults, workers: raw.workers };
99
+ return true;
100
+ },
101
+ },
102
+ {
103
+ file: "remote-workers.json",
104
+ apply: (raw, into) => {
105
+ if (into.remote.workers.length > 0 || !Array.isArray(raw?.workers)) return false;
106
+ into.remote = { injectStatus: raw.injectStatus !== false, defaults: raw.defaults, workers: raw.workers };
107
+ return true;
108
+ },
109
+ },
110
+ ];
111
+
112
+ let cache: PiPlusConfig | undefined;
113
+
114
+ export function configPath(): string {
115
+ return process.env.PI_PLUS_CONFIG ?? agentPath(CONFIG_FILE);
116
+ }
117
+
118
+ function normalize(raw: Partial<PiPlusConfig> | undefined): PiPlusConfig {
119
+ return {
120
+ env: raw?.env && typeof raw.env === "object" ? { ...raw.env } : {},
121
+ policy: {
122
+ autoApprove: Array.isArray(raw?.policy?.autoApprove) ? raw.policy.autoApprove : DEFAULTS.policy.autoApprove,
123
+ requireApproval: Array.isArray(raw?.policy?.requireApproval)
124
+ ? raw.policy.requireApproval
125
+ : DEFAULTS.policy.requireApproval,
126
+ deny: Array.isArray(raw?.policy?.deny) ? raw.policy.deny : [],
127
+ },
128
+ remote: {
129
+ injectStatus: raw?.remote?.injectStatus !== false,
130
+ defaults: raw?.remote?.defaults,
131
+ workers: Array.isArray(raw?.remote?.workers) ? raw.remote.workers : [],
132
+ },
133
+ };
134
+ }
135
+
136
+ export function readConfig(): PiPlusConfig {
137
+ if (cache) return cache;
138
+
139
+ const path = configPath();
140
+ const exists = existsSync(path);
141
+ const config = normalize(exists ? readJson<Partial<PiPlusConfig>>(path, {}) : undefined);
142
+
143
+ // Only consider legacy sources when the unified file is absent or partial.
144
+ let migrated = false;
145
+ for (const migration of MIGRATIONS) {
146
+ const legacyPath = agentPath(migration.file);
147
+ if (!existsSync(legacyPath)) continue;
148
+ const raw = readJson<any>(legacyPath, undefined);
149
+ if (raw === undefined) continue;
150
+ if (migration.apply(raw, config)) migrated = true;
151
+ }
152
+
153
+ if (!exists || migrated) writeJson(path, config, true);
154
+ cache = config;
155
+ return config;
156
+ }
157
+
158
+ /** Read-modify-write of the whole file, so sections never clobber each other. */
159
+ export function updateConfig(mutate: (config: PiPlusConfig) => void): boolean {
160
+ const config = readConfig();
161
+ mutate(config);
162
+ cache = config;
163
+ return writeJson(configPath(), config, true);
164
+ }
165
+
166
+ /** Drops the in-memory copy. Used by tests and after external edits. */
167
+ export function resetConfigCache(): void {
168
+ cache = undefined;
169
+ }
@@ -0,0 +1,58 @@
1
+ import { readConfig, resetConfigCache, updateConfig } from "./config.ts";
2
+
3
+ /**
4
+ * Credentials and endpoints, stored in the `env` section of `pi-plus.json`.
5
+ *
6
+ * Precedence is `process.env` first, then the file. That keeps CI and one-off
7
+ * shell overrides working exactly as people expect, while giving the TUI a
8
+ * single place to read and write.
9
+ */
10
+
11
+ export type EnvKey =
12
+ | "ARTIFICIAL_ANALYSIS_API_KEY"
13
+ | "AGENT_BOARD_URL"
14
+ | "AGENT_BOARD_TOKEN"
15
+ | "AGENT_BOARD_NAME"
16
+ | "AGENT_BOARD_MODE"
17
+ | "AGENT_BOARD_SSH";
18
+
19
+ export const ENV_KEYS: { key: EnvKey; label: string; secret: boolean }[] = [
20
+ { key: "ARTIFICIAL_ANALYSIS_API_KEY", label: "Artificial Analysis API key", secret: true },
21
+ { key: "AGENT_BOARD_URL", label: "Agent board URL", secret: false },
22
+ { key: "AGENT_BOARD_TOKEN", label: "Agent board token", secret: true },
23
+ { key: "AGENT_BOARD_NAME", label: "Agent board display name", secret: false },
24
+ { key: "AGENT_BOARD_MODE", label: "Agent board deployment (local|remote|external)", secret: false },
25
+ { key: "AGENT_BOARD_SSH", label: "Agent board SSH host, when remote", secret: false },
26
+ ];
27
+
28
+ /** Reads a setting: real environment first, then the config file. */
29
+ export function env(key: EnvKey): string | undefined {
30
+ const fromProcess = process.env[key];
31
+ if (typeof fromProcess === "string" && fromProcess.trim()) return fromProcess.trim();
32
+ const value = readConfig().env[key];
33
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
34
+ }
35
+
36
+ /** Writes a setting. Passing undefined or blank removes it. */
37
+ export function setEnv(key: EnvKey, value: string | undefined): boolean {
38
+ return updateConfig((config) => {
39
+ if (value === undefined || value.trim() === "") delete config.env[key];
40
+ else config.env[key] = value.trim();
41
+ });
42
+ }
43
+
44
+ /** True when the real environment supplies this key, so the file is ignored. */
45
+ export function isFromProcessEnv(key: EnvKey): boolean {
46
+ const value = process.env[key];
47
+ return typeof value === "string" && value.trim() !== "";
48
+ }
49
+
50
+ /** `aa_vaoi…rXcC`: enough to recognise, not enough to use. */
51
+ export function maskSecret(value: string): string {
52
+ if (value.length <= 10) return "•".repeat(value.length);
53
+ return `${value.slice(0, 5)}…${value.slice(-4)}`;
54
+ }
55
+
56
+ export function resetEnvCache(): void {
57
+ resetConfigCache();
58
+ }
@@ -0,0 +1,146 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createReadStream } from "node:fs";
3
+
4
+ /**
5
+ * Process and SSH execution primitives.
6
+ *
7
+ * Extracted from the remote-jobs extension so anything needing a bounded child
8
+ * process gets the same timeout, abort and output-cap behaviour.
9
+ */
10
+
11
+ export const SSH_ARGS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=8"];
12
+
13
+ const PROCESS_TAIL_CHARS = 2 * 1024 * 1024;
14
+
15
+ export interface ProcessResult {
16
+ code: number;
17
+ stdout: string;
18
+ stderr: string;
19
+ timedOut: boolean;
20
+ aborted: boolean;
21
+ totalOutputBytes: number;
22
+ }
23
+
24
+ export interface RunOptions {
25
+ cwd?: string;
26
+ input?: string | Buffer | { file: string };
27
+ timeoutSeconds?: number;
28
+ signal?: AbortSignal;
29
+ onData?: (chunk: string) => void;
30
+ }
31
+
32
+ /** Keeps only the trailing window of a stream so long builds cannot exhaust memory. */
33
+ export function appendTail(current: string, chunk: string): string {
34
+ const next = current + chunk;
35
+ return next.length > PROCESS_TAIL_CHARS ? next.slice(-PROCESS_TAIL_CHARS) : next;
36
+ }
37
+
38
+ export function runProcess(command: string, args: string[], options: RunOptions = {}): Promise<ProcessResult> {
39
+ return new Promise((resolvePromise, reject) => {
40
+ const child = spawn(command, args, {
41
+ cwd: options.cwd,
42
+ shell: false,
43
+ stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
44
+ windowsHide: true,
45
+ });
46
+
47
+ let stdout = "";
48
+ let stderr = "";
49
+ let totalOutputBytes = 0;
50
+ let timedOut = false;
51
+ let aborted = false;
52
+ let settled = false;
53
+ let timer: NodeJS.Timeout | undefined;
54
+
55
+ const finishReject = (error: Error) => {
56
+ if (settled) return;
57
+ settled = true;
58
+ if (timer) clearTimeout(timer);
59
+ reject(error);
60
+ };
61
+
62
+ const kill = () => {
63
+ try {
64
+ child.kill("SIGTERM");
65
+ } catch {
66
+ // Process already exited.
67
+ }
68
+ };
69
+
70
+ child.stdout?.on("data", (data: Buffer) => {
71
+ const text = data.toString("utf8");
72
+ totalOutputBytes += data.length;
73
+ stdout = appendTail(stdout, text);
74
+ options.onData?.(text);
75
+ });
76
+
77
+ child.stderr?.on("data", (data: Buffer) => {
78
+ const text = data.toString("utf8");
79
+ totalOutputBytes += data.length;
80
+ stderr = appendTail(stderr, text);
81
+ options.onData?.(text);
82
+ });
83
+
84
+ child.on("error", finishReject);
85
+
86
+ if (options.timeoutSeconds) {
87
+ timer = setTimeout(() => {
88
+ timedOut = true;
89
+ kill();
90
+ }, options.timeoutSeconds * 1000);
91
+ }
92
+
93
+ const onAbort = () => {
94
+ aborted = true;
95
+ kill();
96
+ };
97
+
98
+ if (options.signal) {
99
+ if (options.signal.aborted) onAbort();
100
+ else options.signal.addEventListener("abort", onAbort, { once: true });
101
+ }
102
+
103
+ if (typeof options.input === "string" || Buffer.isBuffer(options.input)) {
104
+ child.stdin?.end(options.input);
105
+ } else if (options.input?.file && child.stdin) {
106
+ const target = child.stdin;
107
+ const stream = createReadStream(options.input.file);
108
+ stream.on("error", (error) => {
109
+ kill();
110
+ finishReject(error);
111
+ });
112
+ stream.pipe(target);
113
+ }
114
+
115
+ child.on("close", (code) => {
116
+ if (settled) return;
117
+ settled = true;
118
+ if (timer) clearTimeout(timer);
119
+ options.signal?.removeEventListener("abort", onAbort);
120
+ resolvePromise({ code: code ?? 1, stdout, stderr, timedOut, aborted, totalOutputBytes });
121
+ });
122
+ });
123
+ }
124
+
125
+ export async function runLocal(command: string, args: string[], cwd?: string): Promise<ProcessResult> {
126
+ return runProcess(command, args, { cwd, timeoutSeconds: 60 });
127
+ }
128
+
129
+ export async function runSshCommand(
130
+ host: string,
131
+ remoteCommand: string,
132
+ options: RunOptions = {},
133
+ extraArgs: string[] = [],
134
+ ): Promise<ProcessResult> {
135
+ return runProcess("ssh", [...SSH_ARGS, ...extraArgs, host, remoteCommand], options);
136
+ }
137
+
138
+ /** POSIX single-quote escaping for values interpolated into remote scripts. */
139
+ export function shQuote(value: string): string {
140
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
141
+ }
142
+
143
+ export function rootAssignment(root: string): string {
144
+ if (root.startsWith("~/")) return `ROOT="$HOME"/${shQuote(root.slice(2))}`;
145
+ return `ROOT=${shQuote(root)}`;
146
+ }
@@ -0,0 +1,157 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { globSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, isAbsolute, join } from "node:path";
5
+
6
+ /**
7
+ * Minimal `~/.ssh/config` reader.
8
+ *
9
+ * Deliberately narrow: it extracts only what is needed to offer a host as a
10
+ * remote worker: the alias, HostName, User, Port, and whether an IdentityFile
11
+ * is configured. It never reads key material, and callers must keep the results
12
+ * in the UI layer: host aliases frequently name internal infrastructure and have
13
+ * no business being sent to a model provider.
14
+ */
15
+
16
+ export interface SshHost {
17
+ /** The `Host` alias, which is what you pass to `ssh`. */
18
+ alias: string;
19
+ hostName?: string;
20
+ user?: string;
21
+ port?: number;
22
+ /** Path as written in the config. The file is never opened. */
23
+ identityFile?: string;
24
+ /** Config file this block came from, for display. */
25
+ source: string;
26
+ }
27
+
28
+ function defaultConfigPath(): string {
29
+ return join(homedir(), ".ssh", "config");
30
+ }
31
+
32
+ /** `Host` patterns that cannot be connected to directly. */
33
+ function isPattern(alias: string): boolean {
34
+ return alias.includes("*") || alias.includes("?") || alias.startsWith("!");
35
+ }
36
+
37
+ function expandPath(value: string, base: string): string {
38
+ if (value.startsWith("~/")) return join(homedir(), value.slice(2));
39
+ return isAbsolute(value) ? value : join(base, value);
40
+ }
41
+
42
+ /**
43
+ * Parses one config file, following `Include` directives.
44
+ * `seen` guards against include cycles.
45
+ */
46
+ function parseFile(path: string, seen: Set<string>, out: SshHost[]): void {
47
+ if (seen.has(path)) return;
48
+ seen.add(path);
49
+
50
+ let text: string;
51
+ try {
52
+ text = readFileSync(path, "utf8");
53
+ } catch {
54
+ return; // Missing or unreadable config is simply "no hosts".
55
+ }
56
+
57
+ let current: SshHost | undefined;
58
+ const push = () => {
59
+ if (current && !isPattern(current.alias)) out.push(current);
60
+ current = undefined;
61
+ };
62
+
63
+ for (const rawLine of text.split(/\r?\n/)) {
64
+ const line = rawLine.trim();
65
+ if (!line || line.startsWith("#")) continue;
66
+
67
+ // Keywords are case-insensitive; separator is whitespace or '='.
68
+ const match = /^([A-Za-z]+)[\s=]+(.*)$/.exec(line);
69
+ if (!match) continue;
70
+ const keyword = match[1].toLowerCase();
71
+ const value = match[2].trim().replace(/^"(.*)"$/, "$1");
72
+ if (!value) continue;
73
+
74
+ if (keyword === "host") {
75
+ push();
76
+ // `Host a b c` declares several aliases; the first connectable one wins.
77
+ const alias = value.split(/\s+/).find((candidate) => !isPattern(candidate));
78
+ if (alias) current = { alias, source: path };
79
+ continue;
80
+ }
81
+
82
+ if (keyword === "include") {
83
+ // Includes are resolved relative to the containing file's directory.
84
+ for (const pattern of value.split(/\s+/)) {
85
+ const resolved = expandPath(pattern, dirname(path));
86
+ let matches: string[] = [];
87
+ try {
88
+ matches = globSync(resolved);
89
+ } catch {
90
+ matches = [];
91
+ }
92
+ // A literal path that does not glob should still be attempted.
93
+ for (const file of matches.length > 0 ? matches : [resolved]) parseFile(file, seen, out);
94
+ }
95
+ continue;
96
+ }
97
+
98
+ if (!current) continue;
99
+ switch (keyword) {
100
+ case "hostname":
101
+ current.hostName = value;
102
+ break;
103
+ case "user":
104
+ current.user = value;
105
+ break;
106
+ case "port": {
107
+ const port = Number(value);
108
+ if (Number.isInteger(port) && port > 0 && port < 65536) current.port = port;
109
+ break;
110
+ }
111
+ case "identityfile":
112
+ // Recorded as a path only. The key itself is never read.
113
+ current.identityFile ??= value;
114
+ break;
115
+ default:
116
+ break;
117
+ }
118
+ }
119
+ push();
120
+ }
121
+
122
+ /** Every connectable host in the user's SSH config, in file order. */
123
+ export function readSshHosts(path = defaultConfigPath()): SshHost[] {
124
+ const hosts: SshHost[] = [];
125
+ parseFile(path, new Set(), hosts);
126
+
127
+ // A later duplicate alias never overrides the first, matching ssh semantics.
128
+ const byAlias = new Map<string, SshHost>();
129
+ for (const host of hosts) if (!byAlias.has(host.alias)) byAlias.set(host.alias, host);
130
+ return [...byAlias.values()];
131
+ }
132
+
133
+ /** `deploy@203.0.113.9:2222` -> parts. Bare `host` is valid. */
134
+ export function parseTarget(input: string): { user?: string; host: string; port?: number } | undefined {
135
+ const trimmed = input.trim();
136
+ if (!trimmed || /\s/.test(trimmed)) return undefined;
137
+
138
+ const at = trimmed.lastIndexOf("@");
139
+ if (at === 0) return undefined; // "@host" has an empty user
140
+ const user = at > 0 ? trimmed.slice(0, at) : undefined;
141
+ let rest = at > 0 ? trimmed.slice(at + 1) : trimmed;
142
+
143
+ let port: number | undefined;
144
+ // Only treat a trailing :N as a port; IPv6 literals keep their colons.
145
+ const portMatch = /^(.*):(\d+)$/.exec(rest);
146
+ if (portMatch && !portMatch[1].includes(":")) {
147
+ rest = portMatch[1];
148
+ port = Number(portMatch[2]);
149
+ if (!Number.isInteger(port) || port <= 0 || port >= 65536) return undefined;
150
+ }
151
+
152
+ if (!rest) return undefined;
153
+ // A leftover single colon is a malformed port ("host:abc"). Genuine IPv6
154
+ // literals always carry at least two.
155
+ if (rest.includes(":") && (rest.match(/:/g)?.length ?? 0) < 2) return undefined;
156
+ return { user, host: rest, port };
157
+ }