Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 0 additions & 22 deletions cli/scripts/build-all.ts

This file was deleted.

223 changes: 185 additions & 38 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
discoverAll,
findHarness,
installHarness,
HARNESS_CATALOG,
} from './lib/harnesses'
import {
deleteSecret,
Expand All @@ -13,9 +14,18 @@ import {
secretIdFor,
setSecret,
} from './lib/vault'
import { PROVIDER_CATALOG, findProvider } from './lib/providers'
import { probeProvider } from './lib/probes'
import { readConfig, writeConfig } from './lib/vault'
import {
findProvider,
findGateway,
GATEWAY_CATALOG,
listGateways,
selectAnthropicEndpoint,
selectOpenAIEndpoint,
unresolvedPlaceholders,
type ProviderEntry,
} from './lib/providers'
import { probeAnthropic } from './lib/probes'
import { applyWiring, type WireResult } from './lib/wiring'

const VERSION = '0.0.0-dev'

Expand Down Expand Up @@ -78,7 +88,8 @@ program
console.log(`Unknown provider: ${providerId}`)
process.exit(1)
}
const input = await p.password({ message: `Paste ${provider.envKeys[0]}:`, mask: '*' })
const envKeys = provider.envKeys ?? []
const input = await p.password({ message: `Paste ${envKeys[0] ?? 'token'}:`, mask: '*' })
if (!input || p.isCancel(input)) {
p.outro('Cancelled.')
return
Expand All @@ -104,7 +115,15 @@ program
}
const s = p.spinner()
s.start(`Probing ${provider.label}…`)
const result = await probeProvider(provider, value, provider.defaultBaseUrl)
const result =
provider.probeKind === 'anthropicModels'
? await probeAnthropic({ apiKey: value, baseUrl: provider.defaultBaseUrl ?? 'https://api.anthropic.com' })
: {
valid: false,
status: 'error' as const,
detail: `CLI probe for "${provider.id}" not implemented yet. Use the app's Validate button.`,
checkedAt: new Date().toISOString(),
}
s.stop(`${result.valid ? '✓' : '✗'} ${result.status}${result.detail ? ` · ${result.detail}` : ''}`)
process.exit(result.valid ? 0 : 1)
}
Expand All @@ -121,53 +140,183 @@ program
console.log(`Unknown action: ${action}`)
})

