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
2 changes: 1 addition & 1 deletion .fallowrc.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "./node_modules/fallow/schema.json",
"entry": [
"src/cli/{dev,build,studio,mcp}.ts",
"src/cli/{dev,build,studio,mcp,doctor}.ts",
"src/data/index.ts",
"**/*.test-d.ts",
"**/*.{test,spec}.{ts,tsx}",
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,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:
Expand Down
7 changes: 7 additions & 0 deletions bin/loushy.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const USAGE = [
' loushy build --target=<name> --agent=<path> [--out=<dir>]',
' loushy studio [--port N] [--host H] [--prod|--dev]',
' loushy mcp <agent.yaml|json> [--http --port N --host H]',
' loushy doctor [agent.yaml|json] [--json]',
].join('\n');

/**
Expand Down Expand Up @@ -78,6 +79,11 @@ async function runStudio(rest) {
}
}

async function runDoctorCommand(rest) {
const { runDoctorCommand: run } = require(path.join(__dirname, '..', 'dist', 'cli', 'doctor.js'));
process.exitCode = await run(rest);
}

// Serves an agent spec over MCP (stdio by default). The server keeps the
// process alive; see src/cli/mcp.ts. Only the exit code is set on failure.
async function runMcp(rest) {
Expand All @@ -90,6 +96,7 @@ const COMMANDS = new Map([
['build', runBuildCommand],
['studio', runStudio],
['mcp', runMcp],
['doctor', runDoctorCommand],
]);

async function main() {
Expand Down
48 changes: 48 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,52 @@ 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=<your 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](./quick-start.md).
49 changes: 49 additions & 0 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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=<your 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
Expand Down
100 changes: 100 additions & 0 deletions src/cli/doctor.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
112 changes: 112 additions & 0 deletions src/cli/doctor.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
const { default: Docker } = await import('dockerode');
const ping = new Docker().ping().then(() => true);
const timeout = new Promise<boolean>((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<number> {
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;
}
Loading
Loading