File size: 6,186 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
import { spawn } from "node:child_process";
import { join } from "node:path";
import os from "node:os";
import { t } from "../i18n.mjs";
import { resolveActiveContext } from "../contexts.mjs";

function stripTrailingSlash(value) {
  let s = String(value);
  let end = s.length;
  while (end > 0 && s.charCodeAt(end - 1) === 47) end--;
  return end === s.length ? s : s.slice(0, end);
}

/**
 * Build a clean child env for Claude Code pointed at OmniRoute.
 *
 * Strips inherited ANTHROPIC_* (avoids a stale shell token leaking through), then
 * injects the base URL, gateway model discovery, and auto-compact window.
 *
 * @param {Record<string,string>} baseEnv
 * @param {number|string} baseUrlOrPort  a port (β†’ http://localhost:<port>) or a full base URL
 * @param {string|undefined} authToken
 * @param {{ configDir?:string, model?:string }} [opts]
 * @returns {Record<string,string>}
 */
export function buildClaudeEnv(baseEnv, baseUrlOrPort, authToken, opts = {}) {
  const env = { ...baseEnv };
  for (const key of Object.keys(env)) {
    if (key.startsWith("ANTHROPIC_")) delete env[key];
  }

  // Accept a bare port (number/numeric string β†’ localhost) or a full base URL.
  // Claude Code wants the ROOT URL (it appends /v1/messages itself) β€” no /v1 here.
  let baseUrl;
  if (typeof baseUrlOrPort === "number" || /^\d+$/.test(String(baseUrlOrPort))) {
    baseUrl = `http://localhost:${Number(baseUrlOrPort) || 20128}`;
  } else {
    baseUrl = stripTrailingSlash(String(baseUrlOrPort)).replace(/\/v1$/, "");
  }

  env.ANTHROPIC_BASE_URL = baseUrl;
  // Always set a token: when none is resolved, a sentinel keeps newer Claude Code
  // from stopping at its local login gate before it ever contacts OmniRoute (an
  // open backend ignores the value). Mirrors free-claude-code. ANTHROPIC_API_KEY
  // stays stripped (above) so it can't shadow the Bearer token.
  env.ANTHROPIC_AUTH_TOKEN = (authToken && String(authToken).trim()) || "omniroute-no-auth";
  env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
  env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = "190000";
  // Profile isolation (Claude Code has no native profiles β€” CLAUDE_CONFIG_DIR is
  // the idiomatic mechanism: separate settings/credentials/history/cache per dir).
  if (opts.configDir) env.CLAUDE_CONFIG_DIR = opts.configDir;
  if (opts.model) env.ANTHROPIC_MODEL = opts.model;
  return env;
}

/**
 * Resolve the OmniRoute base URL + auth for launch, honouring (in order):
 * explicit flags β†’ the active context (remote mode) β†’ localhost:<port>.
 * @param {{port?:string, remote?:string, baseUrl?:string, token?:string, apiKey?:string, context?:string}} opts
 * @returns {{ baseUrl:string, authToken:string|undefined }}
 */
export function resolveLaunchTarget(opts = {}) {
  const explicit = opts.remote ?? opts.baseUrl;
  let baseUrl;
  if (explicit) {
    baseUrl = stripTrailingSlash(explicit).replace(/\/v1$/, "");
  } else {
    let fromCtx;
    try {
      const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
      fromCtx = ctx?.baseUrl;
    } catch {
      /* no context */
    }
    baseUrl = fromCtx
      ? stripTrailingSlash(fromCtx).replace(/\/v1$/, "")
      : `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
  }

  let authToken = opts.token ?? opts.apiKey ?? opts["api-key"];
  if (!authToken) {
    try {
      const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
      authToken = ctx?.accessToken || ctx?.apiKey || undefined;
    } catch {
      /* no context auth */
    }
  }
  if (!authToken) authToken = process.env.ANTHROPIC_AUTH_TOKEN ?? process.env.OMNIROUTE_API_KEY;
  return { baseUrl, authToken };
}

/**
 * @param {{port?:string, remote?:string, token?:string, apiKey?:string, profile?:string, claudeHome?:string}} opts
 * @param {string[]} claudeArgs  pass-through args for the claude binary
 * @returns {Promise<number>} exit code
 */
export async function runLaunchCommand(opts = {}, claudeArgs = []) {
  const { baseUrl, authToken } = resolveLaunchTarget(opts);

  // Health check the (possibly remote) proxy before launching.
  try {
    const res = await fetch(`${baseUrl}/api/monitoring/health`, {
      signal: AbortSignal.timeout(3000),
    });
    if (!res.ok) throw new Error(`status ${res.status}`);
  } catch {
    console.error(
      (t("launch.notRunning") || "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'.").replace(
        "{port}",
        baseUrl
      )
    );
    return 1;
  }

  const configDir = opts.profile
    ? join(opts.claudeHome || join(os.homedir(), ".claude"), "profiles", opts.profile)
    : undefined;
  const env = buildClaudeEnv(process.env, baseUrl, authToken, { configDir });

  return await new Promise((resolve) => {
    const child = spawn("claude", claudeArgs, { env, stdio: "inherit" });
    child.on("error", (err) => {
      if (err && err.code === "ENOENT") {
        console.error(t("launch.notFound") || "The 'claude' CLI was not found in PATH.");
        resolve(127);
      } else {
        console.error(String(err?.message || err));
        resolve(1);
      }
    });
    child.on("exit", (code) => resolve(code ?? 0));
  });
}

export function registerLaunch(program) {
  program
    .command("launch")
    .description(
      t("launch.description") || "Launch Claude Code pointed at OmniRoute (local or remote)"
    )
    .option("--port <port>", t("serve.port") || "Proxy port", "20128")
    .option("--remote <url>", "Remote OmniRoute base URL (overrides --port and the active context)")
    .option("--profile <name>", "Claude Code profile to use (CLAUDE_CONFIG_DIR ~/.claude/profiles/<name>)")
    .option("--token <token>", t("launch.token") || "Token Claude sends (ANTHROPIC_AUTH_TOKEN)")
    .option("--api-key <key>", "Alias for --token (OmniRoute access token / API key)")
    .allowUnknownOption(true)
    .allowExcessArguments(true)
    .argument("[claudeArgs...]", "arguments passed through to the claude binary")
    .action(async (claudeArgs, opts) => {
      const exitCode = await runLaunchCommand(opts, claudeArgs ?? []);
      if (exitCode !== 0) process.exit(exitCode);
    });
}