program
.command('gateway')
.description('Manage AI gateway routing.')
.argument('[action]', 'show | set | clear', 'show')
.argument('[baseUrl]', 'Gateway base URL')
.action(async (action: string, baseUrl?: string) => {
const config = await readConfig()
if (action === 'show') {
if (!config.gateway?.baseUrl) {
console.log('No gateway configured.')
return
}
console.log(`${config.gateway.provider ?? 'default'}: ${config.gateway.baseUrl}`)
return
const gateway = program.command('gateway').description('Manage AI gateway routing & per-harness wiring.')

gateway
.command('list')
.description('List known AI gateways.')
.action(() => {
for (const g of listGateways()) {
console.log(`${pad(g.id, 24)} ${g.label}`)
console.log(`${' '.repeat(24)} base: ${g.baseUrl || '(custom)'}`)
const ph = g.placeholders.length ? `placeholders: ${g.placeholders.join(', ')}` : null
const ep = `endpoints: ${Object.entries(g.endpoints).filter(([, v]) => v).map(([k, v]) => `${k}=${v}`).join(', ') || '(none)'}`
const auth = `auth: ${g.auth.header}${g.auth.scheme && g.auth.scheme !== 'raw' ? ` (${g.auth.scheme})` : ''} · env: ${g.auth.envVar}`
console.log(`${' '.repeat(24)} ${ep}`)
console.log(`${' '.repeat(24)} ${auth}`)
if (ph) console.log(`${' '.repeat(24)} ${ph}`)
if (g.notes) console.log(`${' '.repeat(24)} ${g.notes}`)
console.log()
}
if (action === 'set') {
if (!baseUrl) {
console.log('Usage: hoist gateway set <baseUrl>')
})

gateway
.command('show')
.description('Show current gateway configuration.')
.action(() => {
console.log('No gateway manager yet. Use `hoist gateway use <id>` to apply one.')
})

gateway
.command('use <gatewayId>')
.description('Apply a gateway to the selected harnesses (writes Claude Code, Codex, OpenCode config).')
.option('-p, --provider <id>', 'Provider id (default: anthropic)')
.option('-u, --base-url <url>', 'Override the gateway base URL (placeholder-substitute)')
.option('-k, --api-key <token>', 'API key (prompted if omitted)')
.option('-H, --harness <ids>', 'Comma-separated harness ids (default: installed harnesses)', 'installed')
.option('--dry-run', 'Print effective base URL & intended writes without touching files')
.action(
async (gatewayId: string, opts: { provider?: string; baseUrl?: string; apiKey?: string; harness?: string; dryRun?: boolean }) => {
const gatewayEntry: import('./lib/providers').GatewayEntry | null =
gatewayId === 'direct' ? null : findGateway(gatewayId) ?? null
if (gatewayId !== 'direct' && !gatewayEntry) {
console.log(`Unknown gateway: ${gatewayId}`)
process.exit(1)
}
config.gateway = { baseUrl, provider: 'anthropic' }
await writeConfig(config)
console.log(`Gateway set: ${baseUrl}`)
console.log(`To route Claude Code: export ANTHROPIC_BASE_URL=${baseUrl}`)
return
const provider: ProviderEntry | undefined = findProvider(opts.provider ?? 'anthropic')
if (!provider) {
console.log(`Unknown provider: ${opts.provider ?? 'anthropic'}`)
process.exit(1)
}

const resolvedBaseUrl = opts.baseUrl ?? gatewayEntry?.baseUrl ?? provider.defaultBaseUrl ?? ''
const placeholders = unresolvedPlaceholders(resolvedBaseUrl)
if (placeholders.length > 0) {
console.log(`Base URL still has placeholders: ${placeholders.map((p) => `<${p}>`).join(', ')}`)
console.log(`Re-run with \`hoist gateway use ${gatewayId} --base-url <your-url>\`.`)
process.exit(1)
}

const effectiveBaseUrl = (() => {
if (!gatewayEntry) return resolvedBaseUrl.replace(/\/+$/, '')
const anthropic = selectAnthropicEndpoint(gatewayEntry, resolvedBaseUrl)
if (provider.probeKind === 'anthropicModels' && anthropic) return anthropic
return selectOpenAIEndpoint(gatewayEntry, resolvedBaseUrl)
})()

const apiKey =
opts.apiKey ??
(await getSecret(secretIdFor(provider.id))) ??
(await p.password({ message: `Paste ${provider.envKeys?.[0] ?? 'token'}:`, mask: '*' }))

if (!apiKey || p.isCancel(apiKey)) {
console.log('No API key.')
process.exit(1)
}
// Persist into vault for next time.
await setSecret(secretIdFor(provider.id), String(apiKey), provider.label)

// Decide harnesses
const harnessIds = opts.harness ?? 'installed'
const harnesses: typeof HARNESS_CATALOG =
harnessIds === 'installed'
? (await discoverAll()).filter((h) => h.installed).map((h) => h.spec)
: harnessIds.split(',').map((id) => findHarness(id.trim())).filter((s): s is NonNullable<typeof s> => !!s)
if (harnesses.length === 0) {
console.log('No harnesses to apply wiring to.')
return
}

console.log(`\n→ Effective base URL: ${effectiveBaseUrl}`)
console.log(`→ Provider: ${provider.label} (${provider.id})`)
console.log(`→ Gateway: ${gatewayEntry?.label ?? 'direct (no gateway)'}`)
console.log(`→ Harnesses: ${harnesses.map((h) => h.id).join(', ')}\n`)
if (opts.dryRun) {
console.log('(dry-run, no files written)')
return
}

const allResults: { harnessId: string; results: WireResult[] }[] = []
for (const h of harnesses) {
try {
const results = await applyWiring({
apiKey: String(apiKey),
baseUrl: effectiveBaseUrl,
harness: h,
provider,
gateway: gatewayEntry,
})
allResults.push({ harnessId: h.id, results })
} catch (err) {
console.log(`✗ ${h.name}: ${errMsg(err)}`)
}
}
for (const { harnessId, results } of allResults) {
for (const r of results) {
console.log(`✓ ${harnessId}: ${r.path || '(no file)'}${r.note ? ` · ${r.note}` : ''}`)
}
}
},
)

const harness = program.command('harness').description('Inspect agent harness configuration.')

harness
.command('list')
.description('List known harnesses.')
.action(() => {
for (const h of HARNESS_CATALOG) {
console.log(`${pad(h.id, 14)} ${h.name}`)
}
if (action === 'clear') {
delete config.gateway
await writeConfig(config)
console.log('Gateway cleared.')
return
})

harness
.command('config [id]')
.description('Show path + excerpt of a harness config file (claude-code | codex | opencode).')
.action(async (id?: string) => {
const ids = id ? [id] : HARNESS_CATALOG.map((h) => h.id)
for (const x of ids) {
const home = process.env.HOME ?? ''
const path =
x === 'claude-code'
? `${home}/.claude/settings.json`
: x === 'codex'
? `${home}/.codex/config.toml`
: x === 'opencode'
? `${process.env.XDG_CONFIG_HOME ?? `${home}/.config`}/opencode/opencode.json`
: '?'
console.log(`\n— ${x}`)
console.log(` ${path}`)
try {
const { readFile } = await import('node:fs/promises')
const blob = await readFile(path, 'utf8')
const truncated = blob.length > 600 ? blob.slice(0, 600) + '\n…(truncated)' : blob
console.log(' ---')
console.log(truncated.split('\n').map((line) => ' ' + line).join('\n'))
} catch (err) {
const msg = (err as NodeJS.ErrnoException).code === 'ENOENT' ? '(not found)' : String((err as Error).message ?? err)
console.log(` ${msg}`)
}
}
})

program
.command('list')
.description('Show providers and harnesses known to Hoist.')
.description('Show providers, gateways and harnesses known to Hoist.')
.action(async () => {
console.log('\nHarnesses:')
const harnesses = await discoverAll()
console.log('\nHarnesses:')
for (const h of harnesses) {
const status = h.installed ? `installed · ${h.version ?? 'unknown'}` : 'not installed'
console.log(` ${pad(h.spec.id, 14)} ${pad(h.spec.name, 16)} ${status}`)
}
console.log('\nProviders:')
for (const provider of PROVIDER_CATALOG) {
console.log(` ${pad(provider.id, 12)} ${provider.label} (${provider.envKeys.join(', ')})`)
for (const p of (await import('./lib/providers')).PROVIDER_CATALOG) {
console.log(` ${pad(p.id, 16)} ${p.label} (${(p.envKeys ?? []).join(', ') || 'cloud creds'})`)
}
console.log('\nGateways:')
for (const g of GATEWAY_CATALOG) {
console.log(` ${pad(g.id, 24)} ${g.label} ${g.baseUrl || '(custom)'}`)
}
})

Expand All @@ -179,8 +328,6 @@ function errMsg(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}

// Commander fontkit is not actually needed; removing import.

program.parseAsync(process.argv).catch((err) => {
console.error(errMsg(err))
process.exit(1)
Expand Down
1 change: 1 addition & 0 deletions cli/src/lib/harnesses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { spawn } from 'node:child_process'
import { access, constants } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import which from 'which'

export interface HarnessSpec {
id: string
name: string
Expand Down
4 changes: 2 additions & 2 deletions cli/src/lib/probes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ export async function probeProvider(provider: ProviderEntry, apiKey: string, bas
return { valid: false, status: 'invalid', detail: 'No API key supplied.', checkedAt }
}
if (provider.id === 'anthropic') {
return probeAnthropic(apiKey, baseUrl ?? provider.defaultBaseUrl ?? 'https://api.anthropic.com')
return probeAnthropic({ apiKey, baseUrl: baseUrl ?? provider.defaultBaseUrl ?? 'https://api.anthropic.com' })
}
return { valid: false, status: 'error', detail: `No probe implemented for "${provider.id}".`, checkedAt }
}

async function probeAnthropic(apiKey: string, baseUrl: string): Promise<ProbeResult> {
export async function probeAnthropic({ apiKey, baseUrl }: { apiKey: string; baseUrl: string }): Promise<ProbeResult> {
const checkedAt = new Date().toISOString()
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
Expand Down
50 changes: 23 additions & 27 deletions cli/src/lib/providers.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,26 @@
export interface ProviderEntry {
id: string
label: string
aliases?: string[]
featured?: boolean
envKeys: string[]
baseUrlEnv?: string
defaultBaseUrl?: string
notes?: string
}
import { PROVIDER_CATALOG, findProvider, type ProviderEntry } from '../../../src/main/providers/catalog'
export { PROVIDER_CATALOG, findProvider, type ProviderEntry } from '../../../src/main/providers/catalog'

import { GATEWAY_CATALOG, findGateway, type GatewayEntry } from '../../../src/main/gateways'
export { GATEWAY_CATALOG, findGateway, type GatewayEntry } from '../../../src/main/gateways'

import {
selectAnthropicEndpoint,
selectOpenAIEndpoint,
unresolvedPlaceholders,
} from '../../../src/main/gateways/resolve'

export const PROVIDER_CATALOG: ProviderEntry[] = [
{
id: 'anthropic',
label: 'Anthropic',
aliases: ['claude'],
featured: true,
envKeys: ['ANTHROPIC_API_KEY'],
baseUrlEnv: 'ANTHROPIC_BASE_URL',
defaultBaseUrl: 'https://api.anthropic.com',
notes: 'Claude API. Look for `sk-ant-…` keys.',
},
]
export { selectAnthropicEndpoint, selectOpenAIEndpoint, unresolvedPlaceholders }

/** CLI-context helper: list provider entries with envKeys defaulted to []. */
export function listProviders(): (ProviderEntry & { envKeys: string[] })[] {
return PROVIDER_CATALOG.map((p) => ({ ...p, envKeys: p.envKeys ?? [] }))
}

export function findProvider(idOrAlias: string): ProviderEntry | undefined {
const lower = idOrAlias.toLowerCase()
return PROVIDER_CATALOG.find(
(p) => p.id === lower || p.aliases?.some((a) => a.toLowerCase() === lower),
)
/** CLI-context helper: list gateway entries with `placeholders` resolved. */
export function listGateways() {
return GATEWAY_CATALOG.map((g) => ({
...g,
placeholders: unresolvedPlaceholders(g.baseUrl),
}))
}
6 changes: 6 additions & 0 deletions cli/src/lib/wiring.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import {
applyWiring,
type WireOptions,
type WireResult,
} from '../../../src/main/wiring'
export { applyWiring, type WireOptions, type WireResult }
2 changes: 1 addition & 1 deletion cli/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@
"@hoist/shared/*": ["src/shared/*"]
}
},
"include": ["src", "../src/shared"]
"include": ["src", "../src/main", "../src/shared", "../src/main/providers/catalog.source.json", "../src/main/gateways/catalog.source.json"]
}
Loading
Loading