diff --git a/.fallowrc.json b/.fallowrc.json index 0ed0688a4..2f59d6dd3 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -1,7 +1,7 @@ { "$schema": "./node_modules/fallow/schema.json", "entry": [ - "src/cli/{dev,build,studio,mcp,doctor}.ts", + "src/cli/{dev,build,studio,mcp,doctor,init}.ts", "src/data/index.ts", "**/*.test-d.ts", "**/*.{test,spec}.{ts,tsx}", diff --git a/README.md b/README.md index d79abfcd8..03d37fcff 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,15 @@ const result = await resumeAfterApproval( ## Quickstart +**Start a new project** with one command: it scaffolds an agent, an example tool and an offline test, installs dependencies and runs `git init`: + +```bash +npx loushy init my-agent # or: npm create loushy-agent my-agent +cd my-agent && npm run dev # set your API key in .env first; `npm test` runs offline +``` + +See [Quick Start](docs/quick-start.md#start-a-new-project) for the flags (`--provider`, `--template`, `--yes`, ...). Or add the SDK to an existing project by hand: + **1. Install** ```bash @@ -546,9 +555,9 @@ const result = renderTemplate(template, { name: 'Alice', count: 5 }); ## CLI ```bash -npx create-loushy-agent --name=my-agent --provider=openai --yes # scaffold a project -npx loushy dev agent.yaml # local chat UI + hot reload -npx loushy build --target=node-server --agent=agent.yaml # or docker / cloudflare-worker +npx loushy init my-agent # scaffold a project (or: npm create loushy-agent my-agent) +npx loushy dev agent.yaml # local chat UI + hot reload +npx loushy build --target=node-server --agent=agent.yaml # or docker / cloudflare-worker ``` See [Installation](docs/installation.md) and [Deployment](docs/deployment.md) diff --git a/bin/loushy.js b/bin/loushy.js index ed7e3c3b8..11d6a5494 100644 --- a/bin/loushy.js +++ b/bin/loushy.js @@ -5,6 +5,7 @@ const path = require('node:path'); const USAGE = [ 'Usage:', + ' loushy init [dir] [--provider P] [--template T] [--yes] [--no-install] [--no-git] [--package-manager PM] [--force]', ' loushy dev [--port N] [--host H]', ' loushy build --target= --agent= [--out=]', ' loushy studio [--port N] [--host H] [--prod|--dev]', @@ -79,6 +80,12 @@ async function runStudio(rest) { } } +// Scaffolds a new project (LOU-D3); see src/cli/init.ts. Also what `npm create loushy-agent` runs. +async function runInitCommand(rest) { + const { runInit } = require(path.join(__dirname, '..', 'dist', 'cli', 'init.js')); + process.exitCode = await runInit(rest); +} + async function runDoctorCommand(rest) { const { runDoctorCommand: run } = require(path.join(__dirname, '..', 'dist', 'cli', 'doctor.js')); process.exitCode = await run(rest); @@ -92,6 +99,7 @@ async function runMcp(rest) { } const COMMANDS = new Map([ + ['init', runInitCommand], ['dev', runDev], ['build', runBuildCommand], ['studio', runStudio], diff --git a/docs/installation.md b/docs/installation.md index 06f537d86..b30f2977d 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -61,7 +61,7 @@ feature needs them: `undici` (the built-in `http` tool), `dockerode` To try an unreleased version, build and pack the SDK from a checkout of this repository, then install the tarball into your project - the same approach -`create-loushy-agent` uses for the projects it generates: +`loushy init --sdk-path` uses for the projects it generates: ```bash # in the SDK checkout @@ -75,21 +75,36 @@ npm install ./loushy-build-ai-agent-.tgz ai zod ## Scaffolding a new project -`packages/create-loushy-agent` in this repository scaffolds a ready-to-build -project (`package.json`, `tsconfig.json`, `src/agent.ts` calling -`createAgent()`, and a `.env.example` naming your provider's credential -variable): +`loushy init` creates a ready-to-run project: `package.json` (ESM, depending +on this SDK by version range), a strict `tsconfig.json`, `src/agent.ts` calling +`createAgent({ model, instructions })` with an example `defineTool()` tool, an +offline `src/agent.test.ts` using `mockModel`, a `.env.example` naming your +provider's key variable, `.gitignore` and a README. It then installs the +dependencies and runs `git init`. ```bash -# from a checkout of this repository -cd packages/create-loushy-agent && npm install && npm run build && cd ../.. -node packages/create-loushy-agent/bin/cli.js --name=my-agent --provider=openai --yes +npx loushy init my-agent # or: npm create loushy-agent my-agent +npx loushy init my-agent --yes --provider anthropic --template tools --no-install ``` +| Flag | Meaning | +| ---- | ------- | +| `--provider openai\|anthropic\|openrouter\|ollama` | Default: the provider whose API key variable is set, else `openai`. | +| `--template minimal\|tools\|yaml` | `minimal` (one tool), `tools` (three tools) or `yaml` (an `agent.yaml` spec run by `loushy dev`). Default `minimal`. | +| `--package-manager npm\|pnpm\|yarn\|bun` | Default: the one that launched the command (`npm_config_user_agent`), else `npm`. | +| `--yes`, `-y` | Never prompt; use defaults for anything not given. Prompts only appear on a terminal. | +| `--no-install`, `--no-git` | Skip installing dependencies / `git init`. | +| `--force` | Write into a directory that is not empty (otherwise `init` refuses). | +| `--sdk-path ` | For SDK development: depend on a checkout (it is `npm pack`ed) or a packed `.tgz` instead of the published version. Also read from `LOUSHY_SDK_PATH`. | + +`create-loushy-agent` (`packages/create-loushy-agent`) is a thin wrapper that +runs `loushy init` with the same arguments. + ## The `loushy` CLI Installing the package also installs the `loushy` command: +- `loushy init [dir]` - scaffold a new project (see above). - `loushy dev ` - local dev server with a chat UI and hot reload (see [Configuration](./configuration.md)). - `loushy build --target= --agent=` - build a deployable diff --git a/docs/quick-start.md b/docs/quick-start.md index d753584a4..845c4d219 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -7,7 +7,32 @@ with the real API. All of them run as-is with the SDK's built-in mock provider - no API key needed - except the first, which talks to a real model and is therefore only type-checked. -Install first (see [Installation](./installation.md)): +## Start a new project + +The fastest way in is one command, which creates a runnable project (an +agent, an example tool, an offline test, a `.env.example` for your provider), +installs its dependencies and runs `git init`: + +```bash +npx loushy init my-agent +cd my-agent +cp .env.example .env # put your API key in .env +npm run dev # chat with your agent in the terminal +npm test # offline tests: no API key needed +``` + +`npm create loushy-agent my-agent` is equivalent, and so is +`npx @loushy/build-ai-agent init my-agent` when the SDK is not installed yet +(a bare `npx loushy` only finds the `loushy` command once the SDK is in your +`node_modules`). Without arguments, `init` asks for the directory, provider and +template; for scripts pass `--yes` (defaults: the `minimal` template and the +provider whose API key variable is set, else OpenAI). Useful flags: +`--provider openai|anthropic|openrouter|ollama`, `--template minimal|tools|yaml`, +`--package-manager npm|pnpm|yarn|bun`, `--no-install`, `--no-git`, `--force` +(write into a non-empty directory). `loushy init --help` lists them all. + +Prefer to add the SDK to an existing project? Install it by hand (see +[Installation](./installation.md)): ```bash npm install @loushy/build-ai-agent ai zod diff --git a/llms-full.txt b/llms-full.txt index 44a60ab1e..2a12cb28e 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -103,6 +103,15 @@ const result = await resumeAfterApproval( ## Quickstart +**Start a new project** with one command: it scaffolds an agent, an example tool and an offline test, installs dependencies and runs `git init`: + +```bash +npx loushy init my-agent # or: npm create loushy-agent my-agent +cd my-agent && npm run dev # set your API key in .env first; `npm test` runs offline +``` + +See [Quick Start](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/quick-start.md#start-a-new-project) for the flags (`--provider`, `--template`, `--yes`, ...). Or add the SDK to an existing project by hand: + **1. Install** ```bash @@ -544,9 +553,9 @@ const result = renderTemplate(template, { name: 'Alice', count: 5 }); ## CLI ```bash -npx create-loushy-agent --name=my-agent --provider=openai --yes # scaffold a project -npx loushy dev agent.yaml # local chat UI + hot reload -npx loushy build --target=node-server --agent=agent.yaml # or docker / cloudflare-worker +npx loushy init my-agent # scaffold a project (or: npm create loushy-agent my-agent) +npx loushy dev agent.yaml # local chat UI + hot reload +npx loushy build --target=node-server --agent=agent.yaml # or docker / cloudflare-worker ``` See [Installation](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/installation.md) and [Deployment](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/deployment.md) @@ -755,7 +764,7 @@ feature needs them: `undici` (the built-in `http` tool), `dockerode` To try an unreleased version, build and pack the SDK from a checkout of this repository, then install the tarball into your project - the same approach -`create-loushy-agent` uses for the projects it generates: +`loushy init --sdk-path` uses for the projects it generates: ```bash # in the SDK checkout @@ -769,21 +778,36 @@ npm install ./loushy-build-ai-agent-.tgz ai zod ## Scaffolding a new project -`packages/create-loushy-agent` in this repository scaffolds a ready-to-build -project (`package.json`, `tsconfig.json`, `src/agent.ts` calling -`createAgent()`, and a `.env.example` naming your provider's credential -variable): +`loushy init` creates a ready-to-run project: `package.json` (ESM, depending +on this SDK by version range), a strict `tsconfig.json`, `src/agent.ts` calling +`createAgent({ model, instructions })` with an example `defineTool()` tool, an +offline `src/agent.test.ts` using `mockModel`, a `.env.example` naming your +provider's key variable, `.gitignore` and a README. It then installs the +dependencies and runs `git init`. ```bash -# from a checkout of this repository -cd packages/create-loushy-agent && npm install && npm run build && cd ../.. -node packages/create-loushy-agent/bin/cli.js --name=my-agent --provider=openai --yes +npx loushy init my-agent # or: npm create loushy-agent my-agent +npx loushy init my-agent --yes --provider anthropic --template tools --no-install ``` +| Flag | Meaning | +| ---- | ------- | +| `--provider openai\|anthropic\|openrouter\|ollama` | Default: the provider whose API key variable is set, else `openai`. | +| `--template minimal\|tools\|yaml` | `minimal` (one tool), `tools` (three tools) or `yaml` (an `agent.yaml` spec run by `loushy dev`). Default `minimal`. | +| `--package-manager npm\|pnpm\|yarn\|bun` | Default: the one that launched the command (`npm_config_user_agent`), else `npm`. | +| `--yes`, `-y` | Never prompt; use defaults for anything not given. Prompts only appear on a terminal. | +| `--no-install`, `--no-git` | Skip installing dependencies / `git init`. | +| `--force` | Write into a directory that is not empty (otherwise `init` refuses). | +| `--sdk-path ` | For SDK development: depend on a checkout (it is `npm pack`ed) or a packed `.tgz` instead of the published version. Also read from `LOUSHY_SDK_PATH`. | + +`create-loushy-agent` (`packages/create-loushy-agent`) is a thin wrapper that +runs `loushy init` with the same arguments. + ## The `loushy` CLI Installing the package also installs the `loushy` command: +- `loushy init [dir]` - scaffold a new project (see above). - `loushy dev ` - local dev server with a chat UI and hot reload (see [Configuration](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/configuration.md)). - `loushy build --target= --agent=` - build a deployable @@ -851,7 +875,32 @@ with the real API. All of them run as-is with the SDK's built-in mock provider - no API key needed - except the first, which talks to a real model and is therefore only type-checked. -Install first (see [Installation](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/installation.md)): +## Start a new project + +The fastest way in is one command, which creates a runnable project (an +agent, an example tool, an offline test, a `.env.example` for your provider), +installs its dependencies and runs `git init`: + +```bash +npx loushy init my-agent +cd my-agent +cp .env.example .env # put your API key in .env +npm run dev # chat with your agent in the terminal +npm test # offline tests: no API key needed +``` + +`npm create loushy-agent my-agent` is equivalent, and so is +`npx @loushy/build-ai-agent init my-agent` when the SDK is not installed yet +(a bare `npx loushy` only finds the `loushy` command once the SDK is in your +`node_modules`). Without arguments, `init` asks for the directory, provider and +template; for scripts pass `--yes` (defaults: the `minimal` template and the +provider whose API key variable is set, else OpenAI). Useful flags: +`--provider openai|anthropic|openrouter|ollama`, `--template minimal|tools|yaml`, +`--package-manager npm|pnpm|yarn|bun`, `--no-install`, `--no-git`, `--force` +(write into a non-empty directory). `loushy init --help` lists them all. + +Prefer to add the SDK to an existing project? Install it by hand (see +[Installation](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/installation.md)): ```bash npm install @loushy/build-ai-agent ai zod diff --git a/package-lock.json b/package-lock.json index 12de0211e..f9f3e7271 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@modelcontextprotocol/sdk": "^1.30.1", "dockerode": "^5.0.1", "nanoid": "^5.0.7", + "prompts": "^2.4.2", "undici": "^8.11.2", "yaml": "^2.9.1" }, @@ -29,6 +30,7 @@ "@opentelemetry/sdk-trace-node": "^2.11.0", "@types/dockerode": "^4.0.1", "@types/node": "^20", + "@types/prompts": "^2.4.9", "@vitest/coverage-v8": "^1.0.0", "esbuild": "^0.25.12", "eslint": "^10.11.0", @@ -12436,14 +12438,13 @@ "packages/create-loushy-agent": { "version": "0.1.0", "dependencies": { - "prompts": "^2.4.2" + "@loushy/build-ai-agent": "^1.0.0-alpha.8" }, "bin": { "create-loushy-agent": "bin/cli.js" }, "devDependencies": { "@types/node": "^20.0.0", - "@types/prompts": "^2.4.9", "tsup": "^8.0.0", "typescript": "^5.0.0", "vitest": "^1.6.1" diff --git a/package.json b/package.json index fb05b7b9a..cd79e3143 100644 --- a/package.json +++ b/package.json @@ -173,6 +173,7 @@ "@modelcontextprotocol/sdk": "^1.30.1", "dockerode": "^5.0.1", "nanoid": "^5.0.7", + "prompts": "^2.4.2", "undici": "^8.11.2", "yaml": "^2.9.1" }, @@ -183,6 +184,7 @@ "@opentelemetry/sdk-trace-node": "^2.11.0", "@types/dockerode": "^4.0.1", "@types/node": "^20", + "@types/prompts": "^2.4.9", "@vitest/coverage-v8": "^1.0.0", "esbuild": "^0.25.12", "eslint": "^10.11.0", diff --git a/packages/create-loushy-agent/package.json b/packages/create-loushy-agent/package.json index e2302e1a9..750d228de 100644 --- a/packages/create-loushy-agent/package.json +++ b/packages/create-loushy-agent/package.json @@ -1,15 +1,14 @@ { "name": "create-loushy-agent", "version": "0.1.0", - "description": "Scaffold a new @loushy/build-ai-agent project", + "description": "npm create loushy-agent: scaffold a new @loushy/build-ai-agent project (runs `loushy init`)", "type": "commonjs", "bin": { "create-loushy-agent": "bin/cli.js" }, "files": [ "dist", - "bin", - "templates" + "bin" ], "scripts": { "build": "tsup", @@ -17,11 +16,10 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "prompts": "^2.4.2" + "@loushy/build-ai-agent": "^1.0.0-alpha.8" }, "devDependencies": { "@types/node": "^20.0.0", - "@types/prompts": "^2.4.9", "tsup": "^8.0.0", "typescript": "^5.0.0", "vitest": "^1.6.1" diff --git a/packages/create-loushy-agent/src/cli.integration.test.ts b/packages/create-loushy-agent/src/cli.integration.test.ts deleted file mode 100644 index 627f70789..000000000 --- a/packages/create-loushy-agent/src/cli.integration.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, it, expect, afterEach } from 'vitest'; -import { execFileSync } from 'node:child_process'; -import path from 'node:path'; -import fs from 'node:fs'; -import os from 'node:os'; - -const CLI = path.join(__dirname, '..', 'bin', 'cli.js'); - -const tempDirs: string[] = []; -afterEach(() => { - for (const dir of tempDirs.splice(0)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -describe('cli (flag-based non-interactive shortcut)', () => { - it('scaffolds using --name/--provider without touching the interactive prompts', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'create-loushy-agent-cli-')); - tempDirs.push(dir); - - const output = execFileSync( - process.execPath, - [ - CLI, - '--name=test-agent', - '--provider=anthropic', - '--tools=http', - '--yes', - `--dir=${dir}`, - ], - { encoding: 'utf8' } - ); - - expect(output).toContain('Scaffolding test-agent (anthropic)'); - expect(fs.existsSync(path.join(dir, 'package.json'))).toBe(true); - expect(fs.existsSync(path.join(dir, 'src', 'agent.ts'))).toBe(true); - }); -}); diff --git a/packages/create-loushy-agent/src/index.test.ts b/packages/create-loushy-agent/src/index.test.ts new file mode 100644 index 000000000..580c48da4 --- /dev/null +++ b/packages/create-loushy-agent/src/index.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { loushyBin } from './index'; + +const CLI = path.join(__dirname, '..', 'bin', 'cli.js'); + +const tempDirs: string[] = []; +afterEach(() => { + for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +function tempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'create-loushy-agent-')); + tempDirs.push(dir); + return dir; +} + +function run(args: string[]): string { + return execFileSync(process.execPath, [CLI, ...args], { encoding: 'utf8', stdio: 'pipe' }); +} + +describe('create-loushy-agent wraps loushy init', () => { + it('finds the loushy bin of the installed SDK', () => { + expect(fs.existsSync(loushyBin())).toBe(true); + expect(path.basename(loushyBin())).toBe('loushy.js'); + }); + + it('forwards its arguments to loushy init (--help)', () => { + expect(run(['--help'])).toContain('Usage: loushy init'); + }); + + it('scaffolds a project from the positional directory and flags', () => { + const base = tempDir(); + const tarball = path.join(base, 'sdk-0.0.0.tgz'); + fs.writeFileSync(tarball, 'tgz'); + const dir = path.join(base, 'demo'); + + const output = run([dir, '--yes', '--provider', 'anthropic', '--no-install', '--no-git', '--sdk-path', tarball]); + + expect(output).toContain('Next steps:'); + expect(fs.readFileSync(path.join(dir, '.env.example'), 'utf8')).toContain('ANTHROPIC_API_KEY='); + expect(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).dependencies['@loushy/build-ai-agent']).toBe( + 'file:./sdk-0.0.0.tgz' + ); + }); + + it('exits non-zero with the init error for a bad flag', () => { + expect(() => run(['--provider', 'gemini'])).toThrow(/invalid --provider 'gemini'/); + }); +}); diff --git a/packages/create-loushy-agent/src/index.ts b/packages/create-loushy-agent/src/index.ts index 746e68c03..389ed0bc8 100644 --- a/packages/create-loushy-agent/src/index.ts +++ b/packages/create-loushy-agent/src/index.ts @@ -1,142 +1,30 @@ -import { parseArgs } from 'node:util'; +import { spawn } from 'node:child_process'; import path from 'node:path'; -import { collectAnswers, validateAnswers, AnswerConfig } from './prompts'; -import { generateProject } from './template'; - -const USAGE = `create-loushy-agent - scaffold a new @loushy/build-ai-agent project - -Usage: - create-loushy-agent [options] - -Options: - --name Project name (skips the interactive prompt) - --provider LLM provider: openai | anthropic | ollama - --tools Comma-separated tool list (e.g. "http,github") - --yes, -y Accept flag values / defaults without prompting - --help, -h Show this help message -`; - -interface ParsedCliArgs { - help: boolean; - yes: boolean; - name?: string; - provider?: string; - tools?: string; - dir?: string; -} - -/** - * Parses argv using Node's built-in util.parseArgs (no new dependency). - * Throws a descriptive error for an unrecognized flag rather than letting - * parseArgs's own generic error surface. - */ -function parseCliArgs(argv: string[]): ParsedCliArgs { - let values: Record; - try { - ({ values } = parseArgs({ - args: argv, - options: { - help: { type: 'boolean', short: 'h', default: false }, - yes: { type: 'boolean', short: 'y', default: false }, - name: { type: 'string' }, - provider: { type: 'string' }, - tools: { type: 'string' }, - dir: { type: 'string' }, - }, - allowPositionals: true, - strict: true, - })); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - // node:util's parseArgs error message for an unknown flag looks like - // "Unknown option '--xyz'" - normalize to the exact phrasing this - // ticket asks for. - const match = message.match(/Unknown option '(--?[^']+)'/); - if (match) { - throw new CliArgError(`Unknown flag: ${match[1]}`); - } - throw new CliArgError(message); - } - - return { - help: !!values.help, - yes: !!values.yes, - name: values.name as string | undefined, - provider: values.provider as string | undefined, - tools: values.tools as string | undefined, - dir: values.dir as string | undefined, - }; -} - -class CliArgError extends Error {} /** - * CLI entry point. Returns a process exit code. + * `npm create loushy-agent [dir] [options]` is `loushy init [dir] [options]`. + * + * The generator lives in the SDK (`src/cli/init.ts`) so there is exactly one + * copy of it; this package only depends on the SDK (same version line) and + * runs its `loushy` binary. `loushy init --help` lists the options. */ -export async function main(argv: string[]): Promise { - let args: ParsedCliArgs; - try { - args = parseCliArgs(argv); - } catch (error) { - if (error instanceof CliArgError) { - process.stderr.write(error.message + '\n'); - return 1; - } - throw error; - } - - if (args.help) { - process.stdout.write(USAGE); - return 0; - } - return runScaffold(args); +/** Absolute path of the `loushy` bin of the installed `@loushy/build-ai-agent`. */ +export function loushyBin(): string { + // The package's "exports" map hides package.json, but the "." entry + // (/dist/index.js) is resolvable and bin/ is its sibling of dist/. + const entry = require.resolve('@loushy/build-ai-agent'); + return path.join(path.dirname(entry), '..', 'bin', 'loushy.js'); } -/** Splits a comma-separated --tools value into a trimmed, non-empty list. */ -function parseToolList(raw: string | undefined): string[] { - if (!raw) return []; - return raw - .split(',') - .map((t) => t.trim()) - .filter(Boolean); -} - -/** True when flags alone are enough to scaffold, so no interactive prompt is needed. */ -function canSkipPrompts(args: ParsedCliArgs): boolean { - return args.yes || (!!args.name && !!args.provider); -} - -/** - * Resolves the AnswerConfig either from --yes/flag shortcuts (non-interactive, - * testable via a piped/scripted process) or by running the interactive - * prompts.ts flow. A flag-only invocation ("--yes" or both "--name" and - * "--provider" given) never touches the interactive prompts, which is what - * makes this reliably testable end-to-end without a real TTY. - */ -async function resolveAnswers(args: ParsedCliArgs): Promise { - if (!canSkipPrompts(args)) { - return collectAnswers(); - } - - return validateAnswers({ - name: args.name || 'my-loushy-agent', - provider: args.provider || 'openai', - tools: parseToolList(args.tools), +/** Runs `loushy init `; resolves to its exit code. */ +export function main(argv: string[]): Promise { + return new Promise((resolve) => { + const child = spawn(process.execPath, [loushyBin(), 'init', ...argv], { stdio: 'inherit' }); + child.on('error', (error) => { + process.stderr.write(`create-loushy-agent: could not run loushy init: ${error.message}\n`); + resolve(1); + }); + child.on('close', (code) => resolve(code ?? 1)); }); } - -/** - * Scaffold step: resolve answers, then generate the project on disk. - * --dir overrides the target directory (defaults to ./); used by - * tests to target a temp directory instead of the CLI's own cwd. - */ -async function runScaffold(args: ParsedCliArgs): Promise { - const answers = await resolveAnswers(args); - const targetDir = args.dir || path.join(process.cwd(), answers.name); - - process.stdout.write(`Scaffolding ${answers.name} (${answers.provider}) into ${targetDir}...\n`); - await generateProject(targetDir, answers); - process.stdout.write('Done.\n'); - return 0; -} diff --git a/packages/create-loushy-agent/src/prompts.test.ts b/packages/create-loushy-agent/src/prompts.test.ts deleted file mode 100644 index dadc5260d..000000000 --- a/packages/create-loushy-agent/src/prompts.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import prompts from 'prompts'; -import { collectAnswers, validateAnswers } from './prompts'; - -describe('collectAnswers', () => { - it('collects a name/provider/tools answer set via injected (non-interactive) answers', async () => { - prompts.inject(['my-agent', 'anthropic', ['http', 'github']]); - - const answers = await collectAnswers(); - - expect(answers).toEqual({ - name: 'my-agent', - provider: 'anthropic', - tools: ['http', 'github'], - }); - }); -}); - -describe('validateAnswers', () => { - it('throws naming the invalid provider and the allowed set', () => { - expect(() => - validateAnswers({ name: 'x', provider: 'not-a-provider', tools: [] }) - ).toThrow(/invalid provider 'not-a-provider'.*openai, anthropic, ollama/); - }); - - it('accepts a valid provider', () => { - const result = validateAnswers({ name: 'x', provider: 'openai', tools: [] }); - expect(result.provider).toBe('openai'); - }); -}); diff --git a/packages/create-loushy-agent/src/prompts.ts b/packages/create-loushy-agent/src/prompts.ts deleted file mode 100644 index 8ec5ca92a..000000000 --- a/packages/create-loushy-agent/src/prompts.ts +++ /dev/null @@ -1,76 +0,0 @@ -import prompts from 'prompts'; - -const PROVIDERS = ['openai', 'anthropic', 'ollama'] as const; -export type Provider = (typeof PROVIDERS)[number]; - -const TOOL_CHOICES = ['http', 'github'] as const; - -export interface AnswerConfig { - name: string; - provider: Provider; - tools: string[]; -} - -/** - * Interactively collects the answers needed to scaffold a project: project - * name (text), provider (select), tools (multiselect). Uses the `prompts` - * library, which supports non-interactive testing via `prompts.inject()` - - * tests call that before invoking collectAnswers() instead of driving a - * real TTY. - */ -export async function collectAnswers(): Promise { - const response = await prompts([ - { - type: 'text', - name: 'name', - message: 'Project name', - initial: 'my-loushy-agent', - }, - { - type: 'select', - name: 'provider', - message: 'LLM provider', - choices: PROVIDERS.map((p) => ({ title: p, value: p })), - initial: 0, - }, - { - type: 'multiselect', - name: 'tools', - message: 'Starter tools', - choices: TOOL_CHOICES.map((t) => ({ title: t, value: t })), - }, - ]); - - return validateAnswers({ - name: response.name, - provider: response.provider, - tools: response.tools || [], - }); -} - -/** - * Validates an AnswerConfig, throwing a clear error naming the invalid - * value and the allowed set. Used both after interactive collection and - * whenever a non-interactive flag override (--provider) supplies a - * provider value directly, bypassing the select prompt's own restricted - * choice list. - */ -export function validateAnswers(answers: { - name: string; - provider: string; - tools: string[]; -}): AnswerConfig { - if (!answers.name || !answers.name.trim()) { - throw new Error('collectAnswers: a project name is required'); - } - if (!(PROVIDERS as readonly string[]).includes(answers.provider)) { - throw new Error( - `collectAnswers: invalid provider '${answers.provider}'. Allowed values: ${PROVIDERS.join(', ')}` - ); - } - return { - name: answers.name.trim(), - provider: answers.provider as Provider, - tools: answers.tools, - }; -} diff --git a/packages/create-loushy-agent/src/template.e2e.test.ts b/packages/create-loushy-agent/src/template.e2e.test.ts index 9111ab82c..cdb4b5560 100644 --- a/packages/create-loushy-agent/src/template.e2e.test.ts +++ b/packages/create-loushy-agent/src/template.e2e.test.ts @@ -1,59 +1,68 @@ /** - * LOU-H4 real, end-to-end integration test: runs the CLI in a FRESH TEMP - * DIRECTORY (os.tmpdir()+mkdtemp, never inside this worktree), then - * actually runs `npm install && npm run build` inside the generated - * project and asserts both succeed. This is slow (npm install downloads - * real packages) so it's kept in its own file / a generous timeout. + * End-to-end test of `create-loushy-agent` (which runs `loushy init`): scaffolds + * a project in a FRESH TEMP DIRECTORY (never inside this checkout) with + * `--sdk-path` pointing at this checkout, so the generator `npm pack`s the + * local SDK build instead of fetching a published version. It lets `init` + * run the real `npm install`, then typechecks the generated project and runs + * its own `npm test` offline. Slow (a real install), hence the long timeout. + * + * Needs the SDK built first: `npm run build` at the repository root. */ import { describe, it, expect, afterAll } from 'vitest'; -import { execFileSync } from 'node:child_process'; +import { execFileSync, execSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; const CLI = path.join(__dirname, '..', 'bin', 'cli.js'); +const SDK_ROOT = path.join(__dirname, '..', '..', '..'); -describe('end-to-end scaffold + install + build', () => { - let dir: string | undefined; +/** Environment without any provider credentials, to prove the generated tests need none. */ +function offlineEnv(): NodeJS.ProcessEnv { + const env = { ...process.env }; + for (const key of ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENROUTER_API_KEY', 'OLLAMA_BASE_URL', 'LOUSHY_MODEL']) { + delete env[key]; + } + return env; +} + +function npm(args: string[], cwd: string): string { + return execSync(`npm ${args.join(' ')}`, { cwd, encoding: 'utf8', env: offlineEnv() }); +} + +describe('end-to-end scaffold + install + typecheck + test', () => { + let base: string | undefined; afterAll(() => { - if (dir) fs.rmSync(dir, { recursive: true, force: true }); + if (base) fs.rmSync(base, { recursive: true, force: true }); }); it( - 'generates an installable, buildable project pinned to the SDK\'s exact own version', + 'generates a project that installs from the locally packed SDK, typechecks, and passes its own tests', () => { - const base = fs.mkdtempSync(path.join(os.tmpdir(), 'create-loushy-agent-e2e-')); - dir = path.join(base, 'test-agent'); + base = fs.mkdtempSync(path.join(os.tmpdir(), 'create-loushy-agent-e2e-')); + const dir = path.join(base, 'test-agent'); execFileSync( process.execPath, - [CLI, '--name=test-agent', '--provider=openai', '--yes', `--dir=${dir}`], - { encoding: 'utf8' } + [CLI, dir, '--yes', '--no-git', '--provider', 'openai', '--package-manager', 'npm', '--sdk-path', SDK_ROOT], + { encoding: 'utf8', env: offlineEnv(), stdio: 'pipe' } ); - expect(fs.existsSync(path.join(dir, 'package.json'))).toBe(true); + const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); + expect(pkg.dependencies['@loushy/build-ai-agent']).toMatch(/^file:\.\/.*\.tgz$/); + expect(fs.existsSync(path.join(dir, 'node_modules', '@loushy', 'build-ai-agent'))).toBe(true); - execFileSync('npm', ['install'], { cwd: dir, encoding: 'utf8', shell: true }); - const buildOutput = execFileSync('npm', ['run', 'build'], { - cwd: dir, - encoding: 'utf8', - shell: true, - }); - expect(buildOutput).toBeDefined(); + npm(['run', 'typecheck'], dir); + const testOutput = npm(['test'], dir); + expect(testOutput).toMatch(/2 passed/); - const sdkRootPkg = JSON.parse( - fs.readFileSync(path.join(__dirname, '..', '..', '..', 'package.json'), 'utf8') + const sdkPkg = JSON.parse(fs.readFileSync(path.join(SDK_ROOT, 'package.json'), 'utf8')); + const installed = JSON.parse( + fs.readFileSync(path.join(dir, 'node_modules', '@loushy', 'build-ai-agent', 'package.json'), 'utf8') ); - const installedPkg = JSON.parse( - fs.readFileSync( - path.join(dir, 'node_modules', '@loushy', 'build-ai-agent', 'package.json'), - 'utf8' - ) - ); - - expect(installedPkg.version).toBe(sdkRootPkg.version); + expect(installed.version).toBe(sdkPkg.version); }, - 180_000 + 300_000 ); }); diff --git a/packages/create-loushy-agent/src/template.env.test.ts b/packages/create-loushy-agent/src/template.env.test.ts deleted file mode 100644 index 8f191b0e4..000000000 --- a/packages/create-loushy-agent/src/template.env.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * LOU-H5: provider/env-var wiring test. Scaffolds two projects with - * different --provider flags into separate temp dirs and greps each - * generated .env.example for the expected exact var name (matching - * LOU-F8's resolveProvider() env table: OPENAI_API_KEY, ANTHROPIC_API_KEY, - * OLLAMA_BASE_URL). - */ -import { describe, it, expect, afterEach } from 'vitest'; -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { PROVIDER_ENV_VARS } from './template'; - -const CLI = path.join(__dirname, '..', 'bin', 'cli.js'); - -const tempDirs: string[] = []; -afterEach(() => { - for (const dir of tempDirs.splice(0)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -describe('provider env-var wiring', () => { - it.each([ - ['openai', 'OPENAI_API_KEY'], - ['anthropic', 'ANTHROPIC_API_KEY'], - ['ollama', 'OLLAMA_BASE_URL'], - ])('writes %s -> %s into .env.example', (provider, envVar) => { - expect(PROVIDER_ENV_VARS[provider]).toBe(envVar); - - const dir = fs.mkdtempSync(path.join(os.tmpdir(), `create-loushy-agent-env-${provider}-`)); - tempDirs.push(dir); - const projectDir = path.join(dir, 'proj'); - - execFileSync( - process.execPath, - [CLI, `--name=proj`, `--provider=${provider}`, '--yes', `--dir=${projectDir}`], - { encoding: 'utf8' } - ); - - const envExample = fs.readFileSync(path.join(projectDir, '.env.example'), 'utf8'); - expect(envExample).toMatch(new RegExp(`^${envVar}=`, 'm')); - }); -}); diff --git a/packages/create-loushy-agent/src/template.ts b/packages/create-loushy-agent/src/template.ts deleted file mode 100644 index 95ce2f333..000000000 --- a/packages/create-loushy-agent/src/template.ts +++ /dev/null @@ -1,179 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { execFileSync } from 'node:child_process'; -import { AnswerConfig } from './prompts'; - -/** - * Locates the @loushy/build-ai-agent SDK's own package.json. - * - * This CLI is developed inside the same monorepo/worktree as the SDK - * (packages/create-loushy-agent, with the SDK at the worktree root) and - * the SDK is NOT published to the public npm registry, so a plain semver - * dependency spec in a generated project's package.json would not be - * installable. generateProject() instead packs the SDK's own source tree - * with `npm pack` and copies the resulting tarball into the generated - * project, wiring a `file:./` dependency that installs correctly - * from any directory, including one far outside this worktree. The - * tarball's version always matches the SDK's real package.json version - * exactly, which is what LOU-H4's "pinned version" requirement is about. - */ -function findSdkRoot(): string { - // packages/create-loushy-agent/src -> packages/create-loushy-agent -> packages -> - const candidate = path.resolve(__dirname, '..', '..', '..'); - const pkgPath = path.join(candidate, 'package.json'); - if (!fs.existsSync(pkgPath)) { - throw new Error( - `generateProject: could not locate the @loushy/build-ai-agent SDK root from ${__dirname} (expected package.json at ${pkgPath})` - ); - } - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); - if (pkg.name !== '@loushy/build-ai-agent') { - throw new Error( - `generateProject: expected @loushy/build-ai-agent at ${pkgPath}, found '${pkg.name}'` - ); - } - return candidate; -} - -/** Env var each provider's generated agent reads its credential from (LOU-H5, matching LOU-F8's resolveProvider table). */ -export const PROVIDER_ENV_VARS: Record = { - openai: 'OPENAI_API_KEY', - anthropic: 'ANTHROPIC_API_KEY', - ollama: 'OLLAMA_BASE_URL', -}; - -function toolImportLine(tool: string): string { - if (tool === 'http') return "import { httpTool } from '@loushy/build-ai-agent/tools';"; - // 'github' (and any other future starter tool) needs real credentials - // (token/owner/repo) that generateProject() has no value for, so it is - // left commented out with a TODO rather than wired to a broken config. - return `// TODO: wire up the '${tool}' tool (needs credentials) - see @loushy/build-ai-agent/tools`; -} - -function toolsObjectSource(tools: string[]): string { - const entries: string[] = []; - if (tools.includes('http')) { - entries.push(' http: httpTool,'); - } - return entries.join('\n'); -} - -/** - * Packs the SDK's current source tree into `dir` so `npm install` works - * standalone in the generated project, from any cwd. Returns the tarball's - * file name. - */ -function packSdkTarball(sdkRoot: string, dir: string): string { - // shell:true is required for execFileSync to run the `npm`/`npm.cmd` - // batch script on Windows; every argument here is either a fixed literal - // or a path generateProject() itself computed (never raw user input), - // so this isn't a shell-injection risk in practice. - const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'; - const packOutput = execFileSync(npmCmd, ['pack', '--pack-destination', dir], { - cwd: sdkRoot, - encoding: 'utf8', - shell: true, - }); - return packOutput.trim().split(/\r?\n/).pop()!.trim(); -} - -function buildPackageJson(name: string, tarballName: string) { - return { - name, - version: '0.1.0', - private: true, - type: 'commonjs', - scripts: { - build: 'tsc', - start: 'node dist/agent.js', - }, - dependencies: { - '@loushy/build-ai-agent': `file:./${tarballName}`, - }, - devDependencies: { - typescript: '^5.0.0', - '@types/node': '^20.0.0', - }, - }; -} - -function buildTsconfig() { - return { - compilerOptions: { - outDir: './dist', - rootDir: './src', - module: 'CommonJS', - moduleResolution: 'node', - target: 'ES2022', - lib: ['ES2022'], - skipLibCheck: true, - strict: true, - esModuleInterop: true, - resolveJsonModule: true, - declaration: false, - types: ['node'], - }, - include: ['src/**/*'], - }; -} - -function buildAgentSource(answers: AnswerConfig): string { - const toolImports = answers.tools.map(toolImportLine).join('\n'); - const toolsSource = toolsObjectSource(answers.tools); - - return `import { createAgent } from '@loushy/build-ai-agent'; -${toolImports} - -const agent = createAgent({ - model: '${answers.provider}/${defaultModelFor(answers.provider)}', - instructions: 'You are a helpful assistant built with @loushy/build-ai-agent.', -${toolsSource ? ` tools: {\n${toolsSource}\n },\n` : ''}}); - -async function main() { - const result = await agent.send(process.argv.slice(2).join(' ') || 'hello'); - console.log(result.text); -} - -main().catch((error) => { - console.error(error); - process.exitCode = 1; -}); -`; -} - -/** - * Generates a new @loushy/build-ai-agent project at `dir`: package.json - * (with a real, installable, exact-version-pinned SDK dependency), - * tsconfig.json (mirroring the root project's baseline), src/agent.ts - * (calling createAgent() with the chosen provider/tools), and a - * .env.example naming the right provider env var (LOU-H5). - */ -export async function generateProject(dir: string, answers: AnswerConfig): Promise { - fs.mkdirSync(dir, { recursive: true }); - fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); - - const sdkRoot = findSdkRoot(); - - // Pack the SDK's current source tree and copy the tarball alongside the - // generated project (see packSdkTarball()). - const tarballName = packSdkTarball(sdkRoot, dir); - - const pkgJson = buildPackageJson(answers.name, tarballName); - fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(pkgJson, null, 2) + '\n'); - - fs.writeFileSync(path.join(dir, 'tsconfig.json'), JSON.stringify(buildTsconfig(), null, 2) + '\n'); - - const envVar = PROVIDER_ENV_VARS[answers.provider]; - fs.writeFileSync( - path.join(dir, '.env.example'), - `# LLM provider credential for '${answers.provider}'\n${envVar}=\n` - ); - - fs.writeFileSync(path.join(dir, 'src', 'agent.ts'), buildAgentSource(answers)); -} - -function defaultModelFor(provider: string): string { - if (provider === 'openai') return 'gpt-4o-mini'; - if (provider === 'anthropic') return 'claude-3-5-sonnet-latest'; - return 'llama3'; -} diff --git a/src/cli/init.test.ts b/src/cli/init.test.ts new file mode 100644 index 000000000..df3388d95 --- /dev/null +++ b/src/cli/init.test.ts @@ -0,0 +1,190 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import prompts from 'prompts'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { runInit, type InitEnvironment } from './init'; + +interface Harness { + environment: InitEnvironment; + out: string[]; + err: string[]; + commands: string[]; +} + +let root: string; +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'loushy-init-')); +}); +afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + +function harness(overrides: Partial = {}, execCode = 0): Harness { + const out: string[] = []; + const err: string[] = []; + const commands: string[] = []; + const environment: InitEnvironment = { + env: {}, + cwd: root, + interactive: false, + sdk: { version: '1.0.0-alpha.8', peerDependencies: { ai: '^4.3.19', zod: '^3.25.76' } }, + write: (text) => out.push(text), + writeError: (text) => err.push(text), + exec: async (command, args) => { + commands.push([command, ...args].join(' ')); + return execCode; + }, + ...overrides, + }; + return { environment, out, err, commands }; +} + +const read = (...parts: string[]) => fs.readFileSync(path.join(root, ...parts), 'utf8'); + +describe('runInit --yes', () => { + it('scaffolds the minimal openai project with defaults, installs and git-inits', async () => { + const h = harness(); + expect(await runInit(['my-agent', '--yes'], h.environment)).toBe(0); + + expect(fs.existsSync(path.join(root, 'my-agent', 'src', 'agent.ts'))).toBe(true); + expect(JSON.parse(read('my-agent', 'package.json'))).toMatchObject({ name: 'my-agent', type: 'module' }); + expect(read('my-agent', '.env.example')).toContain('OPENAI_API_KEY='); + expect(h.commands).toEqual(['git init', 'npm install']); + const text = h.out.join(''); + expect(text).toContain('Next steps:'); + expect(text).toContain('cd my-agent'); + expect(text).toContain('OPENAI_API_KEY'); + expect(text).toContain('npm run dev'); + }); + + it('uses my-agent as the directory when none is given', async () => { + expect(await runInit(['--yes', '--no-install', '--no-git'], harness().environment)).toBe(0); + expect(fs.existsSync(path.join(root, 'my-agent', 'package.json'))).toBe(true); + }); + + it('detects the provider from the API key in the environment', async () => { + const h = harness({ env: { ANTHROPIC_API_KEY: 'sk-test' } }); + await runInit(['demo', '--yes', '--no-install', '--no-git'], h.environment); + expect(read('demo', 'src', 'agent.ts')).toContain("model: 'anthropic/"); + expect(read('demo', '.env.example')).toContain('ANTHROPIC_API_KEY='); + }); + + it('lets --provider and --template override detection and defaults', async () => { + const h = harness({ env: { ANTHROPIC_API_KEY: 'sk-test' } }); + await runInit(['demo', '--yes', '--provider', 'ollama', '--template', 'yaml', '--no-install', '--no-git'], h.environment); + expect(read('demo', 'agent.yaml')).toContain('type: ollama'); + expect(fs.existsSync(path.join(root, 'demo', 'src', 'agent.ts'))).toBe(false); + }); + + it('uses the package manager from npm_config_user_agent, or --package-manager', async () => { + const detected = harness({ env: { npm_config_user_agent: 'pnpm/9.1.0 npm/? node/v22.19.0 linux x64' } }); + await runInit(['a', '--yes', '--no-git'], detected.environment); + expect(detected.commands).toEqual(['pnpm install']); + expect(detected.out.join('')).toContain('pnpm run dev'); + + const forced = harness({ env: { npm_config_user_agent: 'pnpm/9.1.0' } }); + await runInit(['b', '--yes', '--no-git', '--package-manager', 'yarn'], forced.environment); + expect(forced.commands).toEqual(['yarn install']); + }); + + it('skips install and git on request and then lists the install step', async () => { + const h = harness(); + await runInit(['demo', '--yes', '--no-install', '--no-git'], h.environment); + expect(h.commands).toEqual([]); + expect(h.out.join('')).toContain('npm install'); + }); + + it('writes a caret range of the SDK version, or a tarball with --sdk-path', async () => { + await runInit(['plain', '--yes', '--no-install', '--no-git'], harness().environment); + expect(JSON.parse(read('plain', 'package.json')).dependencies['@loushy/build-ai-agent']).toBe('^1.0.0-alpha.8'); + + const tarball = path.join(root, 'sdk-9.9.9.tgz'); + fs.writeFileSync(tarball, 'tgz'); + await runInit(['local', '--yes', '--no-install', '--no-git', '--sdk-path', tarball], harness().environment); + expect(JSON.parse(read('local', 'package.json')).dependencies['@loushy/build-ai-agent']).toBe('file:./sdk-9.9.9.tgz'); + expect(fs.existsSync(path.join(root, 'local', 'sdk-9.9.9.tgz'))).toBe(true); + }); + + it('derives a valid package name from the directory', async () => { + await runInit(['My Cool_Agent!', '--yes', '--no-install', '--no-git'], harness().environment); + expect(JSON.parse(read('My Cool_Agent!', 'package.json')).name).toBe('my-cool_agent'); + }); +}); + +describe('runInit guards', () => { + it('refuses a non-empty directory, naming it and the --force escape hatch, and writes nothing', async () => { + fs.mkdirSync(path.join(root, 'taken')); + fs.writeFileSync(path.join(root, 'taken', 'notes.txt'), 'mine'); + const h = harness(); + + expect(await runInit(['taken', '--yes'], h.environment)).toBe(1); + + expect(h.err.join('')).toMatch(/taken' is not empty.*--force/); + expect(fs.readdirSync(path.join(root, 'taken'))).toEqual(['notes.txt']); + expect(h.commands).toEqual([]); + }); + + it('writes into a non-empty directory with --force, keeping other files', async () => { + fs.mkdirSync(path.join(root, 'taken')); + fs.writeFileSync(path.join(root, 'taken', 'notes.txt'), 'mine'); + + expect(await runInit(['taken', '--yes', '--force', '--no-install', '--no-git'], harness().environment)).toBe(0); + expect(read('taken', 'notes.txt')).toBe('mine'); + expect(fs.existsSync(path.join(root, 'taken', 'package.json'))).toBe(true); + }); + + it('accepts an existing empty directory', async () => { + fs.mkdirSync(path.join(root, 'empty')); + expect(await runInit(['empty', '--yes', '--no-install', '--no-git'], harness().environment)).toBe(0); + }); + + it.each([ + [['--provider', 'gemini'], /invalid --provider 'gemini'.*openai, anthropic, openrouter, ollama/], + [['--template', 'huge'], /invalid --template 'huge'.*minimal, tools, yaml/], + [['--package-manager', 'deno'], /invalid --package-manager 'deno'.*npm, pnpm, yarn, bun/], + [['--wat'], /--wat/], + ])('rejects bad flags %j with a clear message', async (flags, message) => { + const h = harness(); + expect(await runInit(['x', '--yes', ...flags], h.environment)).toBe(1); + expect(h.err.join('')).toMatch(message); + expect(fs.existsSync(path.join(root, 'x'))).toBe(false); + }); + + it('prints usage for --help', async () => { + const h = harness(); + expect(await runInit(['--help'], h.environment)).toBe(0); + expect(h.out.join('')).toContain('Usage: loushy init'); + }); + + it('reports a failed install, still prints next steps, and exits 1', async () => { + const h = harness({}, 1); + expect(await runInit(['demo', '--yes'], h.environment)).toBe(1); + expect(h.err.join('')).toContain('`npm install` failed'); + expect(h.out.join('')).toContain('Next steps:'); + expect(h.out.join('')).toMatch(/\n {2}npm install\n/); + }); +}); + +describe('runInit interactive', () => { + it('prompts for what the flags left out, with detected defaults', async () => { + prompts.inject(['asked', 'openrouter', 'tools']); + const h = harness({ interactive: true }); + + expect(await runInit(['--no-install', '--no-git'], h.environment)).toBe(0); + + expect(read('asked', 'src', 'agent.ts')).toContain("model: 'openrouter/"); + expect(read('asked', 'src', 'agent.ts')).toContain('roll_die'); + }); + + it('does not prompt with --yes even on a terminal', async () => { + const h = harness({ interactive: true }); + expect(await runInit(['--yes', '--no-install', '--no-git'], h.environment)).toBe(0); + expect(fs.existsSync(path.join(root, 'my-agent'))).toBe(true); + }); + + it('exits 1 when the user cancels', async () => { + prompts.inject([new Error('cancel')]); + const h = harness({ interactive: true }); + expect(await runInit([], h.environment)).toBe(1); + expect(h.err.join('')).toContain('cancelled'); + }); +}); diff --git a/src/cli/init.ts b/src/cli/init.ts new file mode 100644 index 000000000..f4ddb2441 --- /dev/null +++ b/src/cli/init.ts @@ -0,0 +1,165 @@ +/** + * `loushy init [dir] [options]` - scaffold a runnable project in one command + * (LOU-D3). + * + * This is the one generator. `npm create loushy-agent` (packages/create-loushy-agent) + * is a thin wrapper that runs this command. See init/templates.ts for what is + * generated. + */ +import { spawn } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { detectProviderFromEnv, listProviders } from '../providers/providerSpec'; +import { + INIT_USAGE, + PACKAGE_MANAGERS, + PROVIDER_NAMES, + TEMPLATES, + detectPackageManager, + expectOneOf, + parseInitArgs, + type InitOptions, + type PackageManager, + type Template, +} from './init/options'; +import { askMissing, type InitAnswers } from './init/prompts'; +import { assertWritable, packageNameFor, writeFiles } from './init/scaffold'; +import { resolveSdkDependency, type SdkManifest } from './init/sdkDependency'; +import { renderProject } from './init/templates'; + +/** Everything `runInit` touches outside the project directory, so tests can fake it. */ +export interface InitEnvironment { + env: NodeJS.ProcessEnv; + cwd: string; + /** True when stdin is a terminal, so prompting is possible. */ + interactive: boolean; + sdk: SdkManifest; + write: (text: string) => void; + writeError: (text: string) => void; + /** Runs a command with inherited stdio in `cwd`; resolves to its exit code. */ + exec: (command: string, args: string[], cwd: string) => Promise; +} + +const DEFAULT_DIR = 'my-agent'; + +function readSdkManifest(): SdkManifest { + // dist/cli/init.js and src/cli/init.ts both sit two levels below the package root. + return JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')); +} + +function exec(command: string, args: string[], cwd: string): Promise { + return new Promise((resolve) => { + // One command line through the shell, so npm/pnpm/yarn resolve through their .cmd + // shims on Windows. Both parts are fixed literals, never user input. + const child = spawn(`${command} ${args.join(' ')}`, { cwd, stdio: 'inherit', shell: true }); + child.on('error', () => resolve(1)); + child.on('close', (code) => resolve(code ?? 1)); + }); +} + +/** The real environment for the current process. */ +export function realInitEnvironment(): InitEnvironment { + return { + env: process.env, + cwd: process.cwd(), + interactive: process.stdin.isTTY === true && process.stdout.isTTY === true, + sdk: readSdkManifest(), + write: (text) => process.stdout.write(text), + writeError: (text) => process.stderr.write(text), + exec, + }; +} + +interface Choices extends InitAnswers { + packageManager: PackageManager; +} + +/** Validates the flag values that were given, before anything is asked or written. */ +function validateFlags(options: InitOptions): void { + if (options.provider !== undefined) expectOneOf('--provider', options.provider, PROVIDER_NAMES); + if (options.template !== undefined) expectOneOf('--template', options.template, TEMPLATES); + if (options.packageManager !== undefined) expectOneOf('--package-manager', options.packageManager, PACKAGE_MANAGERS); +} + +/** Flags, then prompts (when a terminal is attached and `--yes` is absent), then defaults. */ +async function resolveChoices(options: InitOptions, environment: InitEnvironment): Promise { + const defaults: InitAnswers = { + dir: DEFAULT_DIR, + provider: detectProviderFromEnv(environment.env) ?? 'openai', + template: 'minimal', + }; + const known = { dir: options.dir, provider: options.provider, template: options.template }; + const defined = Object.fromEntries(Object.entries(known).filter(([, value]) => value !== undefined)); + const answers = + options.yes || !environment.interactive + ? { ...defaults, ...defined } + : await askMissing({ known: defined, defaults }); + return { + ...answers, + packageManager: (options.packageManager as PackageManager | undefined) ?? detectPackageManager(environment.env), + }; +} + +function nextSteps(dir: string, cwd: string, pm: PackageManager, envKey: string, installed: boolean): string { + const relative = path.relative(cwd, dir) || '.'; + const lines = [`cd ${relative.includes(' ') ? JSON.stringify(relative) : relative}`]; + if (!installed) lines.push(`${pm} install`); + lines.push(`cp .env.example .env # then set ${envKey}`, `${pm} run dev`); + return `\nNext steps:\n${lines.map((line) => ` ${line}`).join('\n')}\n\n(\`${pm} run test\` runs the offline tests; \`${pm} run doctor\` checks your setup.)\n`; +} + +/** Runs `git init` and the install; failures are reported, not fatal to generation. */ +async function finish(options: InitOptions, choices: Choices, dir: string, environment: InitEnvironment): Promise { + if (options.git && (await environment.exec('git', ['init'], dir)) !== 0) { + environment.writeError('loushy init: `git init` failed; continuing without a git repository.\n'); + } + if (!options.install) return false; + const code = await environment.exec(choices.packageManager, ['install'], dir); + if (code !== 0) { + environment.writeError(`loushy init: \`${choices.packageManager} install\` failed (exit ${code}). Run it yourself in ${dir}.\n`); + } + return code === 0; +} + +async function scaffold(options: InitOptions, environment: InitEnvironment): Promise { + const choices = await resolveChoices(options, environment); + const dir = path.resolve(environment.cwd, choices.dir); + assertWritable(dir, options.force); + + const sdkDependency = resolveSdkDependency(environment.sdk.version, dir, options.sdkPath); + const files = renderProject({ + name: packageNameFor(dir), + provider: choices.provider, + template: choices.template as Template, + packageManager: choices.packageManager, + sdkDependency, + sdk: environment.sdk, + }); + environment.write(`Creating ${choices.template} ${choices.provider} agent in ${dir}\n`); + writeFiles(dir, files); + + const installed = await finish(options, choices, dir, environment); + const envKey = listProviders().find((info) => info.name === choices.provider)!.envKey; + environment.write(nextSteps(dir, environment.cwd, choices.packageManager, envKey, installed)); + return options.install && !installed ? 1 : 0; +} + +/** + * Entry point behind `loushy init` (and `npm create loushy-agent`). Resolves to + * the process exit code; usage errors are printed, not thrown. + */ +export async function runInit(argv: string[], environment: InitEnvironment = realInitEnvironment()): Promise { + try { + const options = parseInitArgs(argv, environment.env); + if (options.help) { + environment.write(`${INIT_USAGE}\n`); + return 0; + } + validateFlags(options); + return await scaffold(options, environment); + } catch (error) { + if (!(error instanceof Error)) throw error; + environment.writeError(`${error.message}\n`); + return 1; + } +} diff --git a/src/cli/init/__snapshots__/templates.test.ts.snap b/src/cli/init/__snapshots__/templates.test.ts.snap new file mode 100644 index 000000000..c31c2ccf7 --- /dev/null +++ b/src/cli/init/__snapshots__/templates.test.ts.snap @@ -0,0 +1,390 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`renderProject > snapshot: anthropic / minimal agent.ts 1`] = ` +"import { createAgent, defineTool, type LLMProvider } from '@loushy/build-ai-agent'; +import { z } from 'zod'; + +/** An example tool: the model calls it when it needs the current date or time. */ +export const currentTime = defineTool({ + name: 'current_time', + description: 'Get the current date and time, optionally in an IANA time zone.', + input: z.object({ + timeZone: z.string().optional().describe('IANA time zone, e.g. "Europe/Paris". Defaults to UTC.'), + }), + execute: async ({ timeZone }) => ({ now: new Date().toLocaleString('en-US', { timeZone: timeZone ?? 'UTC' }) }), +}); + +const instructions = 'You are a helpful assistant. Use your tools when they help.'; +const tools = [currentTime]; + +/** + * The agent. \`createAgent({ model, instructions })\` is the whole idea; the + * optional \`provider\` lets tests swap in a scripted model (see agent.test.ts). + */ +export function buildAgent(provider?: LLMProvider) { + return provider + ? createAgent({ provider, instructions, tools }) + : createAgent({ model: 'anthropic/claude-3-5-sonnet-latest', instructions, tools }); +} +" +`; + +exports[`renderProject > snapshot: anthropic / tools agent.ts 1`] = ` +"import { createAgent, defineTool, type LLMProvider } from '@loushy/build-ai-agent'; +import { z } from 'zod'; + +/** An example tool: the model calls it when it needs the current date or time. */ +export const currentTime = defineTool({ + name: 'current_time', + description: 'Get the current date and time, optionally in an IANA time zone.', + input: z.object({ + timeZone: z.string().optional().describe('IANA time zone, e.g. "Europe/Paris". Defaults to UTC.'), + }), + execute: async ({ timeZone }) => ({ now: new Date().toLocaleString('en-US', { timeZone: timeZone ?? 'UTC' }) }), +}); + +/** Rolls an n-sided die. */ +export const rollDie = defineTool({ + name: 'roll_die', + description: 'Roll a die with the given number of sides and return the result.', + input: z.object({ sides: z.number().int().min(2).max(1000).default(6) }), + execute: async ({ sides }) => ({ sides, result: 1 + Math.floor(Math.random() * sides) }), +}); + +/** Counts the words in a piece of text. */ +export const wordCount = defineTool({ + name: 'word_count', + description: 'Count the words in a piece of text.', + input: z.object({ text: z.string() }), + execute: async ({ text }) => ({ words: text.split(/\\s+/).filter(Boolean).length }), +}); + +const instructions = 'You are a helpful assistant. Use your tools when they help.'; +const tools = [currentTime, rollDie, wordCount]; + +/** + * The agent. \`createAgent({ model, instructions })\` is the whole idea; the + * optional \`provider\` lets tests swap in a scripted model (see agent.test.ts). + */ +export function buildAgent(provider?: LLMProvider) { + return provider + ? createAgent({ provider, instructions, tools }) + : createAgent({ model: 'anthropic/claude-3-5-sonnet-latest', instructions, tools }); +} +" +`; + +exports[`renderProject > snapshot: minimal package.json 1`] = ` +"{ + "name": "demo-agent", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx --env-file-if-exists=.env src/index.ts", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "doctor": "loushy doctor" + }, + "dependencies": { + "@ai-sdk/openai": "^0.0.42", + "@loushy/build-ai-agent": "^1.0.0-alpha.8", + "ai": "^4.3.19", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0", + "vitest": "^3.2.4" + }, + "engines": { + "node": ">=22.19.0" + } +} +" +`; + +exports[`renderProject > snapshot: ollama / minimal agent.ts 1`] = ` +"import { createAgent, defineTool, type LLMProvider } from '@loushy/build-ai-agent'; +import { z } from 'zod'; + +/** An example tool: the model calls it when it needs the current date or time. */ +export const currentTime = defineTool({ + name: 'current_time', + description: 'Get the current date and time, optionally in an IANA time zone.', + input: z.object({ + timeZone: z.string().optional().describe('IANA time zone, e.g. "Europe/Paris". Defaults to UTC.'), + }), + execute: async ({ timeZone }) => ({ now: new Date().toLocaleString('en-US', { timeZone: timeZone ?? 'UTC' }) }), +}); + +const instructions = 'You are a helpful assistant. Use your tools when they help.'; +const tools = [currentTime]; + +/** + * The agent. \`createAgent({ model, instructions })\` is the whole idea; the + * optional \`provider\` lets tests swap in a scripted model (see agent.test.ts). + */ +export function buildAgent(provider?: LLMProvider) { + return provider + ? createAgent({ provider, instructions, tools }) + : createAgent({ model: 'ollama/llama3', instructions, tools }); +} +" +`; + +exports[`renderProject > snapshot: ollama / tools agent.ts 1`] = ` +"import { createAgent, defineTool, type LLMProvider } from '@loushy/build-ai-agent'; +import { z } from 'zod'; + +/** An example tool: the model calls it when it needs the current date or time. */ +export const currentTime = defineTool({ + name: 'current_time', + description: 'Get the current date and time, optionally in an IANA time zone.', + input: z.object({ + timeZone: z.string().optional().describe('IANA time zone, e.g. "Europe/Paris". Defaults to UTC.'), + }), + execute: async ({ timeZone }) => ({ now: new Date().toLocaleString('en-US', { timeZone: timeZone ?? 'UTC' }) }), +}); + +/** Rolls an n-sided die. */ +export const rollDie = defineTool({ + name: 'roll_die', + description: 'Roll a die with the given number of sides and return the result.', + input: z.object({ sides: z.number().int().min(2).max(1000).default(6) }), + execute: async ({ sides }) => ({ sides, result: 1 + Math.floor(Math.random() * sides) }), +}); + +/** Counts the words in a piece of text. */ +export const wordCount = defineTool({ + name: 'word_count', + description: 'Count the words in a piece of text.', + input: z.object({ text: z.string() }), + execute: async ({ text }) => ({ words: text.split(/\\s+/).filter(Boolean).length }), +}); + +const instructions = 'You are a helpful assistant. Use your tools when they help.'; +const tools = [currentTime, rollDie, wordCount]; + +/** + * The agent. \`createAgent({ model, instructions })\` is the whole idea; the + * optional \`provider\` lets tests swap in a scripted model (see agent.test.ts). + */ +export function buildAgent(provider?: LLMProvider) { + return provider + ? createAgent({ provider, instructions, tools }) + : createAgent({ model: 'ollama/llama3', instructions, tools }); +} +" +`; + +exports[`renderProject > snapshot: openai / minimal agent.ts 1`] = ` +"import { createAgent, defineTool, type LLMProvider } from '@loushy/build-ai-agent'; +import { z } from 'zod'; + +/** An example tool: the model calls it when it needs the current date or time. */ +export const currentTime = defineTool({ + name: 'current_time', + description: 'Get the current date and time, optionally in an IANA time zone.', + input: z.object({ + timeZone: z.string().optional().describe('IANA time zone, e.g. "Europe/Paris". Defaults to UTC.'), + }), + execute: async ({ timeZone }) => ({ now: new Date().toLocaleString('en-US', { timeZone: timeZone ?? 'UTC' }) }), +}); + +const instructions = 'You are a helpful assistant. Use your tools when they help.'; +const tools = [currentTime]; + +/** + * The agent. \`createAgent({ model, instructions })\` is the whole idea; the + * optional \`provider\` lets tests swap in a scripted model (see agent.test.ts). + */ +export function buildAgent(provider?: LLMProvider) { + return provider + ? createAgent({ provider, instructions, tools }) + : createAgent({ model: 'openai/gpt-4o-mini', instructions, tools }); +} +" +`; + +exports[`renderProject > snapshot: openai / tools agent.ts 1`] = ` +"import { createAgent, defineTool, type LLMProvider } from '@loushy/build-ai-agent'; +import { z } from 'zod'; + +/** An example tool: the model calls it when it needs the current date or time. */ +export const currentTime = defineTool({ + name: 'current_time', + description: 'Get the current date and time, optionally in an IANA time zone.', + input: z.object({ + timeZone: z.string().optional().describe('IANA time zone, e.g. "Europe/Paris". Defaults to UTC.'), + }), + execute: async ({ timeZone }) => ({ now: new Date().toLocaleString('en-US', { timeZone: timeZone ?? 'UTC' }) }), +}); + +/** Rolls an n-sided die. */ +export const rollDie = defineTool({ + name: 'roll_die', + description: 'Roll a die with the given number of sides and return the result.', + input: z.object({ sides: z.number().int().min(2).max(1000).default(6) }), + execute: async ({ sides }) => ({ sides, result: 1 + Math.floor(Math.random() * sides) }), +}); + +/** Counts the words in a piece of text. */ +export const wordCount = defineTool({ + name: 'word_count', + description: 'Count the words in a piece of text.', + input: z.object({ text: z.string() }), + execute: async ({ text }) => ({ words: text.split(/\\s+/).filter(Boolean).length }), +}); + +const instructions = 'You are a helpful assistant. Use your tools when they help.'; +const tools = [currentTime, rollDie, wordCount]; + +/** + * The agent. \`createAgent({ model, instructions })\` is the whole idea; the + * optional \`provider\` lets tests swap in a scripted model (see agent.test.ts). + */ +export function buildAgent(provider?: LLMProvider) { + return provider + ? createAgent({ provider, instructions, tools }) + : createAgent({ model: 'openai/gpt-4o-mini', instructions, tools }); +} +" +`; + +exports[`renderProject > snapshot: openrouter / minimal agent.ts 1`] = ` +"import { createAgent, defineTool, type LLMProvider } from '@loushy/build-ai-agent'; +import { z } from 'zod'; + +/** An example tool: the model calls it when it needs the current date or time. */ +export const currentTime = defineTool({ + name: 'current_time', + description: 'Get the current date and time, optionally in an IANA time zone.', + input: z.object({ + timeZone: z.string().optional().describe('IANA time zone, e.g. "Europe/Paris". Defaults to UTC.'), + }), + execute: async ({ timeZone }) => ({ now: new Date().toLocaleString('en-US', { timeZone: timeZone ?? 'UTC' }) }), +}); + +const instructions = 'You are a helpful assistant. Use your tools when they help.'; +const tools = [currentTime]; + +/** + * The agent. \`createAgent({ model, instructions })\` is the whole idea; the + * optional \`provider\` lets tests swap in a scripted model (see agent.test.ts). + */ +export function buildAgent(provider?: LLMProvider) { + return provider + ? createAgent({ provider, instructions, tools }) + : createAgent({ model: 'openrouter/openai/gpt-4o-mini', instructions, tools }); +} +" +`; + +exports[`renderProject > snapshot: openrouter / tools agent.ts 1`] = ` +"import { createAgent, defineTool, type LLMProvider } from '@loushy/build-ai-agent'; +import { z } from 'zod'; + +/** An example tool: the model calls it when it needs the current date or time. */ +export const currentTime = defineTool({ + name: 'current_time', + description: 'Get the current date and time, optionally in an IANA time zone.', + input: z.object({ + timeZone: z.string().optional().describe('IANA time zone, e.g. "Europe/Paris". Defaults to UTC.'), + }), + execute: async ({ timeZone }) => ({ now: new Date().toLocaleString('en-US', { timeZone: timeZone ?? 'UTC' }) }), +}); + +/** Rolls an n-sided die. */ +export const rollDie = defineTool({ + name: 'roll_die', + description: 'Roll a die with the given number of sides and return the result.', + input: z.object({ sides: z.number().int().min(2).max(1000).default(6) }), + execute: async ({ sides }) => ({ sides, result: 1 + Math.floor(Math.random() * sides) }), +}); + +/** Counts the words in a piece of text. */ +export const wordCount = defineTool({ + name: 'word_count', + description: 'Count the words in a piece of text.', + input: z.object({ text: z.string() }), + execute: async ({ text }) => ({ words: text.split(/\\s+/).filter(Boolean).length }), +}); + +const instructions = 'You are a helpful assistant. Use your tools when they help.'; +const tools = [currentTime, rollDie, wordCount]; + +/** + * The agent. \`createAgent({ model, instructions })\` is the whole idea; the + * optional \`provider\` lets tests swap in a scripted model (see agent.test.ts). + */ +export function buildAgent(provider?: LLMProvider) { + return provider + ? createAgent({ provider, instructions, tools }) + : createAgent({ model: 'openrouter/openai/gpt-4o-mini', instructions, tools }); +} +" +`; + +exports[`renderProject > snapshot: tools package.json 1`] = ` +"{ + "name": "demo-agent", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx --env-file-if-exists=.env src/index.ts", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "doctor": "loushy doctor" + }, + "dependencies": { + "@ai-sdk/openai": "^0.0.42", + "@loushy/build-ai-agent": "^1.0.0-alpha.8", + "ai": "^4.3.19", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0", + "vitest": "^3.2.4" + }, + "engines": { + "node": ">=22.19.0" + } +} +" +`; + +exports[`renderProject > snapshot: yaml package.json 1`] = ` +"{ + "name": "demo-agent", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "loushy dev agent.yaml", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "doctor": "loushy doctor agent.yaml" + }, + "dependencies": { + "@ai-sdk/openai": "^0.0.42", + "@loushy/build-ai-agent": "^1.0.0-alpha.8", + "ai": "^4.3.19", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0", + "vitest": "^3.2.4" + }, + "engines": { + "node": ">=22.19.0" + } +} +" +`; diff --git a/src/cli/init/options.test.ts b/src/cli/init/options.test.ts new file mode 100644 index 000000000..251794d06 --- /dev/null +++ b/src/cli/init/options.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { InitUsageError, detectPackageManager, expectOneOf, parseInitArgs } from './options'; + +describe('parseInitArgs', () => { + it('defaults: nothing given, install and git on', () => { + expect(parseInitArgs([], {})).toEqual({ + help: false, + dir: undefined, + provider: undefined, + template: undefined, + packageManager: undefined, + yes: false, + install: true, + git: true, + force: false, + sdkPath: undefined, + }); + }); + + it('parses every flag and the positional directory', () => { + const options = parseInitArgs( + ['my-agent', '--provider', 'anthropic', '--template=tools', '-y', '--no-install', '--no-git', '--force', '--package-manager', 'pnpm', '--sdk-path', './sdk'], + {} + ); + expect(options).toMatchObject({ + dir: 'my-agent', + provider: 'anthropic', + template: 'tools', + yes: true, + install: false, + git: false, + force: true, + packageManager: 'pnpm', + sdkPath: './sdk', + }); + }); + + it('reads the SDK path from LOUSHY_SDK_PATH, with the flag taking precedence', () => { + expect(parseInitArgs([], { LOUSHY_SDK_PATH: '/env/sdk' }).sdkPath).toBe('/env/sdk'); + expect(parseInitArgs(['--sdk-path=/flag/sdk'], { LOUSHY_SDK_PATH: '/env/sdk' }).sdkPath).toBe('/flag/sdk'); + }); + + it('rejects unknown flags and more than one directory', () => { + expect(() => parseInitArgs(['--bogus'], {})).toThrow(InitUsageError); + expect(() => parseInitArgs(['--bogus'], {})).toThrow(/--bogus/); + expect(() => parseInitArgs(['a', 'b'], {})).toThrow(/at most one directory/); + }); +}); + +describe('expectOneOf', () => { + it('passes allowed values through and names the flag and choices otherwise', () => { + expect(expectOneOf('--template', 'tools', ['minimal', 'tools'])).toBe('tools'); + expect(() => expectOneOf('--template', 'nope', ['minimal', 'tools'])).toThrow( + "invalid --template 'nope'. Allowed values: minimal, tools." + ); + }); +}); + +describe('detectPackageManager', () => { + it.each([ + ['pnpm/9.1.0 npm/? node/v22.19.0 linux x64', 'pnpm'], + ['yarn/1.22.22 npm/? node/v22.19.0 linux x64', 'yarn'], + ['bun/1.1.0 npm/? node/v22.19.0 linux x64', 'bun'], + ['npm/10.9.0 node/v22.19.0 linux x64 workspaces/false', 'npm'], + ['something-else/1.0.0', 'npm'], + [undefined, 'npm'], + ])('%s -> %s', (agent, expected) => { + expect(detectPackageManager({ npm_config_user_agent: agent })).toBe(expected); + }); +}); diff --git a/src/cli/init/options.ts b/src/cli/init/options.ts new file mode 100644 index 000000000..a2124496f --- /dev/null +++ b/src/cli/init/options.ts @@ -0,0 +1,103 @@ +/** + * Flag parsing and value validation for `loushy init`. + */ +import { parseArgs } from 'node:util'; +import { listProviders } from '../../providers/providerSpec'; + +export const TEMPLATES = ['minimal', 'tools', 'yaml'] as const; +export type Template = (typeof TEMPLATES)[number]; + +export const PACKAGE_MANAGERS = ['npm', 'pnpm', 'yarn', 'bun'] as const; +export type PackageManager = (typeof PACKAGE_MANAGERS)[number]; + +/** Provider names `--provider` accepts (openai, anthropic, openrouter, ollama). */ +export const PROVIDER_NAMES: readonly string[] = listProviders().map((info) => info.name); + +export const INIT_USAGE = `Usage: loushy init [dir] [options] + +Scaffold a runnable @loushy/build-ai-agent project in one command. + +Options: + --provider ${PROVIDER_NAMES.join(' | ')} (default: detected from your API key env var, else openai) + --template ${TEMPLATES.join(' | ')} (default: minimal) + --package-manager ${PACKAGE_MANAGERS.join(' | ')} (default: detected from how you ran this, else npm) + --yes, -y Do not prompt; use defaults for anything not given as a flag + --no-install Do not install dependencies + --no-git Do not run git init + --force Write into a directory even if it is not empty + --sdk-path Local development: depend on a checkout or packed .tgz of the SDK + instead of the published version (env: LOUSHY_SDK_PATH) + --help, -h Show this help`; + +export interface InitOptions { + help: boolean; + dir?: string; + provider?: string; + template?: string; + packageManager?: string; + yes: boolean; + install: boolean; + git: boolean; + force: boolean; + sdkPath?: string; +} + +/** Thrown for any bad flag or value; the message is printed as-is. */ +export class InitUsageError extends Error {} + +/** Parses `loushy init` argv. Unknown flags and extra positionals are errors. */ +export function parseInitArgs(argv: readonly string[], env: NodeJS.ProcessEnv = process.env): InitOptions { + let parsed: ReturnType; + try { + parsed = parseArgs({ + args: [...argv], + allowPositionals: true, + strict: true, + options: { + help: { type: 'boolean', short: 'h' }, + yes: { type: 'boolean', short: 'y' }, + force: { type: 'boolean' }, + 'no-install': { type: 'boolean' }, + 'no-git': { type: 'boolean' }, + provider: { type: 'string' }, + template: { type: 'string' }, + 'package-manager': { type: 'string' }, + 'sdk-path': { type: 'string' }, + }, + }); + } catch (error) { + throw new InitUsageError(`loushy init: ${error instanceof Error ? error.message : String(error)}`); + } + const { values, positionals } = parsed; + if (positionals.length > 1) { + throw new InitUsageError(`loushy init: expected at most one directory, got ${positionals.length}: ${positionals.join(' ')}`); + } + return { + help: values.help === true, + dir: positionals[0], + provider: values.provider as string | undefined, + template: values.template as string | undefined, + packageManager: values['package-manager'] as string | undefined, + yes: values.yes === true, + install: values['no-install'] !== true, + git: values['no-git'] !== true, + force: values.force === true, + sdkPath: (values['sdk-path'] as string | undefined) ?? (env.LOUSHY_SDK_PATH || undefined), + }; +} + +/** Returns `value` if it is one of `allowed`, otherwise throws naming the flag and the allowed set. */ +export function expectOneOf(flag: string, value: string, allowed: readonly T[]): T { + if ((allowed as readonly string[]).includes(value)) return value as T; + throw new InitUsageError(`loushy init: invalid ${flag} '${value}'. Allowed values: ${allowed.join(', ')}.`); +} + +/** + * The package manager that launched this process, read from + * `npm_config_user_agent` (e.g. `pnpm/9.1.0 npm/? node/v22.0.0 linux x64`); + * `npm` when unset or unrecognised. + */ +export function detectPackageManager(env: NodeJS.ProcessEnv = process.env): PackageManager { + const name = (env.npm_config_user_agent ?? '').split('/')[0]; + return (PACKAGE_MANAGERS as readonly string[]).includes(name) ? (name as PackageManager) : 'npm'; +} diff --git a/src/cli/init/prompts.test.ts b/src/cli/init/prompts.test.ts new file mode 100644 index 000000000..b7f7cce32 --- /dev/null +++ b/src/cli/init/prompts.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import prompts from 'prompts'; +import { askMissing } from './prompts'; + +const defaults = { dir: 'my-agent', provider: 'openai', template: 'minimal' }; + +describe('askMissing', () => { + it('asks every question when nothing is known (injected, non-interactive answers)', async () => { + prompts.inject(['demo', 'anthropic', 'tools']); + expect(await askMissing({ known: {}, defaults })).toEqual({ + dir: 'demo', + provider: 'anthropic', + template: 'tools', + }); + }); + + it('only asks for what the flags did not provide', async () => { + prompts.inject(['ollama']); + expect(await askMissing({ known: { dir: 'given', template: 'yaml' }, defaults })).toEqual({ + dir: 'given', + provider: 'ollama', + template: 'yaml', + }); + }); + + it('asks nothing when every answer is known', async () => { + const known = { dir: 'a', provider: 'openrouter', template: 'minimal' }; + expect(await askMissing({ known, defaults })).toEqual(known); + }); + + it('rejects when the user cancels (Ctrl+C)', async () => { + prompts.inject([new Error('cancel')]); + await expect(askMissing({ known: {}, defaults })).rejects.toThrow('cancelled'); + }); +}); diff --git a/src/cli/init/prompts.ts b/src/cli/init/prompts.ts new file mode 100644 index 000000000..efbd5165a --- /dev/null +++ b/src/cli/init/prompts.ts @@ -0,0 +1,63 @@ +import prompts, { type PromptObject } from 'prompts'; +import { PROVIDER_NAMES, TEMPLATES } from './options'; + +/** The answers `loushy init` can ask for; a key is only asked when it is missing. */ +export interface InitAnswers { + dir: string; + provider: string; + template: string; +} + +/** What is already known (from flags) plus the defaults to pre-select. */ +export interface AskInput { + known: Partial; + defaults: InitAnswers; +} + +const TEMPLATE_HINTS: Record = { + minimal: 'one agent, one example tool, an offline test', + tools: 'several example tools', + yaml: 'an agent.yaml spec, run with `loushy dev`', +}; + +function choices(values: readonly string[], hints: Record = {}) { + return values.map((value) => ({ title: value, value, description: hints[value] })); +} + +function questions({ known, defaults }: AskInput): PromptObject[] { + const all: PromptObject[] = [ + { type: 'text', name: 'dir', message: 'Project directory', initial: defaults.dir }, + { + type: 'select', + name: 'provider', + message: 'LLM provider', + choices: choices(PROVIDER_NAMES), + initial: Math.max(0, PROVIDER_NAMES.indexOf(defaults.provider)), + }, + { + type: 'select', + name: 'template', + message: 'Template', + choices: choices(TEMPLATES, TEMPLATE_HINTS), + initial: Math.max(0, TEMPLATES.indexOf(defaults.template as (typeof TEMPLATES)[number])), + }, + ]; + return all.filter((question) => known[question.name as keyof InitAnswers] === undefined); +} + +/** + * Interactively asks only for the answers not already given as flags, using + * the `prompts` library (tests drive it with `prompts.inject()`). Ctrl+C + * cancels the whole scaffold. + */ +export async function askMissing(input: AskInput): Promise { + let cancelled = false; + const response = await prompts(questions(input), { + onCancel: () => { + cancelled = true; + return false; + }, + }); + if (cancelled) throw new Error('loushy init: cancelled.'); + return { ...input.defaults, ...input.known, ...response }; +} diff --git a/src/cli/init/scaffold.ts b/src/cli/init/scaffold.ts new file mode 100644 index 000000000..8d2314d78 --- /dev/null +++ b/src/cli/init/scaffold.ts @@ -0,0 +1,41 @@ +/** + * Filesystem side of `loushy init`: name derivation, the non-empty-directory + * guard, and writing the rendered files. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { InitUsageError } from './options'; + +/** A valid, lowercase npm package name derived from a directory's base name. */ +export function packageNameFor(dir: string): string { + const slug = path + .basename(dir) + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^[._-]+|-+$/g, ''); + return slug || 'my-agent'; +} + +/** Throws a clear error when `dir` exists and has files in it, unless `force`. */ +export function assertWritable(dir: string, force: boolean): void { + if (force || !fs.existsSync(dir)) return; + if (!fs.statSync(dir).isDirectory()) { + throw new InitUsageError(`loushy init: '${dir}' exists and is not a directory. Choose another name.`); + } + const entries = fs.readdirSync(dir); + if (entries.length > 0) { + throw new InitUsageError( + `loushy init: '${dir}' is not empty (${entries.slice(0, 3).join(', ')}${entries.length > 3 ? ', ...' : ''}). ` + + 'Choose an empty or new directory, or pass --force to write into it anyway (existing files with the same names are overwritten).' + ); + } +} + +/** Writes `files` (paths relative to `dir`) under `dir`, creating directories as needed. */ +export function writeFiles(dir: string, files: Record): void { + for (const [relative, contents] of Object.entries(files)) { + const target = path.join(dir, relative); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, contents); + } +} diff --git a/src/cli/init/sdkDependency.test.ts b/src/cli/init/sdkDependency.test.ts new file mode 100644 index 000000000..541e87e7b --- /dev/null +++ b/src/cli/init/sdkDependency.test.ts @@ -0,0 +1,41 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { resolveSdkDependency } from './sdkDependency'; + +const tempDirs: string[] = []; +function tempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'loushy-sdkdep-')); + tempDirs.push(dir); + return dir; +} +afterEach(() => { + for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe('resolveSdkDependency', () => { + it('is a caret range of the SDK version by default and writes nothing', () => { + const project = tempDir(); + expect(resolveSdkDependency('1.0.0-alpha.8', project)).toBe('^1.0.0-alpha.8'); + expect(fs.readdirSync(project)).toEqual([]); + }); + + it('copies a given tarball into the project and points at it with file:', () => { + const tarball = path.join(tempDir(), 'sdk-1.2.3.tgz'); + fs.writeFileSync(tarball, 'not really a tarball'); + const project = tempDir(); + + expect(resolveSdkDependency('1.0.0', project, tarball)).toBe('file:./sdk-1.2.3.tgz'); + expect(fs.readFileSync(path.join(project, 'sdk-1.2.3.tgz'), 'utf8')).toBe('not really a tarball'); + }); + + it('explains a missing path and a non-tarball file', () => { + const project = tempDir(); + expect(() => resolveSdkDependency('1.0.0', project, path.join(project, 'nope'))).toThrow(/does not exist/); + + const notTarball = path.join(tempDir(), 'sdk.txt'); + fs.writeFileSync(notTarball, 'x'); + expect(() => resolveSdkDependency('1.0.0', project, notTarball)).toThrow(/not a \.tgz/); + }); +}); diff --git a/src/cli/init/sdkDependency.ts b/src/cli/init/sdkDependency.ts new file mode 100644 index 000000000..531322cae --- /dev/null +++ b/src/cli/init/sdkDependency.ts @@ -0,0 +1,53 @@ +/** + * How the generated project depends on `@loushy/build-ai-agent`. + * + * Normally a semver range of the version that generated it. For local + * development (and the scaffolder's own e2e test) `--sdk-path` / + * `LOUSHY_SDK_PATH` points at an SDK checkout or a packed `.tgz` instead, and + * the project depends on a tarball copied next to its package.json. + */ +import { execSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +/** The fields of the SDK's own package.json that generation needs. */ +export interface SdkManifest { + version: string; + peerDependencies?: Record; +} + +function isTarball(file: string): boolean { + return /\.(tgz|tar\.gz)$/i.test(file); +} + +/** `npm pack`s the SDK checkout at `sdkRoot` into `dest`; returns the tarball's file name. */ +function packSdk(sdkRoot: string, dest: string): string { + // execSync runs through the shell, which is what lets it find npm's .cmd shim on + // Windows; `dest` is quoted and comes from the caller, not from a remote source. + const output = execSync(`npm pack --pack-destination ${JSON.stringify(dest)}`, { cwd: sdkRoot, encoding: 'utf8' }); + return output.trim().split(/\r?\n/).pop()!.trim(); +} + +/** Puts a tarball of the SDK at `sdkPath` (a checkout or a .tgz) into `projectDir`; returns its file name. */ +function vendorSdk(sdkPath: string, projectDir: string): string { + const resolved = path.resolve(sdkPath); + if (!fs.existsSync(resolved)) { + throw new Error(`loushy init: --sdk-path '${sdkPath}' does not exist. Pass an SDK checkout directory or a packed .tgz file.`); + } + fs.mkdirSync(projectDir, { recursive: true }); + if (fs.statSync(resolved).isDirectory()) return packSdk(resolved, projectDir); + if (!isTarball(resolved)) { + throw new Error(`loushy init: --sdk-path '${sdkPath}' is a file but not a .tgz tarball. Pass a checkout directory or a packed .tgz file.`); + } + fs.copyFileSync(resolved, path.join(projectDir, path.basename(resolved))); + return path.basename(resolved); +} + +/** + * The `dependencies["@loushy/build-ai-agent"]` value for the generated + * project: a caret range of `version` (e.g. `^1.2.3`), or `file:./` when `sdkPath` is given + * (which also writes the tarball into `projectDir`). + */ +export function resolveSdkDependency(version: string, projectDir: string, sdkPath?: string): string { + return sdkPath ? `file:./${vendorSdk(sdkPath, projectDir)}` : `^${version}`; +} diff --git a/src/cli/init/templates.test.ts b/src/cli/init/templates.test.ts new file mode 100644 index 000000000..ca5802ed9 --- /dev/null +++ b/src/cli/init/templates.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { PROVIDER_NAMES, TEMPLATES, type Template } from './options'; +import { renderProject, type ProjectConfig } from './templates'; + +const SDK = { version: '1.0.0-alpha.8', peerDependencies: { ai: '^4.3.19', zod: '^3.25.76' } }; + +function config(provider: string, template: Template): ProjectConfig { + return { + name: 'demo-agent', + provider, + template, + packageManager: 'npm', + sdkDependency: '^1.0.0-alpha.8', + sdk: SDK, + }; +} + +const ENV_KEYS: Record = { + openai: 'OPENAI_API_KEY', + anthropic: 'ANTHROPIC_API_KEY', + openrouter: 'OPENROUTER_API_KEY', + ollama: 'OLLAMA_BASE_URL', +}; + +const combos = PROVIDER_NAMES.flatMap((provider) => TEMPLATES.map((template) => [provider, template] as const)); + +describe('renderProject', () => { + it.each(combos)('%s / %s: file set and key contents', (provider, template) => { + const files = renderProject(config(provider, template)); + + const expected = ['.env.example', '.gitignore', 'README.md', 'package.json', 'src/agent.test.ts', 'tsconfig.json']; + expected.push(...(template === 'yaml' ? ['agent.yaml'] : ['src/agent.ts', 'src/index.ts'])); + expect(Object.keys(files).sort()).toEqual(expected.sort()); + + expect(files['.env.example']).toMatch(new RegExp(`^${ENV_KEYS[provider]}=$`, 'm')); + expect(files['.gitignore']).toMatch(/^\.env$/m); + expect(files['.gitignore']).toMatch(/^!\.env\.example$/m); + + const pkg = JSON.parse(files['package.json']!); + expect(pkg).toMatchObject({ name: 'demo-agent', type: 'module', private: true }); + expect(Object.keys(pkg.scripts)).toEqual(expect.arrayContaining(['dev', 'test', 'doctor'])); + expect(pkg.scripts.doctor).toMatch(/^loushy doctor/); + expect(pkg.dependencies['@loushy/build-ai-agent']).toBe('^1.0.0-alpha.8'); + expect(pkg.dependencies).toMatchObject({ ai: '^4.3.19', zod: '^3.25.76' }); + + expect(JSON.parse(files['tsconfig.json']!).compilerOptions).toMatchObject({ strict: true, module: 'NodeNext' }); + expect(files['README.md']).toContain(ENV_KEYS[provider]); + expect(files['README.md']).toContain('npm run dev'); + }); + + it.each(combos.filter(([, template]) => template !== 'yaml'))( + '%s / %s: agent.ts uses createAgent + defineTool and the test uses mockModel', + (provider, template) => { + const files = renderProject(config(provider, template)); + expect(files['src/agent.ts']).toContain("import { createAgent, defineTool, type LLMProvider } from '@loushy/build-ai-agent';"); + expect(files['src/agent.ts']).toMatch(new RegExp(`model: '${provider}/[^']+'`)); + expect(files['src/agent.ts']).toContain('instructions'); + expect(files['src/agent.test.ts']).toContain("import { mockModel } from '@loushy/build-ai-agent/testing';"); + expect(files['agent.yaml']).toBeUndefined(); + expect(JSON.parse(files['package.json']!).scripts.dev).toContain('src/index.ts'); + } + ); + + it('the tools template defines more tools than minimal', () => { + const count = (template: Template) => + (renderProject(config('openai', template))['src/agent.ts']!.match(/defineTool\(\{/g) ?? []).length; + expect(count('minimal')).toBe(1); + expect(count('tools')).toBe(3); + }); + + it.each([ + ['openai', '@ai-sdk/openai', ['@ai-sdk/anthropic', 'ollama-ai-provider']], + ['openrouter', '@ai-sdk/openai', ['@ai-sdk/anthropic', 'ollama-ai-provider']], + ['anthropic', '@ai-sdk/anthropic', ['@ai-sdk/openai', 'ollama-ai-provider']], + ['ollama', 'ollama-ai-provider', ['@ai-sdk/openai', '@ai-sdk/anthropic']], + ])('%s depends on its own provider package only', (provider, peer, others) => { + const dependencies = JSON.parse(renderProject(config(provider, 'minimal'))['package.json']!).dependencies; + expect(Object.keys(dependencies)).toContain(peer); + for (const other of others) expect(Object.keys(dependencies)).not.toContain(other); + }); + + it('the yaml template ships agent.yaml and runs it with loushy dev', () => { + const files = renderProject(config('anthropic', 'yaml')); + expect(files['agent.yaml']).toContain('type: anthropic'); + expect(JSON.parse(files['package.json']!).scripts.dev).toBe('loushy dev agent.yaml'); + expect(files['src/agent.ts']).toBeUndefined(); + }); + + it('uses the chosen package manager in the README commands', () => { + const files = renderProject({ ...config('openai', 'minimal'), packageManager: 'pnpm' }); + expect(files['README.md']).toContain('pnpm run dev'); + expect(files['README.md']).not.toMatch(/(^|\s)npm run/); + }); + + it.each(TEMPLATES)('snapshot: %s package.json', (template) => { + expect(renderProject(config('openai', template))['package.json']).toMatchSnapshot(); + }); + + it.each(combos.filter(([, template]) => template !== 'yaml'))('snapshot: %s / %s agent.ts', (provider, template) => { + expect(renderProject(config(provider, template))['src/agent.ts']).toMatchSnapshot(); + }); +}); diff --git a/src/cli/init/templates.ts b/src/cli/init/templates.ts new file mode 100644 index 000000000..51fab447a --- /dev/null +++ b/src/cli/init/templates.ts @@ -0,0 +1,283 @@ +/** + * File contents for the generated project. Pure functions: a + * {@link ProjectConfig} in, strings out, so every combination is snapshot-testable. + */ +import { listProviders } from '../../providers/providerSpec'; +import type { PackageManager, Template } from './options'; +import type { SdkManifest } from './sdkDependency'; + +export interface ProjectConfig { + /** npm package name (also the directory's base name). */ + name: string; + provider: string; + template: Template; + packageManager: PackageManager; + /** `dependencies["@loushy/build-ai-agent"]`: a range, or `file:./`. */ + sdkDependency: string; + sdk: SdkManifest; +} + +function providerInfo(provider: string) { + const info = listProviders().find((candidate) => candidate.name === provider); + if (!info) throw new Error(`loushy init: unknown provider '${provider}'.`); + return info; +} + +function sortKeys(record: Record): Record { + return Object.fromEntries(Object.entries(record).sort(([a], [b]) => a.localeCompare(b))); +} + +/** Only the chosen provider's package: the SDK loads provider packages on first use. */ +function providerPackage(provider: string): Record { + const info = providerInfo(provider); + return { [info.peerPackage]: info.peerInstall.slice(info.peerPackage.length + 1) }; +} + +function packageJson(config: ProjectConfig): string { + const peers = config.sdk.peerDependencies ?? {}; + const yaml = config.template === 'yaml'; + const pkg = { + name: config.name, + version: '0.1.0', + private: true, + type: 'module', + scripts: { + dev: yaml ? 'loushy dev agent.yaml' : 'tsx --env-file-if-exists=.env src/index.ts', + test: 'vitest run', + typecheck: 'tsc --noEmit', + doctor: yaml ? 'loushy doctor agent.yaml' : 'loushy doctor', + }, + dependencies: sortKeys({ + '@loushy/build-ai-agent': config.sdkDependency, + ai: peers.ai ?? '^4.3.19', + zod: peers.zod ?? '^3.25.76', + ...providerPackage(config.provider), + }), + devDependencies: { + '@types/node': '^22.0.0', + tsx: '^4.19.0', + typescript: '^5.6.0', + vitest: '^3.2.4', + }, + engines: { node: '>=22.19.0' }, + }; + return JSON.stringify(pkg, null, 2) + '\n'; +} + +function tsconfig(): string { + const options = { + compilerOptions: { + target: 'ES2022', + module: 'NodeNext', + moduleResolution: 'NodeNext', + lib: ['ES2022'], + strict: true, + noUncheckedIndexedAccess: true, + esModuleInterop: true, + skipLibCheck: true, + noEmit: true, + types: ['node'], + }, + include: ['src'], + }; + return JSON.stringify(options, null, 2) + '\n'; +} + +const TIME_TOOL = `/** An example tool: the model calls it when it needs the current date or time. */ +export const currentTime = defineTool({ + name: 'current_time', + description: 'Get the current date and time, optionally in an IANA time zone.', + input: z.object({ + timeZone: z.string().optional().describe('IANA time zone, e.g. "Europe/Paris". Defaults to UTC.'), + }), + execute: async ({ timeZone }) => ({ now: new Date().toLocaleString('en-US', { timeZone: timeZone ?? 'UTC' }) }), +});`; + +const EXTRA_TOOLS = ` + +/** Rolls an n-sided die. */ +export const rollDie = defineTool({ + name: 'roll_die', + description: 'Roll a die with the given number of sides and return the result.', + input: z.object({ sides: z.number().int().min(2).max(1000).default(6) }), + execute: async ({ sides }) => ({ sides, result: 1 + Math.floor(Math.random() * sides) }), +}); + +/** Counts the words in a piece of text. */ +export const wordCount = defineTool({ + name: 'word_count', + description: 'Count the words in a piece of text.', + input: z.object({ text: z.string() }), + execute: async ({ text }) => ({ words: text.split(/\\s+/).filter(Boolean).length }), +});`; + +function toolNames(template: Template): string[] { + return template === 'tools' ? ['currentTime', 'rollDie', 'wordCount'] : ['currentTime']; +} + +function agentSource(config: ProjectConfig): string { + const info = providerInfo(config.provider); + const extra = config.template === 'tools' ? EXTRA_TOOLS : ''; + return `import { createAgent, defineTool, type LLMProvider } from '@loushy/build-ai-agent'; +import { z } from 'zod'; + +${TIME_TOOL}${extra} + +const instructions = 'You are a helpful assistant. Use your tools when they help.'; +const tools = [${toolNames(config.template).join(', ')}]; + +/** + * The agent. \`createAgent({ model, instructions })\` is the whole idea; the + * optional \`provider\` lets tests swap in a scripted model (see agent.test.ts). + */ +export function buildAgent(provider?: LLMProvider) { + return provider + ? createAgent({ provider, instructions, tools }) + : createAgent({ model: '${config.provider}/${info.defaultModel}', instructions, tools }); +} +`; +} + +function indexSource(): string { + return `import { createInterface } from 'node:readline/promises'; +import { buildAgent } from './agent.js'; + +const agent = buildAgent(); +const rl = createInterface({ input: process.stdin, output: process.stdout }); +rl.on('close', () => process.exit(0)); + +console.log('Chat with your agent. Press Ctrl+C to quit.'); +for (;;) { + const message = (await rl.question('you> ')).trim(); + if (message) console.log(\`agent> \${(await agent.send(message)).text}\`); +} +`; +} + +function testSource(): string { + return `import { describe, expect, it } from 'vitest'; +import { mockModel } from '@loushy/build-ai-agent/testing'; +import { buildAgent } from './agent.js'; + +// mockModel scripts the model turn by turn: no network and no API key needed. +describe('agent', () => { + it('replies with the scripted answer', async () => { + const model = mockModel(['Hello from the mock model!']); + + const { text } = await buildAgent(model).send('Hi'); + + expect(text).toBe('Hello from the mock model!'); + model.assertExhausted(); + }); + + it('calls the current_time tool and answers with its result', async () => { + const model = mockModel([ + { toolCalls: [{ name: 'current_time', args: { timeZone: 'UTC' } }] }, + { text: 'It is time for lunch.' }, + ]); + + const { text } = await buildAgent(model).send('What time is it?'); + + expect(text).toBe('It is time for lunch.'); + expect(model.calls).toHaveLength(2); + expect(model.calls[1]?.messages.at(-1)).toMatchObject({ role: 'tool', toolCallId: 'call_1' }); + }); +}); +`; +} + +function yamlTestSource(config: ProjectConfig): string { + return `import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { loadSpec } from '@loushy/build-ai-agent'; + +describe('agent.yaml', () => { + it('is a valid agent spec', () => { + const spec = loadSpec(fileURLToPath(new URL('../agent.yaml', import.meta.url))); + + expect(spec.name).toBe('${config.name}'); + expect(spec.provider.type).toBe('${config.provider}'); + expect(spec.tools).toContain('current-date'); + }); +}); +`; +} + +function agentYaml(config: ProjectConfig): string { + return `name: ${config.name} +prompt: You are a helpful assistant. Use your tools when they help. +provider: + type: ${config.provider} + model: ${providerInfo(config.provider).defaultModel} +tools: + - current-date +`; +} + +function envExample(config: ProjectConfig): string { + const info = providerInfo(config.provider); + const hint = info.envRequired ? 'API key' : 'base URL (optional, defaults to http://localhost:11434)'; + return `# ${config.provider} ${hint}\n${info.envKey}=\n`; +} + +const GITIGNORE = `node_modules +dist +.env +.env.* +!.env.example +*.log +`; + +function run(pm: PackageManager, script: string): string { + return `${pm} run ${script}`; +} + +function readme(config: ProjectConfig): string { + const pm = config.packageManager; + const info = providerInfo(config.provider); + const yaml = config.template === 'yaml'; + const files = yaml + ? '- `agent.yaml` - the agent: prompt, provider and tools\n- `src/agent.test.ts` - checks the spec loads, offline' + : '- `src/agent.ts` - the agent and an example tool\n- `src/agent.test.ts` - tests with a scripted model, offline\n- `src/index.ts` - a tiny terminal chat'; + return `# ${config.name} + +An agent built with [@loushy/build-ai-agent](https://github.com/LinuxDevil/agent-sdk). + +## Next 3 commands + +\`\`\`bash +cp .env.example .env # then put your ${info.envKey} in .env${yaml ? ' (or export it)' : ''} +${run(pm, 'dev')}${yaml ? ' # chat UI with hot reload' : ' # chat with the agent in your terminal'} +${run(pm, 'test')} # offline tests, no API key needed +\`\`\` + +Run \`${run(pm, 'doctor')}\` if something does not work: it checks Node, packages and API keys. + +## What is here + +${files} +- \`.env.example\` - the environment variable your provider needs + +Docs: https://github.com/LinuxDevil/agent-sdk/blob/main/docs/quick-start.md +`; +} + +/** Every file of the project, keyed by its path relative to the project directory. */ +export function renderProject(config: ProjectConfig): Record { + const yaml = config.template === 'yaml'; + const files: Record = { + 'package.json': packageJson(config), + 'tsconfig.json': tsconfig(), + '.gitignore': GITIGNORE, + '.env.example': envExample(config), + 'README.md': readme(config), + 'src/agent.test.ts': yaml ? yamlTestSource(config) : testSource(), + }; + if (yaml) { + files['agent.yaml'] = agentYaml(config); + } else { + files['src/agent.ts'] = agentSource(config); + files['src/index.ts'] = indexSource(); + } + return files; +} diff --git a/src/providers/providerSpec.ts b/src/providers/providerSpec.ts index 9225cc327..53269b9e4 100644 --- a/src/providers/providerSpec.ts +++ b/src/providers/providerSpec.ts @@ -31,6 +31,8 @@ export interface ProviderInfo { peerPackage: string; /** The `npm install` argument for the peer, e.g. `@ai-sdk/openai@^0.0.42`. */ peerInstall: string; + /** Model used when the provider is picked from the environment alone. */ + defaultModel: string; } /** Every supported provider, in env-detection order. */ @@ -41,9 +43,19 @@ export function listProviders(): ProviderInfo[] { envRequired: entry.envRequired, peerPackage: peerPackageName(entry.peer), peerInstall: entry.peer, + defaultModel: entry.envDefaultModel, })); } +/** + * Name of the first provider (in env-detection order) whose env var is set in + * `env`, or `undefined` when none is. `loushy init --yes` uses it to pick a + * provider; `LOUSHY_MODEL` is deliberately not consulted. + */ +export function detectProviderFromEnv(env: Record = process.env): string | undefined { + return Object.entries(PROVIDERS).find(([, entry]) => env[entry.envKey])?.[0]; +} + /** * The `npm install` command for an optional peer package, with the version * range the SDK supports (single source of truth for every install hint). diff --git a/tsup.config.ts b/tsup.config.ts index 06b88c252..d0cd46de6 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -32,6 +32,7 @@ export default defineConfig({ 'cli/studio': 'src/cli/studio.ts', 'cli/mcp': 'src/cli/mcp.ts', 'cli/doctor': 'src/cli/doctor.ts', + 'cli/init': 'src/cli/init.ts', 'execution/otel': 'src/execution/otel.ts', 'execution/hooks': 'src/execution/hooks.ts', 'triggers/index': 'src/triggers/index.ts',