From ad30992d94bbbb80b84aaabef30a5f4a24fab6a4 Mon Sep 17 00:00:00 2001 From: Iliass JABALI Date: Sun, 29 Mar 2026 14:40:04 +0100 Subject: [PATCH] feat(cli): fetch models dynamically from OpenRouter public API After selecting a provider, the init wizard now fetches available models from OpenRouter's public /api/v1/models endpoint (no API key required), filters by provider prefix, and presents them as a selectable list. Falls back to a text input with the hardcoded default if the fetch fails (offline, timeout, etc). The --yes flag still uses PROVIDER_DEFAULTS with no network calls. --- packages/cli/src/__tests__/commands.test.ts | 179 ++++-- packages/cli/src/__tests__/init.test.ts | 592 ++++++++++++++++++++ packages/cli/src/commands/init-helpers.ts | 349 ++++++++++++ packages/cli/src/commands/init.ts | 519 ++++++++++------- 4 files changed, 1388 insertions(+), 251 deletions(-) create mode 100644 packages/cli/src/__tests__/init.test.ts create mode 100644 packages/cli/src/commands/init-helpers.ts diff --git a/packages/cli/src/__tests__/commands.test.ts b/packages/cli/src/__tests__/commands.test.ts index ef34118..ba24138 100644 --- a/packages/cli/src/__tests__/commands.test.ts +++ b/packages/cli/src/__tests__/commands.test.ts @@ -29,10 +29,11 @@ const { mockIsLatestVersion: vi.fn(), })) -const { mockWriteFileSync, mockReadFileSync, mockExistsSync } = vi.hoisted(() => ({ +const { mockWriteFileSync, mockReadFileSync, mockExistsSync, mockMkdirSync } = vi.hoisted(() => ({ mockWriteFileSync: vi.fn(), mockReadFileSync: vi.fn(), mockExistsSync: vi.fn(), + mockMkdirSync: vi.fn(), })) const { @@ -42,6 +43,9 @@ const { mockIntro, mockGroup, mockOutro, + mockSelect, + mockText, + mockLog, } = vi.hoisted(() => ({ mockConfirm: vi.fn(), mockIsCancel: vi.fn(), @@ -49,6 +53,9 @@ const { mockIntro: vi.fn(), mockGroup: vi.fn(), mockOutro: vi.fn(), + mockSelect: vi.fn(), + mockText: vi.fn(), + mockLog: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, })) // ── Module mocks ────────────────────────────────────────────────────────────── @@ -67,6 +74,7 @@ vi.mock('node:fs', () => ({ writeFileSync: mockWriteFileSync, readFileSync: mockReadFileSync, existsSync: mockExistsSync, + mkdirSync: mockMkdirSync, })) vi.mock('@clack/prompts', () => ({ @@ -76,6 +84,9 @@ vi.mock('@clack/prompts', () => ({ intro: mockIntro, group: mockGroup, outro: mockOutro, + select: mockSelect, + text: mockText, + log: mockLog, spinner: () => ({ start: vi.fn(), stop: vi.fn(), message: vi.fn() }), })) @@ -840,25 +851,59 @@ describe('migrate command', () => { // ── init command ────────────────────────────────────────────────────────────── describe('init command', () => { + const originalFetch = globalThis.fetch + + beforeEach(() => { + // Stub global fetch so fetchAvailableModels returns null (offline fallback) + globalThis.fetch = vi.fn().mockRejectedValue(new Error('mocked offline')) as unknown as typeof fetch + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + async function run(args: string[]): Promise { const { registerInitCommand } = await import('../commands/init.js') return runCommand(registerInitCommand, ['init', ...args]) } + /** + * Set up mocks for a full interactive run. + * Phase 1 group no longer has modelId — after Phase 1, a model select/text prompt fires. + * Pass modelId separately to mock the model selection prompt. + */ + function setupInteractiveMocks( + phase1: Record, + phase2: Record, + modelId = 'gpt-4o-mini', + ): void { + mockGroup + .mockResolvedValueOnce(phase1) + .mockResolvedValueOnce(phase2) + mockIsCancel.mockReturnValue(false) + // fetchAvailableModels returns null (mocked offline) → falls back to p.text for model + mockText.mockResolvedValueOnce(modelId) + } + it('creates agent.yaml with defaults using --yes (file does not exist)', async () => { mockExistsSync.mockReturnValue(false) await run(['--yes']) - expect(mockWriteFileSync).toHaveBeenCalledOnce() + // agent.yaml + system.md + .env.example = 3 writeFileSync calls + expect(mockWriteFileSync).toHaveBeenCalledTimes(3) const content = mockWriteFileSync.mock.calls[0][1] as string expect(content).toContain('apiVersion: agentspec.io/v1') expect(content).toContain('my-agent') }) it('skips overwrite prompt and creates file with --yes even when file exists', async () => { - mockExistsSync.mockReturnValue(true) + // existsSync: true for agent.yaml check, then false for scaffold files + mockExistsSync + .mockReturnValueOnce(true) // agent.yaml exists + .mockReturnValueOnce(false) // system.md does not exist + .mockReturnValueOnce(false) // .env.example does not exist await run(['.', '--yes']) expect(mockConfirm).not.toHaveBeenCalled() - expect(mockWriteFileSync).toHaveBeenCalledOnce() + expect(mockWriteFileSync).toHaveBeenCalledTimes(3) }) it('creates manifest without memory section by default (--yes)', async () => { @@ -876,22 +921,20 @@ describe('init command', () => { }) it('prompts for overwrite when file exists without --yes', async () => { - mockExistsSync.mockReturnValue(true) + mockExistsSync + .mockReturnValueOnce(true) // agent.yaml exists + .mockReturnValueOnce(false) // system.md + .mockReturnValueOnce(false) // .env.example mockConfirm.mockResolvedValue(true) mockIsCancel.mockReturnValue(false) - mockGroup.mockResolvedValue({ - name: 'test-agent', - description: 'A test agent', - version: '1.0.0', - provider: 'openai', - modelId: 'gpt-4o', - includeMemory: false, - includeGuardrails: true, - includeEval: false, - }) + setupInteractiveMocks( + { name: 'test-agent', description: 'A test agent', version: '1.0.0', provider: 'openai' }, + { includeMemory: false, includeApi: false, includeObservability: false, includeGuardrails: true, includeEval: false, includeToolsStarter: false }, + 'gpt-4o', + ) await run(['.']) expect(mockConfirm).toHaveBeenCalledOnce() - expect(mockWriteFileSync).toHaveBeenCalledOnce() + expect(mockWriteFileSync).toHaveBeenCalled() }) it('cancels init when user declines overwrite (confirm returns false)', async () => { @@ -913,19 +956,17 @@ describe('init command', () => { expect(mockWriteFileSync).not.toHaveBeenCalled() }) - it('uses answers from group prompt for manifest content (interactive mode)', async () => { + it('uses answers from group prompts for manifest content (interactive mode)', async () => { mockExistsSync.mockReturnValue(false) - mockGroup.mockResolvedValue({ - name: 'my-custom-agent', - description: 'Custom description', - version: '2.0.0', - provider: 'anthropic', - modelId: 'claude-sonnet-4-6', - includeMemory: true, - includeGuardrails: false, - includeEval: true, - }) + setupInteractiveMocks( + { name: 'my-custom-agent', description: 'Custom description', version: '2.0.0', provider: 'anthropic' }, + { includeMemory: true, includeApi: false, includeObservability: false, includeGuardrails: false, includeEval: true, includeToolsStarter: false }, + 'claude-sonnet-4-6', + ) + // Phase 3: memory backend select + mockSelect.mockResolvedValueOnce('in-memory') await run(['.']) + expect(mockGroup).toHaveBeenCalledTimes(2) const content = mockWriteFileSync.mock.calls[0][1] as string expect(content).toContain('my-custom-agent') expect(content).toContain('anthropic') @@ -937,16 +978,11 @@ describe('init command', () => { it('generates ANTHROPIC_API_KEY env var reference for anthropic provider', async () => { mockExistsSync.mockReturnValue(false) - mockGroup.mockResolvedValue({ - name: 'claude-agent', - description: 'Claude agent', - version: '0.1.0', - provider: 'anthropic', - modelId: 'claude-haiku-4-5-20251001', - includeMemory: false, - includeGuardrails: false, - includeEval: false, - }) + setupInteractiveMocks( + { name: 'claude-agent', description: 'Claude agent', version: '0.1.0', provider: 'anthropic' }, + { includeMemory: false, includeApi: false, includeObservability: false, includeGuardrails: false, includeEval: false, includeToolsStarter: false }, + 'claude-haiku-4-5-20251001', + ) await run(['.']) const content = mockWriteFileSync.mock.calls[0][1] as string expect(content).toContain('ANTHROPIC_API_KEY') @@ -959,4 +995,73 @@ describe('init command', () => { expect(out).toContain('Next steps') expect(out).toContain('validate') }) + + it('creates scaffold files (system.md and .env.example) with --yes', async () => { + mockExistsSync.mockReturnValue(false) + await run(['--yes']) + expect(mockMkdirSync).toHaveBeenCalled() + // 3 writes: agent.yaml, system.md, .env.example + expect(mockWriteFileSync).toHaveBeenCalledTimes(3) + const systemMd = mockWriteFileSync.mock.calls[1][1] as string + expect(systemMd).toContain('# My Agent') + const envExample = mockWriteFileSync.mock.calls[2][1] as string + expect(envExample).toContain('OPENAI_API_KEY') + }) + + it('skips scaffold files with warning when they already exist', async () => { + // agent.yaml does not exist, but scaffold files do + mockExistsSync + .mockReturnValueOnce(false) // agent.yaml + .mockReturnValueOnce(true) // system.md exists + .mockReturnValueOnce(true) // .env.example exists + await run(['--yes']) + // Only agent.yaml should be written + expect(mockWriteFileSync).toHaveBeenCalledOnce() + expect(mockLog.warn).toHaveBeenCalledTimes(2) + }) + + it('calls phase 3 select for memory backend when includeMemory is true', async () => { + mockExistsSync.mockReturnValue(false) + setupInteractiveMocks( + { name: 'test', description: 'test', version: '0.1.0', provider: 'openai' }, + { includeMemory: true, includeApi: false, includeObservability: false, includeGuardrails: false, includeEval: false, includeToolsStarter: false }, + ) + // Phase 3: memory backend select + mockSelect.mockResolvedValueOnce('redis') + await run(['.']) + // mockText called once for model (offline fallback), mockSelect once for memory backend + expect(mockSelect).toHaveBeenCalledOnce() + const content = mockWriteFileSync.mock.calls[0][1] as string + expect(content).toContain('backend: redis') + expect(content).toContain('$env:REDIS_URL') + }) + + it('does not call phase 3 select prompts when all phase 2 toggles are false', async () => { + mockExistsSync.mockReturnValue(false) + setupInteractiveMocks( + { name: 'test', description: 'test', version: '0.1.0', provider: 'openai' }, + { includeMemory: false, includeApi: false, includeObservability: false, includeGuardrails: false, includeEval: false, includeToolsStarter: false }, + ) + await run(['.']) + // mockText called once for model (offline fallback), no select prompts for phase 3 + expect(mockSelect).not.toHaveBeenCalled() + }) + + it('calls phase 3 prompts for API type and port when includeApi is true', async () => { + mockExistsSync.mockReturnValue(false) + setupInteractiveMocks( + { name: 'test', description: 'test', version: '0.1.0', provider: 'openai' }, + { includeMemory: false, includeApi: true, includeObservability: false, includeGuardrails: false, includeEval: false, includeToolsStarter: false }, + ) + // Phase 3: API type select + port text + mockSelect.mockResolvedValueOnce('rest') + mockText.mockResolvedValueOnce('8080') + await run(['.']) + expect(mockSelect).toHaveBeenCalledOnce() + // mockText: 1 for model (offline fallback) + 1 for port = 2 + expect(mockText).toHaveBeenCalledTimes(2) + const content = mockWriteFileSync.mock.calls[0][1] as string + expect(content).toContain('type: rest') + expect(content).toContain('port: 8080') + }) }) diff --git a/packages/cli/src/__tests__/init.test.ts b/packages/cli/src/__tests__/init.test.ts new file mode 100644 index 0000000..a24c4a3 --- /dev/null +++ b/packages/cli/src/__tests__/init.test.ts @@ -0,0 +1,592 @@ +/** + * Unit tests for init-helpers.ts — all pure function tests, no mocking needed. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest' +import { + PROVIDER_DEFAULTS, + PROVIDER_ENV_KEYS, + generateManifest, + collectRequiredEnvVars, + generateSystemPrompt, + generateEnvExample, + fetchAvailableModels, + type InitOptions, +} from '../commands/init-helpers.js' + +// ── Test helpers ───────────────────────────────────────────────────────────── + +function baseOpts(overrides: Partial = {}): InitOptions { + return { + name: 'test-agent', + description: 'A test agent', + version: '0.1.0', + provider: 'openai', + modelId: 'gpt-4o-mini', + includeMemory: false, + includeApi: false, + includeObservability: false, + includeGuardrails: false, + includeEval: false, + includeToolsStarter: false, + ...overrides, + } +} + +// ── PROVIDER_DEFAULTS ──────────────────────────────────────────────────────── + +describe('PROVIDER_DEFAULTS', () => { + it('maps openai to gpt-4o-mini', () => { + expect(PROVIDER_DEFAULTS.openai).toBe('gpt-4o-mini') + }) + + it('maps anthropic to claude-sonnet-4-6', () => { + expect(PROVIDER_DEFAULTS.anthropic).toBe('claude-sonnet-4-6') + }) + + it('maps groq to llama-3.3-70b-versatile', () => { + expect(PROVIDER_DEFAULTS.groq).toBe('llama-3.3-70b-versatile') + }) + + it('maps google to gemini-2.0-flash', () => { + expect(PROVIDER_DEFAULTS.google).toBe('gemini-2.0-flash') + }) + + it('maps mistral to mistral-large-latest', () => { + expect(PROVIDER_DEFAULTS.mistral).toBe('mistral-large-latest') + }) + + it('maps azure to gpt-4o', () => { + expect(PROVIDER_DEFAULTS.azure).toBe('gpt-4o') + }) +}) + +// ── PROVIDER_ENV_KEYS ──────────────────────────────────────────────────────── + +describe('PROVIDER_ENV_KEYS', () => { + it('maps openai to OPENAI_API_KEY', () => { + expect(PROVIDER_ENV_KEYS.openai).toBe('OPENAI_API_KEY') + }) + + it('maps anthropic to ANTHROPIC_API_KEY', () => { + expect(PROVIDER_ENV_KEYS.anthropic).toBe('ANTHROPIC_API_KEY') + }) + + it('maps groq to GROQ_API_KEY', () => { + expect(PROVIDER_ENV_KEYS.groq).toBe('GROQ_API_KEY') + }) + + it('maps google to GOOGLE_API_KEY', () => { + expect(PROVIDER_ENV_KEYS.google).toBe('GOOGLE_API_KEY') + }) + + it('maps mistral to MISTRAL_API_KEY', () => { + expect(PROVIDER_ENV_KEYS.mistral).toBe('MISTRAL_API_KEY') + }) + + it('maps azure to AZURE_OPENAI_API_KEY', () => { + expect(PROVIDER_ENV_KEYS.azure).toBe('AZURE_OPENAI_API_KEY') + }) +}) + +// ── generateManifest — always-present sections ────────────────────────────── + +describe('generateManifest — always-present sections', () => { + const yaml = generateManifest(baseOpts()) + + it('includes apiVersion header', () => { + expect(yaml).toContain('apiVersion: agentspec.io/v1') + }) + + it('includes kind AgentSpec', () => { + expect(yaml).toContain('kind: AgentSpec') + }) + + it('includes metadata section', () => { + expect(yaml).toContain('metadata:') + expect(yaml).toContain('name: test-agent') + }) + + it('includes model section', () => { + expect(yaml).toContain('model:') + expect(yaml).toContain('provider: openai') + }) + + it('includes prompts section', () => { + expect(yaml).toContain('prompts:') + expect(yaml).toContain('$file:prompts/system.md') + }) + + it('includes compliance and requires sections', () => { + expect(yaml).toContain('compliance:') + expect(yaml).toContain('requires:') + }) +}) + +// ── generateManifest — conditional sections ───────────────────────────────── + +describe('generateManifest — conditional sections', () => { + it('excludes memory when includeMemory is false', () => { + const yaml = generateManifest(baseOpts({ includeMemory: false })) + expect(yaml).not.toMatch(/^\s+memory:/m) + }) + + it('includes memory when includeMemory is true', () => { + const yaml = generateManifest(baseOpts({ includeMemory: true, memoryBackend: 'in-memory' })) + expect(yaml).toContain('memory:') + }) + + it('excludes api when includeApi is false', () => { + const yaml = generateManifest(baseOpts({ includeApi: false })) + expect(yaml).not.toMatch(/^\s+api:/m) + }) + + it('includes api when includeApi is true', () => { + const yaml = generateManifest(baseOpts({ includeApi: true, apiType: 'rest' })) + expect(yaml).toContain('api:') + }) + + it('excludes observability when includeObservability is false', () => { + const yaml = generateManifest(baseOpts({ includeObservability: false })) + expect(yaml).not.toContain('observability:') + }) + + it('includes observability when includeObservability is true', () => { + const yaml = generateManifest( + baseOpts({ includeObservability: true, tracingBackend: 'langfuse' }), + ) + expect(yaml).toContain('observability:') + }) +}) + +// ── generateManifest — model section ──────────────────────────────────────── + +describe('generateManifest — model section', () => { + it('uses the specified provider', () => { + const yaml = generateManifest(baseOpts({ provider: 'anthropic', modelId: 'claude-sonnet-4-6' })) + expect(yaml).toContain('provider: anthropic') + }) + + it('uses the specified model ID', () => { + const yaml = generateManifest(baseOpts({ modelId: 'gpt-4o' })) + expect(yaml).toContain('id: gpt-4o') + }) + + it('references the correct env var for apiKey', () => { + const yaml = generateManifest(baseOpts({ provider: 'anthropic' })) + expect(yaml).toContain('$env:ANTHROPIC_API_KEY') + }) + + it('includes temperature and maxTokens parameters', () => { + const yaml = generateManifest(baseOpts()) + expect(yaml).toContain('temperature: 0.7') + expect(yaml).toContain('maxTokens: 2000') + }) +}) + +// ── generateManifest — tools section ──────────────────────────────────────── + +describe('generateManifest — tools section', () => { + it('shows commented-out tools when includeToolsStarter is false', () => { + const yaml = generateManifest(baseOpts({ includeToolsStarter: false })) + expect(yaml).toContain('# tools:') + }) + + it('shows uncommented tools section when includeToolsStarter is true', () => { + const yaml = generateManifest(baseOpts({ includeToolsStarter: true })) + expect(yaml).toMatch(/^\s+tools:/m) + expect(yaml).toContain('name: my-tool') + }) +}) + +// ── generateManifest — memory backends ────────────────────────────────────── + +describe('generateManifest — memory backends', () => { + it('generates in-memory backend without connection line', () => { + const yaml = generateManifest(baseOpts({ includeMemory: true, memoryBackend: 'in-memory' })) + expect(yaml).toContain('backend: in-memory') + expect(yaml).not.toContain('connection:') + }) + + it('generates redis backend with REDIS_URL connection', () => { + const yaml = generateManifest(baseOpts({ includeMemory: true, memoryBackend: 'redis' })) + expect(yaml).toContain('backend: redis') + expect(yaml).toContain('connection: $env:REDIS_URL') + }) + + it('generates sqlite backend with file connection', () => { + const yaml = generateManifest(baseOpts({ includeMemory: true, memoryBackend: 'sqlite' })) + expect(yaml).toContain('backend: sqlite') + expect(yaml).toContain('connection: file:./data/memory.db') + }) +}) + +// ── generateManifest — memory hygiene ─────────────────────────────────────── + +describe('generateManifest — memory hygiene', () => { + const yaml = generateManifest(baseOpts({ includeMemory: true, memoryBackend: 'in-memory' })) + + it('includes piiScrubFields', () => { + expect(yaml).toContain('piiScrubFields: []') + }) + + it('includes auditLog', () => { + expect(yaml).toContain('auditLog: false') + }) +}) + +// ── generateManifest — API variants ───────────────────────────────────────── + +describe('generateManifest — API variants', () => { + it('generates rest API with streaming and chat path', () => { + const yaml = generateManifest(baseOpts({ includeApi: true, apiType: 'rest', apiPort: 3000 })) + expect(yaml).toContain('type: rest') + expect(yaml).toContain('streaming: true') + expect(yaml).toContain('path: /v1/chat') + }) + + it('generates mcp API without chat config', () => { + const yaml = generateManifest(baseOpts({ includeApi: true, apiType: 'mcp', apiPort: 3000 })) + expect(yaml).toContain('type: mcp') + expect(yaml).not.toContain('streaming:') + }) + + it('uses default port 3000 when apiPort is not specified', () => { + const yaml = generateManifest(baseOpts({ includeApi: true, apiType: 'rest' })) + expect(yaml).toContain('port: 3000') + }) + + it('uses custom port when specified', () => { + const yaml = generateManifest(baseOpts({ includeApi: true, apiType: 'rest', apiPort: 8080 })) + expect(yaml).toContain('port: 8080') + }) +}) + +// ── generateManifest — observability backends ─────────────────────────────── + +describe('generateManifest — observability backends', () => { + it('generates langfuse config with public/secret keys', () => { + const yaml = generateManifest( + baseOpts({ includeObservability: true, tracingBackend: 'langfuse' }), + ) + expect(yaml).toContain('backend: langfuse') + expect(yaml).toContain('$env:LANGFUSE_PUBLIC_KEY') + expect(yaml).toContain('$env:LANGFUSE_SECRET_KEY') + }) + + it('generates otel config with OTLP endpoint', () => { + const yaml = generateManifest(baseOpts({ includeObservability: true, tracingBackend: 'otel' })) + expect(yaml).toContain('backend: otel') + expect(yaml).toContain('$env:OTEL_EXPORTER_OTLP_ENDPOINT') + }) + + it('generates datadog config with agent URL', () => { + const yaml = generateManifest( + baseOpts({ includeObservability: true, tracingBackend: 'datadog' }), + ) + expect(yaml).toContain('backend: datadog') + expect(yaml).toContain('$env:DD_TRACE_AGENT_URL') + }) +}) + +// ── generateManifest — guardrails content ─────────────────────────────────── + +describe('generateManifest — guardrails content', () => { + const yaml = generateManifest(baseOpts({ includeGuardrails: true })) + + it('includes prompt-injection input guardrail', () => { + expect(yaml).toContain('type: prompt-injection') + expect(yaml).toContain('action: reject') + }) + + it('includes toxicity-filter output guardrail', () => { + expect(yaml).toContain('type: toxicity-filter') + expect(yaml).toContain('threshold: 0.7') + }) +}) + +// ── generateManifest — eval content ───────────────────────────────────────── + +describe('generateManifest — eval content', () => { + const yaml = generateManifest(baseOpts({ includeEval: true })) + + it('includes deepeval framework', () => { + expect(yaml).toContain('framework: deepeval') + }) + + it('includes faithfulness and hallucination metrics', () => { + expect(yaml).toContain('- faithfulness') + expect(yaml).toContain('- hallucination') + }) +}) + +// ── generateManifest — compliance ─────────────────────────────────────────── + +describe('generateManifest — compliance', () => { + const yaml = generateManifest(baseOpts()) + + it('includes owasp-llm-top10 pack', () => { + expect(yaml).toContain('- owasp-llm-top10') + }) + + it('includes model-resilience and memory-hygiene packs', () => { + expect(yaml).toContain('- model-resilience') + expect(yaml).toContain('- memory-hygiene') + }) +}) + +// ── collectRequiredEnvVars ────────────────────────────────────────────────── + +describe('collectRequiredEnvVars', () => { + it('includes provider API key for openai', () => { + const vars = collectRequiredEnvVars(baseOpts({ provider: 'openai' })) + expect(vars).toContain('OPENAI_API_KEY') + }) + + it('includes provider API key for anthropic', () => { + const vars = collectRequiredEnvVars(baseOpts({ provider: 'anthropic' })) + expect(vars).toContain('ANTHROPIC_API_KEY') + }) + + it('includes AZURE_OPENAI_API_KEY for azure (not AZURE_API_KEY)', () => { + const vars = collectRequiredEnvVars(baseOpts({ provider: 'azure' })) + expect(vars).toContain('AZURE_OPENAI_API_KEY') + expect(vars).not.toContain('AZURE_API_KEY') + }) + + it('includes REDIS_URL when memory backend is redis', () => { + const vars = collectRequiredEnvVars( + baseOpts({ includeMemory: true, memoryBackend: 'redis' }), + ) + expect(vars).toContain('REDIS_URL') + }) + + it('does not include REDIS_URL when memory is in-memory', () => { + const vars = collectRequiredEnvVars( + baseOpts({ includeMemory: true, memoryBackend: 'in-memory' }), + ) + expect(vars).not.toContain('REDIS_URL') + }) + + it('does not include REDIS_URL when memory is disabled', () => { + const vars = collectRequiredEnvVars(baseOpts({ includeMemory: false })) + expect(vars).not.toContain('REDIS_URL') + }) + + it('includes langfuse keys for langfuse tracing', () => { + const vars = collectRequiredEnvVars( + baseOpts({ includeObservability: true, tracingBackend: 'langfuse' }), + ) + expect(vars).toContain('LANGFUSE_PUBLIC_KEY') + expect(vars).toContain('LANGFUSE_SECRET_KEY') + }) + + it('includes OTEL endpoint for otel tracing', () => { + const vars = collectRequiredEnvVars( + baseOpts({ includeObservability: true, tracingBackend: 'otel' }), + ) + expect(vars).toContain('OTEL_EXPORTER_OTLP_ENDPOINT') + }) + + it('includes DD agent URL for datadog tracing', () => { + const vars = collectRequiredEnvVars( + baseOpts({ includeObservability: true, tracingBackend: 'datadog' }), + ) + expect(vars).toContain('DD_TRACE_AGENT_URL') + }) + + it('combines provider key + redis + langfuse keys', () => { + const vars = collectRequiredEnvVars( + baseOpts({ + provider: 'groq', + includeMemory: true, + memoryBackend: 'redis', + includeObservability: true, + tracingBackend: 'langfuse', + }), + ) + expect(vars).toEqual([ + 'GROQ_API_KEY', + 'REDIS_URL', + 'LANGFUSE_PUBLIC_KEY', + 'LANGFUSE_SECRET_KEY', + ]) + }) +}) + +// ── generateSystemPrompt ──────────────────────────────────────────────────── + +describe('generateSystemPrompt', () => { + const prompt = generateSystemPrompt('my-cool-agent', 'A helpful assistant') + + it('title-cases the name in the heading', () => { + expect(prompt).toContain('# My Cool Agent') + }) + + it('includes the description in the body', () => { + expect(prompt).toContain('A helpful assistant') + }) + + it('includes instructions section', () => { + expect(prompt).toContain('## Instructions') + expect(prompt).toContain('Be helpful and concise') + }) +}) + +// ── generateEnvExample ────────────────────────────────────────────────────── + +describe('generateEnvExample', () => { + const envFile = generateEnvExample('my-agent', ['OPENAI_API_KEY', 'REDIS_URL']) + + it('includes header comment with agent name', () => { + expect(envFile).toContain('# Environment variables for my-agent') + }) + + it('includes OPENAI_API_KEY with placeholder', () => { + expect(envFile).toContain('OPENAI_API_KEY=sk-your-openai-api-key') + }) + + it('includes REDIS_URL with localhost placeholder', () => { + expect(envFile).toContain('REDIS_URL=redis://localhost:6379') + }) + + it('uses generic placeholder for unknown env vars', () => { + const output = generateEnvExample('agent', ['CUSTOM_VAR']) + expect(output).toContain('CUSTOM_VAR=your-value-here') + }) + + it('places each env var on its own line', () => { + const lines = envFile.split('\n').filter((l) => l && !l.startsWith('#')) + expect(lines).toHaveLength(2) + }) +}) + +// ── Section ordering ──────────────────────────────────────────────────────── + +describe('section ordering', () => { + const yaml = generateManifest( + baseOpts({ + includeMemory: true, + memoryBackend: 'in-memory', + includeGuardrails: true, + includeObservability: true, + tracingBackend: 'langfuse', + }), + ) + + it('places model before memory', () => { + const modelIdx = yaml.indexOf('model:') + const memoryIdx = yaml.indexOf('memory:') + expect(modelIdx).toBeLessThan(memoryIdx) + }) + + it('places compliance before requires', () => { + const complianceIdx = yaml.indexOf('compliance:') + const requiresIdx = yaml.indexOf('requires:') + expect(complianceIdx).toBeLessThan(requiresIdx) + }) +}) + +// ── Edge cases ────────────────────────────────────────────────────────────── + +describe('edge cases', () => { + it('handles empty description', () => { + const yaml = generateManifest(baseOpts({ description: '' })) + expect(yaml).toContain('description: ""') + }) + + it('handles hyphenated name', () => { + const yaml = generateManifest(baseOpts({ name: 'my-complex-agent-v2' })) + expect(yaml).toContain('name: my-complex-agent-v2') + }) + + it('produces valid manifest with all toggles false', () => { + const yaml = generateManifest(baseOpts()) + expect(yaml).toContain('apiVersion: agentspec.io/v1') + expect(yaml).not.toContain('memory:') + expect(yaml).not.toMatch(/^\s+api:/m) + expect(yaml).not.toContain('guardrails:') + expect(yaml).not.toContain('evaluation:') + expect(yaml).not.toContain('observability:') + }) + + it('produces valid manifest with all toggles true', () => { + const yaml = generateManifest( + baseOpts({ + includeMemory: true, + memoryBackend: 'redis', + includeApi: true, + apiType: 'rest', + apiPort: 4000, + includeObservability: true, + tracingBackend: 'otel', + includeGuardrails: true, + includeEval: true, + includeToolsStarter: true, + }), + ) + expect(yaml).toContain('memory:') + expect(yaml).toContain('api:') + expect(yaml).toContain('observability:') + expect(yaml).toContain('guardrails:') + expect(yaml).toContain('evaluation:') + expect(yaml).toMatch(/^\s+tools:/m) + }) +}) + +// ── fetchAvailableModels ──────────────────────────────────────────────────── + +describe('fetchAvailableModels', () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + function mockFetch(data: { id: string }[]): void { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ data }), + }) as unknown as typeof fetch + } + + it('returns filtered models for a provider', async () => { + mockFetch([ + { id: 'openai/gpt-4o' }, + { id: 'openai/gpt-4o-mini' }, + { id: 'anthropic/claude-sonnet-4-6' }, + ]) + const models = await fetchAvailableModels('openai') + expect(models).toEqual(['gpt-4o', 'gpt-4o-mini']) + }) + + it('strips provider prefix from model IDs', async () => { + mockFetch([{ id: 'anthropic/claude-opus-4-6' }]) + const models = await fetchAvailableModels('anthropic') + expect(models).toEqual(['claude-opus-4-6']) + }) + + it('excludes models with :free suffix', async () => { + mockFetch([ + { id: 'google/gemini-2.0-flash' }, + { id: 'google/gemini-2.0-flash:free' }, + ]) + const models = await fetchAvailableModels('google') + expect(models).toEqual(['gemini-2.0-flash']) + }) + + it('returns null on network error', async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error')) as unknown as typeof fetch + const models = await fetchAvailableModels('openai') + expect(models).toBeNull() + }) + + it('returns null when response is not ok', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + }) as unknown as typeof fetch + const models = await fetchAvailableModels('openai') + expect(models).toBeNull() + }) +}) diff --git a/packages/cli/src/commands/init-helpers.ts b/packages/cli/src/commands/init-helpers.ts new file mode 100644 index 0000000..405e978 --- /dev/null +++ b/packages/cli/src/commands/init-helpers.ts @@ -0,0 +1,349 @@ +// ── Constants ────────────────────────────────────────────────────────────────── + +export const PROVIDER_DEFAULTS: Record = { + openai: 'gpt-4o-mini', + anthropic: 'claude-sonnet-4-6', + groq: 'llama-3.3-70b-versatile', + google: 'gemini-2.0-flash', + mistral: 'mistral-large-latest', + azure: 'gpt-4o', +} + +export const PROVIDER_ENV_KEYS: Record = { + openai: 'OPENAI_API_KEY', + anthropic: 'ANTHROPIC_API_KEY', + groq: 'GROQ_API_KEY', + google: 'GOOGLE_API_KEY', + mistral: 'MISTRAL_API_KEY', + azure: 'AZURE_OPENAI_API_KEY', +} + +// ── Interface ────────────────────────────────────────────────────────────────── + +export interface InitOptions { + // Phase 1 + name: string + description: string + version: string + provider: string + modelId: string + // Phase 2 toggles + includeMemory: boolean + includeApi: boolean + includeObservability: boolean + includeGuardrails: boolean + includeEval: boolean + includeToolsStarter: boolean + // Phase 3 conditionals + memoryBackend?: 'in-memory' | 'redis' | 'sqlite' + apiType?: 'rest' | 'mcp' + apiPort?: number + tracingBackend?: 'langfuse' | 'otel' | 'datadog' +} + +// ── Private section generators ───────────────────────────────────────────────── + +function generateMetadataSection(opts: InitOptions): string { + return `apiVersion: agentspec.io/v1 +kind: AgentSpec + +metadata: + name: ${opts.name} + version: ${opts.version} + description: "${opts.description}" + tags: [] + author: "" + license: MIT + +spec:` +} + +function generateModelSection(opts: InitOptions): string { + const apiKeyEnv = PROVIDER_ENV_KEYS[opts.provider] ?? `${opts.provider.toUpperCase()}_API_KEY` + return ` + # ── MODEL ────────────────────────────────────────────────────────────────── + model: + provider: ${opts.provider} + id: ${opts.modelId} + apiKey: $env:${apiKeyEnv} + parameters: + temperature: 0.7 + maxTokens: 2000 + # Uncomment to add fallback: + # fallback: + # provider: openai + # id: gpt-4o-mini + # apiKey: $env:OPENAI_API_KEY + # triggerOn: [rate_limit, timeout, error_5xx] + # maxRetries: 2` +} + +function generatePromptsSection(): string { + return ` + # ── PROMPTS ──────────────────────────────────────────────────────────────── + prompts: + system: $file:prompts/system.md + fallback: "I'm experiencing difficulties. Please try again." + hotReload: false` +} + +function generateToolsSection(include: boolean): string { + if (include) { + return ` + # ── TOOLS ──────────────────────────────────────────────────────────────── + tools: + - name: my-tool + type: function + description: "Description of what this tool does" + module: $file:tools/my_tool.py + function: my_tool_function + annotations: + readOnlyHint: true + destructiveHint: false` + } + return ` + # ── TOOLS (optional) ─────────────────────────────────────────────────────── + # tools: + # - name: my-tool + # type: function + # description: "Description of what this tool does" + # module: $file:tools/my_tool.py + # function: my_tool_function + # annotations: + # readOnlyHint: true + # destructiveHint: false` +} + +function generateMemorySection(opts: InitOptions): string { + const backend = opts.memoryBackend ?? 'in-memory' + let connectionLine = '' + if (backend === 'redis') { + connectionLine = '\n connection: $env:REDIS_URL' + } else if (backend === 'sqlite') { + connectionLine = '\n connection: file:./data/memory.db' + } + + return ` + # ── MEMORY ───────────────────────────────────────────────────────────────── + memory: + shortTerm: + backend: ${backend} + maxTurns: 20 + maxTokens: 8000${connectionLine} + hygiene: + piiScrubFields: [] + auditLog: false` +} + +function generateApiSection(opts: InitOptions): string { + const apiType = opts.apiType ?? 'rest' + const port = opts.apiPort ?? 3000 + + if (apiType === 'mcp') { + return ` + # ── API ────────────────────────────────────────────────────────────────── + api: + type: mcp + port: ${port}` + } + + return ` + # ── API ────────────────────────────────────────────────────────────────── + api: + type: rest + port: ${port} + chat: + path: /v1/chat + protocol: openai-compatible + streaming: true` +} + +function generateGuardrailsSection(): string { + return ` + # ── GUARDRAILS ───────────────────────────────────────────────────────────── + guardrails: + input: + - type: prompt-injection + action: reject + sensitivity: high + output: + - type: toxicity-filter + threshold: 0.7 + action: reject` +} + +function generateEvalSection(): string { + return ` + # ── EVALUATION ───────────────────────────────────────────────────────────── + evaluation: + framework: deepeval + datasets: + - name: qa-test + path: $file:eval/datasets/qa.jsonl + metrics: + - faithfulness + - hallucination + thresholds: + hallucination: 0.05 + ciGate: false` +} + +function generateObservabilitySection(opts: InitOptions): string { + const backend = opts.tracingBackend ?? 'langfuse' + + let tracingConfig: string + if (backend === 'langfuse') { + tracingConfig = ` backend: langfuse + publicKey: $env:LANGFUSE_PUBLIC_KEY + secretKey: $env:LANGFUSE_SECRET_KEY` + } else if (backend === 'otel') { + tracingConfig = ` backend: otel + endpoint: $env:OTEL_EXPORTER_OTLP_ENDPOINT` + } else { + tracingConfig = ` backend: datadog + endpoint: $env:DD_TRACE_AGENT_URL` + } + + return ` + # ── OBSERVABILITY ────────────────────────────────────────────────────────── + observability: + tracing: + ${tracingConfig} + logging: + level: info + structured: true` +} + +function generateComplianceSection(): string { + return ` + # ── COMPLIANCE ───────────────────────────────────────────────────────────── + compliance: + packs: + - owasp-llm-top10 + - model-resilience + - memory-hygiene` +} + +function generateRequiresSection(envVars: string[]): string { + const envLines = envVars.map((v) => ` - ${v}`).join('\n') + return ` + # ── RUNTIME REQUIREMENTS ─────────────────────────────────────────────────── + requires: + envVars: +${envLines} +` +} + +// ── Exported functions ───────────────────────────────────────────────────────── + +export function collectRequiredEnvVars(opts: InitOptions): string[] { + const vars: string[] = [] + + const providerKey = PROVIDER_ENV_KEYS[opts.provider] ?? `${opts.provider.toUpperCase()}_API_KEY` + vars.push(providerKey) + + if (opts.includeMemory && opts.memoryBackend === 'redis') { + vars.push('REDIS_URL') + } + + if (opts.includeObservability) { + const backend = opts.tracingBackend ?? 'langfuse' + if (backend === 'langfuse') { + vars.push('LANGFUSE_PUBLIC_KEY', 'LANGFUSE_SECRET_KEY') + } else if (backend === 'otel') { + vars.push('OTEL_EXPORTER_OTLP_ENDPOINT') + } else if (backend === 'datadog') { + vars.push('DD_TRACE_AGENT_URL') + } + } + + return vars +} + +export function generateManifest(opts: InitOptions): string { + const envVars = collectRequiredEnvVars(opts) + + const sections: string[] = [ + generateMetadataSection(opts), + generateModelSection(opts), + generatePromptsSection(), + generateToolsSection(opts.includeToolsStarter), + ] + + if (opts.includeMemory) sections.push(generateMemorySection(opts)) + if (opts.includeApi) sections.push(generateApiSection(opts)) + if (opts.includeGuardrails) sections.push(generateGuardrailsSection()) + if (opts.includeEval) sections.push(generateEvalSection()) + if (opts.includeObservability) sections.push(generateObservabilitySection(opts)) + + sections.push(generateComplianceSection()) + sections.push(generateRequiresSection(envVars)) + + return sections.join('\n') +} + +export function generateSystemPrompt(name: string, description: string): string { + const title = name + .split('-') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' ') + + return `# ${title} + +${description} + +## Instructions + +- Be helpful and concise +- Follow the user's instructions carefully +- Ask for clarification when the request is ambiguous +` +} + +export async function fetchAvailableModels(provider: string): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 5000) + + try { + const res = await fetch('https://openrouter.ai/api/v1/models', { + signal: controller.signal, + }) + if (!res.ok) return null + + const json = (await res.json()) as { data?: { id: string }[] } + if (!json.data || !Array.isArray(json.data)) return null + + const prefix = provider + '/' + return json.data + .map((m) => m.id) + .filter((id) => id.startsWith(prefix) && !id.endsWith(':free')) + .map((id) => id.slice(prefix.length)) + .sort() + } catch { + return null + } finally { + clearTimeout(timeout) + } +} + +export function generateEnvExample(name: string, envVars: string[]): string { + const placeholders: Record = { + OPENAI_API_KEY: 'sk-your-openai-api-key', + ANTHROPIC_API_KEY: 'sk-ant-your-anthropic-api-key', + GROQ_API_KEY: 'gsk_your-groq-api-key', + GOOGLE_API_KEY: 'your-google-api-key', + MISTRAL_API_KEY: 'your-mistral-api-key', + AZURE_OPENAI_API_KEY: 'your-azure-openai-api-key', + REDIS_URL: 'redis://localhost:6379', + LANGFUSE_PUBLIC_KEY: 'pk-lf-your-langfuse-public-key', + LANGFUSE_SECRET_KEY: 'sk-lf-your-langfuse-secret-key', + OTEL_EXPORTER_OTLP_ENDPOINT: 'http://localhost:4318', + DD_TRACE_AGENT_URL: 'http://localhost:8126', + } + + const lines = [`# Environment variables for ${name}`] + for (const v of envVars) { + lines.push(`${v}=${placeholders[v] ?? 'your-value-here'}`) + } + return lines.join('\n') + '\n' +} diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index f748d94..a40207e 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,22 +1,298 @@ import type { Command } from 'commander' -import { writeFileSync, existsSync } from 'node:fs' +import { writeFileSync, existsSync, mkdirSync } from 'node:fs' import { resolve, join } from 'node:path' import chalk from 'chalk' import * as p from '@clack/prompts' import { printHeader } from '../utils/output.js' +import { + PROVIDER_DEFAULTS, + generateManifest, + generateSystemPrompt, + generateEnvExample, + collectRequiredEnvVars, + fetchAvailableModels, + type InitOptions, +} from './init-helpers.js' + +// ── Private helpers ──────────────────────────────────────────────────────────── + +function buildDefaultOptions(): InitOptions { + return { + name: 'my-agent', + description: 'An AI agent', + version: '0.1.0', + provider: 'openai', + modelId: 'gpt-4o-mini', + includeMemory: false, + includeApi: false, + includeObservability: false, + includeGuardrails: true, + includeEval: false, + includeToolsStarter: false, + } +} + +async function runPhase1(): Promise<{ + name: string + description: string + version: string + provider: string + modelId: string +}> { + const basics = await p.group( + { + name: () => + p.text({ + message: 'Agent name (slug)', + placeholder: 'my-agent', + validate: (v) => + /^[a-z0-9-]+$/.test(v) ? undefined : 'Must be lowercase slug (a-z, 0-9, -)', + }), + description: () => + p.text({ + message: 'Description', + placeholder: 'An AI agent that...', + }), + version: () => + p.text({ + message: 'Version', + placeholder: '0.1.0', + initialValue: '0.1.0', + }), + provider: () => + p.select({ + message: 'Model provider', + options: [ + { value: 'openai', label: 'OpenAI' }, + { value: 'anthropic', label: 'Anthropic' }, + { value: 'groq', label: 'Groq' }, + { value: 'google', label: 'Google' }, + { value: 'mistral', label: 'Mistral' }, + { value: 'azure', label: 'Azure OpenAI' }, + ], + }), + }, + { + onCancel: () => { + p.cancel('Init cancelled.') + process.exit(0) + }, + }, + ) + + const provider = String(basics.provider || 'openai') + const modelId = await selectModel(provider) + + return { + ...(basics as { name: string; description: string; version: string; provider: string }), + modelId, + } +} + +async function selectModel(provider: string): Promise { + const s = p.spinner() + s.start('Fetching available models...') + const models = await fetchAvailableModels(provider) + if (models && models.length > 0) { + s.stop('Models loaded') + const defaultModel = PROVIDER_DEFAULTS[provider] + const selected = await p.select({ + message: 'Model', + options: models.map((id) => ({ value: id, label: id })), + initialValue: models.includes(defaultModel) ? defaultModel : models[0], + }) + if (p.isCancel(selected)) { + p.cancel('Init cancelled.') + process.exit(0) + } + return String(selected) + } + + s.stop('Could not fetch models') + const modelId = await p.text({ + message: 'Model ID', + placeholder: PROVIDER_DEFAULTS[provider] ?? 'gpt-4o-mini', + initialValue: PROVIDER_DEFAULTS[provider] ?? 'gpt-4o-mini', + }) + if (p.isCancel(modelId)) { + p.cancel('Init cancelled.') + process.exit(0) + } + return String(modelId) +} + +interface Phase2Answers { + includeMemory: boolean + includeApi: boolean + includeObservability: boolean + includeGuardrails: boolean + includeEval: boolean + includeToolsStarter: boolean +} + +async function runPhase2(): Promise { + const answers = await p.group( + { + includeMemory: () => + p.confirm({ + message: 'Include memory configuration?', + initialValue: false, + }), + includeApi: () => + p.confirm({ + message: 'Include API endpoint configuration?', + initialValue: false, + }), + includeObservability: () => + p.confirm({ + message: 'Include observability (tracing/logging)?', + initialValue: false, + }), + includeGuardrails: () => + p.confirm({ + message: 'Include guardrails?', + initialValue: true, + }), + includeEval: () => + p.confirm({ + message: 'Include evaluation configuration?', + initialValue: false, + }), + includeToolsStarter: () => + p.confirm({ + message: 'Include starter tool template?', + initialValue: false, + }), + }, + { + onCancel: () => { + p.cancel('Init cancelled.') + process.exit(0) + }, + }, + ) + + return answers +} + +async function runPhase3(phase2: { + includeMemory: boolean + includeApi: boolean + includeObservability: boolean +}): Promise<{ + memoryBackend?: 'in-memory' | 'redis' | 'sqlite' + apiType?: 'rest' | 'mcp' + apiPort?: number + tracingBackend?: 'langfuse' | 'otel' | 'datadog' +}> { + const result: { + memoryBackend?: 'in-memory' | 'redis' | 'sqlite' + apiType?: 'rest' | 'mcp' + apiPort?: number + tracingBackend?: 'langfuse' | 'otel' | 'datadog' + } = {} + + if (phase2.includeMemory) { + const backend = await p.select({ + message: 'Memory backend', + options: [ + { value: 'in-memory', label: 'In-memory (no persistence)' }, + { value: 'redis', label: 'Redis' }, + { value: 'sqlite', label: 'SQLite' }, + ], + }) + if (p.isCancel(backend)) { + p.cancel('Init cancelled.') + process.exit(0) + } + result.memoryBackend = backend as 'in-memory' | 'redis' | 'sqlite' + } + + if (phase2.includeApi) { + const apiType = await p.select({ + message: 'API type', + options: [ + { value: 'rest', label: 'REST (OpenAI-compatible)' }, + { value: 'mcp', label: 'MCP (Model Context Protocol)' }, + ], + }) + if (p.isCancel(apiType)) { + p.cancel('Init cancelled.') + process.exit(0) + } + result.apiType = apiType as 'rest' | 'mcp' + + const port = await p.text({ + message: 'API port', + placeholder: '3000', + initialValue: '3000', + validate: (v) => { + const n = parseInt(v, 10) + return isNaN(n) || n < 1 || n > 65535 ? 'Must be a valid port (1-65535)' : undefined + }, + }) + if (p.isCancel(port)) { + p.cancel('Init cancelled.') + process.exit(0) + } + result.apiPort = parseInt(String(port), 10) + } + + if (phase2.includeObservability) { + const backend = await p.select({ + message: 'Tracing backend', + options: [ + { value: 'langfuse', label: 'Langfuse' }, + { value: 'otel', label: 'OpenTelemetry (OTLP)' }, + { value: 'datadog', label: 'Datadog' }, + ], + }) + if (p.isCancel(backend)) { + p.cancel('Init cancelled.') + process.exit(0) + } + result.tracingBackend = backend as 'langfuse' | 'otel' | 'datadog' + } + + return result +} + +function scaffoldFiles(outDir: string, opts: InitOptions): void { + const promptsDir = join(outDir, 'prompts') + const systemMdPath = join(promptsDir, 'system.md') + const envExamplePath = join(outDir, '.env.example') + + const envVars = collectRequiredEnvVars(opts) + + mkdirSync(promptsDir, { recursive: true }) + + if (existsSync(systemMdPath)) { + p.log.warn(`Skipped prompts/system.md (already exists)`) + } else { + writeFileSync(systemMdPath, generateSystemPrompt(opts.name, opts.description), 'utf-8') + } + + if (existsSync(envExamplePath)) { + p.log.warn(`Skipped .env.example (already exists)`) + } else { + writeFileSync(envExamplePath, generateEnvExample(opts.name, envVars), 'utf-8') + } +} + +// ── Public command registration ──────────────────────────────────────────────── export function registerInitCommand(program: Command): void { program .command('init [dir]') .description('Interactive wizard to create a new agent.yaml manifest') .option('--yes', 'Skip prompts, create a minimal manifest') - .action(async (dir: string = '.', opts: { yes?: boolean }) => { + .action(async (dir: string = '.', cmdOpts: { yes?: boolean }) => { const outDir = resolve(dir) const outFile = join(outDir, 'agent.yaml') printHeader('AgentSpec Init') - if (existsSync(outFile) && !opts.yes) { + if (existsSync(outFile) && !cmdOpts.yes) { const overwrite = await p.confirm({ message: `agent.yaml already exists at ${outFile}. Overwrite?`, initialValue: false, @@ -27,106 +303,39 @@ export function registerInitCommand(program: Command): void { } } - let name = 'my-agent' - let description = 'An AI agent' - let version = '0.1.0' - let provider = 'openai' - let modelId = 'gpt-4o-mini' - let includeMemory = false - let includeGuardrails = true - let includeEval = false + let opts: InitOptions - if (!opts.yes) { + if (cmdOpts.yes) { + opts = buildDefaultOptions() + } else { p.intro(chalk.cyan('Creating your agent.yaml')) - const answers = await p.group( - { - name: () => - p.text({ - message: 'Agent name (slug)', - placeholder: 'my-agent', - validate: (v) => - /^[a-z0-9-]+$/.test(v) ? undefined : 'Must be lowercase slug (a-z, 0-9, -)', - }), - description: () => - p.text({ - message: 'Description', - placeholder: 'An AI agent that...', - }), - version: () => - p.text({ - message: 'Version', - placeholder: '0.1.0', - initialValue: '0.1.0', - }), - provider: () => - p.select({ - message: 'Model provider', - options: [ - { value: 'openai', label: 'OpenAI' }, - { value: 'anthropic', label: 'Anthropic' }, - { value: 'groq', label: 'Groq' }, - { value: 'google', label: 'Google' }, - { value: 'mistral', label: 'Mistral' }, - { value: 'azure', label: 'Azure OpenAI' }, - ], - }), - modelId: () => - p.text({ - message: 'Model ID', - placeholder: 'gpt-4o-mini', - }), - includeMemory: () => - p.confirm({ - message: 'Include memory configuration?', - initialValue: false, - }), - includeGuardrails: () => - p.confirm({ - message: 'Include guardrails?', - initialValue: true, - }), - includeEval: () => - p.confirm({ - message: 'Include evaluation configuration?', - initialValue: false, - }), - }, - { - onCancel: () => { - p.cancel('Init cancelled.') - process.exit(0) - }, - }, - ) - - name = String(answers.name || 'my-agent') - description = String(answers.description || 'An AI agent') - version = String(answers.version || '0.1.0') - provider = String(answers.provider || 'openai') - modelId = String(answers.modelId || 'gpt-4o-mini') - includeMemory = Boolean(answers.includeMemory) - includeGuardrails = Boolean(answers.includeGuardrails) - includeEval = Boolean(answers.includeEval) - } - - const apiKeyEnv = `${provider.toUpperCase()}_API_KEY` + const phase1 = await runPhase1() + const phase2 = await runPhase2() + const phase3 = await runPhase3(phase2) - const yaml = generateManifest({ - name, - description, - version, - provider, - modelId, - apiKeyEnv, - includeMemory, - includeGuardrails, - includeEval, - }) + opts = { + name: String(phase1.name || 'my-agent'), + description: String(phase1.description || 'An AI agent'), + version: String(phase1.version || '0.1.0'), + provider: String(phase1.provider || 'openai'), + modelId: String(phase1.modelId || 'gpt-4o-mini'), + includeMemory: Boolean(phase2.includeMemory), + includeApi: Boolean(phase2.includeApi), + includeObservability: Boolean(phase2.includeObservability), + includeGuardrails: Boolean(phase2.includeGuardrails), + includeEval: Boolean(phase2.includeEval), + includeToolsStarter: Boolean(phase2.includeToolsStarter), + ...phase3, + } + } + const yaml = generateManifest(opts) writeFileSync(outFile, yaml, 'utf-8') - if (!opts.yes) p.outro(chalk.green(`✓ Created ${outFile}`)) + scaffoldFiles(outDir, opts) + + if (!cmdOpts.yes) p.outro(chalk.green(`✓ Created ${outFile}`)) else console.log(chalk.green(`\n ✓ Created ${outFile}\n`)) console.log(chalk.gray(' Next steps:')) @@ -137,121 +346,3 @@ export function registerInitCommand(program: Command): void { console.log() }) } - -function generateManifest(opts: { - name: string - description: string - version: string - provider: string - modelId: string - apiKeyEnv: string - includeMemory: boolean - includeGuardrails: boolean - includeEval: boolean -}): string { - const sections: string[] = [ - `apiVersion: agentspec.io/v1 -kind: AgentSpec - -metadata: - name: ${opts.name} - version: ${opts.version} - description: "${opts.description}" - tags: [] - author: "" - license: MIT - -spec: - # ── MODEL ────────────────────────────────────────────────────────────────── - model: - provider: ${opts.provider} - id: ${opts.modelId} - apiKey: $env:${opts.apiKeyEnv} - parameters: - temperature: 0.7 - maxTokens: 2000 - # Uncomment to add fallback: - # fallback: - # provider: openai - # id: gpt-4o-mini - # apiKey: $env:OPENAI_API_KEY - # triggerOn: [rate_limit, timeout, error_5xx] - # maxRetries: 2 - - # ── PROMPTS ──────────────────────────────────────────────────────────────── - prompts: - system: $file:prompts/system.md - fallback: "I'm experiencing difficulties. Please try again." - hotReload: false - - # ── TOOLS (optional) ─────────────────────────────────────────────────────── - # tools: - # - name: my-tool - # type: function - # description: "Description of what this tool does" - # module: $file:tools/my_tool.py - # function: my_tool_function - # annotations: - # readOnlyHint: true - # destructiveHint: false`, - ] - - if (opts.includeMemory) { - sections.push(` - # ── MEMORY ───────────────────────────────────────────────────────────────── - memory: - shortTerm: - backend: in-memory - maxTurns: 20 - maxTokens: 8000 - hygiene: - piiScrubFields: [] - auditLog: false`) - } - - if (opts.includeGuardrails) { - sections.push(` - # ── GUARDRAILS ───────────────────────────────────────────────────────────── - guardrails: - input: - - type: prompt-injection - action: reject - sensitivity: high - output: - - type: toxicity-filter - threshold: 0.7 - action: reject`) - } - - if (opts.includeEval) { - sections.push(` - # ── EVALUATION ───────────────────────────────────────────────────────────── - evaluation: - framework: deepeval - datasets: - - name: qa-test - path: $file:eval/datasets/qa.jsonl - metrics: - - faithfulness - - hallucination - thresholds: - hallucination: 0.05 - ciGate: false`) - } - - sections.push(` - # ── COMPLIANCE ───────────────────────────────────────────────────────────── - compliance: - packs: - - owasp-llm-top10 - - model-resilience - - memory-hygiene - - # ── RUNTIME REQUIREMENTS ─────────────────────────────────────────────────── - requires: - envVars: - - ${opts.apiKeyEnv} -`) - - return sections.join('\n') -}