ampcode-connector 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.
@@ -0,0 +1,162 @@
1
+ /** Auto-configure Amp CLI to route through ampcode-connector. */
2
+
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { loadConfig } from "../config/config.ts";
7
+ import { line, out, s } from "./ansi.ts";
8
+ import * as status from "./status.ts";
9
+
10
+ const AMP_SECRETS_DIR = join(homedir(), ".local", "share", "amp");
11
+ const AMP_SECRETS_PATH = join(AMP_SECRETS_DIR, "secrets.json");
12
+
13
+ const AMP_SETTINGS_PATHS = [
14
+ join(homedir(), ".config", "amp", "settings.json"),
15
+ join(homedir(), ".amp", "settings.json"),
16
+ ];
17
+
18
+ function ampSettingsPaths(): string[] {
19
+ const envPath = process.env["AMP_SETTINGS_FILE"];
20
+ return envPath ? [envPath] : AMP_SETTINGS_PATHS;
21
+ }
22
+
23
+ function readJson(path: string): Record<string, unknown> {
24
+ if (!existsSync(path)) return {};
25
+ try {
26
+ return JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
27
+ } catch {
28
+ return {};
29
+ }
30
+ }
31
+
32
+ function writeJson(path: string, data: Record<string, unknown>): void {
33
+ mkdirSync(dirname(path), { recursive: true });
34
+ writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
35
+ }
36
+
37
+ function findAmpApiKey(proxyUrl: string): string | undefined {
38
+ if (process.env["AMP_API_KEY"]) return process.env["AMP_API_KEY"];
39
+
40
+ const secrets = readJson(AMP_SECRETS_PATH);
41
+ const exact = secrets[`apiKey@${proxyUrl}`];
42
+ if (typeof exact === "string" && exact.length > 0) return exact;
43
+
44
+ for (const value of Object.values(secrets)) {
45
+ if (typeof value === "string" && value.startsWith("sgamp_")) return value;
46
+ }
47
+ return undefined;
48
+ }
49
+
50
+ /** Save key under proxy URL and migrate stale entries to keep secrets.json clean. */
51
+ function saveAmpApiKey(token: string, proxyUrl: string): void {
52
+ const secrets = readJson(AMP_SECRETS_PATH);
53
+ for (const key of Object.keys(secrets)) {
54
+ if (key.startsWith("apiKey")) delete secrets[key];
55
+ }
56
+ secrets[`apiKey@${proxyUrl}`] = token;
57
+ mkdirSync(AMP_SECRETS_DIR, { recursive: true, mode: 0o700 });
58
+ writeJson(AMP_SECRETS_PATH, secrets);
59
+ }
60
+
61
+ function prompt(question: string): Promise<string> {
62
+ return new Promise((resolve) => {
63
+ out(question);
64
+ const chunks: string[] = [];
65
+
66
+ const onData = (data: Buffer) => {
67
+ const str = data.toString();
68
+ if (str.includes("\n") || str.includes("\r")) {
69
+ process.stdin.removeListener("data", onData);
70
+ process.stdin.pause();
71
+ resolve(chunks.join("").trim());
72
+ return;
73
+ }
74
+ chunks.push(str);
75
+ };
76
+
77
+ process.stdin.resume();
78
+ process.stdin.setEncoding("utf-8");
79
+ process.stdin.on("data", onData);
80
+ });
81
+ }
82
+
83
+ export async function setup(): Promise<void> {
84
+ const config = await loadConfig();
85
+ const proxyUrl = `http://localhost:${config.port}`;
86
+
87
+ line();
88
+ line(`${s.bold}ampcode-connector setup${s.reset}`);
89
+ line();
90
+
91
+ // Step 1: Configure amp.url in all settings files
92
+ for (const settingsPath of ampSettingsPaths()) {
93
+ const settings = readJson(settingsPath);
94
+ if (settings["amp.url"] === proxyUrl) continue;
95
+ settings["amp.url"] = proxyUrl;
96
+ writeJson(settingsPath, settings);
97
+ }
98
+ line(`${s.green}ok${s.reset} amp.url = ${s.cyan}${proxyUrl}${s.reset}`);
99
+
100
+ // Step 2: Amp API key
101
+ const existingKey = findAmpApiKey(proxyUrl);
102
+
103
+ if (existingKey) {
104
+ saveAmpApiKey(existingKey, proxyUrl);
105
+ const preview = existingKey.slice(0, 10) + "...";
106
+ line(`${s.green}ok${s.reset} Amp token found ${s.dim}${preview}${s.reset}`);
107
+ } else {
108
+ line();
109
+ line(`${s.yellow}!${s.reset} No Amp token found.`);
110
+ line(` Get one from ${s.cyan}https://ampcode.com/settings${s.reset}`);
111
+ line(` Or run ${s.cyan}amp login${s.reset} after starting the proxy.`);
112
+ line();
113
+
114
+ const token = await prompt(` Paste token (or press Enter to skip): `);
115
+ line();
116
+
117
+ if (token) {
118
+ saveAmpApiKey(token, proxyUrl);
119
+ line(`${s.green}ok${s.reset} Token saved ${s.dim}${AMP_SECRETS_PATH}${s.reset}`);
120
+ } else {
121
+ line(`${s.dim}-- skipped${s.reset}`);
122
+ }
123
+ }
124
+
125
+ // Step 3: Provider status
126
+ line();
127
+ line(`${s.bold}Providers${s.reset}`);
128
+
129
+ const providers = status.all();
130
+ let hasAny = false;
131
+
132
+ for (const p of providers) {
133
+ const connected = p.accounts.filter((a) => a.status === "connected");
134
+ const total = p.accounts.filter((a) => a.status !== "disconnected");
135
+
136
+ if (connected.length > 0) {
137
+ hasAny = true;
138
+ const emails = connected.map((a) => a.email).filter(Boolean).join(", ");
139
+ const info = emails ? ` ${s.dim}${emails}${s.reset}` : "";
140
+ line(` ${p.label.padEnd(16)} ${s.green}${connected.length} account(s)${s.reset}${info}`);
141
+ } else if (total.length > 0) {
142
+ line(` ${p.label.padEnd(16)} ${s.yellow}${total.length} expired${s.reset}`);
143
+ } else {
144
+ line(` ${p.label.padEnd(16)} ${s.dim}--${s.reset}`);
145
+ }
146
+ }
147
+
148
+ if (!hasAny) {
149
+ line();
150
+ line(` Run ${s.cyan}bun run login${s.reset} to authenticate providers.`);
151
+ }
152
+
153
+ // Summary
154
+ line();
155
+ line(`${s.bold}Next${s.reset}`);
156
+ line(` ${s.cyan}bun start${s.reset} Start proxy`);
157
+ if (!existingKey) {
158
+ line(` ${s.cyan}amp login${s.reset} Authenticate with ampcode.com (proxy must be running)`);
159
+ }
160
+ line(` ${s.cyan}amp "hello"${s.reset} Test`);
161
+ line();
162
+ }
@@ -0,0 +1,52 @@
1
+ import type { Credentials, ProviderName } from "../auth/store.ts";
2
+ import * as store from "../auth/store.ts";
3
+
4
+ export type ConnectionStatus = "connected" | "expired" | "disconnected";
5
+
6
+ export interface AccountStatus {
7
+ account: number;
8
+ status: ConnectionStatus;
9
+ email?: string;
10
+ expiresAt?: number;
11
+ }
12
+
13
+ export interface ProviderStatus {
14
+ name: ProviderName;
15
+ label: string;
16
+ sublabel?: string;
17
+ accounts: AccountStatus[];
18
+ }
19
+
20
+ const PROVIDERS: { name: ProviderName; label: string; sublabel?: string }[] = [
21
+ { name: "anthropic", label: "Claude Code" },
22
+ { name: "codex", label: "OpenAI Codex" },
23
+ { name: "google", label: "Google", sublabel: "Gemini CLI + Antigravity" },
24
+ ];
25
+
26
+ function connectionOf(creds: Credentials): ConnectionStatus {
27
+ if (!creds.refreshToken) return "disconnected";
28
+ return store.fresh(creds) ? "connected" : "expired";
29
+ }
30
+
31
+ export function all(): ProviderStatus[] {
32
+ return PROVIDERS.map(({ name, label, sublabel }) => ({
33
+ name,
34
+ label,
35
+ sublabel,
36
+ accounts: store.getAll(name).map((e) => ({
37
+ account: e.account,
38
+ status: connectionOf(e.credentials),
39
+ email: e.credentials.email,
40
+ expiresAt: e.credentials.expiresAt,
41
+ })),
42
+ }));
43
+ }
44
+
45
+ export function remaining(expiresAt: number): string {
46
+ const diff = expiresAt - Date.now();
47
+ if (diff <= 0) return "expired";
48
+ const mins = Math.floor(diff / 60_000);
49
+ const hrs = Math.floor(mins / 60);
50
+ if (hrs > 0) return `${hrs}h ${mins % 60}m`;
51
+ return `${mins}m`;
52
+ }
package/src/cli/tui.ts ADDED
@@ -0,0 +1,177 @@
1
+ import * as configs from "../auth/configs.ts";
2
+ import type { OAuthConfig } from "../auth/oauth.ts";
3
+ import * as oauth from "../auth/oauth.ts";
4
+ import type { ProviderName } from "../auth/store.ts";
5
+ import * as store from "../auth/store.ts";
6
+ import { cursor, line, s, screen } from "./ansi.ts";
7
+ import type { AccountStatus, ConnectionStatus, ProviderStatus } from "./status.ts";
8
+ import * as status from "./status.ts";
9
+
10
+ const oauthConfigs: Record<ProviderName, OAuthConfig> = {
11
+ anthropic: configs.anthropic,
12
+ codex: configs.codex,
13
+ google: configs.google,
14
+ };
15
+
16
+ const ICON: Record<ConnectionStatus, string> = {
17
+ connected: `${s.green}●${s.reset}`,
18
+ expired: `${s.yellow}●${s.reset}`,
19
+ disconnected: `${s.dim}○${s.reset}`,
20
+ };
21
+
22
+ type Item =
23
+ | { type: "provider"; provider: ProviderStatus }
24
+ | { type: "account"; provider: ProviderStatus; account: AccountStatus };
25
+
26
+ let selected = 0;
27
+ let items: Item[] = [];
28
+ let message = "";
29
+ let busy = false;
30
+ let timer: ReturnType<typeof setInterval> | null = null;
31
+
32
+ export function dashboard(): void {
33
+ if (!process.stdin.isTTY) throw new Error("Interactive dashboard requires a TTY.");
34
+
35
+ rebuild();
36
+ process.stdin.setRawMode(true);
37
+ process.stdin.resume();
38
+ process.stdin.setEncoding("utf8");
39
+ cursor.hide();
40
+ screen.clear();
41
+ render();
42
+ timer = setInterval(render, 1_000);
43
+ process.stdin.on("data", onKey);
44
+ process.on("exit", cleanup);
45
+ process.on("SIGINT", () => process.exit());
46
+ process.on("SIGTERM", () => process.exit());
47
+ }
48
+
49
+ function rebuild(): void {
50
+ items = [];
51
+ for (const p of status.all()) {
52
+ items.push({ type: "provider", provider: p });
53
+ for (const a of p.accounts) items.push({ type: "account", provider: p, account: a });
54
+ }
55
+ if (selected >= items.length) selected = Math.max(0, items.length - 1);
56
+ }
57
+
58
+ function cleanup(): void {
59
+ if (timer) clearInterval(timer);
60
+ cursor.show();
61
+ screen.clear();
62
+ }
63
+
64
+ function render(): void {
65
+ cursor.home();
66
+ line(`${s.bold} ampcode-connector${s.reset}`);
67
+ line(`${s.dim} ↑↓ navigate · enter login/add · d disconnect · q quit${s.reset}`);
68
+ line();
69
+
70
+ for (let i = 0; i < items.length; i++) {
71
+ const item = items[i]!;
72
+ const sel = i === selected;
73
+
74
+ if (item.type === "provider") {
75
+ renderProvider(item.provider, sel);
76
+ } else {
77
+ renderAccount(item.account, sel);
78
+ }
79
+ }
80
+
81
+ line();
82
+ if (busy) line(`${s.cyan} ⟳ waiting for browser…${s.reset}`);
83
+ else if (message) line(` ${message}`);
84
+ else line();
85
+ }
86
+
87
+ function renderProvider(p: ProviderStatus, sel: boolean): void {
88
+ const n = p.accounts.length;
89
+ const connected = p.accounts.filter((a) => a.status === "connected").length;
90
+ const suffix = n > 0 ? ` ${s.dim}(${connected}/${n})${s.reset}` : "";
91
+ const label = p.label.padEnd(16);
92
+
93
+ if (sel) line(`${s.inverse} › ${s.bold}${label}${s.reset}${s.inverse}${suffix} ${s.reset}`);
94
+ else line(` ${s.bold}${label}${s.reset}${suffix}`);
95
+
96
+ if (p.sublabel) line(` ${s.dim}${p.sublabel}${s.reset}`);
97
+ }
98
+
99
+ function renderAccount(a: AccountStatus, sel: boolean): void {
100
+ const ic = ICON[a.status];
101
+ const tag = `#${a.account}`.padEnd(4);
102
+ const info = formatInfo(a);
103
+
104
+ if (sel) line(`${s.inverse} ${ic} ${tag} ${info} ${s.reset}`);
105
+ else line(` ${ic} ${s.dim}${tag}${s.reset} ${info}`);
106
+ }
107
+
108
+ function formatInfo(a: AccountStatus): string {
109
+ if (a.status === "disconnected") return `${s.dim}—${s.reset}`;
110
+
111
+ const parts: string[] = [];
112
+ parts.push(a.status === "connected" ? `${s.green}connected${s.reset}` : `${s.yellow}expired${s.reset}`);
113
+ if (a.expiresAt && a.status === "connected") parts.push(`${s.dim}${status.remaining(a.expiresAt)}${s.reset}`);
114
+ if (a.email) parts.push(`${s.dim}${a.email}${s.reset}`);
115
+ return parts.join(`${s.dim} · ${s.reset}`);
116
+ }
117
+
118
+ async function onKey(data: string): Promise<void> {
119
+ if (busy) return;
120
+
121
+ if (data === "\x03" || data === "q") return void process.exit();
122
+ if (data === "\x1b[A") {
123
+ selected = Math.max(0, selected - 1);
124
+ render();
125
+ return;
126
+ }
127
+ if (data === "\x1b[B") {
128
+ selected = Math.min(items.length - 1, selected + 1);
129
+ render();
130
+ return;
131
+ }
132
+
133
+ if (data === "\r" || data === "\n") {
134
+ const item = items[selected]!;
135
+ await doLogin(item.provider);
136
+ return;
137
+ }
138
+
139
+ if (data === "d" || data === "D") {
140
+ const item = items[selected]!;
141
+ if (item.type === "account") doDisconnect(item.provider, item.account.account);
142
+ else doDisconnectAll(item.provider);
143
+ return;
144
+ }
145
+ }
146
+
147
+ async function doLogin(p: ProviderStatus): Promise<void> {
148
+ busy = true;
149
+ message = "";
150
+ render();
151
+
152
+ try {
153
+ const creds = await oauth.login(oauthConfigs[p.name]);
154
+ message = `${s.green}✓ ${p.label} ${creds.email ?? "account"} logged in${s.reset}`;
155
+ } catch (err) {
156
+ message = `${s.red}✗ ${err instanceof Error ? err.message : String(err)}${s.reset}`;
157
+ }
158
+
159
+ rebuild();
160
+ busy = false;
161
+ render();
162
+ }
163
+
164
+ function doDisconnect(p: ProviderStatus, account: number): void {
165
+ store.remove(p.name, account);
166
+ message = `${s.yellow}✗ ${p.label} #${account} disconnected${s.reset}`;
167
+ rebuild();
168
+ render();
169
+ }
170
+
171
+ function doDisconnectAll(p: ProviderStatus): void {
172
+ if (p.accounts.length === 0) return;
173
+ store.remove(p.name);
174
+ message = `${s.yellow}✗ ${p.label} all disconnected${s.reset}`;
175
+ rebuild();
176
+ render();
177
+ }
@@ -0,0 +1,108 @@
1
+ /** YAML config loader with env/file fallback for API key. */
2
+
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { DEFAULT_AMP_UPSTREAM_URL } from "../constants.ts";
6
+ import type { LogLevel } from "../utils/logger.ts";
7
+ import { logger } from "../utils/logger.ts";
8
+
9
+ export interface ProxyConfig {
10
+ port: number;
11
+ ampUpstreamUrl: string;
12
+ ampApiKey?: string;
13
+ logLevel: LogLevel;
14
+ providers: {
15
+ anthropic: boolean;
16
+ codex: boolean;
17
+ google: boolean;
18
+ };
19
+ }
20
+
21
+ const DEFAULTS: ProxyConfig = {
22
+ port: 8765,
23
+ ampUpstreamUrl: DEFAULT_AMP_UPSTREAM_URL,
24
+ logLevel: "info",
25
+ providers: { anthropic: true, codex: true, google: true },
26
+ };
27
+
28
+ const CONFIG_PATH = join(process.cwd(), "config.yaml");
29
+ const SECRETS_PATH = join(homedir(), ".local", "share", "amp", "secrets.json");
30
+
31
+ export async function loadConfig(): Promise<ProxyConfig> {
32
+ const file = await readConfigFile();
33
+ const apiKey = await resolveApiKey(file);
34
+ const providers = asRecord(file?.["providers"]);
35
+
36
+ return {
37
+ port: asNumber(file?.["port"]) ?? DEFAULTS.port,
38
+ ampUpstreamUrl: asString(file?.["ampUpstreamUrl"]) ?? DEFAULTS.ampUpstreamUrl,
39
+ ampApiKey: apiKey,
40
+ logLevel: asLogLevel(file?.["logLevel"]) ?? DEFAULTS.logLevel,
41
+ providers: {
42
+ anthropic: asBool(providers?.["anthropic"]) ?? DEFAULTS.providers.anthropic,
43
+ codex: asBool(providers?.["codex"]) ?? DEFAULTS.providers.codex,
44
+ google: asBool(providers?.["google"]) ?? DEFAULTS.providers.google,
45
+ },
46
+ };
47
+ }
48
+
49
+ async function readConfigFile(): Promise<Record<string, unknown> | null> {
50
+ const file = Bun.file(CONFIG_PATH);
51
+ if (!(await file.exists())) return null;
52
+ try {
53
+ const text = await file.text();
54
+ return Bun.YAML.parse(text) as Record<string, unknown>;
55
+ } catch (err) {
56
+ throw new Error(`Invalid config.yaml: ${err}`);
57
+ }
58
+ }
59
+
60
+ /** Amp API key resolution: config file → AMP_API_KEY env → secrets.json */
61
+ async function resolveApiKey(file: Record<string, unknown> | null): Promise<string | undefined> {
62
+ const fromFile = asString(file?.["ampApiKey"]);
63
+ if (fromFile) return fromFile;
64
+
65
+ const fromEnv = process.env["AMP_API_KEY"];
66
+ if (fromEnv) return fromEnv;
67
+
68
+ return readSecretsFile();
69
+ }
70
+
71
+ async function readSecretsFile(): Promise<string | undefined> {
72
+ const file = Bun.file(SECRETS_PATH);
73
+ if (!(await file.exists())) return undefined;
74
+ try {
75
+ const secrets = (await file.json()) as Record<string, unknown>;
76
+ const canonical = asString(secrets[`apiKey@${DEFAULT_AMP_UPSTREAM_URL}/`]);
77
+ if (canonical) return canonical;
78
+ for (const value of Object.values(secrets)) {
79
+ if (typeof value === "string" && value.startsWith("sgamp_")) return value;
80
+ }
81
+ return undefined;
82
+ } catch (err) {
83
+ logger.warn("Failed to read secrets.json", { error: String(err) });
84
+ return undefined;
85
+ }
86
+ }
87
+
88
+ function asRecord(v: unknown): Record<string, unknown> | undefined {
89
+ return v != null && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
90
+ }
91
+
92
+ function asNumber(v: unknown): number | undefined {
93
+ return typeof v === "number" && !isNaN(v) ? v : undefined;
94
+ }
95
+
96
+ function asString(v: unknown): string | undefined {
97
+ return typeof v === "string" && v.length > 0 ? v : undefined;
98
+ }
99
+
100
+ function asBool(v: unknown): boolean | undefined {
101
+ return typeof v === "boolean" ? v : undefined;
102
+ }
103
+
104
+ const VALID_LOG_LEVELS = new Set<string>(["debug", "info", "warn", "error"]);
105
+
106
+ function asLogLevel(v: unknown): LogLevel | undefined {
107
+ return typeof v === "string" && VALID_LOG_LEVELS.has(v) ? (v as LogLevel) : undefined;
108
+ }
@@ -0,0 +1,64 @@
1
+ /** Single source of truth — no magic strings scattered across files. */
2
+
3
+ export const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com";
4
+ export const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
5
+ export const AUTOPUSH_ENDPOINT = "https://autopush-cloudcode-pa.sandbox.googleapis.com";
6
+ export const DEFAULT_ANTIGRAVITY_PROJECT = "rising-fact-p41fc";
7
+
8
+ export const ANTHROPIC_API_URL = "https://api.anthropic.com";
9
+ export const OPENAI_API_URL = "https://api.openai.com";
10
+ export const DEFAULT_AMP_UPSTREAM_URL = "https://ampcode.com";
11
+
12
+ export const ANTHROPIC_TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
13
+ export const OPENAI_TOKEN_URL = "https://auth.openai.com/oauth/token";
14
+ export const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
15
+
16
+ export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
17
+ export const OAUTH_CALLBACK_TIMEOUT_MS = 120_000;
18
+ export const CLAUDE_CODE_VERSION = "2.1.39";
19
+
20
+ export const stainlessHeaders: Readonly<Record<string, string>> = {
21
+ "X-Stainless-Helper-Method": "stream",
22
+ "X-Stainless-Retry-Count": "0",
23
+ "X-Stainless-Runtime-Version": "v24.13.1",
24
+ "X-Stainless-Package-Version": "0.73.0",
25
+ "X-Stainless-Runtime": "node",
26
+ "X-Stainless-Lang": "js",
27
+ "X-Stainless-Arch": "arm64",
28
+ "X-Stainless-Os": "MacOS",
29
+ "X-Stainless-Timeout": "600",
30
+ };
31
+
32
+ export const claudeCodeBetas = [
33
+ "claude-code-20250219",
34
+ "oauth-2025-04-20",
35
+ "interleaved-thinking-2025-05-14",
36
+ "prompt-caching-scope-2026-01-05",
37
+ ] as const;
38
+
39
+ export const filteredBetaFeatures = ["context-1m-2025-08-07"] as const;
40
+
41
+ export const modelFieldPaths = [
42
+ "model",
43
+ "message.model",
44
+ "modelVersion",
45
+ "response.model",
46
+ "response.modelVersion",
47
+ ] as const;
48
+
49
+ export const passthroughPrefixes = [
50
+ "/api/internal",
51
+ "/api/user",
52
+ "/api/auth",
53
+ "/api/meta",
54
+ "/api/ads",
55
+ "/api/telemetry",
56
+ "/api/threads",
57
+ "/api/otel",
58
+ "/api/tab",
59
+ ] as const;
60
+
61
+ /** Browser routes — redirect to ampcode.com (auth cookies need correct domain). */
62
+ export const browserPrefixes = ["/auth", "/threads", "/docs", "/settings"] as const;
63
+
64
+ export const passthroughExact = ["/threads.rss", "/news.rss"] as const;
package/src/index.ts ADDED
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env bun
2
+ /** ampcode-connector entry point. */
3
+
4
+ import * as configs from "./auth/configs.ts";
5
+ import type { OAuthConfig } from "./auth/oauth.ts";
6
+ import * as oauth from "./auth/oauth.ts";
7
+ import { line, s } from "./cli/ansi.ts";
8
+ import { setup } from "./cli/setup.ts";
9
+ import { dashboard } from "./cli/tui.ts";
10
+ import { loadConfig } from "./config/config.ts";
11
+ import { startServer } from "./server/server.ts";
12
+ import { logger, setLogLevel } from "./utils/logger.ts";
13
+
14
+ const providers: Record<string, OAuthConfig> = {
15
+ anthropic: configs.anthropic,
16
+ codex: configs.codex,
17
+ google: configs.google,
18
+ };
19
+
20
+ async function main(): Promise<void> {
21
+ const [command, arg] = process.argv.slice(2);
22
+
23
+ if (command === "setup") return setup();
24
+
25
+ if (command === "login") {
26
+ if (!arg) return dashboard();
27
+
28
+ const config = providers[arg];
29
+ if (!config) {
30
+ logger.error(`Unknown provider: ${arg}. Available: ${Object.keys(providers).join(", ")}`);
31
+ process.exit(1);
32
+ }
33
+ await oauth.login(config);
34
+ return;
35
+ }
36
+
37
+ if (command === "help" || command === "--help" || command === "-h") {
38
+ usage();
39
+ return;
40
+ }
41
+
42
+ const config = await loadConfig();
43
+ setLogLevel(config.logLevel);
44
+ startServer(config);
45
+ }
46
+
47
+ function usage(): void {
48
+ line();
49
+ line(`${s.bold}ampcode-connector${s.reset} ${s.dim}— proxy Amp CLI through local OAuth subscriptions${s.reset}`);
50
+ line();
51
+ line(`${s.bold}USAGE${s.reset}`);
52
+ line(` ${s.cyan}bun start${s.reset} Start the proxy server`);
53
+ line(` ${s.cyan}bun run setup${s.reset} Configure Amp CLI to use this proxy`);
54
+ line(` ${s.cyan}bun run login${s.reset} Interactive login dashboard`);
55
+ line(` ${s.cyan}bun run login <p>${s.reset} Login to a specific provider`);
56
+ line();
57
+ line(`${s.bold}PROVIDERS${s.reset}`);
58
+ line(` anthropic Claude Code ${s.dim}(Anthropic models)${s.reset}`);
59
+ line(` codex OpenAI Codex ${s.dim}(GPT/o3 models)${s.reset}`);
60
+ line(` google Google ${s.dim}(Gemini CLI + Antigravity, dual quota)${s.reset}`);
61
+ line();
62
+ line(`${s.bold}CONFIG${s.reset}`);
63
+ line(` Edit ${s.cyan}config.yaml${s.reset} to customize port, providers, and log level.`);
64
+ line();
65
+ }
66
+
67
+ main().catch((err) => {
68
+ logger.error("Fatal", { error: String(err) });
69
+ process.exit(1);
70
+ });
@@ -0,0 +1,65 @@
1
+ /** Forwards requests to api.anthropic.com with Claude Code stealth headers. */
2
+
3
+ import { anthropic as config } from "../auth/configs.ts";
4
+ import * as oauth from "../auth/oauth.ts";
5
+ import * as store from "../auth/store.ts";
6
+ import {
7
+ ANTHROPIC_API_URL,
8
+ CLAUDE_CODE_VERSION,
9
+ claudeCodeBetas,
10
+ filteredBetaFeatures,
11
+ stainlessHeaders,
12
+ } from "../constants.ts";
13
+ import * as path from "../utils/path.ts";
14
+ import type { Provider } from "./base.ts";
15
+ import { denied, forward } from "./base.ts";
16
+
17
+ export const provider: Provider = {
18
+ name: "Anthropic",
19
+ routeDecision: "LOCAL_CLAUDE",
20
+
21
+ isAvailable: (account?: number) =>
22
+ account !== undefined ? !!store.get("anthropic", account)?.refreshToken : oauth.ready(config),
23
+
24
+ accountCount: () => oauth.accountCount(config),
25
+
26
+ async forward(sub, body, originalHeaders, rewrite, account = 0) {
27
+ const accessToken = await oauth.token(config, account);
28
+ if (!accessToken) return denied("Anthropic");
29
+
30
+ return forward({
31
+ url: `${ANTHROPIC_API_URL}${sub}`,
32
+ body,
33
+ providerName: "Anthropic",
34
+ rewrite,
35
+ headers: {
36
+ ...stainlessHeaders,
37
+ Accept: path.streaming(body) ? "text/event-stream" : "application/json",
38
+ "Accept-Encoding": "br, gzip, deflate",
39
+ Connection: "keep-alive",
40
+ "Content-Type": "application/json",
41
+ "Anthropic-Version": "2023-06-01",
42
+ "Anthropic-Dangerous-Direct-Browser-Access": "true",
43
+ "Anthropic-Beta": betaHeader(originalHeaders.get("anthropic-beta")),
44
+ "User-Agent": `claude-cli/${CLAUDE_CODE_VERSION} (external, cli)`,
45
+ "X-App": "cli",
46
+ Authorization: `Bearer ${accessToken}`,
47
+ },
48
+ });
49
+ },
50
+ };
51
+
52
+ function betaHeader(original: string | null): string {
53
+ const features = new Set<string>(claudeCodeBetas);
54
+
55
+ if (original) {
56
+ for (const raw of original.split(",")) {
57
+ const feature = raw.trim();
58
+ if (feature && !filteredBetaFeatures.includes(feature as (typeof filteredBetaFeatures)[number])) {
59
+ features.add(feature);
60
+ }
61
+ }
62
+ }
63
+
64
+ return Array.from(features).join(",");
65
+ }