From 26fdd4bfaa993fbccc172d109c54520f38afc07d Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Thu, 1 Oct 2026 09:40:40 +0300 Subject: [PATCH 1/2] LOU-D4: loushy doctor first-run diagnostic Adds `loushy doctor [agent.yaml|json] [--json]`, which checks Node against engines.node, the required peers (ai, zod) and optional provider peers against the SDK's peerDependencies, API-key env vars (name and set/not set only, never the value), and, given a spec, validates it with the existing loader and checks its provider, built-in tools, MCP server commands, Ollama reachability and Docker availability. Each non-ok line carries a concrete fix. Exit code is 1 on any failure, 0 otherwise. The core is a pure runDoctor(env: DoctorEnvironment) with everything external injected; doctor.ts is the thin CLI wrapper. The provider->env-var table moved from resolveProvider.ts to providers/providerEnv.ts (not in the public barrel) so the doctor reuses it instead of duplicating it. Co-Authored-By: Claude Sonnet 5.5 --- .fallowrc.json | 2 +- README.md | 1 + bin/loushy.js | 7 + docs/installation.md | 45 +++++ src/cli/doctor.test.ts | 100 ++++++++++ src/cli/doctor.ts | 112 +++++++++++ src/cli/doctorChecks.ts | 180 +++++++++++++++++ src/cli/doctorCore.test.ts | 331 +++++++++++++++++++++++++++++++ src/cli/doctorCore.ts | 39 ++++ src/cli/doctorRender.ts | 38 ++++ src/cli/doctorSpec.ts | 141 +++++++++++++ src/cli/doctorTypes.ts | 54 +++++ src/cli/versionRange.ts | 60 ++++++ src/providers/providerEnv.ts | 22 ++ src/providers/resolveProvider.ts | 13 +- tsup.config.ts | 1 + 16 files changed, 1133 insertions(+), 13 deletions(-) create mode 100644 src/cli/doctor.test.ts create mode 100644 src/cli/doctor.ts create mode 100644 src/cli/doctorChecks.ts create mode 100644 src/cli/doctorCore.test.ts create mode 100644 src/cli/doctorCore.ts create mode 100644 src/cli/doctorRender.ts create mode 100644 src/cli/doctorSpec.ts create mode 100644 src/cli/doctorTypes.ts create mode 100644 src/cli/versionRange.ts create mode 100644 src/providers/providerEnv.ts diff --git a/.fallowrc.json b/.fallowrc.json index 98bb3e770..8cccf229f 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -1,7 +1,7 @@ { "$schema": "./node_modules/fallow/schema.json", "entry": [ - "src/cli/{dev,build,studio}.ts", + "src/cli/{dev,build,studio,doctor}.ts", "src/data/index.ts", "**/*.test-d.ts", "**/*.{test,spec}.{ts,tsx}", diff --git a/README.md b/README.md index b53c00fce..439410c0a 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ const result = await resumeAfterApproval( ```bash npm install @loushy/build-ai-agent ai zod +npx loushy doctor # checks Node, peers and API keys, and tells you how to fix anything ``` **2. Configure** — describe the agent as data: diff --git a/bin/loushy.js b/bin/loushy.js index 3960e951d..ff62f5c98 100644 --- a/bin/loushy.js +++ b/bin/loushy.js @@ -8,6 +8,7 @@ const USAGE = [ ' loushy dev [--port N] [--host H]', ' loushy build --target= --agent= [--out=]', ' loushy studio [--port N] [--host H] [--prod|--dev]', + ' loushy doctor [agent.yaml|json] [--json]', ].join('\n'); /** @@ -77,10 +78,16 @@ async function runStudio(rest) { } } +async function runDoctorCommand(rest) { + const { runDoctorCommand: run } = require(path.join(__dirname, '..', 'dist', 'cli', 'doctor.js')); + process.exitCode = await run(rest); +} + const COMMANDS = new Map([ ['dev', runDev], ['build', runBuildCommand], ['studio', runStudio], + ['doctor', runDoctorCommand], ]); async function main() { diff --git a/docs/installation.md b/docs/installation.md index 3f990e6b1..8a88f0376 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -78,4 +78,49 @@ Installing the package also installs the `loushy` command: artifact (see [Deployment](./deployment.md)). Building requires `tsup` (`npm install --save-dev tsup`). +- `loushy doctor [agent.yaml|json] [--json]` - diagnose your setup (see below). + +## Troubleshooting: loushy doctor + +Run `npx loushy doctor` right after installing. It prints one line per check +with a status (`ok`, `warn`, `FAIL`), what it found, and, for anything that is +not ok, the command to run or the setting to change: + +```text +loushy doctor + +[ ok ] Node.js: v22.19.0 satisfies >=22.19.0 +[ ok ] Required peer ai: 4.3.19 satisfies ^4.3.19 +[FAIL] Required peer zod: not installed + fix: npm install zod@"^3.25.76" +[ ok ] Provider package @ai-sdk/openai: 0.0.42 installed +[warn] Provider package @ai-sdk/anthropic: not installed (optional) + fix: npm install @ai-sdk/anthropic@"^0.0.42" +[warn] openai (OPENAI_API_KEY): not set + fix: Set OPENAI_API_KEY in your environment, e.g. export OPENAI_API_KEY= +[ ok ] Docker: daemon not reachable (only needed for sandboxed tools; none configured) + +4 ok, 2 warnings, 1 failure +``` + +What it checks: + +1. Your Node version against the package's `engines.node`. +2. The required peers `ai` and `zod`: installed, and within the SDK's + `peerDependencies` range (resolved from the current directory). +3. The optional provider packages (`@ai-sdk/openai`, `@ai-sdk/anthropic`, + `ollama-ai-provider`), with the `npm install` command for each missing one. +4. Whether each provider's API key variable is set. Only the variable name and + `set` / `not set` are printed, never the value. +5. With a spec path (`loushy doctor agent.yaml`): the spec is validated with + field paths for every error, its provider package and key are checked + (missing ones become failures), its `tools` must be built-in tools, and any + `mcpServers` command must be resolvable. +6. Ollama reachability, only when the spec uses Ollama or `OLLAMA_HOST` is set. +7. Docker availability, a warning only when the spec uses a sandboxed tool. + +The exit code is `1` if any check fails and `0` otherwise (warnings do not +fail), so it can gate CI. Add `--json` for machine-readable output. Colour is +used only when stdout is a terminal and `NO_COLOR` is unset. + Next: [Quick Start](./quick-start.md). diff --git a/src/cli/doctor.test.ts b/src/cli/doctor.test.ts new file mode 100644 index 000000000..a75bce2ef --- /dev/null +++ b/src/cli/doctor.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; +import * as os from 'node:os'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { buildEnvironment, parseDoctorArgs, runDoctorCommand } from './doctor'; +import { satisfiesRange } from './versionRange'; + +describe('satisfiesRange', () => { + it.each([ + ['22.19.0', '>=22.19.0', true], + ['22.18.9', '>=22.19.0', false], + ['v23.0.0', '>=22.19.0', true], + ['4.9.0', '^4.3.19', true], + ['5.0.0', '^4.3.19', false], + ['0.0.42', '^0.0.42', true], + ['0.0.43', '^0.0.42', false], + ['0.2.9', '^0.2.1', true], + ['0.3.0', '^0.2.1', false], + ['1.2.9', '~1.2.3', true], + ['1.3.0', '~1.2.3', false], + ['1.2.3', '1.2.3', true], + ['2.0.0', '^1.0.0 || ^2.0.0', true], + ['3.0.0', '^1.0.0 || ^2.0.0', false], + ['1.0.0', '*', true], + ['garbage', '^1.0.0', false], + ])('%s vs %s -> %s', (version, range, expected) => { + expect(satisfiesRange(version, range)).toBe(expected); + }); +}); + +describe('parseDoctorArgs', () => { + it('reads the spec path and --json', () => { + expect(parseDoctorArgs(['agent.yaml', '--json'])).toEqual({ specPath: 'agent.yaml', json: true }); + expect(parseDoctorArgs([])).toEqual({ specPath: undefined, json: false }); + }); +}); + +describe('real environment', () => { + it('resolves installed packages from cwd and reports missing ones', () => { + const env = buildEnvironment({ json: false }); + expect(env.resolvePackageVersion('zod')).toMatch(/^\d+\.\d+\.\d+/); + expect(env.resolvePackageVersion('definitely-not-installed-pkg')).toBeNull(); + expect(env.sdk.engines?.node).toBeTruthy(); + }); + + it('finds commands by path and on PATH', () => { + const env = buildEnvironment({ json: false }); + expect(env.commandExists(process.execPath)).toBe(true); + expect(env.commandExists('node')).toBe(true); + expect(env.commandExists('definitely-not-a-command-xyz')).toBe(false); + }); + + it('loads a spec file and its raw mcpServers', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'loushy-doctor-')); + fs.writeFileSync( + path.join(dir, 'a.yaml'), + 'name: bot\nprompt: hi\nprovider:\n type: mock\n model: m\nmcpServers:\n fs:\n command: npx\n' + ); + fs.writeFileSync(path.join(dir, 'a.json'), '{"mcpServers":{}}'); + const env = buildEnvironment({ json: false, specPath: 'a.yaml' }, dir); + expect(env.specPath).toBe(path.join(dir, 'a.yaml')); + expect(env.loadSpec(env.specPath as string).name).toBe('bot'); + expect(env.readRawSpec(env.specPath as string)).toMatchObject({ mcpServers: { fs: { command: 'npx' } } }); + expect(env.readRawSpec(path.join(dir, 'a.json'))).toEqual({ mcpServers: {} }); + expect(env.resolveTool('http')).toBeTruthy(); + expect(() => env.resolveTool('nope')).toThrow(); + }); +}); + +describe('runDoctorCommand', () => { + it('prints text and returns the exit code; --json prints JSON', async () => { + const env = buildEnvironment({ json: false }); + const fakeEnv = { + ...env, + nodeVersion: '1.0.0', + dockerReachable: async () => false, + fetch: async () => ({ ok: true, status: 200 }), + }; + const out: string[] = []; + expect(await runDoctorCommand([], fakeEnv, (t) => out.push(t))).toBe(1); + expect(out[0]).toContain('[FAIL] Node.js'); + + const json: string[] = []; + await runDoctorCommand(['--json'], fakeEnv, (t) => json.push(t)); + expect(JSON.parse(json[0]).exitCode).toBe(1); + }); + + it('uses console.log by default', async () => { + const env = buildEnvironment({ json: false }); + const lines: unknown[] = []; + const original = console.log; + console.log = (...args: unknown[]) => lines.push(...args); + try { + await runDoctorCommand([], { ...env, dockerReachable: async () => false }); + } finally { + console.log = original; + } + expect(String(lines[0])).toContain('loushy doctor'); + }); +}); diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts new file mode 100644 index 000000000..a632d2cca --- /dev/null +++ b/src/cli/doctor.ts @@ -0,0 +1,112 @@ +/** + * `loushy doctor [agent.yaml|json] [--json]` - first-run diagnostic. + * + * Thin wrapper over the pure core in doctorCore.ts: builds the real + * DoctorEnvironment (process, fs, module resolution, fetch, Docker), + * renders the report and returns the exit code (1 if any check failed). + */ +import * as fs from 'node:fs'; +import { createRequire } from 'node:module'; +import * as path from 'node:path'; +import { parse as parseYaml } from 'yaml'; +import { loadSpec } from '../spec/loadSpec'; +import { resolveSpecTool } from '../spec/specToAgent'; +import { runDoctor } from './doctorCore'; +import { renderJson, renderReport } from './doctorRender'; +import type { DoctorEnvironment, SdkManifest } from './doctorTypes'; + +const DOCKER_TIMEOUT_MS = 2000; + +export interface DoctorArgs { + specPath?: string; + json: boolean; +} + +export function parseDoctorArgs(argv: string[]): DoctorArgs { + return { + specPath: argv.find((arg) => !arg.startsWith('--')), + json: argv.includes('--json'), + }; +} + +/** Walks up from a resolved entry file to the package.json named `name`. */ +function findPackageJson(entry: string, name: string): string | null { + for (let dir = path.dirname(entry); dir !== path.dirname(dir); dir = path.dirname(dir)) { + const candidate = path.join(dir, 'package.json'); + if (fs.existsSync(candidate) && JSON.parse(fs.readFileSync(candidate, 'utf8')).name === name) { + return candidate; + } + } + return null; +} + +/** Version of `name` as Node would resolve it from `cwd`, or null when it is not installed. */ +function resolveVersion(cwd: string, name: string): string | null { + const require = createRequire(path.join(cwd, 'noop.js')); + try { + return JSON.parse(fs.readFileSync(require.resolve(`${name}/package.json`), 'utf8')).version; + } catch { + // Not installed, or the package's "exports" hides package.json: fall back to its entry point. + } + try { + const manifest = findPackageJson(require.resolve(name), name); + return manifest ? JSON.parse(fs.readFileSync(manifest, 'utf8')).version : null; + } catch { + return null; + } +} + +function commandExists(command: string, env: NodeJS.ProcessEnv): boolean { + if (/[\\/]/.test(command)) return fs.existsSync(command); + const extensions = process.platform === 'win32' ? (env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';') : ['']; + const dirs = (env.PATH ?? '').split(path.delimiter).filter(Boolean); + return dirs.some((dir) => + ['', ...extensions].some((ext) => fs.existsSync(path.join(dir, command + ext))) + ); +} + +async function dockerReachable(): Promise { + const { default: Docker } = await import('dockerode'); + const ping = new Docker().ping().then(() => true); + const timeout = new Promise((resolve) => setTimeout(() => resolve(false), DOCKER_TIMEOUT_MS).unref()); + return Promise.race([ping, timeout]); +} + +function readSdkManifest(): SdkManifest { + // dist/cli/doctor.js and src/cli/doctor.ts both sit two levels below the package root. + return JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')); +} + +/** The real DoctorEnvironment for the current process. */ +export function buildEnvironment(args: DoctorArgs, cwd: string = process.cwd()): DoctorEnvironment { + const resolveSpecFile = (file: string) => path.resolve(cwd, file); + return { + nodeVersion: process.versions.node, + env: process.env, + sdk: readSdkManifest(), + resolvePackageVersion: (name) => resolveVersion(cwd, name), + specPath: args.specPath ? resolveSpecFile(args.specPath) : undefined, + loadSpec, + readRawSpec: (file) => { + const raw = fs.readFileSync(file, 'utf8'); + return path.extname(file).toLowerCase() === '.json' ? JSON.parse(raw) : parseYaml(raw); + }, + resolveTool: resolveSpecTool, + commandExists: (command) => commandExists(command, process.env), + fetch: (url, init) => fetch(url, init), + dockerReachable, + }; +} + +/** Runs the command and prints the report; resolves to the process exit code. */ +export async function runDoctorCommand( + argv: string[], + env?: DoctorEnvironment, + write: (text: string) => void = (text) => console.log(text) +): Promise { + const args = parseDoctorArgs(argv); + const report = await runDoctor(env ?? buildEnvironment(args)); + const color = process.stdout.isTTY === true && !process.env.NO_COLOR; + write(args.json ? renderJson(report) : renderReport(report, { color })); + return report.exitCode; +} diff --git a/src/cli/doctorChecks.ts b/src/cli/doctorChecks.ts new file mode 100644 index 000000000..6bd084996 --- /dev/null +++ b/src/cli/doctorChecks.ts @@ -0,0 +1,180 @@ +/** + * The individual `loushy doctor` checks. Each is a small function over the + * injected DoctorEnvironment returning one or more DoctorCheck lines. + */ +import { PROVIDER_ENV_TABLE } from '../providers/providerEnv'; +import type { DoctorCheck, DoctorEnvironment } from './doctorTypes'; +import { satisfiesRange } from './versionRange'; + +const REQUIRED_PEERS = ['ai', 'zod']; +const DEFAULT_OLLAMA_URL = 'http://localhost:11434'; +const OLLAMA_TIMEOUT_MS = 2000; + +/** What the agent spec (if any) needs from the rest of the setup. */ +export interface SpecNeeds { + /** Lowercased provider types the spec uses. */ + providers: Set; + usesSandbox: boolean; +} + +export const NO_SPEC_NEEDS: SpecNeeds = { providers: new Set(), usesSandbox: false }; + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function installCommand(env: DoctorEnvironment, name: string): string { + const range = env.sdk.peerDependencies?.[name]; + return range ? `npm install ${name}@"${range}"` : `npm install ${name}`; +} + +export function checkNode(env: DoctorEnvironment): DoctorCheck { + const range = env.sdk.engines?.node; + const base = { id: 'node', title: 'Node.js' }; + if (!range) { + return { ...base, status: 'ok', finding: `v${env.nodeVersion} (the SDK declares no engines.node)` }; + } + if (satisfiesRange(env.nodeVersion, range)) { + return { ...base, status: 'ok', finding: `v${env.nodeVersion} satisfies ${range}` }; + } + return { + ...base, + status: 'fail', + finding: `v${env.nodeVersion} does not satisfy ${range}`, + fix: `Install a Node.js version matching "${range}" (for example with nvm: nvm install --lts).`, + }; +} + +function checkRequiredPeer(env: DoctorEnvironment, name: string): DoctorCheck { + const base = { id: `peer.${name}`, title: `Required peer ${name}` }; + const version = env.resolvePackageVersion(name); + if (version === null) { + return { ...base, status: 'fail', finding: 'not installed', fix: installCommand(env, name) }; + } + const range = env.sdk.peerDependencies?.[name]; + if (range && !satisfiesRange(version, range)) { + return { + ...base, + status: 'fail', + finding: `${version} installed, but the SDK needs ${range}`, + fix: installCommand(env, name), + }; + } + return { ...base, status: 'ok', finding: range ? `${version} satisfies ${range}` : `${version} installed` }; +} + +export function checkRequiredPeers(env: DoctorEnvironment): DoctorCheck[] { + return REQUIRED_PEERS.map((name) => checkRequiredPeer(env, name)); +} + +/** The optional peer packages behind the providers, each with the providers that need it. */ +function optionalPeerProviders(): Map { + const peers = new Map(); + for (const [provider, entry] of Object.entries(PROVIDER_ENV_TABLE)) { + peers.set(entry.peerPackage, [...(peers.get(entry.peerPackage) ?? []), provider]); + } + return peers; +} + +function checkOptionalPeer( + env: DoctorEnvironment, + name: string, + providers: string[], + needs: SpecNeeds +): DoctorCheck { + const required = providers.some((provider) => needs.providers.has(provider)); + const base = { id: `optional-peer.${name}`, title: `Provider package ${name}` }; + const version = env.resolvePackageVersion(name); + if (version === null) { + return { + ...base, + status: required ? 'fail' : 'warn', + finding: required ? 'not installed, and the agent spec needs it' : 'not installed (optional)', + fix: installCommand(env, name), + }; + } + const range = env.sdk.peerDependencies?.[name]; + if (range && !satisfiesRange(version, range)) { + return { + ...base, + status: required ? 'fail' : 'warn', + finding: `${version} installed, but the SDK expects ${range}`, + fix: installCommand(env, name), + }; + } + return { ...base, status: 'ok', finding: `${version} installed` }; +} + +export function checkOptionalPeers(env: DoctorEnvironment, needs: SpecNeeds): DoctorCheck[] { + return [...optionalPeerProviders()].map(([name, providers]) => + checkOptionalPeer(env, name, providers, needs) + ); +} + +function checkApiKey(env: DoctorEnvironment, provider: string, needs: SpecNeeds): DoctorCheck { + const entry = PROVIDER_ENV_TABLE[provider]; + const isSet = Boolean(env.env[entry.envKey]); + const base = { id: `env.${provider}`, title: `${provider} (${entry.envKey})` }; + if (isSet) return { ...base, status: 'ok', finding: 'set' }; + if (entry.configField === 'baseURL') { + return { ...base, status: 'ok', finding: 'not set (optional; the provider default endpoint is used)' }; + } + return { + ...base, + status: needs.providers.has(provider) ? 'fail' : 'warn', + finding: needs.providers.has(provider) ? 'not set, and the agent spec needs it' : 'not set', + fix: `Set ${entry.envKey} in your environment, e.g. export ${entry.envKey}=`, + }; +} + +/** Reports only whether each provider's env var is set - never its value. */ +export function checkApiKeys(env: DoctorEnvironment, needs: SpecNeeds): DoctorCheck[] { + return Object.keys(PROVIDER_ENV_TABLE).map((provider) => checkApiKey(env, provider, needs)); +} + +function ollamaBaseUrl(env: DoctorEnvironment): string { + const raw = env.env.OLLAMA_HOST || env.env.OLLAMA_BASE_URL || DEFAULT_OLLAMA_URL; + const withScheme = /^https?:\/\//.test(raw) ? raw : `http://${raw}`; + return withScheme.replace(/\/+$/, '').replace(/\/api$/, ''); +} + +/** Only relevant when the spec uses Ollama or OLLAMA_HOST is set. */ +export async function checkOllama( + env: DoctorEnvironment, + needs: SpecNeeds +): Promise { + if (!needs.providers.has('ollama') && !env.env.OLLAMA_HOST) return null; + const base = { id: 'ollama', title: 'Ollama server' }; + try { + const response = await env.fetch(`${ollamaBaseUrl(env)}/api/tags`, { + signal: AbortSignal.timeout(OLLAMA_TIMEOUT_MS), + }); + if (response.ok) return { ...base, status: 'ok', finding: 'reachable' }; + return { ...base, status: 'warn', finding: `unreachable (HTTP ${response.status})`, fix: OLLAMA_FIX }; + } catch (error) { + return { ...base, status: 'warn', finding: `unreachable (${message(error)})`, fix: OLLAMA_FIX }; + } +} + +const OLLAMA_FIX = + 'Start it with `ollama serve`, or set OLLAMA_HOST (or OLLAMA_BASE_URL) to its address.'; + +/** Docker is only needed for sandboxed tools, so a missing daemon is a warning only when the spec needs one. */ +export async function checkDocker(env: DoctorEnvironment, needs: SpecNeeds): Promise { + const base = { id: 'docker', title: 'Docker' }; + const reachable = await env.dockerReachable().catch(() => false); + if (reachable) return { ...base, status: 'ok', finding: 'daemon reachable (sandboxed tools can run)' }; + if (needs.usesSandbox) { + return { + ...base, + status: 'warn', + finding: 'daemon not reachable, but the agent spec uses a sandboxed tool', + fix: 'Install and start Docker (https://docs.docker.com/get-docker/), then re-run `loushy doctor`.', + }; + } + return { + ...base, + status: 'ok', + finding: 'daemon not reachable (only needed for sandboxed tools; none configured)', + }; +} diff --git a/src/cli/doctorCore.test.ts b/src/cli/doctorCore.test.ts new file mode 100644 index 000000000..630e3f0e0 --- /dev/null +++ b/src/cli/doctorCore.test.ts @@ -0,0 +1,331 @@ +import { describe, it, expect } from 'vitest'; +import { runDoctor } from './doctorCore'; +import { renderJson, renderReport } from './doctorRender'; +import type { DoctorCheck, DoctorEnvironment } from './doctorTypes'; +import type { AgentSpec } from '../spec/schema'; + +const SECRET = 'sk-super-secret-value-123456'; + +const INSTALLED: Record = { + ai: '4.3.19', + zod: '3.25.76', + '@ai-sdk/openai': '0.0.42', + '@ai-sdk/anthropic': '0.0.42', + 'ollama-ai-provider': '1.2.0', +}; + +function makeEnv(overrides: Partial = {}): DoctorEnvironment { + return { + nodeVersion: '22.19.0', + env: {}, + sdk: { + engines: { node: '>=22.19.0' }, + peerDependencies: { + ai: '^4.3.19', + zod: '^3.25.76', + '@ai-sdk/openai': '^0.0.42', + '@ai-sdk/anthropic': '^0.0.42', + 'ollama-ai-provider': '^1.2.0', + }, + }, + resolvePackageVersion: (name) => INSTALLED[name] ?? null, + loadSpec: () => { + throw new Error('no spec'); + }, + readRawSpec: () => undefined, + resolveTool: () => ({}), + commandExists: () => true, + fetch: async () => ({ ok: true, status: 200 }), + dockerReachable: async () => true, + ...overrides, + }; +} + +async function check(env: DoctorEnvironment, id: string): Promise { + const report = await runDoctor(env); + const found = report.checks.find((c) => c.id === id); + if (!found) throw new Error(`no check '${id}' in ${report.checks.map((c) => c.id).join(', ')}`); + return found; +} + +function spec(overrides: Partial = {}): AgentSpec { + return { name: 'bot', prompt: 'hi', provider: { type: 'openai', model: 'gpt-4o' }, ...overrides }; +} + +describe('node check', () => { + it('ok when the version satisfies engines.node', async () => { + expect((await check(makeEnv(), 'node')).status).toBe('ok'); + }); + it('fail with a fix when too old', async () => { + const result = await check(makeEnv({ nodeVersion: '18.0.0' }), 'node'); + expect(result.status).toBe('fail'); + expect(result.fix).toContain('>=22.19.0'); + }); + it('ok when the SDK declares no engines', async () => { + expect((await check(makeEnv({ sdk: {} }), 'node')).status).toBe('ok'); + }); +}); + +describe('required peers', () => { + it('ok when installed within range', async () => { + expect((await check(makeEnv(), 'peer.ai')).status).toBe('ok'); + }); + it('fail when missing, with the install command', async () => { + const env = makeEnv({ resolvePackageVersion: (n) => (n === 'zod' ? null : INSTALLED[n] ?? null) }); + const result = await check(env, 'peer.zod'); + expect(result.status).toBe('fail'); + expect(result.fix).toBe('npm install zod@"^3.25.76"'); + }); + it('fail when the version is outside the peer range', async () => { + const env = makeEnv({ resolvePackageVersion: (n) => (n === 'ai' ? '3.0.0' : INSTALLED[n] ?? null) }); + const result = await check(env, 'peer.ai'); + expect(result.status).toBe('fail'); + expect(result.finding).toContain('^4.3.19'); + }); + it('ok without a declared range', async () => { + const result = await check(makeEnv({ sdk: { engines: { node: '>=1' } } }), 'peer.ai'); + expect(result.status).toBe('ok'); + }); +}); + +describe('optional provider peers', () => { + const without = (missing: string) => (n: string) => (n === missing ? null : INSTALLED[n] ?? null); + + it('ok when installed', async () => { + expect((await check(makeEnv(), 'optional-peer.@ai-sdk/openai')).status).toBe('ok'); + }); + it('warn with an install command when missing and unused', async () => { + const result = await check( + makeEnv({ resolvePackageVersion: without('@ai-sdk/anthropic') }), + 'optional-peer.@ai-sdk/anthropic' + ); + expect(result.status).toBe('warn'); + expect(result.fix).toBe('npm install @ai-sdk/anthropic@"^0.0.42"'); + }); + it('fail when missing and the spec needs it', async () => { + const env = makeEnv({ + resolvePackageVersion: without('@ai-sdk/openai'), + specPath: 'a.yaml', + loadSpec: () => spec(), + }); + expect((await check(env, 'optional-peer.@ai-sdk/openai')).status).toBe('fail'); + }); + it('warn on an out-of-range version, fail when needed', async () => { + const old = (n: string) => (n === 'ollama-ai-provider' ? '0.1.0' : INSTALLED[n] ?? null); + expect((await check(makeEnv({ resolvePackageVersion: old }), 'optional-peer.ollama-ai-provider')).status).toBe('warn'); + const needed = makeEnv({ + resolvePackageVersion: old, + specPath: 'a.yaml', + loadSpec: () => spec({ provider: { type: 'ollama', model: 'llama3' } }), + }); + expect((await check(needed, 'optional-peer.ollama-ai-provider')).status).toBe('fail'); + }); +}); + +describe('API keys', () => { + it('ok and prints only "set" when the variable is set', async () => { + const result = await check(makeEnv({ env: { OPENAI_API_KEY: SECRET } }), 'env.openai'); + expect(result).toMatchObject({ status: 'ok', finding: 'set', title: 'openai (OPENAI_API_KEY)' }); + }); + it('warn with a fix when not set', async () => { + const result = await check(makeEnv(), 'env.anthropic'); + expect(result.status).toBe('warn'); + expect(result.fix).toContain('ANTHROPIC_API_KEY'); + }); + it('fail when not set and the spec needs it', async () => { + const env = makeEnv({ specPath: 'a.yaml', loadSpec: () => spec() }); + expect((await check(env, 'env.openai')).status).toBe('fail'); + }); + it('ollama needs no key: unset is ok', async () => { + const env = makeEnv({ + specPath: 'a.yaml', + loadSpec: () => spec({ provider: { type: 'ollama', model: 'llama3' } }), + }); + expect((await check(env, 'env.ollama')).status).toBe('ok'); + }); + it('never leaks a key value into text or JSON output', async () => { + const env = makeEnv({ + env: { OPENAI_API_KEY: SECRET, ANTHROPIC_API_KEY: `${SECRET}-2`, OPENROUTER_API_KEY: SECRET }, + }); + const report = await runDoctor(env); + for (const output of [renderReport(report), renderReport(report, { color: true }), renderJson(report)]) { + expect(output).not.toContain('sk-'); + expect(output).not.toContain('secret'); + } + }); +}); + +describe('spec checks', () => { + const withSpec = (loaded: AgentSpec, extra: Partial = {}) => + makeEnv({ specPath: 'a.yaml', loadSpec: () => loaded, ...extra }); + + it('reports validation errors with field paths', async () => { + const env = makeEnv({ + specPath: 'a.yaml', + loadSpec: () => { + throw new Error("loadSpec: 'a.yaml' failed validation - 'provider.model': Required"); + }, + }); + const result = await check(env, 'spec'); + expect(result.status).toBe('fail'); + expect(result.finding).toContain("'provider.model'"); + }); + it('ok for a valid spec', async () => { + expect((await check(withSpec(spec()), 'spec')).status).toBe('ok'); + }); + it('fails an unknown provider type but accepts mock', async () => { + const unknown = await check(withSpec(spec({ provider: { type: 'foo', model: 'm' } })), 'spec.provider'); + expect(unknown.status).toBe('fail'); + const mock = await check(withSpec(spec({ provider: { type: 'mock', model: 'm' } })), 'spec.provider'); + expect(mock.status).toBe('ok'); + }); + it('checks tools against the built-in registry', async () => { + const resolveTool = (name: string) => { + if (name === 'nope') throw new Error("unknown tool 'nope'"); + return {}; + }; + const env = withSpec(spec({ tools: ['http', 'nope'] }), { resolveTool }); + expect((await check(env, 'spec.tool.http')).status).toBe('ok'); + const bad = await check(env, 'spec.tool.nope'); + expect(bad.status).toBe('fail'); + expect(bad.fix).toContain("Remove 'nope'"); + }); + it('checks MCP server commands from a list or a map', async () => { + const commandExists = (command: string) => command === 'npx'; + const list = withSpec(spec(), { + commandExists, + readRawSpec: () => ({ mcpServers: [{ name: 'fs', command: 'npx' }, { name: 'gone', command: 'nonexistent' }, { url: 'https://x' }] }), + }); + expect((await check(list, 'spec.mcp.fs')).status).toBe('ok'); + expect((await check(list, 'spec.mcp.gone')).status).toBe('fail'); + expect((await check(list, 'spec.mcp.#3')).status).toBe('ok'); + const map = withSpec(spec(), { + commandExists, + readRawSpec: () => ({ mcpServers: { git: { command: 'uvx' } } }), + }); + expect((await check(map, 'spec.mcp.git')).status).toBe('fail'); + }); + it('survives an unreadable raw spec', async () => { + const env = withSpec(spec(), { + readRawSpec: () => { + throw new Error('boom'); + }, + }); + expect((await check(env, 'spec')).status).toBe('ok'); + }); +}); + +describe('ollama check', () => { + const ollamaSpec = spec({ provider: { type: 'ollama', model: 'llama3' } }); + + it('is skipped unless the spec uses ollama or OLLAMA_HOST is set', async () => { + const report = await runDoctor(makeEnv()); + expect(report.checks.some((c) => c.id === 'ollama')).toBe(false); + }); + it('ok when reachable (OLLAMA_HOST without a scheme is normalised)', async () => { + const urls: string[] = []; + const env = makeEnv({ + env: { OLLAMA_HOST: 'box:11434' }, + fetch: async (url) => { + urls.push(url); + return { ok: true, status: 200 }; + }, + }); + expect((await check(env, 'ollama')).status).toBe('ok'); + expect(urls).toEqual(['http://box:11434/api/tags']); + }); + it('warn when the server answers with an error status', async () => { + const env = makeEnv({ env: { OLLAMA_HOST: 'http://x' }, fetch: async () => ({ ok: false, status: 503 }) }); + const result = await check(env, 'ollama'); + expect(result.status).toBe('warn'); + expect(result.finding).toContain('503'); + }); + it('warn when the request fails; uses the default endpoint for a spec', async () => { + const urls: string[] = []; + const env = makeEnv({ + specPath: 'a.yaml', + loadSpec: () => ollamaSpec, + fetch: async (url) => { + urls.push(url); + throw new Error('ECONNREFUSED'); + }, + }); + const result = await check(env, 'ollama'); + expect(result.status).toBe('warn'); + expect(result.fix).toContain('ollama serve'); + expect(urls[0]).toBe('http://localhost:11434/api/tags'); + }); +}); + +describe('docker check', () => { + it('ok when reachable', async () => { + expect((await check(makeEnv(), 'docker')).finding).toContain('reachable'); + }); + it('informational ok when unreachable and not needed', async () => { + const result = await check(makeEnv({ dockerReachable: async () => false }), 'docker'); + expect(result.status).toBe('ok'); + }); + it('warn when unreachable and a sandboxed tool is configured', async () => { + const env = makeEnv({ + dockerReachable: async () => { + throw new Error('no socket'); + }, + specPath: 'a.yaml', + loadSpec: () => spec({ tools: ['sandboxed'] }), + resolveTool: () => ({ requiresSandbox: true }), + }); + const result = await check(env, 'docker'); + expect(result.status).toBe('warn'); + expect(result.fix).toContain('Docker'); + }); +}); + +describe('report, rendering and exit codes', () => { + it('exit code 0 with warnings only, 1 with any failure', async () => { + expect((await runDoctor(makeEnv())).exitCode).toBe(0); + expect((await runDoctor(makeEnv({ nodeVersion: '10.0.0' }))).exitCode).toBe(1); + }); + + it('renders a mixed report as plain ASCII text', async () => { + const env = makeEnv({ + env: { OPENAI_API_KEY: SECRET }, + resolvePackageVersion: (n) => (n === 'ollama-ai-provider' ? null : INSTALLED[n] ?? null), + dockerReachable: async () => false, + nodeVersion: '20.1.0', + }); + const text = renderReport(await runDoctor(env)); + expect(text).toMatchInlineSnapshot(` + "loushy doctor + + [FAIL] Node.js: v20.1.0 does not satisfy >=22.19.0 + fix: Install a Node.js version matching ">=22.19.0" (for example with nvm: nvm install --lts). + [ ok ] Required peer ai: 4.3.19 satisfies ^4.3.19 + [ ok ] Required peer zod: 3.25.76 satisfies ^3.25.76 + [ ok ] Provider package @ai-sdk/openai: 0.0.42 installed + [ ok ] Provider package @ai-sdk/anthropic: 0.0.42 installed + [warn] Provider package ollama-ai-provider: not installed (optional) + fix: npm install ollama-ai-provider@"^1.2.0" + [ ok ] openai (OPENAI_API_KEY): set + [warn] anthropic (ANTHROPIC_API_KEY): not set + fix: Set ANTHROPIC_API_KEY in your environment, e.g. export ANTHROPIC_API_KEY= + [ ok ] ollama (OLLAMA_BASE_URL): not set (optional; the provider default endpoint is used) + [warn] openrouter (OPENROUTER_API_KEY): not set + fix: Set OPENROUTER_API_KEY in your environment, e.g. export OPENROUTER_API_KEY= + [ ok ] Docker: daemon not reachable (only needed for sandboxed tools; none configured) + + 7 ok, 3 warnings, 1 failure" + `); + // eslint-disable-next-line no-control-regex + expect(text).not.toMatch(/[^\x20-\x7e\n]/); + }); + + it('colours only the status tag when asked', async () => { + const text = renderReport(await runDoctor(makeEnv()), { color: true }); + expect(text).toContain('\u001b[32m[ ok ]\u001b[0m'); + }); + + it('renders JSON that round-trips', async () => { + const report = await runDoctor(makeEnv()); + expect(JSON.parse(renderJson(report))).toEqual(report); + }); +}); diff --git a/src/cli/doctorCore.ts b/src/cli/doctorCore.ts new file mode 100644 index 000000000..6d81b74d5 --- /dev/null +++ b/src/cli/doctorCore.ts @@ -0,0 +1,39 @@ +/** + * `runDoctor`: the pure, testable core of `loushy doctor`. All access to the + * outside world goes through the injected DoctorEnvironment. + */ +import { + checkApiKeys, + checkDocker, + checkNode, + checkOllama, + checkOptionalPeers, + checkRequiredPeers, +} from './doctorChecks'; +import { inspectSpec } from './doctorSpec'; +import type { CheckStatus, DoctorCheck, DoctorEnvironment, DoctorReport } from './doctorTypes'; + +function summarize(checks: DoctorCheck[]): Record { + const summary: Record = { ok: 0, warn: 0, fail: 0 }; + for (const check of checks) summary[check.status] += 1; + return summary; +} + +/** Runs every check and returns the report; never throws for a bad setup. */ +export async function runDoctor(env: DoctorEnvironment): Promise { + const spec = inspectSpec(env); + const ollama = await checkOllama(env, spec.needs); + const docker = await checkDocker(env, spec.needs); + + const checks: DoctorCheck[] = [ + checkNode(env), + ...checkRequiredPeers(env), + ...checkOptionalPeers(env, spec.needs), + ...checkApiKeys(env, spec.needs), + ...spec.checks, + ...(ollama ? [ollama] : []), + docker, + ]; + const summary = summarize(checks); + return { checks, summary, exitCode: summary.fail > 0 ? 1 : 0 }; +} diff --git a/src/cli/doctorRender.ts b/src/cli/doctorRender.ts new file mode 100644 index 000000000..3452a079f --- /dev/null +++ b/src/cli/doctorRender.ts @@ -0,0 +1,38 @@ +/** + * Plain ASCII rendering of a DoctorReport. Colour is opt-in and applied only + * to the status tag, so the text reads the same without it. + */ +import type { CheckStatus, DoctorCheck, DoctorReport } from './doctorTypes'; + +const TAGS: Record = { ok: '[ ok ]', warn: '[warn]', fail: '[FAIL]' }; +const COLORS: Record = { ok: '32', warn: '33', fail: '31' }; + +export interface RenderOptions { + color?: boolean; +} + +function tag(status: CheckStatus, color: boolean): string { + return color ? `\u001b[${COLORS[status]}m${TAGS[status]}\u001b[0m` : TAGS[status]; +} + +function renderCheck(check: DoctorCheck, color: boolean): string[] { + const lines = [`${tag(check.status, color)} ${check.title}: ${check.finding}`]; + if (check.status !== 'ok' && check.fix) lines.push(` fix: ${check.fix}`); + return lines; +} + +export function renderReport(report: DoctorReport, options: RenderOptions = {}): string { + const color = options.color === true; + const { ok, warn, fail } = report.summary; + return [ + 'loushy doctor', + '', + ...report.checks.flatMap((check) => renderCheck(check, color)), + '', + `${ok} ok, ${warn} warning${warn === 1 ? '' : 's'}, ${fail} failure${fail === 1 ? '' : 's'}`, + ].join('\n'); +} + +export function renderJson(report: DoctorReport): string { + return JSON.stringify(report, null, 2); +} diff --git a/src/cli/doctorSpec.ts b/src/cli/doctorSpec.ts new file mode 100644 index 000000000..55858861d --- /dev/null +++ b/src/cli/doctorSpec.ts @@ -0,0 +1,141 @@ +/** + * `loushy doctor `: validates the spec with the SDK's own loader + * and checks what the spec references (provider, built-in tools, MCP servers). + */ +import type { AgentSpec } from '../spec/schema'; +import { PROVIDER_ENV_TABLE } from '../providers/providerEnv'; +import { NO_SPEC_NEEDS, type SpecNeeds } from './doctorChecks'; +import type { DoctorCheck, DoctorEnvironment } from './doctorTypes'; + +export interface SpecInspection { + checks: DoctorCheck[]; + needs: SpecNeeds; +} + +interface McpReference { + name: string; + command?: string; +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function toReference(name: string, value: unknown): McpReference { + const command = isRecord(value) && typeof value.command === 'string' ? value.command : undefined; + return { name, command }; +} + +/** Reads `mcpServers` from a raw spec, as either a list or a name -> config map. */ +function mcpReferences(raw: unknown): McpReference[] { + const servers = isRecord(raw) ? raw.mcpServers : undefined; + if (Array.isArray(servers)) { + return servers.map((server, index) => + toReference(isRecord(server) && typeof server.name === 'string' ? server.name : `#${index + 1}`, server) + ); + } + if (isRecord(servers)) { + return Object.entries(servers).map(([name, server]) => toReference(name, server)); + } + return []; +} + +function checkProvider(spec: AgentSpec): DoctorCheck { + const type = spec.provider.type.toLowerCase(); + const base = { id: 'spec.provider', title: 'Spec provider' }; + if (type === 'mock' || PROVIDER_ENV_TABLE[type]) { + return { ...base, status: 'ok', finding: `'${spec.provider.type}' with model '${spec.provider.model}'` }; + } + return { + ...base, + status: 'fail', + finding: `unknown provider type '${spec.provider.type}'`, + fix: `Set provider.type to one of: ${Object.keys(PROVIDER_ENV_TABLE).join(', ')}.`, + }; +} + +function checkTool(env: DoctorEnvironment, name: string): { check: DoctorCheck; sandboxed: boolean } { + const base = { id: `spec.tool.${name}`, title: `Spec tool '${name}'` }; + try { + const tool = env.resolveTool(name); + const sandboxed = tool.requiresSandbox === true; + const finding = sandboxed ? 'built-in tool found (runs sandboxed)' : 'built-in tool found'; + return { check: { ...base, status: 'ok', finding }, sandboxed }; + } catch (error) { + return { + check: { + ...base, + status: 'fail', + finding: message(error), + fix: `Remove '${name}' from the spec's tools list or use a built-in tool name.`, + }, + sandboxed: false, + }; + } +} + +function checkMcpServer(env: DoctorEnvironment, server: McpReference): DoctorCheck { + const base = { id: `spec.mcp.${server.name}`, title: `MCP server '${server.name}'` }; + if (!server.command) { + return { ...base, status: 'ok', finding: 'no command to resolve (remote or unspecified)' }; + } + if (env.commandExists(server.command)) { + return { ...base, status: 'ok', finding: `command '${server.command}' found` }; + } + return { + ...base, + status: 'fail', + finding: `command '${server.command}' not found on PATH`, + fix: `Install '${server.command}' or fix mcpServers.${server.name}.command in the spec.`, + }; +} + +function loadFailure(path: string, error: unknown): SpecInspection { + const check: DoctorCheck = { + id: 'spec', + status: 'fail', + title: 'Agent spec', + finding: message(error), + fix: `Fix the field(s) named above in ${path}.`, + }; + return { checks: [check], needs: NO_SPEC_NEEDS }; +} + +function safeRaw(env: DoctorEnvironment, path: string): unknown { + try { + return env.readRawSpec(path); + } catch { + return undefined; + } +} + +/** Loads the spec (if a path was given) and checks everything it references. */ +export function inspectSpec(env: DoctorEnvironment): SpecInspection { + const path = env.specPath; + if (!path) return { checks: [], needs: NO_SPEC_NEEDS }; + + let spec: AgentSpec; + try { + spec = env.loadSpec(path); + } catch (error) { + return loadFailure(path, error); + } + + const tools = (spec.tools ?? []).map((name) => checkTool(env, name)); + const mcp = mcpReferences(safeRaw(env, path)).map((server) => checkMcpServer(env, server)); + const checks: DoctorCheck[] = [ + { id: 'spec', status: 'ok', title: 'Agent spec', finding: `'${spec.name}' is valid` }, + checkProvider(spec), + ...tools.map((tool) => tool.check), + ...mcp, + ]; + const needs: SpecNeeds = { + providers: new Set([spec.provider.type.toLowerCase()]), + usesSandbox: tools.some((tool) => tool.sandboxed), + }; + return { checks, needs }; +} diff --git a/src/cli/doctorTypes.ts b/src/cli/doctorTypes.ts new file mode 100644 index 000000000..8cfc66e73 --- /dev/null +++ b/src/cli/doctorTypes.ts @@ -0,0 +1,54 @@ +import type { AgentSpec } from '../spec/schema'; + +export type CheckStatus = 'ok' | 'warn' | 'fail'; + +/** One diagnostic line: what was checked, what was found, and (when not ok) how to fix it. */ +export interface DoctorCheck { + id: string; + status: CheckStatus; + /** What was checked, e.g. "Node.js". */ + title: string; + /** What was found. Never contains an API key value. */ + finding: string; + /** A command to run or a setting to change; present for warn/fail. */ + fix?: string; +} + +export interface DoctorReport { + checks: DoctorCheck[]; + summary: Record; + /** 1 if any check failed, 0 otherwise (warnings do not fail). */ + exitCode: 0 | 1; +} + +/** The subset of the SDK's own package.json that the doctor reads at runtime. */ +export interface SdkManifest { + engines?: { node?: string }; + peerDependencies?: Record; +} + +/** + * Everything `runDoctor` needs from the outside world. The real + * implementation lives in doctor.ts; tests inject plain objects. + */ +export interface DoctorEnvironment { + /** Node version without the leading "v", e.g. "22.19.0". */ + nodeVersion: string; + env: Record; + sdk: SdkManifest; + /** Installed version of `name` resolved from the user's cwd, or null when not installed. */ + resolvePackageVersion(name: string): string | null; + /** Agent spec path given on the command line, if any. */ + specPath?: string; + /** Loads and validates a spec (the SDK's own loader); throws with field paths on invalid input. */ + loadSpec(path: string): AgentSpec; + /** The unvalidated spec document, for fields the schema does not model (e.g. `mcpServers`). */ + readRawSpec(path: string): unknown; + /** Resolves a built-in tool name; throws if unknown. */ + resolveTool(name: string): { requiresSandbox?: boolean }; + /** True when `command` is an existing file or is found on PATH. */ + commandExists(command: string): boolean; + fetch(url: string, init: { signal: AbortSignal }): Promise<{ ok: boolean; status: number }>; + /** True when a Docker daemon answers a ping. */ + dockerReachable(): Promise; +} diff --git a/src/cli/versionRange.ts b/src/cli/versionRange.ts new file mode 100644 index 000000000..6d3d1dedb --- /dev/null +++ b/src/cli/versionRange.ts @@ -0,0 +1,60 @@ +/** + * Minimal semver range check for `loushy doctor` (the SDK has no `semver` + * dependency). Supports the range forms this package's own `engines` and + * `peerDependencies` use: `>=x.y.z`, `^x.y.z`, `~x.y.z`, exact versions and + * `||` alternatives. A range it cannot parse is treated as satisfied rather + * than reported as a false failure. + */ + +type Triple = [number, number, number]; + +function parseTriple(text: string): Triple | null { + const match = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(text.trim()); + if (!match) return null; + return [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)]; +} + +function compare(a: Triple, b: Triple): number { + for (let i = 0; i < 3; i++) { + if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1; + } + return 0; +} + +/** Exclusive upper bound of a caret range: `^1.2.3` < 2.0.0, `^0.2.3` < 0.3.0, `^0.0.3` < 0.0.4. */ +function caretCeiling([major, minor, patch]: Triple): Triple { + if (major > 0) return [major + 1, 0, 0]; + if (minor > 0) return [0, minor + 1, 0]; + return [0, 0, patch + 1]; +} + +function inRange(version: Triple, floor: Triple, ceiling: Triple): boolean { + return compare(version, floor) >= 0 && compare(version, ceiling) < 0; +} + +function satisfiesComparator(version: Triple, comparator: string): boolean { + const text = comparator.trim(); + if (text.startsWith('>=')) { + const floor = parseTriple(text.slice(2)); + return floor === null || compare(version, floor) >= 0; + } + const operator = text[0]; + const base = parseTriple(operator === '^' || operator === '~' ? text.slice(1) : text); + if (base === null) return true; + if (operator === '^') return inRange(version, base, caretCeiling(base)); + if (operator === '~') return inRange(version, base, [base[0], base[1] + 1, 0]); + return compare(version, base) === 0; +} + +/** True when `version` (e.g. "22.19.0" or "v22.19.0") satisfies `range` (e.g. ">=22.19.0"). */ +export function satisfiesRange(version: string, range: string): boolean { + const parsed = parseTriple(version); + if (parsed === null) return false; + return range.split('||').some((alternative) => + alternative + .trim() + .split(/\s+/) + .filter((part) => part.length > 0) + .every((part) => satisfiesComparator(parsed, part)) + ); +} diff --git a/src/providers/providerEnv.ts b/src/providers/providerEnv.ts new file mode 100644 index 000000000..0721d4924 --- /dev/null +++ b/src/providers/providerEnv.ts @@ -0,0 +1,22 @@ +/** + * The single source of truth for which env var holds each real provider's + * credential/config, and which optional peer package implements it. Shared + * by resolveProvider() and `loushy doctor` (kept out of the providers barrel + * so it is not public API). + */ + +export interface ProviderEnvEntry { + /** Env var that holds the credential or endpoint. */ + envKey: string; + /** LLMProviderConfig field the env value belongs in. */ + configField: 'apiKey' | 'baseURL'; + /** Optional peer dependency that implements this provider. */ + peerPackage: string; +} + +export const PROVIDER_ENV_TABLE: Record = { + openai: { envKey: 'OPENAI_API_KEY', configField: 'apiKey', peerPackage: '@ai-sdk/openai' }, + anthropic: { envKey: 'ANTHROPIC_API_KEY', configField: 'apiKey', peerPackage: '@ai-sdk/anthropic' }, + ollama: { envKey: 'OLLAMA_BASE_URL', configField: 'baseURL', peerPackage: 'ollama-ai-provider' }, + openrouter: { envKey: 'OPENROUTER_API_KEY', configField: 'apiKey', peerPackage: '@ai-sdk/openai' }, +}; diff --git a/src/providers/resolveProvider.ts b/src/providers/resolveProvider.ts index b2a0e7c09..5587d4e79 100644 --- a/src/providers/resolveProvider.ts +++ b/src/providers/resolveProvider.ts @@ -10,20 +10,9 @@ * instance, pre-configured with `defaultModel` set to the parsed model. */ +import { PROVIDER_ENV_TABLE } from './providerEnv'; import { LLMProvider, LLMProviderConfig, LLMProviderRegistry } from './llm'; -/** - * Which env var holds a given provider's credential/config, and which - * LLMProviderConfig field that value belongs in. Most providers need an - * API key; Ollama instead needs a base URL (it has no API key concept). - */ -const PROVIDER_ENV_TABLE: Record = { - openai: { envKey: 'OPENAI_API_KEY', configField: 'apiKey' }, - anthropic: { envKey: 'ANTHROPIC_API_KEY', configField: 'apiKey' }, - ollama: { envKey: 'OLLAMA_BASE_URL', configField: 'baseURL' }, - openrouter: { envKey: 'OPENROUTER_API_KEY', configField: 'apiKey' }, -}; - /** * Resolve a "/" spec into a configured LLMProvider * instance. diff --git a/tsup.config.ts b/tsup.config.ts index f486b9193..061a4ff07 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -30,6 +30,7 @@ export default defineConfig({ 'cli/dev': 'src/cli/dev.ts', 'cli/build': 'src/cli/build.ts', 'cli/studio': 'src/cli/studio.ts', + 'cli/doctor': 'src/cli/doctor.ts', 'execution/otel': 'src/execution/otel.ts', 'execution/hooks': 'src/execution/hooks.ts', 'triggers/index': 'src/triggers/index.ts', From 852f2f408513703b0e18cbc989d34bdcd801e415 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Thu, 1 Oct 2026 09:58:09 +0300 Subject: [PATCH 2/2] docs: regenerate llms.txt --- llms-full.txt | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/llms-full.txt b/llms-full.txt index 5f7e015cd..f257a71ab 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -104,6 +104,7 @@ const result = await resumeAfterApproval( ```bash npm install @loushy/build-ai-agent ai zod +npx loushy doctor # checks Node, peers and API keys, and tells you how to fix anything ``` **2. Configure** — describe the agent as data: @@ -748,6 +749,54 @@ Installing the package also installs the `loushy` command: artifact (see [Deployment](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/deployment.md)). Building requires `tsup` (`npm install --save-dev tsup`). +- `loushy doctor [agent.yaml|json] [--json]` - diagnose your setup (see below). + +## Troubleshooting: loushy doctor + +Run `npx loushy doctor` right after installing. It prints one line per check +with a status (`ok`, `warn`, `FAIL`), what it found, and, for anything that is +not ok, the command to run or the setting to change: + +```text +loushy doctor + +[ ok ] Node.js: v22.19.0 satisfies >=22.19.0 +[ ok ] Required peer ai: 4.3.19 satisfies ^4.3.19 +[FAIL] Required peer zod: not installed + fix: npm install zod@^3.25.76 +[ ok ] Provider package @ai-sdk/openai: 0.0.42 installed +[warn] Provider package @ai-sdk/anthropic: not installed (optional) + fix: npm install @ai-sdk/anthropic@^0.0.42 +[warn] openai (OPENAI_API_KEY): not set + fix: Set OPENAI_API_KEY in your environment, e.g. export OPENAI_API_KEY= +[warn] Default provider for createAgent(): none configured (createAgent() needs a model, a provider instance, or an env var) + fix: Set LOUSHY_MODEL (e.g. openai/gpt-4o-mini) or one of the API key variables above. +[ ok ] Docker: daemon not reachable (only needed for sandboxed tools; none configured) + +4 ok, 3 warnings, 1 failure +``` + +What it checks: + +1. Your Node version against the package's `engines.node`. +2. The required peers `ai` and `zod`: installed, and within the SDK's + `peerDependencies` range (resolved from the current directory). +3. The optional provider packages (`@ai-sdk/openai`, `@ai-sdk/anthropic`, + `ollama-ai-provider`), with the `npm install` command for each missing one. +4. Whether each provider's API key variable is set. Only the variable name and + `set` / `not set` are printed, never the value. It also shows which + provider `createAgent()` would pick by default with your environment. +5. With a spec path (`loushy doctor agent.yaml`): the spec is validated with + field paths for every error, its provider package and key are checked + (missing ones become failures), its `tools` must be built-in tools, and any + `mcpServers` command must be resolvable. +6. Ollama reachability, only when the spec uses Ollama or `OLLAMA_HOST` is set. +7. Docker availability, a warning only when the spec uses a sandboxed tool. + +The exit code is `1` if any check fails and `0` otherwise (warnings do not +fail), so it can gate CI. Add `--json` for machine-readable output. Colour is +used only when stdout is a terminal and `NO_COLOR` is unset. + Next: [Quick Start](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/quick-start.md). # Quick Start