Download cli/src/index.ts from automindy/fokemal: direct link, hf CLI and curl.
- Browser
- Download file 20.9 kB
-
https://huggingface.co/spaces/automindy/fokemal/resolve/main/cli/src/index.ts
- Command line
-
hf download hf://spaces/automindy/fokemal/cli/src/index.ts
-
curl -L -o index.ts https://huggingface.co/spaces/automindy/fokemal/resolve/main/cli/src/index.ts
20.9 kB
| import os from 'node:os'; | |
| import fs from 'node:fs'; | |
| import path from 'node:path'; | |
| import process from 'node:process'; | |
| import { spawn } from 'node:child_process'; | |
| import { Writable } from 'node:stream'; | |
| import { fileURLToPath } from 'node:url'; | |
| import { applyGeneratedFiles, printDryRunDiff } from './config-files.js'; | |
| import { getTool, tools } from './tools.js'; | |
| import { resolveLaunchModel, type ResolvedModel } from './models.js'; | |
| import { DOCTOR_TOOLS, diagnose, exitCodeFor, formatReport, type ToolReport } from './doctor.js'; | |
| import { keysHelp, runKeys, type KeyCommandOptions } from './keys.js'; | |
| import type { CatalogModel, GenerateContext } from './types.js'; | |
| interface CliOptions extends KeyCommandOptions { | |
| apiKey?: string; | |
| profile: string; | |
| model?: string; | |
| } | |
| function rootUrl(url: string): string { | |
| return url.trim().replace(/\/+$/, '').replace(/\/v1$/i, ''); | |
| } | |
| function validateProfile(profile: string): string { | |
| if ( | |
| !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(profile) | |
| || profile === '.' | |
| || profile === '..' | |
| ) { | |
| throw new Error( | |
| '--profile must use only letters, numbers, dots, underscores, or hyphens', | |
| ); | |
| } | |
| return profile; | |
| } | |
| function parseTimeout(value: string): number { | |
| const ms = Number(value); | |
| // Rejected rather than clamped: a typo'd `--timeout 5s` parsing to NaN and | |
| // silently becoming the default is the kind of quiet no-op this command is | |
| // supposed to be immune to. | |
| if (!Number.isFinite(ms) || ms <= 0) { | |
| throw new Error(`--timeout must be a positive number of milliseconds, got '${value}'`); | |
| } | |
| return ms; | |
| } | |
| export function parseArgs(argv: string[]): { command?: string; options: CliOptions } { | |
| const options: CliOptions = { | |
| url: process.env.FREELLMAPI_URL || 'http://localhost:3000', | |
| apiKey: process.env.FREELLMAPI_API_KEY, | |
| token: process.env.FREELLMAPI_DASHBOARD_TOKEN, | |
| profile: 'default', | |
| dryRun: false, | |
| args: [], | |
| }; | |
| let command: string | undefined; | |
| for (let index = 0; index < argv.length; index += 1) { | |
| const arg = argv[index]; | |
| if (!arg.startsWith('-') && !command) { | |
| command = arg; | |
| continue; | |
| } | |
| if (!arg.startsWith('-')) { | |
| // Collected rather than rejected here so the parser stays generic; the | |
| // dispatcher rejects extras for commands that take none, which keeps | |
| // `setup-claude typo` an error instead of a silently ignored word. | |
| options.args.push(arg); | |
| continue; | |
| } | |
| if (arg === '--dry-run') { | |
| options.dryRun = true; | |
| continue; | |
| } | |
| const equals = arg.indexOf('='); | |
| const flag = equals < 0 ? arg : arg.slice(0, equals); | |
| const inline = equals < 0 ? undefined : arg.slice(equals + 1); | |
| const value = inline ?? argv[index + 1]; | |
| if ( | |
| flag === '--url' || flag === '--api-key' || flag === '--profile' | |
| || flag === '--model' || flag === '--timeout' | |
| || flag === '--token' || flag === '--key' || flag === '--id' | |
| ) { | |
| if (inline === undefined) index += 1; | |
| if (!value || (inline === undefined && value.startsWith('-'))) { | |
| throw new Error(`${flag} requires a value`); | |
| } | |
| if (flag === '--url') options.url = value; | |
| else if (flag === '--api-key') options.apiKey = value; | |
| else if (flag === '--profile') options.profile = validateProfile(value); | |
| else if (flag === '--timeout') options.timeoutMs = parseTimeout(value); | |
| else if (flag === '--token') options.token = value; | |
| else if (flag === '--key') options.key = value; | |
| else if (flag === '--id') { | |
| if (!/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value))) { | |
| throw new Error('--id must be a positive integer'); | |
| } | |
| options.keyId = Number(value); | |
| } | |
| else options.model = value; | |
| continue; | |
| } | |
| if (arg !== '--help' && arg !== '-h') throw new Error(`Unknown option: ${arg}`); | |
| } | |
| return { command, options }; | |
| } | |
| async function promptForKey(provider = false): Promise<string> { | |
| if (!process.stdin.isTTY) { | |
| throw new Error( | |
| provider | |
| ? 'No provider key supplied. Pass --key or use an interactive terminal for hidden input.' | |
| : 'No API key supplied. Pass --api-key or set FREELLMAPI_API_KEY.', | |
| ); | |
| } | |
| // Imported lazily, not statically: `node:readline/promises` only exists on | |
| // Node >=17.4, and a static import of it crashes the whole CLI at load time | |
| // on older runtimes (#1283) — `npx freellmapi --help` died with | |
| // ERR_UNKNOWN_BUILTIN_MODULE before main() or the engines check could say | |
| // anything useful. Deferring it means only the interactive key prompt needs | |
| // a modern Node; every other command degrades to the readable error below. | |
| const readline = (await import('node:readline/promises')).default; | |
| let muted = false; | |
| const output = new Writable({ | |
| write(chunk, _encoding, callback) { | |
| if (!muted) process.stderr.write(chunk); | |
| callback(); | |
| }, | |
| }); | |
| const rl = readline.createInterface({ | |
| input: process.stdin, | |
| output, | |
| terminal: true, | |
| }); | |
| const controller = new AbortController(); | |
| const cancel = (): void => { controller.abort(); }; | |
| rl.on('SIGINT', cancel); | |
| rl.on('close', cancel); | |
| try { | |
| const answer = rl.question(provider ? 'Provider API key: ' : 'FreeLLMAPI unified API key: ', { | |
| signal: controller.signal, | |
| }); | |
| muted = true; | |
| const value = (await answer).trim(); | |
| muted = false; | |
| process.stderr.write('\n'); | |
| if (!value) throw new Error('An API key is required'); | |
| return value; | |
| } catch (error) { | |
| if (controller.signal.aborted) throw new Error('Key entry cancelled'); | |
| throw error; | |
| } finally { | |
| muted = false; | |
| rl.off('SIGINT', cancel); | |
| rl.off('close', cancel); | |
| rl.close(); | |
| } | |
| } | |
| async function catalog(url: string, apiKey: string, availableOnly = true): Promise<CatalogModel[]> { | |
| let response: Response; | |
| try { | |
| response = await fetch(`${rootUrl(url)}/v1/models${availableOnly ? '?available=true' : ''}`, { | |
| headers: { Authorization: `Bearer ${apiKey}` }, | |
| signal: AbortSignal.timeout(10_000), | |
| }); | |
| } catch (error) { | |
| const cause = error instanceof Error && error.cause instanceof Error | |
| ? error.cause.message | |
| : undefined; | |
| const reason = cause ?? (error instanceof Error ? error.message : String(error)); | |
| throw new Error( | |
| `Could not reach the FreeLLMAPI gateway at ${rootUrl(url)} (${reason}). ` | |
| + 'Check that the server is running, or point the CLI at it with --url or FREELLMAPI_URL.', | |
| ); | |
| } | |
| if (!response.ok) { | |
| const detail = (await response.text()).slice(0, 200); | |
| throw new Error(`Catalog request failed (HTTP ${response.status}): ${detail}`); | |
| } | |
| const body = await response.json() as { data?: CatalogModel[] }; | |
| const models = body.data ?? []; | |
| if (!models.length) throw new Error('The live catalog returned no models'); | |
| return models; | |
| } | |
| export interface Catalogs { | |
| available: CatalogModel[]; | |
| full: CatalogModel[]; | |
| /** Why the unfiltered fetch failed. Set means `full` is really the FILTERED | |
| * roster, so "unknown model" and "out of quota" can no longer be told | |
| * apart — the exact ambiguity this pair of fetches exists to remove. */ | |
| degradedReason?: string; | |
| } | |
| /** | |
| * The available-only roster plus the unfiltered one. | |
| * | |
| * Only the unfiltered roster can distinguish "no such model" from "that model | |
| * exists but is out of quota right now", and reporting the second as the first | |
| * turns a rate limit into a spurious typo error. The unfiltered fetch is | |
| * best-effort: an older gateway that ignores the parameter, or any failure, | |
| * degrades to the filtered roster rather than blocking a launch — but it | |
| * RECORDS that it degraded, so the degradation can be reported instead of | |
| * silently reinstating the ambiguity. | |
| */ | |
| export async function catalogs(url: string, apiKey: string): Promise<Catalogs> { | |
| const available = await catalog(url, apiKey); | |
| try { | |
| return { available, full: await catalog(url, apiKey, false) }; | |
| } catch (error) { | |
| return { | |
| available, | |
| full: available, | |
| degradedReason: error instanceof Error ? error.message : String(error), | |
| }; | |
| } | |
| } | |
| /** | |
| * Resolve a pinned `--model` ONCE, and say out loud anything that makes the | |
| * verdict less than certain. | |
| * | |
| * One resolution feeding both the id that gets pinned and the warning the user | |
| * reads, so the two can never disagree. Returns undefined when nothing was | |
| * pinned — the launcher then picks from the filtered roster, where there is no | |
| * ambiguity to lose and so nothing to warn about. | |
| */ | |
| export function resolvePinnedModel( | |
| requested: string | undefined, | |
| rosters: Catalogs, | |
| warn: (message: string) => void = message => process.stderr.write(message), | |
| ): ResolvedModel | undefined { | |
| if (!requested) return undefined; | |
| // Reported, never silent: degrading to the filtered roster is exactly the | |
| // state in which an out-of-quota model gets called a typo, so the user has | |
| // to be told which roster the verdict below came from. | |
| if (rosters.degradedReason) { | |
| warn( | |
| `freellmapi: could not fetch the unfiltered model catalog (${rosters.degradedReason}). ` | |
| + `Checking '${requested}' against the available-only roster instead — a model that ` | |
| + 'exists but is out of quota may be reported as unknown.\n', | |
| ); | |
| } | |
| const resolved = resolveLaunchModel(requested, rosters.available, rosters.full); | |
| // A pinned model that is registered but not servable right now is a launch we | |
| // should still make — the router may recover it mid-session — but never one | |
| // we should make silently. | |
| if (resolved.unavailable) { | |
| warn( | |
| `freellmapi: '${resolved.id}' is registered but not currently available ` | |
| + '(out of quota, cooling down, or its key is disabled). Launching anyway.\n', | |
| ); | |
| } | |
| return resolved; | |
| } | |
| function help(): string { | |
| return [ | |
| 'FreeLLMAPI coding-agent setup and provider key management', | |
| '', | |
| 'Usage:', | |
| ' freellmapi <command> [--url URL] [--api-key KEY] [--profile NAME] [--model ID] [--dry-run]', | |
| '', | |
| 'Commands:', | |
| ...tools.map(tool => ` ${tool.command.padEnd(17)} ${tool.name}`), | |
| ' doctor [tool…] Check whether a tool\'s requests actually reach this gateway', | |
| ' (--timeout MS raises the probe wait on a slow link)', | |
| ' launch Run Claude Code with credentials injected into the child environment', | |
| ' launch-codex Run Codex with provider overrides and injected credentials', | |
| ' list List supported coding agents', | |
| ' keys Add, list, remove, or test provider keys (keys --help)', | |
| '', | |
| 'Environment:', | |
| ' FREELLMAPI_URL, FREELLMAPI_API_KEY, FREELLMAPI_DASHBOARD_TOKEN (keys only)', | |
| ].join('\n'); | |
| } | |
| async function setup(command: string, options: CliOptions): Promise<void> { | |
| const tool = getTool(command.replace(/^setup-/, '')); | |
| if (!tool) throw new Error(`Unknown setup command '${command}'`); | |
| const apiKey = options.apiKey ?? await promptForKey(); | |
| const rosters = await catalogs(options.url, apiKey); | |
| // --model was parsed but never reached the generators, so `setup-x --model y` | |
| // silently wrote whatever primaryModel() preferred. Validate it against the | |
| // unfiltered catalog (so an out-of-quota model is not reported as a typo) | |
| // and thread it through. | |
| const requestedModelId = resolvePinnedModel(options.model, rosters)?.id; | |
| const context: GenerateContext = { | |
| url: rootUrl(options.url), | |
| apiKey, | |
| profile: options.profile, | |
| models: rosters.available, | |
| homeDir: os.homedir(), | |
| requestedModelId, | |
| }; | |
| const generation = tool.generate(context); | |
| if (generation.files.length === 0) { | |
| for (const note of generation.notes) process.stdout.write(`${note}\n`); | |
| return; | |
| } | |
| const results = applyGeneratedFiles(generation.files, options.dryRun); | |
| for (const result of results) { | |
| if (options.dryRun && result.changed) { | |
| process.stdout.write(`${printDryRunDiff(result)}\n`); | |
| } else if (result.changed) { | |
| process.stdout.write(`Updated ${result.path}\n`); | |
| if (result.backupPath) process.stdout.write(`Backup: ${result.backupPath}\n`); | |
| } else { | |
| process.stdout.write(`Unchanged ${result.path}\n`); | |
| } | |
| } | |
| for (const note of generation.notes) process.stdout.write(`\n${note}\n`); | |
| } | |
| // Runs an interactive child with inherited stdio. SIGINT is ignored in the | |
| // wrapper (the foreground child receives it from the terminal and decides | |
| // what to do), SIGTERM is forwarded, and the child's exit code is returned. | |
| function runChild( | |
| command: string, | |
| args: string[], | |
| env: NodeJS.ProcessEnv, | |
| ): Promise<number> { | |
| const child = spawn(command, args, { stdio: 'inherit', env }); | |
| const ignoreSigint = (): void => {}; | |
| const forwardSigterm = (): void => { child.kill('SIGTERM'); }; | |
| process.on('SIGINT', ignoreSigint); | |
| process.on('SIGTERM', forwardSigterm); | |
| const cleanup = (): void => { | |
| process.off('SIGINT', ignoreSigint); | |
| process.off('SIGTERM', forwardSigterm); | |
| }; | |
| return new Promise<number>((resolve, reject) => { | |
| child.once('error', error => { | |
| cleanup(); | |
| reject(error); | |
| }); | |
| child.once('exit', (code, signal) => { | |
| cleanup(); | |
| if (code !== null) resolve(code); | |
| else if (signal) resolve(128 + (os.constants.signals[signal] ?? 0)); | |
| else resolve(1); | |
| }); | |
| }); | |
| } | |
| // `--profile` selects a CLAUDE_CONFIG_DIR generated by setup-claude and is | |
| // never a model id; `--model` picks the model explicitly. | |
| export function claudeLaunchEnv( | |
| options: CliOptions, | |
| apiKey: string, | |
| models: CatalogModel[], | |
| baseEnv: NodeJS.ProcessEnv = process.env, | |
| homeDir = os.homedir(), | |
| fullCatalog: CatalogModel[] = models, | |
| ): NodeJS.ProcessEnv { | |
| const resolved = resolveLaunchModel(options.model, models, fullCatalog); | |
| const selected = resolved.id; | |
| const env: NodeJS.ProcessEnv = { ...baseEnv }; | |
| // Never leak the user's real Anthropic credentials to the gateway. | |
| delete env.ANTHROPIC_API_KEY; | |
| delete env.ANTHROPIC_AUTH_TOKEN; | |
| if (options.profile !== 'default') { | |
| const directory = path.join(homeDir, '.claude', 'profiles', options.profile); | |
| if (!fs.existsSync(directory)) { | |
| throw new Error( | |
| `Claude profile '${options.profile}' has no configuration directory at ${directory}. ` | |
| + `Create it with: freellmapi setup-claude --profile ${options.profile}`, | |
| ); | |
| } | |
| env.CLAUDE_CONFIG_DIR = directory; | |
| } | |
| Object.assign(env, { | |
| ANTHROPIC_BASE_URL: rootUrl(options.url), | |
| ANTHROPIC_AUTH_TOKEN: apiKey, | |
| ANTHROPIC_MODEL: selected, | |
| ANTHROPIC_DEFAULT_OPUS_MODEL: selected, | |
| ANTHROPIC_DEFAULT_SONNET_MODEL: selected, | |
| ANTHROPIC_DEFAULT_HAIKU_MODEL: selected, | |
| CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '1', | |
| }); | |
| // Only pin the compaction window when the catalog actually published one. | |
| // The old `?? 128_000` invented a number for every model with no stated | |
| // window, and a wrong window is worse than none: too high and Claude Code | |
| // compacts after the gateway has already rejected the request, too low and | |
| // it compacts a conversation that still fits. Absent, Claude Code applies | |
| // its own default. | |
| if (resolved.contextWindow !== undefined) { | |
| env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(resolved.contextWindow); | |
| } else { | |
| delete env.CLAUDE_CODE_AUTO_COMPACT_WINDOW; | |
| } | |
| return env; | |
| } | |
| async function launchClaude(options: CliOptions): Promise<number> { | |
| const apiKey = options.apiKey ?? await promptForKey(); | |
| const rosters = await catalogs(options.url, apiKey); | |
| // Resolved here for the warning; claudeLaunchEnv resolves again to build the | |
| // environment. Both are pure calls over the same two rosters, so they cannot | |
| // reach different verdicts — keeping claudeLaunchEnv a side-effect-free env | |
| // builder is worth more than eliding the second call. | |
| resolvePinnedModel(options.model, rosters); | |
| const env = claudeLaunchEnv( | |
| options, apiKey, rosters.available, process.env, os.homedir(), rosters.full, | |
| ); | |
| return runChild('claude', [], env); | |
| } | |
| export function codexArgs(url: string, selected: string): string[] { | |
| return [ | |
| '-c', 'model_provider="freellmapi"', | |
| '-c', `model=${JSON.stringify(selected)}`, | |
| '-c', 'model_providers.freellmapi.name="FreeLLMAPI"', | |
| '-c', `model_providers.freellmapi.base_url=${JSON.stringify(`${rootUrl(url)}/v1`)}`, | |
| '-c', 'model_providers.freellmapi.wire_api="responses"', | |
| '-c', 'model_providers.freellmapi.env_key="FREELLMAPI_API_KEY"', | |
| '-c', 'model_providers.freellmapi.requires_openai_auth=false', | |
| ]; | |
| } | |
| async function launchCodex(options: CliOptions): Promise<number> { | |
| const apiKey = options.apiKey ?? await promptForKey(); | |
| const rosters = await catalogs(options.url, apiKey); | |
| const selected = ( | |
| resolvePinnedModel(options.model, rosters) | |
| ?? resolveLaunchModel(undefined, rosters.available, rosters.full) | |
| ).id; | |
| const env = { ...process.env, FREELLMAPI_API_KEY: apiKey }; | |
| const args = codexArgs(options.url, selected); | |
| return runChild('codex', args, env); | |
| } | |
| async function runDoctor(options: CliOptions): Promise<number> { | |
| const requested = options.args.length ? options.args : DOCTOR_TOOLS; | |
| const reports: ToolReport[] = []; | |
| for (const tool of requested) { | |
| reports.push(await diagnose(tool, { | |
| expectedUrl: rootUrl(options.url), | |
| timeoutMs: options.timeoutMs, | |
| })); | |
| } | |
| for (const report of reports) process.stdout.write(`${formatReport(report)}\n`); | |
| // Nonzero when anything is not routed, so this is usable as a precondition | |
| // in a script rather than only readable by eye. | |
| return exitCodeFor(reports); | |
| } | |
| export async function main(argv = process.argv.slice(2)): Promise<number> { | |
| const { command, options } = parseArgs(argv); | |
| if (!command || command === 'help' || argv.includes('--help') || argv.includes('-h')) { | |
| process.stdout.write(`${command === 'keys' ? keysHelp() : help()}\n`); | |
| return 0; | |
| } | |
| for (const arg of argv) { | |
| const flag = arg.split('=', 1)[0]; | |
| if (command !== 'keys' && ['--token', '--key', '--id'].includes(flag)) { | |
| throw new Error(`${flag} is only supported by keys`); | |
| } | |
| if (command === 'keys' && ['--api-key', '--profile', '--model'].includes(flag)) { | |
| throw new Error(`${flag} is not supported by keys; use keys --help for available options`); | |
| } | |
| } | |
| if (command === 'keys') return runKeys(options, () => promptForKey(true)); | |
| // `doctor` and `keys` take positional arguments. Every other | |
| // one rejects them here, BEFORE dispatch — checking after the setup-* branch | |
| // would let `setup-claude typo` run with the stray word silently ignored, | |
| // where it used to be an error. | |
| if (command !== 'doctor' && options.args.length) { | |
| throw new Error(`Unknown option: ${options.args[0]}`); | |
| } | |
| if (command === 'list') { | |
| for (const tool of tools) { | |
| process.stdout.write(`${tool.id}\t${tool.protocol}\t${tool.baseUrlSupport}\n`); | |
| } | |
| return 0; | |
| } | |
| if (command.startsWith('setup-')) { | |
| await setup(command, options); | |
| return 0; | |
| } | |
| if (command === 'doctor') return runDoctor(options); | |
| if (command === 'launch') return launchClaude(options); | |
| if (command === 'launch-codex') return launchCodex(options); | |
| throw new Error(`Unknown command '${command}'\n\n${help()}`); | |
| } | |
| function isDirectExecution(): boolean { | |
| if (!process.argv[1]) return false; | |
| try { | |
| return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); | |
| } catch { | |
| return false; | |
| } | |
| } | |
| /** Minimum Node major this CLI actually runs on: `AbortSignal.timeout` | |
| * (17.3), global `fetch` (18), `node:readline/promises` (17.4) — rounded to | |
| * the package's engines floor of 20. npm treats `engines` as a warning by | |
| * default (and npm 6, still common on Windows boxes hitting #1283, ignores it | |
| * for `npx`), so without this check an unsupported runtime gets a stack | |
| * trace instead of a sentence. Returns null when the runtime is fine. */ | |
| export function unsupportedNodeVersion( | |
| version: string | undefined = process.versions.node, | |
| major: number | undefined = Number(version?.split('.')[0]), | |
| ): string | null { | |
| if (!version || !Number.isFinite(major)) return null; // Non-Node runtimes: don't guess. | |
| if (major >= 20) return null; | |
| return `freellmapi requires Node.js 20 or newer (found ${version}). Install one from https://nodejs.org and re-run.`; | |
| } | |
| if (isDirectExecution()) { | |
| const unsupported = unsupportedNodeVersion(); | |
| if (unsupported) { | |
| process.stderr.write(`freellmapi: ${unsupported}\n`); | |
| process.exitCode = 1; | |
| } else { | |
| main().then( | |
| code => { process.exitCode = code; }, | |
| error => { | |
| process.stderr.write(`freellmapi: ${error instanceof Error ? error.message : String(error)}\n`); | |
| process.exitCode = 1; | |
| }, | |
| ); | |
| } | |
| } | |