diff --git a/CHANGELOG.md b/CHANGELOG.md index 88c3d20..4587bc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Install Command** - Added `ai-devkit install` to apply project configuration from `.ai-devkit.json` + - Supports `--config ` for custom config file locations + - Supports `--overwrite` for non-interactive full overwrite mode + - Installs environments, phases, and skills in a single run with summary output + ## [0.14.0] - 2026-02-21 ### Changed diff --git a/docs/ai/design/feature-install-command.md b/docs/ai/design/feature-install-command.md new file mode 100644 index 0000000..d205702 --- /dev/null +++ b/docs/ai/design/feature-install-command.md @@ -0,0 +1,120 @@ +--- +phase: design +title: System Design & Architecture +description: Define the technical architecture, components, and data models +feature: install-command +--- + +# System Design & Architecture - Install Command + +## Architecture Overview + +**What is the high-level system structure?** + +```mermaid +graph TD + User[User: ai-devkit install] --> InstallCommand + InstallCommand --> ConfigLoader[Config Service: load .ai-devkit.json] + ConfigLoader --> Validator[Config Util Validator] + Validator --> Reconciler[Install Reconciler] + Reconciler --> Confirmer[Overwrite Confirmation Prompt] + Reconciler --> EnvSetup[TemplateManager.setupMultipleEnvironments] + Reconciler --> PhaseSetup[TemplateManager.copyPhaseTemplate] + Reconciler --> SkillSetup[SkillManager.addSkill] + SkillSetup --> SkillConfigSync[ConfigManager.update skills metadata] + Reconciler --> Reporter[Install Summary Reporter] +``` + +**Key components and responsibilities:** + +- `install` command handler: orchestrates install lifecycle. +- `InstallConfig Validator`: validates environments, phases, and optional skills. +- `Install Reconciler`: computes desired state vs existing files. +- `TemplateManager` integration: applies environment and phase templates. +- `SkillManager` integration: installs skills from config entries. +- `Overwrite Confirmation Prompt`: when destination artifacts already exist, ask user to confirm replacement. +- `SkillConfigSync`: ensures `ai-devkit skill add` writes skill metadata back to `.ai-devkit.json`. +- `Reporter`: emits per-section summary and final exit status. + +## Data Models + +**What data do we need to manage?** + +```typescript +interface DevKitInstallConfig { + version: string; + environments: EnvironmentCode[]; + phases: Phase[]; + skills?: Array<{ + registry: string; + name: string; + }>; + createdAt: string; + updatedAt: string; +} +``` + +- Existing fields continue unchanged. +- New `skills` field is optional for backward compatibility. +- Duplicate skill entries deduplicated by `registry + name`. + +## API Design + +**How do components communicate?** + +**CLI surface:** + +- `ai-devkit install` +- Optional follow-up flags (proposed): + - `--config ` (default `.ai-devkit.json`) + - `--overwrite` (overwrite all existing artifacts without additional prompts) + +**Internal interfaces (proposed):** + +```typescript +async function installCommand(options: InstallOptions): Promise; +async function validateInstallConfig(config: unknown): Promise; +async function reconcileAndInstall(config: ValidatedInstallConfig, options: InstallOptions): Promise; +``` + +**Output contract:** + +- Section summaries for environments, phases, and skills. +- Final totals: installed/skipped/failed. +- Exit codes: + - Invalid/missing config: `1` + - Valid config with success: `0` + - Partial skill-install failures: `0` with warning output and failed item details. + +## Component Breakdown + +**What are the major building blocks?** + +1. `packages/cli/src/commands/install.ts` (new): top-level command execution. +2. `packages/cli/src/services/config/config.service.ts` (new): load config file from disk. +3. `packages/cli/src/util/config.ts` (new): schema validation and normalization. +4. `packages/cli/src/services/install/install.service.ts` (new): reconcile and apply installation. +5. `packages/cli/src/lib/Config.ts` (update): persist/read `skills` metadata. +6. `packages/cli/src/lib/SkillManager.ts` (update): on successful `addSkill`, sync skill entry into `.ai-devkit.json`. +7. `packages/cli/src/cli.ts` (update): register `install` command and options. + +## Design Decisions + +**Why did we choose this approach?** + +- Reuse existing managers (`TemplateManager`, `SkillManager`) for consistency and lower risk. +- Add `install` as separate command instead of overloading `init` to keep intent clear: + - `init`: configure project interactively/template-first. + - `install`: apply existing project config deterministically. +- Keep new config field optional to avoid breaking older projects. +- Existing artifacts require explicit user confirmation before overwrite (safe interactive default). +- Partial skill failures do not fail the whole install run; command exits `0` and reports warnings for failed items. + +## Non-Functional Requirements + +**How should the system perform?** + +- Performance: install should scale linearly with configured phases and skills. +- Reliability: each section continues independently and reports failures. +- Security: validate config values before filesystem/network actions. +- Usability: actionable errors that point to field names and file path. diff --git a/docs/ai/implementation/feature-install-command.md b/docs/ai/implementation/feature-install-command.md new file mode 100644 index 0000000..48266a8 --- /dev/null +++ b/docs/ai/implementation/feature-install-command.md @@ -0,0 +1,76 @@ +--- +phase: implementation +title: Implementation Guide +description: Technical implementation notes, patterns, and code guidelines +feature: install-command +--- + +# Implementation Guide - Install Command + +## Development Setup + +- Implemented in `packages/cli` using existing command architecture (`commander`). +- Uses `zod` for schema-based config validation. +- Feature reuses existing managers: `ConfigManager`, `TemplateManager`, `SkillManager`. + +## Code Structure + +- `packages/cli/src/commands/install.ts` + - New CLI handler for `ai-devkit install`. + - Handles config loading, report output, and process exit code. +- `packages/cli/src/services/config/config.service.ts` + - Loads and parses config file JSON from disk. +- `packages/cli/src/util/config.ts` + - Validates and normalizes install config data using Zod. + - Supports skill shape normalization (`name` and legacy `skill` key). +- `packages/cli/src/services/install/install.service.ts` + - Reconciles desired state and applies environment/phase/skill installation. + - Implements overwrite and warning-based partial-failure policy. +- `packages/cli/src/types.ts` + - Adds optional `skills` metadata to `DevKitConfig`. +- `packages/cli/src/lib/Config.ts` + - Adds `addSkill` helper to persist unique `registry + name` entries. +- `packages/cli/src/lib/SkillManager.ts` + - Persists metadata to `.ai-devkit.json` after successful `skill add`. +- `packages/cli/src/cli.ts` + - Registers new `install` command and options. + +## Implementation Notes + +### CLI Surface + +- `ai-devkit install` +- Options: + - `--config `: alternate config file path (default `.ai-devkit.json`) + - `--overwrite`: overwrite all existing artifacts without additional prompts + +### Reconcile Behavior + +- Environments and phases are installed section-by-section. +- Existing artifacts are skipped unless overwrite mode is confirmed. +- Skill failures are collected as warnings and do not fail run by default. +- Skills are deduplicated by `registry + name` before installation. + +### Exit Code Policy + +- `1` for invalid/missing config. +- `1` for environment/phase failures. +- `0` for successful run and for skill-only partial failures. + +## Error Handling + +- Validation errors include field-level context and config file path. +- Orchestrator aggregates per-item warnings and reports all failures at the end. +- Install command prints summary and warnings before setting final exit code. + +## Performance Considerations + +- Linear processing by environments/phases/skills. +- No additional network calls beyond existing `SkillManager` behavior. +- Config normalization avoids duplicate work for duplicate entries. + +## Security Notes + +- Input is validated before filesystem/network operations. +- Unsupported environments/phases are rejected early. +- Empty/invalid skill metadata is rejected before installation. diff --git a/docs/ai/planning/feature-install-command.md b/docs/ai/planning/feature-install-command.md new file mode 100644 index 0000000..bcc3bcc --- /dev/null +++ b/docs/ai/planning/feature-install-command.md @@ -0,0 +1,95 @@ +--- +phase: planning +title: Project Planning & Task Breakdown +description: Break down work into actionable tasks and estimate timeline +feature: install-command +--- + +# Project Planning & Task Breakdown - Install Command + +## Milestones + +**What are the major checkpoints?** + +- [x] Milestone 1: Requirements/design approved for `ai-devkit install`. +- [x] Milestone 2: Core install flow (config read + env/phase reconcile) implemented. +- [x] Milestone 3: Skill install integration + tests + docs completed. + +## Task Breakdown + +**What specific work needs to be done?** + +### Phase 1: Foundation + +- [x] Task 1.1: Add `install` command wiring in `packages/cli/src/cli.ts`. +- [x] Task 1.2: Implement install config validator for `.ai-devkit.json`. +- [x] Task 1.3: Define backward-compatible skills schema (`skills[]` optional). +- [x] Task 1.4: Add install report model (installed/skipped/failed counters). + +### Phase 2: Core Features + +- [x] Task 2.1: Implement environment setup from `environments` using `TemplateManager`. +- [x] Task 2.2: Implement phase setup from `phases` using `TemplateManager`. +- [x] Task 2.3: Add idempotent handling for existing artifacts. +- [x] Task 2.4: Add `--overwrite` behavior and conflict messaging. + +### Phase 3: Skills Integration + +- [x] Task 3.1: Implement skills install loop from config skills entries. +- [x] Task 3.2: Deduplicate skill entries by `registry + name`. +- [x] Task 3.3: Add partial-failure handling with warning-only skill failures. +- [x] Task 3.4: Update config types/read-write paths for optional `skills` field. + +### Phase 4: Validation & Docs + +- [x] Task 4.1: Unit tests for config validation and normalization. +- [x] Task 4.2: Integration tests for full `ai-devkit install` happy path. +- [x] Task 4.3: Integration tests for missing config, invalid config, and partial failures. +- [x] Task 4.4: Update README/CLI help/changelog with usage examples. + +## Dependencies + +**What needs to happen in what order?** + +```mermaid +graph TD + T11[1.1 CLI wiring] --> T12[1.2 validator] + T12 --> T21[2.1 env setup] + T12 --> T22[2.2 phase setup] + T13[1.3 skills schema] --> T31[3.1 skills install] + T31 --> T33[3.3 strict/partial failure] + T21 --> T41[4.1 tests] + T22 --> T42[4.2 integration] + T33 --> T43[4.3 failure tests] + T42 --> T44[4.4 docs] + T43 --> T44 +``` + +## Timeline & Estimates + +**When will things be done?** + +- Phase 1: completed +- Phase 2: completed +- Phase 3: completed +- Phase 4: completed +- Remaining estimate: 0 day + +## Risks & Mitigation + +**What could go wrong?** + +- Risk: Existing `.ai-devkit.json` files lack `skills`. + - Mitigation: keep field optional and treat as empty array. +- Risk: Skill installs fail because of network/registry issues. + - Mitigation: continue on error and collect warnings with clear per-skill failure details. +- Risk: Overwrite policy causes accidental template replacement. + - Mitigation: default skip existing artifacts unless `--overwrite` is enabled. + +## Resources Needed + +**What do we need to succeed?** + +- Existing CLI command framework (`commander`). +- Existing managers (`ConfigManager`, `TemplateManager`, `SkillManager`). +- Test harness for command-level tests. diff --git a/docs/ai/requirements/feature-install-command.md b/docs/ai/requirements/feature-install-command.md new file mode 100644 index 0000000..f04bd10 --- /dev/null +++ b/docs/ai/requirements/feature-install-command.md @@ -0,0 +1,115 @@ +--- +phase: requirements +title: Requirements & Problem Understanding +description: Clarify the problem space, gather requirements, and define success criteria +feature: install-command +--- + +# Requirements & Problem Understanding - Install Command + +## Problem Statement + +**What problem are we solving?** + +- `ai-devkit init --template` can bootstrap from a file, but there is no dedicated command to apply project setup from an existing `.ai-devkit.json` in one step. +- Users who clone a repository with `.ai-devkit.json` still need to run multiple setup steps manually (phases, commands, skills). +- Teams need a repeatable, non-interactive way to reinstall AI DevKit assets in a project after checkout, cleanup, or machine changes. + +**Who is affected by this problem?** + +- Developers onboarding into existing repositories. +- Maintainers who want deterministic setup instructions (`npx ai-devkit install`). +- CI/local automation scripts that need idempotent re-setup. + +**What is the current situation/workaround?** + +- Run `ai-devkit init` with prompts or template path. +- Run separate `ai-devkit skill add ...` commands. +- Manually verify command and phase files exist. + +## Goals & Objectives + +**What do we want to achieve?** + +**Primary goals:** + +- Add `npx ai-devkit install` command. +- Command reads `.ai-devkit.json` from current working directory. +- Install/reconcile project artifacts from config in a single run: + - Environment command/context files. + - Initialized phase templates. + - Skills declared in config. +- Ensure `ai-devkit skill add` updates `.ai-devkit.json` so skills become part of installable project state. +- Keep behavior non-interactive by default for automation. + +**Secondary goals:** + +- Provide clear per-step summary (installed, skipped, failed). +- Make command safe to rerun (idempotent with overwrite policy). +- Reuse existing internals from `init`, `phase`, and `skill add` where possible. + +**Non-goals (explicitly out of scope):** + +- Replacing `init` command workflows. +- Adding remote config download/registry for `.ai-devkit.json`. +- Installing global commands (`setup --global`) as part of this feature. + +## User Stories & Use Cases + +**How will users interact with the solution?** + +1. As a developer, I want to run `npx ai-devkit install` so my project is configured from `.ai-devkit.json` without prompts. +2. As a team lead, I want setup to be reproducible from committed config so every teammate gets the same result. +3. As a CI maintainer, I want idempotent install behavior so repeated runs do not fail on existing files. + +**Key workflows and scenarios:** + +- `.ai-devkit.json` exists with environments, initialized phases, and skills metadata -> command installs all. +- Some artifacts already exist -> command prompts for confirmation, then overwrites when confirmed. +- Config is partial -> command installs available sections and reports skipped sections. + +**Edge cases to consider:** + +- `.ai-devkit.json` missing. +- Invalid JSON/schema mismatch. +- Unsupported environment or phase codes in file. +- Skill install failure due to registry/network issues. +- Partial success across phases/skills. + +## Success Criteria + +**How will we know when we're done?** + +- [x] `ai-devkit install` command is available and documented. +- [x] Command loads `.ai-devkit.json` from CWD by default. +- [x] Command applies configured environments (command/context templates). +- [x] Command applies configured `phases` templates. +- [x] Command installs configured skills using existing skill installation flow. +- [x] Command prints final summary with installed/skipped/failed counts. +- [x] Command returns non-zero exit code for invalid/missing config. +- [x] Command returns exit code `0` for partial skill-install failures and emits warnings with failure details. +- [x] Re-running command is safe and does not duplicate work. +- [x] `ai-devkit skill add` persists installed skill metadata into `.ai-devkit.json`. +- [x] Existing artifacts trigger user confirmation before overwrite. + +## Constraints & Assumptions + +**What limitations do we need to work within?** + +**Technical constraints:** + +- Existing `.ai-devkit.json` schema currently stores environments and phases; this feature extends it to persist project skills. +- Skill installation depends on registry/network availability and local cache state. +- Must keep compatibility with existing config files. + +**Assumptions:** + +- Repositories using `ai-devkit install` commit a valid `.ai-devkit.json`. +- Skills are represented in config as `skills: [{ registry, name }]` and are updated when `ai-devkit skill add` succeeds. +- Existing template managers remain the source of truth for file generation. + +## Questions & Open Items + +**What do we still need to clarify?** + +- None for Phase 3 design review. diff --git a/docs/ai/testing/feature-install-command.md b/docs/ai/testing/feature-install-command.md new file mode 100644 index 0000000..1e6ac20 --- /dev/null +++ b/docs/ai/testing/feature-install-command.md @@ -0,0 +1,98 @@ +--- +phase: testing +title: Testing Strategy +description: Define testing approach, test cases, and quality assurance +feature: install-command +--- + +# Testing Strategy - Install Command + +## Test Coverage Goals + +- Validate config parsing and normalization for install command. +- Validate install command behavior and exit-code policy. +- Validate config skill metadata persistence path used by `skill add`. + +## Unit Tests + +### Config Validation (`util/config.ts`) + +- [x] Normalizes duplicated environments/phases/skills. +- [x] Accepts legacy `skills[].skill` and normalizes to `name`. +- [x] Fails for unsupported environment values. +- [x] Fails for invalid skill entries. + +### Config Loading (`services/config/config.service.ts`) + +- [x] Fails for missing config file. +- [x] Fails for invalid JSON parsing. +- [x] Returns resolved config path and parsed data on success. + +### `install` Command Handler + +- [x] Returns exit code `1` when config loading fails. +- [x] Returns exit code `1` for empty install sections. +- [x] Calls orchestrator with provided options and uses orchestrator exit code. + +### Existing Modules Updated + +- [x] `ConfigManager.addSkill` adds unique entries. +- [x] `ConfigManager.addSkill` skips duplicates. +- [x] `SkillManager.addSkill` persists skill metadata (`registry`, `name`) to config. + +### Install Orchestration (`services/install/install.service.ts`) + +- [x] Installs environments/phases/skills in happy path. +- [x] Prompts once and skips conflicting artifacts when overwrite is declined. +- [x] Overwrites conflicting artifacts when prompted confirmation is accepted. +- [x] Auto-overwrites without prompt when `--overwrite` is set. +- [x] Skill failures are collected as warnings and do not change exit code. +- [x] Exit code is non-zero when environment/phase failures occur. + +## Integration Tests + +- [x] Command-level flow covered via `install` command tests with mocked orchestrator. +- [x] Service-level integration flow covered with mocked dependencies for overwrite and partial-failure branches. +- [ ] Real filesystem integration test for `ai-devkit install` happy path. +- [ ] Real filesystem integration test for `--overwrite` confirmation flow. +- [ ] Real skill install partial-failure integration with network errors. + +## Test Reporting & Coverage + +Executed on February 23, 2026: + +```bash +npm run test -- --runInBand \ + src/__tests__/services/install/install.service.test.ts \ + src/__tests__/util/config.test.ts \ + src/__tests__/commands/install.test.ts \ + src/__tests__/services/config/config.service.test.ts \ + src/__tests__/lib/Config.test.ts \ + src/__tests__/lib/SkillManager.test.ts \ + src/__tests__/commands/init.test.ts +``` + +Result: targeted suites pass locally (command/config/config-service/config-manager/init/skill-manager coverage). +Additional focused run: + +```bash +npm run test -- --runInBand \ + src/__tests__/services/install/install.service.test.ts \ + src/__tests__/commands/install.test.ts \ + src/__tests__/util/config.test.ts \ + src/__tests__/services/config/config.service.test.ts +``` + +Result: `4 passed, 4 total` suites and `16 passed, 16 total` tests. + +## Manual Testing + +Pending manual verification: + +- [ ] `ai-devkit install` in a repo with existing `.ai-devkit.json`. +- [ ] `ai-devkit install --overwrite` prompt and overwrite behavior. +- [ ] `ai-devkit install` with skill install failure path and warning output. + +## Outstanding Gaps + +- End-to-end filesystem and network-backed integration tests are still pending. diff --git a/package-lock.json b/package-lock.json index bcfe6f2..11bbc40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ai-devkit", - "version": "0.13.0", + "version": "0.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai-devkit", - "version": "0.13.0", + "version": "0.14.0", "license": "MIT", "workspaces": [ "apps/*", @@ -4164,6 +4164,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=10" } @@ -4181,6 +4182,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=10" } @@ -4198,6 +4200,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -4215,6 +4218,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -4232,6 +4236,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -4249,6 +4254,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -4266,6 +4272,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -4283,6 +4290,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=10" } @@ -4300,6 +4308,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=10" } @@ -4317,6 +4326,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=10" } @@ -13027,7 +13037,8 @@ "gray-matter": "^4.0.3", "inquirer": "^8.2.6", "ora": "^9.1.0", - "yaml": "^2.3.4" + "yaml": "^2.3.4", + "zod": "^3.25.76" }, "bin": { "ai-devkit": "dist/cli.js" @@ -13236,6 +13247,15 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "packages/cli/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "packages/memory": { "name": "@ai-devkit/memory", "version": "0.6.0", diff --git a/packages/cli/README.md b/packages/cli/README.md index 95e3b32..025c6d8 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -46,6 +46,12 @@ ai-devkit init # Initialize project from YAML/JSON template ai-devkit init --template ./ai-devkit.init.yaml +# Install/reconcile project setup from .ai-devkit.json +ai-devkit install + +# Overwrite all existing install artifacts without extra prompts +ai-devkit install --overwrite + # Add a development phase ai-devkit phase requirements diff --git a/packages/cli/package.json b/packages/cli/package.json index 42b51fe..2c697c2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -34,7 +34,8 @@ "gray-matter": "^4.0.3", "inquirer": "^8.2.6", "ora": "^9.1.0", - "yaml": "^2.3.4" + "yaml": "^2.3.4", + "zod": "^3.25.76" }, "devDependencies": { "@types/fs-extra": "^11.0.4", diff --git a/packages/cli/src/__tests__/commands/init.test.ts b/packages/cli/src/__tests__/commands/init.test.ts index 7bd8d53..b3ec659 100644 --- a/packages/cli/src/__tests__/commands/init.test.ts +++ b/packages/cli/src/__tests__/commands/init.test.ts @@ -93,7 +93,7 @@ describe('init command template mode', () => { mockConfigManager.exists.mockResolvedValue(false); mockConfigManager.read.mockResolvedValue(null); - mockConfigManager.create.mockResolvedValue({ environments: [], initializedPhases: [] }); + mockConfigManager.create.mockResolvedValue({ environments: [], phases: [] }); mockConfigManager.setEnvironments.mockResolvedValue(undefined); mockConfigManager.addPhase.mockResolvedValue(undefined); @@ -111,6 +111,10 @@ describe('init command template mode', () => { mockLoadInitTemplate.mockResolvedValue({}); }); + afterEach(() => { + process.exitCode = undefined; + }); + it('uses template values and installs multiple skills from same registry without prompts', async () => { mockLoadInitTemplate.mockResolvedValue({ environments: ['codex'], diff --git a/packages/cli/src/__tests__/commands/install.test.ts b/packages/cli/src/__tests__/commands/install.test.ts new file mode 100644 index 0000000..b5e5f5f --- /dev/null +++ b/packages/cli/src/__tests__/commands/install.test.ts @@ -0,0 +1,124 @@ +import { jest } from '@jest/globals'; + +const mockLoadAndValidateInstallConfig: any = jest.fn(); +const mockLoadConfigFile: any = jest.fn(); +const mockReconcileAndInstall: any = jest.fn(); +const mockGetInstallExitCode: any = jest.fn(); + +const mockUi: any = { + warning: jest.fn(), + error: jest.fn(), + success: jest.fn(), + info: jest.fn(), + text: jest.fn(), + summary: jest.fn() +}; + +jest.mock('../../services/install/install.service', () => ({ + reconcileAndInstall: (...args: unknown[]) => mockReconcileAndInstall(...args), + getInstallExitCode: (...args: unknown[]) => mockGetInstallExitCode(...args) +})); + +jest.mock('../../services/config/config.service', () => ({ + loadConfigFile: (...args: unknown[]) => mockLoadConfigFile(...args) +})); + +jest.mock('../../util/config', () => ({ + validateInstallConfig: (...args: unknown[]) => mockLoadAndValidateInstallConfig(...args) +})); + +jest.mock('../../util/terminal-ui', () => ({ + ui: mockUi +})); + +import { installCommand } from '../../commands/install'; + +describe('install command', () => { + beforeEach(() => { + jest.clearAllMocks(); + process.exitCode = undefined; + + mockGetInstallExitCode.mockReturnValue(0); + mockReconcileAndInstall.mockResolvedValue({ + environments: { installed: 1, skipped: 0, failed: 0 }, + phases: { installed: 1, skipped: 0, failed: 0 }, + skills: { installed: 1, skipped: 0, failed: 0 }, + warnings: [] + }); + }); + + afterEach(() => { + process.exitCode = undefined; + }); + + it('fails with non-zero exit code when config loading fails', async () => { + mockLoadConfigFile.mockRejectedValue(new Error('Config file not found: /tmp/.ai-devkit.json')); + + await installCommand({}); + + expect(mockUi.error).toHaveBeenCalledWith('Config file not found: /tmp/.ai-devkit.json'); + expect(process.exitCode).toBe(1); + expect(mockReconcileAndInstall).not.toHaveBeenCalled(); + }); + + it('fails when config has no installable sections', async () => { + mockLoadConfigFile.mockResolvedValue({ + configPath: '/tmp/.ai-devkit.json', + data: {} + }); + mockLoadAndValidateInstallConfig.mockReturnValue({ + environments: [], + phases: [], + skills: [] + }); + + await installCommand({}); + + expect(mockUi.warning).toHaveBeenCalledWith('No installable entries found in /tmp/.ai-devkit.json.'); + expect(process.exitCode).toBe(1); + expect(mockReconcileAndInstall).not.toHaveBeenCalled(); + }); + + it('runs install and sets exit code from orchestrator', async () => { + mockLoadConfigFile.mockResolvedValue({ + configPath: '/tmp/.ai-devkit.json', + data: {} + }); + mockLoadAndValidateInstallConfig.mockReturnValue({ + environments: ['codex'], + phases: ['requirements'], + skills: [{ registry: 'codeaholicguy/ai-devkit', name: 'debug' }] + }); + mockGetInstallExitCode.mockReturnValue(0); + + await installCommand({ overwrite: true }); + + expect(mockReconcileAndInstall).toHaveBeenCalledWith( + expect.objectContaining({ + environments: ['codex'], + phases: ['requirements'] + }), + { overwrite: true } + ); + expect(mockUi.summary).toHaveBeenCalled(); + expect(process.exitCode).toBe(0); + }); + + it('fails with non-zero exit code when reconcile step throws', async () => { + mockLoadConfigFile.mockResolvedValue({ + configPath: '/tmp/.ai-devkit.json', + data: {} + }); + mockLoadAndValidateInstallConfig.mockReturnValue({ + environments: ['codex'], + phases: ['requirements'], + skills: [] + }); + mockReconcileAndInstall.mockRejectedValue(new Error('install failed')); + + await installCommand({}); + + expect(mockUi.error).toHaveBeenCalledWith('install failed'); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/packages/cli/src/__tests__/lib/Config.test.ts b/packages/cli/src/__tests__/lib/Config.test.ts index abd6439..7c4d03e 100644 --- a/packages/cli/src/__tests__/lib/Config.test.ts +++ b/packages/cli/src/__tests__/lib/Config.test.ts @@ -60,7 +60,7 @@ describe('ConfigManager', () => { const mockConfig: DevKitConfig = { version: '1.0.0', environments: ['cursor' as any], - initializedPhases: ['requirements' as any], + phases: ['requirements' as any], createdAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-01T00:00:00.000Z' }; @@ -89,7 +89,7 @@ describe('ConfigManager', () => { const expectedConfig: DevKitConfig = { version: '1.0.0', environments: [], - initializedPhases: [], + phases: [], createdAt: expect.any(String), updatedAt: expect.any(String) }; @@ -112,7 +112,7 @@ describe('ConfigManager', () => { const existingConfig: DevKitConfig = { version: '1.0.0', environments: ['cursor'], - initializedPhases: [], + phases: [], createdAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-01T00:00:00.000Z' }; @@ -140,11 +140,11 @@ describe('ConfigManager', () => { }); describe('addPhase', () => { - it('should add new phase to initializedPhases', async () => { + it('should add new phase to phases', async () => { const config: DevKitConfig = { version: '1.0.0', environments: [], - initializedPhases: ['requirements'], + phases: ['requirements'], createdAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-01T00:00:00.000Z' }; @@ -155,14 +155,14 @@ describe('ConfigManager', () => { const result = await configManager.addPhase('design'); - expect(result.initializedPhases).toEqual(['requirements', 'design']); + expect(result.phases).toEqual(['requirements', 'design']); }); it('should not add duplicate phase', async () => { const config: DevKitConfig = { version: '1.0.0', environments: [], - initializedPhases: ['requirements'], + phases: ['requirements'], createdAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-01T00:00:00.000Z' }; @@ -172,7 +172,7 @@ describe('ConfigManager', () => { const result = await configManager.addPhase('requirements'); - expect(result.initializedPhases).toEqual(['requirements']); + expect(result.phases).toEqual(['requirements']); expect(mockFs.writeJson).not.toHaveBeenCalled(); }); }); @@ -182,7 +182,7 @@ describe('ConfigManager', () => { const config: DevKitConfig = { version: '1.0.0', environments: [], - initializedPhases: ['requirements', 'design'], + phases: ['requirements', 'design'], createdAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-01T00:00:00.000Z' }; @@ -199,7 +199,7 @@ describe('ConfigManager', () => { const config: DevKitConfig = { version: '1.0.0', environments: [], - initializedPhases: ['requirements'], + phases: ['requirements'], createdAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-01T00:00:00.000Z' }; @@ -226,7 +226,7 @@ describe('ConfigManager', () => { const config: DevKitConfig = { version: '1.0.0', environments: ['cursor', 'claude'], - initializedPhases: [], + phases: [], createdAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-01T00:00:00.000Z' }; @@ -251,7 +251,7 @@ describe('ConfigManager', () => { const config: DevKitConfig = { version: '1.0.0', environments: [], - initializedPhases: [], + phases: [], createdAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-01T00:00:00.000Z' }; @@ -270,7 +270,7 @@ describe('ConfigManager', () => { const config: DevKitConfig = { version: '1.0.0', environments: ['cursor'], - initializedPhases: [], + phases: [], createdAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-01T00:00:00.000Z' }; @@ -291,7 +291,7 @@ describe('ConfigManager', () => { const config: DevKitConfig = { version: '1.0.0', environments: ['cursor', 'claude'], - initializedPhases: [], + phases: [], createdAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-01T00:00:00.000Z' }; @@ -308,7 +308,7 @@ describe('ConfigManager', () => { const config: DevKitConfig = { version: '1.0.0', environments: ['cursor'], - initializedPhases: [], + phases: [], createdAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-01T00:00:00.000Z' }; @@ -321,4 +321,54 @@ describe('ConfigManager', () => { expect(result).toBe(false); }); }); + + describe('addSkill', () => { + it('adds a new skill entry to config', async () => { + const config: DevKitConfig = { + version: '1.0.0', + environments: ['cursor'], + phases: [], + skills: [{ registry: 'codeaholicguy/ai-devkit', name: 'debug' }], + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z' + }; + + (mockFs.pathExists as any).mockResolvedValue(true); + (mockFs.readJson as any).mockResolvedValue(config); + (mockFs.writeJson as any).mockResolvedValue(undefined); + + const result = await configManager.addSkill({ + registry: 'codeaholicguy/ai-devkit', + name: 'memory' + }); + + expect(result.skills).toEqual([ + { registry: 'codeaholicguy/ai-devkit', name: 'debug' }, + { registry: 'codeaholicguy/ai-devkit', name: 'memory' } + ]); + expect(mockFs.writeJson).toHaveBeenCalled(); + }); + + it('does not add duplicate skill entry', async () => { + const config: DevKitConfig = { + version: '1.0.0', + environments: ['cursor'], + phases: [], + skills: [{ registry: 'codeaholicguy/ai-devkit', name: 'debug' }], + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z' + }; + + (mockFs.pathExists as any).mockResolvedValue(true); + (mockFs.readJson as any).mockResolvedValue(config); + + const result = await configManager.addSkill({ + registry: 'codeaholicguy/ai-devkit', + name: 'debug' + }); + + expect(result.skills).toEqual([{ registry: 'codeaholicguy/ai-devkit', name: 'debug' }]); + expect(mockFs.writeJson).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/cli/src/__tests__/lib/SkillManager.test.ts b/packages/cli/src/__tests__/lib/SkillManager.test.ts index 720f88a..9c24792 100644 --- a/packages/cli/src/__tests__/lib/SkillManager.test.ts +++ b/packages/cli/src/__tests__/lib/SkillManager.test.ts @@ -85,6 +85,7 @@ describe("SkillManager", () => { mockedSkillUtil.validateRegistryId.mockImplementation(() => { }); mockedSkillUtil.validateSkillName.mockImplementation(() => { }); mockedGitUtil.ensureGitInstalled.mockResolvedValue(undefined); + mockConfigManager.addSkill.mockResolvedValue({} as any); }); afterEach(() => { @@ -131,6 +132,10 @@ describe("SkillManager", () => { mockSkillName, ); expect(mockedGitUtil.ensureGitInstalled).toHaveBeenCalled(); + expect(mockConfigManager.addSkill).toHaveBeenCalledWith({ + registry: mockRegistryId, + name: mockSkillName + }); }); it("should fetch registry using fetch API", async () => { diff --git a/packages/cli/src/__tests__/services/config/config.service.test.ts b/packages/cli/src/__tests__/services/config/config.service.test.ts new file mode 100644 index 0000000..b6584a5 --- /dev/null +++ b/packages/cli/src/__tests__/services/config/config.service.test.ts @@ -0,0 +1,35 @@ +import * as fs from 'fs-extra'; +import { loadConfigFile } from '../../../services/config/config.service'; + +jest.mock('fs-extra'); + +describe('config service', () => { + const mockedFs = fs as jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('loads config file from disk', async () => { + (mockedFs.pathExists as any).mockResolvedValue(true); + (mockedFs.readJson as any).mockResolvedValue({ environments: ['codex'] }); + + const result = await loadConfigFile('.ai-devkit.json'); + + expect(result.configPath).toContain('.ai-devkit.json'); + expect(result.data).toEqual({ environments: ['codex'] }); + }); + + it('throws when config file does not exist', async () => { + (mockedFs.pathExists as any).mockResolvedValue(false); + + await expect(loadConfigFile('.ai-devkit.json')).rejects.toThrow('Config file not found'); + }); + + it('throws when config JSON is invalid', async () => { + (mockedFs.pathExists as any).mockResolvedValue(true); + (mockedFs.readJson as any).mockRejectedValue(new Error('Unexpected token }')); + + await expect(loadConfigFile('.ai-devkit.json')).rejects.toThrow('Invalid JSON in config file'); + }); +}); diff --git a/packages/cli/src/__tests__/services/install/install.service.test.ts b/packages/cli/src/__tests__/services/install/install.service.test.ts new file mode 100644 index 0000000..72291e8 --- /dev/null +++ b/packages/cli/src/__tests__/services/install/install.service.test.ts @@ -0,0 +1,167 @@ +import { jest } from '@jest/globals'; + +const mockPrompt: any = jest.fn(); + +const mockConfigManager: any = { + read: jest.fn(), + create: jest.fn(), + update: jest.fn(), + addPhase: jest.fn() +}; + +const mockTemplateManager: any = { + checkEnvironmentExists: jest.fn(), + fileExists: jest.fn(), + setupMultipleEnvironments: jest.fn(), + copyPhaseTemplate: jest.fn() +}; + +const mockSkillManager: any = { + addSkill: jest.fn() +}; + +jest.mock('inquirer', () => ({ + __esModule: true, + default: { + prompt: (...args: unknown[]) => mockPrompt(...args) + } +})); + +jest.mock('../../../lib/Config', () => ({ + ConfigManager: jest.fn(() => mockConfigManager) +})); + +jest.mock('../../../lib/TemplateManager', () => ({ + TemplateManager: jest.fn(() => mockTemplateManager) +})); + +jest.mock('../../../lib/EnvironmentSelector', () => ({ + EnvironmentSelector: jest.fn() +})); + +jest.mock('../../../lib/SkillManager', () => ({ + SkillManager: jest.fn(() => mockSkillManager) +})); + +import { getInstallExitCode, reconcileAndInstall } from '../../../services/install/install.service'; + +describe('install service', () => { + const installConfig = { + environments: ['codex' as const], + phases: ['requirements' as const], + skills: [{ registry: 'codeaholicguy/ai-devkit', name: 'debug' }] + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockConfigManager.read.mockResolvedValue({ + environments: [], + phases: [] + }); + mockConfigManager.create.mockResolvedValue({ + environments: [], + phases: [] + }); + mockConfigManager.update.mockResolvedValue({}); + mockConfigManager.addPhase.mockResolvedValue({}); + + mockTemplateManager.checkEnvironmentExists.mockResolvedValue(false); + mockTemplateManager.fileExists.mockResolvedValue(false); + mockTemplateManager.setupMultipleEnvironments.mockResolvedValue([]); + mockTemplateManager.copyPhaseTemplate.mockResolvedValue('docs/ai/requirements/README.md'); + + mockSkillManager.addSkill.mockResolvedValue(undefined); + mockPrompt.mockResolvedValue({ overwrite: false }); + }); + + it('installs all sections on happy path', async () => { + const report = await reconcileAndInstall(installConfig, {}); + + expect(mockConfigManager.update).toHaveBeenCalledWith({ + environments: ['codex'], + phases: ['requirements'], + skills: [{ registry: 'codeaholicguy/ai-devkit', name: 'debug' }] + }); + expect(report.environments.installed).toBe(1); + expect(report.phases.installed).toBe(1); + expect(report.skills.installed).toBe(1); + expect(report.warnings).toEqual([]); + }); + + it('prompts and skips conflicting artifacts when overwrite is not confirmed', async () => { + mockTemplateManager.checkEnvironmentExists + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true); + mockTemplateManager.fileExists + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true); + mockPrompt.mockResolvedValue({ overwrite: false }); + + const report = await reconcileAndInstall(installConfig, {}); + + expect(mockPrompt).toHaveBeenCalledTimes(1); + expect(report.environments.skipped).toBe(1); + expect(report.phases.skipped).toBe(1); + expect(report.skills.installed).toBe(1); + expect(mockConfigManager.update).toHaveBeenCalledWith({ + environments: [], + phases: [], + skills: [{ registry: 'codeaholicguy/ai-devkit', name: 'debug' }] + }); + }); + + it('overwrites conflicting artifacts when overwrite is confirmed via prompt', async () => { + mockTemplateManager.checkEnvironmentExists + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true); + mockTemplateManager.fileExists + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true); + mockPrompt.mockResolvedValue({ overwrite: true }); + + const report = await reconcileAndInstall(installConfig, {}); + + expect(mockPrompt).toHaveBeenCalledTimes(1); + expect(report.environments.installed).toBe(1); + expect(report.phases.installed).toBe(1); + }); + + it('auto-overwrites and does not prompt when --overwrite is set', async () => { + mockTemplateManager.checkEnvironmentExists + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true); + mockTemplateManager.fileExists + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true); + + const report = await reconcileAndInstall(installConfig, { overwrite: true }); + + expect(mockPrompt).not.toHaveBeenCalled(); + expect(report.environments.installed).toBe(1); + expect(report.phases.installed).toBe(1); + }); + + it('reports skill failures as warnings and continues', async () => { + mockSkillManager.addSkill.mockRejectedValue(new Error('network down')); + + const report = await reconcileAndInstall(installConfig, {}); + + expect(report.skills.failed).toBe(1); + expect(report.warnings).toEqual([ + 'Skill codeaholicguy/ai-devkit/debug failed: network down' + ]); + expect(getInstallExitCode(report)).toBe(0); + }); + + it('returns non-zero exit code when environment or phase failures occur', () => { + const report = { + environments: { installed: 0, skipped: 0, failed: 1 }, + phases: { installed: 0, skipped: 0, failed: 0 }, + skills: { installed: 0, skipped: 0, failed: 0 }, + warnings: [] + }; + + expect(getInstallExitCode(report)).toBe(1); + }); +}); diff --git a/packages/cli/src/__tests__/util/config.test.ts b/packages/cli/src/__tests__/util/config.test.ts new file mode 100644 index 0000000..6060ecd --- /dev/null +++ b/packages/cli/src/__tests__/util/config.test.ts @@ -0,0 +1,34 @@ +import { validateInstallConfig } from '../../util/config'; + +describe('config util', () => { + it('validates and normalizes valid install config', () => { + const result = validateInstallConfig({ + environments: ['codex', 'codex'], + phases: ['requirements', 'requirements', 'design'], + skills: [ + { registry: 'codeaholicguy/ai-devkit', name: 'debug' }, + { registry: 'codeaholicguy/ai-devkit', skill: 'memory' }, + { registry: 'codeaholicguy/ai-devkit', name: 'debug' } + ] + }, '/tmp/.ai-devkit.json'); + + expect(result.environments).toEqual(['codex']); + expect(result.phases).toEqual(['requirements', 'design']); + expect(result.skills).toEqual([ + { registry: 'codeaholicguy/ai-devkit', name: 'debug' }, + { registry: 'codeaholicguy/ai-devkit', name: 'memory' } + ]); + }); + + it('fails on invalid root value', () => { + expect(() => validateInstallConfig([], '/tmp/.ai-devkit.json')).toThrow('expected a JSON object at root'); + }); + + it('fails on invalid environment code', () => { + expect(() => validateInstallConfig({ environments: ['bad-env'] }, '/tmp/.ai-devkit.json')).toThrow('environments[0] has unsupported value "bad-env"'); + }); + + it('fails when skills entry is invalid', () => { + expect(() => validateInstallConfig({ skills: [{ registry: '', name: 'debug' }] }, '/tmp/.ai-devkit.json')).toThrow('skills[0].registry'); + }); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 2e5c5d4..c805760 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -5,6 +5,7 @@ import { initCommand } from './commands/init'; import { phaseCommand } from './commands/phase'; import { setupCommand } from './commands/setup'; import { lintCommand } from './commands/lint'; +import { installCommand } from './commands/install'; import { registerMemoryCommand } from './commands/memory'; import { registerSkillCommand } from './commands/skill'; import { registerAgentCommand } from './commands/agent'; @@ -43,6 +44,13 @@ program .option('--json', 'Output lint results as JSON') .action(lintCommand); +program + .command('install') + .description('Install AI DevKit artifacts from a project config') + .option('-c, --config ', 'Path to config file (default: .ai-devkit.json)') + .option('--overwrite', 'Overwrite existing install artifacts') + .action(installCommand); + registerMemoryCommand(program); registerSkillCommand(program); registerAgentCommand(program); diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts new file mode 100644 index 0000000..99aae89 --- /dev/null +++ b/packages/cli/src/commands/install.ts @@ -0,0 +1,86 @@ +import { + getInstallExitCode, + reconcileAndInstall +} from '../services/install/install.service'; +import { loadConfigFile } from '../services/config/config.service'; +import { validateInstallConfig } from '../util/config'; +import { ui } from '../util/terminal-ui'; + +interface InstallCommandOptions { + config?: string; + overwrite?: boolean; +} + +export async function installCommand(options: InstallCommandOptions): Promise { + const configPath = options.config?.trim() || '.ai-devkit.json'; + + let loadedConfig; + try { + loadedConfig = await loadConfigFile(configPath); + } catch (error) { + ui.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + return; + } + + let validatedConfig; + try { + validatedConfig = validateInstallConfig(loadedConfig.data, loadedConfig.configPath); + } catch (error) { + ui.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + return; + } + + if ( + validatedConfig.environments.length === 0 + && validatedConfig.phases.length === 0 + && validatedConfig.skills.length === 0 + ) { + ui.warning(`No installable entries found in ${loadedConfig.configPath}.`); + ui.info('Expected one or more of: environments, phases, skills.'); + process.exitCode = 1; + return; + } + + let report; + try { + report = await reconcileAndInstall(validatedConfig, { + overwrite: options.overwrite + }); + } catch (error) { + ui.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + return; + } + + ui.summary({ + title: 'Install Summary', + items: [ + { type: 'success', count: report.environments.installed, label: 'environment(s) installed' }, + { type: 'warning', count: report.environments.skipped, label: 'environment(s) skipped' }, + { type: 'error', count: report.environments.failed, label: 'environment(s) failed' }, + { type: 'success', count: report.phases.installed, label: 'phase template(s) installed' }, + { type: 'warning', count: report.phases.skipped, label: 'phase template(s) skipped' }, + { type: 'error', count: report.phases.failed, label: 'phase template(s) failed' }, + { type: 'success', count: report.skills.installed, label: 'skill(s) installed' }, + { type: 'error', count: report.skills.failed, label: 'skill(s) failed' } + ] + }); + + if (report.warnings.length > 0) { + ui.text(''); + ui.warning('Warnings:'); + report.warnings.forEach(warning => { + ui.text(` - ${warning}`); + }); + + if (report.skills.failed > 0) { + ui.warning('Skill failures are reported as warnings and do not change exit code.'); + } + } + + process.exitCode = getInstallExitCode(report, { + overwrite: options.overwrite + }); +} diff --git a/packages/cli/src/commands/phase.ts b/packages/cli/src/commands/phase.ts index 719c0d3..7ada43a 100644 --- a/packages/cli/src/commands/phase.ts +++ b/packages/cli/src/commands/phase.ts @@ -22,7 +22,7 @@ export async function phaseCommand(phaseName?: string) { return; } else { const config = await configManager.read(); - const availableToAdd = AVAILABLE_PHASES.filter(p => !config?.initializedPhases.includes(p)); + const availableToAdd = AVAILABLE_PHASES.filter(p => !config?.phases.includes(p)); if (availableToAdd.length === 0) { ui.warning('All phases are already initialized.'); @@ -79,4 +79,4 @@ export async function phaseCommand(phaseName?: string) { ui.success(`${PHASE_DISPLAY_NAMES[phase]} created successfully!`); ui.info(` Location: ${file}\n`); -} \ No newline at end of file +} diff --git a/packages/cli/src/lib/Config.ts b/packages/cli/src/lib/Config.ts index 9a054b7..746d4ec 100644 --- a/packages/cli/src/lib/Config.ts +++ b/packages/cli/src/lib/Config.ts @@ -1,6 +1,6 @@ import * as fs from 'fs-extra'; import * as path from 'path'; -import { DevKitConfig, Phase, EnvironmentCode } from '../types'; +import { DevKitConfig, Phase, EnvironmentCode, ConfigSkill } from '../types'; import packageJson from '../../package.json'; const CONFIG_FILE_NAME = '.ai-devkit.json'; @@ -18,7 +18,11 @@ export class ConfigManager { async read(): Promise { if (await this.exists()) { - return fs.readJson(this.configPath); + const raw = await fs.readJson(this.configPath); + if (!raw) { + return null; + } + return raw as DevKitConfig; } return null; } @@ -27,7 +31,7 @@ export class ConfigManager { const config: DevKitConfig = { version: packageJson.version, environments: [], - initializedPhases: [], + phases: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; @@ -58,9 +62,10 @@ export class ConfigManager { throw new Error('Config file not found. Run ai-devkit init first.'); } - if (!config.initializedPhases.includes(phase)) { - config.initializedPhases.push(phase); - return this.update({ initializedPhases: config.initializedPhases }); + const phases = config.phases; + if (!phases.includes(phase)) { + phases.push(phase); + return this.update({ phases }); } return config; @@ -68,7 +73,11 @@ export class ConfigManager { async hasPhase(phase: Phase): Promise { const config = await this.read(); - return config ? config.initializedPhases.includes(phase) : false; + if (!config) { + return false; + } + + return config.phases.includes(phase); } async getEnvironments(): Promise { @@ -84,5 +93,23 @@ export class ConfigManager { const environments = await this.getEnvironments(); return environments.includes(envId); } -} + async addSkill(skill: ConfigSkill): Promise { + const config = await this.read(); + if (!config) { + throw new Error('Config file not found. Run ai-devkit init first.'); + } + + const skills = config.skills || []; + const exists = skills.some( + entry => entry.registry === skill.registry && entry.name === skill.name + ); + + if (exists) { + return config; + } + + skills.push(skill); + return this.update({ skills }); + } +} diff --git a/packages/cli/src/lib/SkillManager.ts b/packages/cli/src/lib/SkillManager.ts index 728f7b0..1d36a57 100644 --- a/packages/cli/src/lib/SkillManager.ts +++ b/packages/cli/src/lib/SkillManager.ts @@ -151,6 +151,11 @@ export class SkillManager { } } + await this.configManager.addSkill({ + registry: registryId, + name: skillName + }); + ui.text(`Successfully installed: ${skillName}`); ui.info(` Source: ${registryId}`); ui.info(` Installed to: ${skillCapableEnvs.join(', ')}`); diff --git a/packages/cli/src/services/config/config.service.ts b/packages/cli/src/services/config/config.service.ts new file mode 100644 index 0000000..71e3d97 --- /dev/null +++ b/packages/cli/src/services/config/config.service.ts @@ -0,0 +1,27 @@ +import * as fs from 'fs-extra'; +import * as path from 'path'; + +export interface LoadedConfigFile { + configPath: string; + data: unknown; +} + +export async function loadConfigFile(configPath: string): Promise { + const resolvedPath = path.resolve(configPath); + + if (!await fs.pathExists(resolvedPath)) { + throw new Error(`Config file not found: ${resolvedPath}`); + } + + try { + const data = await fs.readJson(resolvedPath); + return { + configPath: resolvedPath, + data + }; + } catch (error) { + throw new Error( + `Invalid JSON in config file ${resolvedPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } +} diff --git a/packages/cli/src/services/install/install.service.ts b/packages/cli/src/services/install/install.service.ts new file mode 100644 index 0000000..d8a410d --- /dev/null +++ b/packages/cli/src/services/install/install.service.ts @@ -0,0 +1,170 @@ +import inquirer from 'inquirer'; +import { ConfigManager } from '../../lib/Config'; +import { EnvironmentSelector } from '../../lib/EnvironmentSelector'; +import { SkillManager } from '../../lib/SkillManager'; +import { TemplateManager } from '../../lib/TemplateManager'; +import { InstallConfigData } from '../../util/config'; + +export interface InstallRunOptions { + overwrite?: boolean; +} + +interface InstallSectionReport { + installed: number; + skipped: number; + failed: number; +} + +export interface InstallReport { + environments: InstallSectionReport; + phases: InstallSectionReport; + skills: InstallSectionReport; + warnings: string[]; +} + +export async function reconcileAndInstall( + config: InstallConfigData, + options: InstallRunOptions = {} +): Promise { + const configManager = new ConfigManager(); + const templateManager = new TemplateManager(); + const skillManager = new SkillManager(configManager, new EnvironmentSelector()); + + const report: InstallReport = { + environments: { installed: 0, skipped: 0, failed: 0 }, + phases: { installed: 0, skipped: 0, failed: 0 }, + skills: { installed: 0, skipped: 0, failed: 0 }, + warnings: [] + }; + + const hasConflicts = await hasOverwriteConflicts(templateManager, config); + const shouldOverwrite = await resolveOverwritePolicy(options, hasConflicts); + + let projectConfig = await configManager.read(); + if (!projectConfig) { + await configManager.create(); + projectConfig = await configManager.read(); + } + + if (!projectConfig) { + throw new Error('Failed to initialize project config for install command.'); + } + + const successfulEnvironments: typeof config.environments = []; + const successfulPhases: typeof config.phases = []; + const successfulSkills: typeof config.skills = []; + + for (const envCode of config.environments) { + try { + const exists = await templateManager.checkEnvironmentExists(envCode); + if (exists && !shouldOverwrite) { + report.environments.skipped += 1; + continue; + } + + await templateManager.setupMultipleEnvironments([envCode]); + report.environments.installed += 1; + successfulEnvironments.push(envCode); + } catch (error) { + report.environments.failed += 1; + report.warnings.push( + `Environment ${envCode} failed: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + for (const phase of config.phases) { + try { + const exists = await templateManager.fileExists(phase); + if (exists && !shouldOverwrite) { + report.phases.skipped += 1; + continue; + } + + await templateManager.copyPhaseTemplate(phase); + await configManager.addPhase(phase); + report.phases.installed += 1; + successfulPhases.push(phase); + } catch (error) { + report.phases.failed += 1; + report.warnings.push( + `Phase ${phase} failed: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + for (const skill of config.skills) { + try { + await skillManager.addSkill(skill.registry, skill.name); + report.skills.installed += 1; + successfulSkills.push(skill); + } catch (error) { + report.skills.failed += 1; + report.warnings.push( + `Skill ${skill.registry}/${skill.name} failed: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + await configManager.update({ + environments: successfulEnvironments, + phases: successfulPhases, + skills: successfulSkills + }); + + return report; +} + +export function getInstallExitCode(report: InstallReport, options: InstallRunOptions = {}): number { + void options; + + const requiredFailures = report.environments.failed + report.phases.failed; + if (requiredFailures > 0) { + return 1; + } + + return 0; +} + +async function hasOverwriteConflicts( + templateManager: TemplateManager, + config: InstallConfigData +): Promise { + for (const env of config.environments) { + if (await templateManager.checkEnvironmentExists(env)) { + return true; + } + } + + for (const phase of config.phases) { + if (await templateManager.fileExists(phase)) { + return true; + } + } + + return false; +} + +async function resolveOverwritePolicy( + options: InstallRunOptions, + hasConflicts: boolean +): Promise { + if (!hasConflicts) { + return false; + } + + if (options.overwrite) { + return true; + } + + const answer = await inquirer.prompt([ + { + type: 'confirm', + name: 'overwrite', + message: 'Existing install artifacts were found. Overwrite them?', + default: false + } + ]); + + return Boolean(answer.overwrite); +} diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 1398d89..7ef25fd 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -24,11 +24,17 @@ export type EnvironmentCode = 'cursor' | 'claude' | 'github' | 'gemini' | 'codex export interface DevKitConfig { version: string; environments: EnvironmentCode[]; - initializedPhases: Phase[]; + phases: Phase[]; + skills?: ConfigSkill[]; createdAt: string; updatedAt: string; } +export interface ConfigSkill { + registry: string; + name: string; +} + export interface SkillRegistriesConfig { registries?: Record; } @@ -61,4 +67,4 @@ export const PHASE_DISPLAY_NAMES: Record = { testing: 'Testing Strategy', deployment: 'Deployment Strategy', monitoring: 'Monitoring & Observability' -}; \ No newline at end of file +}; diff --git a/packages/cli/src/util/config.ts b/packages/cli/src/util/config.ts new file mode 100644 index 0000000..aebd5a4 --- /dev/null +++ b/packages/cli/src/util/config.ts @@ -0,0 +1,120 @@ +import { z } from 'zod'; +import { ConfigSkill, EnvironmentCode, Phase, AVAILABLE_PHASES } from '../types'; +import { isValidEnvironmentCode } from './env'; + +export interface InstallConfigData { + environments: EnvironmentCode[]; + phases: Phase[]; + skills: ConfigSkill[]; +} + +const skillEntrySchema = z.object({ + registry: z.string().trim().min(1, 'registry must be a non-empty string'), + name: z.string().trim().min(1).optional(), + skill: z.string().trim().min(1).optional() +}).transform((entry, ctx): ConfigSkill => { + const resolvedName = entry.name ?? entry.skill; + if (!resolvedName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['name'], + message: 'requires a non-empty "name" field' + }); + return z.NEVER; + } + + return { + registry: entry.registry, + name: resolvedName + }; +}); + +const installConfigSchema = z.object({ + environments: z.array(z.string()).optional().default([]).superRefine((values, ctx) => { + values.forEach((value, index) => { + if (!isValidEnvironmentCode(value)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [index], + message: `has unsupported value "${value}"` + }); + } + }); + }).transform(values => dedupe(values) as EnvironmentCode[]), + phases: z.array(z.string()).optional(), + skills: z.array(skillEntrySchema).optional().default([]) +}).transform((data, ctx) => { + const phaseValues = data.phases ?? []; + + phaseValues.forEach((value, index) => { + if (!AVAILABLE_PHASES.includes(value as Phase)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['phases', index], + message: `has unsupported value "${value}"` + }); + } + }); + + return { + environments: data.environments, + phases: dedupe(phaseValues) as Phase[], + skills: dedupeSkills(data.skills) + }; +}); + +export function validateInstallConfig(data: unknown, configPath: string): InstallConfigData { + const parsed = installConfigSchema.safeParse(data); + + if (!parsed.success) { + throw new Error(`Invalid config file ${configPath}: ${formatZodIssue(parsed.error)}`); + } + + return parsed.data; +} + +function formatZodIssue(error: z.ZodError): string { + const issue = error.issues[0]; + if (!issue) { + return 'validation failed'; + } + + if (issue.code === z.ZodIssueCode.invalid_type && issue.path.length === 0) { + return 'expected a JSON object at root'; + } + + if (issue.path.length === 0) { + return issue.message; + } + + return `${formatPath(issue.path)} ${issue.message}`; +} + +function formatPath(pathParts: Array): string { + const [first, ...rest] = pathParts; + let result = String(first); + + for (const part of rest) { + if (typeof part === 'number') { + result += `[${part}]`; + } else { + result += `.${part}`; + } + } + + return result; +} + +function dedupe(values: T[]): T[] { + return [...new Set(values)]; +} + +function dedupeSkills(skills: ConfigSkill[]): ConfigSkill[] { + const unique = new Map(); + + for (const skill of skills) { + unique.set(`${skill.registry}::${skill.name}`, skill); + } + + return [...unique.values()]; +}