diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4b80fb3..ee6627d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -35,20 +35,6 @@ "version": "0.9.0", "keywords": ["issues", "fixes", "tech-debt", "resolution"] }, - { - "name": "devflow-catch-up", - "source": "./plugins/devflow-catch-up", - "description": "Context restoration from development status logs", - "version": "0.9.0", - "keywords": ["context", "status", "documentation"] - }, - { - "name": "devflow-devlog", - "source": "./plugins/devflow-devlog", - "description": "Development session logging and status tracking", - "version": "0.9.0", - "keywords": ["logging", "status", "documentation"] - }, { "name": "devflow-self-review", "source": "./plugins/devflow-self-review", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..73b58c6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build-and-test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18, 20, 22] + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - run: npm ci + - run: npm run build + - run: npm test diff --git a/CHANGELOG.md b/CHANGELOG.md index 90868e0..70bb58b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to DevFlow 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] +## [1.0.0] - 2026-02-13 ### Added - **Agent Teams integration** - Peer-to-peer agent collaboration across workflows @@ -121,6 +121,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`Debug` agent** - Removed entirely - **`PullRequest` agent** - Patterns moved to `devflow-pull-request` skill - **`Release` agent** - Removed (release process documented in CLAUDE.md) +- **`/catch-up` command** - Superseded by Working Memory hooks (automatic context restoration) +- **`/devlog` command** - Superseded by Working Memory hooks (automatic session logging) +- **`catch-up` agent** - No longer needed with automatic Working Memory +- **`devlog` agent** - No longer needed with automatic Working Memory - **`devflow-debug` skill** - Removed entirely - **`GetIssue` agent** - Replaced by Git agent (operation: fetch-issue) - **`Comment` agent** - Replaced by Git agent (operation: comment-pr) @@ -706,6 +710,7 @@ devflow init --- +[1.0.0]: https://github.com/dean0x/devflow/compare/v0.9.0...v1.0.0 [0.9.0]: https://github.com/dean0x/devflow/releases/tag/v0.9.0 [0.8.1]: https://github.com/dean0x/devflow/releases/tag/v0.8.1 [0.8.0]: https://github.com/dean0x/devflow/releases/tag/v0.8.0 diff --git a/CLAUDE.md b/CLAUDE.md index c69070f..8edcb7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ DevFlow enhances Claude Code with intelligent development workflows. Modificatio ## Architecture Overview -Plugin marketplace with 10 self-contained plugins, each following the Claude plugins format (`.claude-plugin/plugin.json`, `commands/`, `agents/`, `skills/`). +Plugin marketplace with 8 self-contained plugins, each following the Claude plugins format (`.claude-plugin/plugin.json`, `commands/`, `agents/`, `skills/`). | Plugin | Purpose | Agent Teams | |--------|---------|-------------| @@ -22,8 +22,6 @@ Plugin marketplace with 10 self-contained plugins, each following the Claude plu | `devflow-resolve` | Review issue resolution | Yes | | `devflow-debug` | Competing hypothesis debugging | Yes | | `devflow-self-review` | Self-review (Simplifier + Scrutinizer) | No | -| `devflow-catch-up` | Context restoration | No | -| `devflow-devlog` | Session logging | No | | `devflow-core-skills` | Auto-activating quality enforcement | No | | `devflow-audit-claude` | Audit CLAUDE.md files (optional) | No | @@ -37,7 +35,7 @@ Plugin marketplace with 10 self-contained plugins, each following the Claude plu devflow/ ├── shared/skills/ # 28 skills (single source of truth) ├── shared/agents/ # 10 shared agents (single source of truth) -├── plugins/devflow-*/ # 10 self-contained plugins +├── plugins/devflow-*/ # 8 self-contained plugins ├── docs/reference/ # Detailed reference documentation ├── scripts/ # Helper scripts (statusline, docs-helpers) │ └── hooks/ # Working Memory hooks (stop, session-start, pre-compact) @@ -76,14 +74,13 @@ All generated docs live under `.docs/` in the project root: ├── reviews/{branch-slug}/ # Review reports per branch ├── design/ # Implementation plans ├── status/ # Development logs + INDEX.md -├── CATCH_UP.md # Latest summary (overwritten) ├── WORKING-MEMORY.md # Auto-maintained by Stop hook (overwritten each response) └── working-memory-backup.json # Pre-compact git state snapshot ``` **Naming conventions**: Timestamps as `YYYY-MM-DD_HHMM`, branch slugs replace `/` with `-`, topic slugs are lowercase-dashes. Use `.devflow/scripts/docs-helpers.sh` for consistent naming. -**Persisting agents**: CatchUp → `.docs/CATCH_UP.md`, Devlog → `.docs/status/`, Reviewer → `.docs/reviews/`, Synthesizer → `.docs/reviews/` (review mode), Working Memory → `.docs/WORKING-MEMORY.md` (automatic) +**Persisting agents**: Reviewer → `.docs/reviews/`, Synthesizer → `.docs/reviews/` (review mode), Working Memory → `.docs/WORKING-MEMORY.md` (automatic) ## Agent & Command Roster @@ -94,12 +91,11 @@ All generated docs live under `.docs/` in the project root: - `/resolve` — N Resolver agents + Git - `/debug` — Agent Teams competing hypotheses - `/self-review` — Simplifier then Scrutinizer (sequential) -- `/devlog`, `/catch-up` — Single-agent utilities - `/audit-claude` — CLAUDE.md audit (optional plugin) **Shared agents** (10): git, synthesizer, skimmer, simplifier, coder, reviewer, resolver, shepherd, scrutinizer, validator -**Plugin-specific agents** (3): devlog, catch-up, claude-md-auditor +**Plugin-specific agents** (1): claude-md-auditor **Agent Teams**: 5 commands use Agent Teams (`/review`, `/implement`, `/debug`, `/specify`, `/resolve`). One-team-per-session constraint — must TeamDelete before creating next team. @@ -124,7 +120,7 @@ All generated docs live under `.docs/` in the project root: - Commands are orchestration-only — spawn agents, never do agent work in main session - Create in `plugins/devflow-{plugin}/commands/` -- Register new plugins in `DEVFLOW_PLUGINS` in `src/cli/commands/init.ts` +- Register new plugins in `DEVFLOW_PLUGINS` in `src/cli/plugins.ts` ### Commits diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e686427 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,104 @@ +# Contributing to DevFlow + +Thanks for your interest in contributing to DevFlow! This guide covers everything you need to get started. + +## Prerequisites + +- **Node.js 18+** ([download](https://nodejs.org/)) +- **Claude Code** ([download](https://claude.ai/download)) +- **Git** + +## Development Setup + +```bash +git clone https://github.com/dean0x/devflow.git +cd devflow +npm install +npm run build +node dist/cli.js init +``` + +After setup, DevFlow commands (`/review`, `/implement`, etc.) are available in Claude Code. + +## Project Structure + +``` +devflow/ +├── shared/skills/ # 28 skills (single source of truth) +├── shared/agents/ # 10 shared agents (single source of truth) +├── plugins/devflow-*/ # 10 self-contained plugins +├── scripts/hooks/ # Working Memory hooks +├── src/cli/ # TypeScript CLI (init, list, uninstall) +├── tests/ # Test suite (Vitest) +└── docs/reference/ # Detailed reference docs +``` + +For the full file organization, see [docs/reference/file-organization.md](docs/reference/file-organization.md). + +## How to Add a New Skill + +1. Create `shared/skills/{skill-name}/SKILL.md` with frontmatter and Iron Law +2. Add the skill name to the relevant plugin's `skills` array in `src/cli/plugins.ts` +3. Run `npm run build` to distribute the skill to plugin directories +4. Run `node dist/cli.js init` to install locally + +Skills are read-only (`allowed-tools: Read, Grep, Glob`) and auto-activate based on context. Target ~120-150 lines per SKILL.md with progressive disclosure to `references/` subdirectories. + +## How to Add a New Agent + +1. Create `shared/agents/{agent-name}.md` with frontmatter +2. Add the agent name to the relevant plugin's `agents` array in `src/cli/plugins.ts` +3. Run `npm run build` to distribute the agent to plugin directories +4. Run `node dist/cli.js init` to install locally + +Agents target 50-150 lines depending on type (Utility 50-80, Worker 80-120). + +## How to Add a New Command or Plugin + +See [docs/reference/adding-commands.md](docs/reference/adding-commands.md) for the full guide. The short version: + +1. Create a plugin directory under `plugins/devflow-{name}/` +2. Add a `plugin.json` manifest declaring commands, agents, and skills +3. Create command files in `plugins/devflow-{name}/commands/` +4. Register the plugin in `DEVFLOW_PLUGINS` in `src/cli/plugins.ts` +5. `npm run build && node dist/cli.js init` + +## Running Tests + +```bash +npm test # Run all tests once +npm run test:watch # Run tests in watch mode +``` + +## Build Commands + +```bash +npm run build # Full build (TypeScript + skill/agent distribution) +npm run build:cli # TypeScript compilation only +npm run build:plugins # Skill/agent distribution only +``` + +## Commit Conventions + +We use [Conventional Commits](https://www.conventionalcommits.org/): + +- `feat:` — New feature +- `fix:` — Bug fix +- `docs:` — Documentation only +- `refactor:` — Code change that neither fixes a bug nor adds a feature +- `test:` — Adding or updating tests +- `chore:` — Build process, tooling, or auxiliary changes + +## Pull Request Process + +1. Fork the repository +2. Create a feature branch from `main` +3. Make your changes with tests +4. Run `npm run build && npm test` to verify +5. Submit a PR against `main` + +Keep PRs focused on a single concern. Include a clear description of what changed and why. + +## Code of Conduct + +Be respectful, constructive, and collaborative. We follow the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). diff --git a/README.md b/README.md index fcd87aa..2defd9f 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,6 @@ npx devflow-kit init --plugin=implement,review | `devflow-resolve` | `/resolve` | Process review issues - fix or defer to tech debt | | `devflow-debug` | `/debug` | Competing hypothesis debugging with agent teams | | `devflow-self-review` | `/self-review` | Self-review workflow (Simplifier + Scrutinizer) | -| `devflow-catch-up` | `/catch-up` | Context restoration from status logs | -| `devflow-devlog` | `/devlog` | Development session logging | | `devflow-core-skills` | (auto) | Auto-activating quality enforcement skills | ## Commands @@ -103,23 +101,6 @@ Processes issues from `/review`: - Fixes low-risk issues immediately - Defers high-risk issues to tech debt backlog -### /catch-up - -Restores context at the start of a session: - -- Reads recent status logs -- Summarizes current project state -- Recommends next actions - -### /devlog - -Documents session state before ending: - -- Captures decisions made -- Records problems encountered -- Notes current progress -- Creates searchable history in `.docs/status/` - ## Auto-Activating Skills The `devflow-core-skills` plugin provides quality enforcement skills that activate automatically: @@ -161,7 +142,6 @@ DevFlow creates project documentation in `.docs/`: ├── status/ # Development logs │ ├── {timestamp}.md │ └── INDEX.md -├── CATCH_UP.md # Latest summary ├── WORKING-MEMORY.md # Auto-maintained by Stop hook └── working-memory-backup.json # Pre-compact git state snapshot ``` @@ -170,11 +150,7 @@ DevFlow creates project documentation in `.docs/`: ### Starting a Session -Session context is restored automatically via Working Memory hooks — no manual steps needed. For a deeper review of recent history: - -```bash -/catch-up # Review previous state and get recommendations -``` +Session context is restored automatically via Working Memory hooks — no manual steps needed. ### Implementing a Feature ```bash @@ -196,11 +172,7 @@ Session context is restored automatically via Working Memory hooks — no manual ### Ending a Session -Working memory is saved automatically. For a more detailed session record: - -```bash -/devlog # Document decisions and state for next session -``` +Working memory is saved automatically — no manual steps needed. ## CLI Reference diff --git a/docs/reference/agent-design.md b/docs/reference/agent-design.md index 62b3162..babe1ca 100644 --- a/docs/reference/agent-design.md +++ b/docs/reference/agent-design.md @@ -35,7 +35,7 @@ frontmatter (name, description, model, skills, hooks) | Agent Type | Target Lines | Examples | |------------|-------------|----------| -| Utility | 50-80 | Skimmer, Simplifier, CatchUp | +| Utility | 50-80 | Skimmer, Simplifier, Validator | | Worker | 80-120 | Coder, Reviewer, Git | | Orchestration | 100-150 | (Commands handle orchestration, not agents) | @@ -93,4 +93,4 @@ Before committing a new or modified agent: 3. Test with explicit invocation 4. Document in plugin README.md -**Note:** Shared agents live in `shared/agents/` and are distributed at build time. Only create plugin-specific agents when tightly coupled to a single workflow (e.g., `devlog.md`, `catch-up.md`). +**Note:** Shared agents live in `shared/agents/` and are distributed at build time. Only create plugin-specific agents when tightly coupled to a single workflow (e.g., `claude-md-auditor.md`). diff --git a/docs/reference/file-organization.md b/docs/reference/file-organization.md index f7fbd2e..ca6bccc 100644 --- a/docs/reference/file-organization.md +++ b/docs/reference/file-organization.md @@ -20,7 +20,7 @@ devflow/ │ ├── synthesizer.md │ ├── coder.md │ └── ... -├── plugins/ # Plugin collection (10 plugins) +├── plugins/ # Plugin collection (8 plugins) │ ├── devflow-specify/ │ │ ├── .claude-plugin/ │ │ │ └── plugin.json @@ -33,8 +33,6 @@ devflow/ │ ├── devflow-resolve/ │ ├── devflow-debug/ │ ├── devflow-self-review/ -│ ├── devflow-catch-up/ -│ ├── devflow-devlog/ │ ├── devflow-core-skills/ │ └── devflow-audit-claude/ ├── docs/ @@ -120,7 +118,7 @@ Skills and agents are **not duplicated** in git. Instead: ### Shared vs Plugin-Specific Agents - **Shared** (10): `git`, `synthesizer`, `skimmer`, `simplifier`, `coder`, `reviewer`, `resolver`, `shepherd`, `scrutinizer`, `validator` -- **Plugin-specific** (3): `devlog`, `catch-up`, `claude-md-auditor` — committed directly in their plugins +- **Plugin-specific** (1): `claude-md-auditor` — committed directly in its plugin ## Settings Override diff --git a/docs/reference/skills-architecture.md b/docs/reference/skills-architecture.md index 0a5512b..83c0e5d 100644 --- a/docs/reference/skills-architecture.md +++ b/docs/reference/skills-architecture.md @@ -15,7 +15,7 @@ Shared patterns used by multiple agents. | `core-patterns` | Engineering patterns (Result types, DI, immutability, pure functions) | Coder, Reviewer | | `review-methodology` | 6-step review process, 3-category issue classification | Reviewer | | `self-review` | 9-pillar self-review framework | Scrutinizer | -| `docs-framework` | Documentation conventions (.docs/ structure, naming, templates) | Devlog, CatchUp | +| `docs-framework` | Documentation conventions (.docs/ structure, naming, templates) | Synthesizer | | `git-safety` | Git operations, lock handling, commit conventions | Coder, Git | | `github-patterns` | GitHub API patterns (rate limiting, PR comments, issues, releases) | Git | | `implementation-patterns` | CRUD, API endpoints, events, config, logging | Coder | @@ -194,7 +194,7 @@ activation: **Use a Command when:** - Requires explicit user decision - Performs state changes (commits, releases) -- User controls timing (devlog, catch-up) +- User controls timing and sequencing - Orchestrates complex workflows ## Creating New Skills diff --git a/package-lock.json b/package-lock.json index 8bc2635..ffa700d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,8 @@ "devDependencies": { "@types/node": "^20.11.0", "tsx": "^4.7.0", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "vitest": "^4.0.18" }, "engines": { "node": ">=18.0.0" @@ -488,6 +489,395 @@ "node": ">=18" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", @@ -498,6 +888,137 @@ "undici-types": "~6.21.0" } }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -507,6 +1028,13 @@ "node": ">=18" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -549,6 +1077,44 @@ "@esbuild/win32-x64": "0.27.2" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -577,12 +1143,101 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -593,12 +1248,132 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -639,6 +1414,176 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/package.json b/package.json index b5643f3..ded8536 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "devflow-kit", - "version": "0.9.0", + "version": "1.0.0", "description": "Agentic Development Toolkit for Claude Code - Enhance AI-assisted development with intelligent commands and workflows", "main": "dist/index.js", "type": "module", @@ -25,7 +25,8 @@ "dev": "tsc --watch", "cli": "node dist/cli.js", "prepublishOnly": "npm run build", - "test": "echo \"No tests yet\" && exit 0" + "test": "vitest run", + "test:watch": "vitest" }, "keywords": [ "claude", @@ -58,6 +59,7 @@ "devDependencies": { "@types/node": "^20.11.0", "tsx": "^4.7.0", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "vitest": "^4.0.18" } } diff --git a/plugins/devflow-catch-up/.claude-plugin/plugin.json b/plugins/devflow-catch-up/.claude-plugin/plugin.json deleted file mode 100644 index e2436c3..0000000 --- a/plugins/devflow-catch-up/.claude-plugin/plugin.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "devflow-catch-up", - "description": "Context restoration from development status logs - get up to speed on project state quickly", - "author": { - "name": "DevFlow Contributors", - "email": "dean@keren.dev" - }, - "version": "0.9.0", - "homepage": "https://github.com/dean0x/devflow", - "repository": "https://github.com/dean0x/devflow", - "license": "MIT", - "keywords": ["context", "status", "documentation", "onboarding"], - "agents": [], - "skills": [] -} diff --git a/plugins/devflow-catch-up/README.md b/plugins/devflow-catch-up/README.md deleted file mode 100644 index ba9fe33..0000000 --- a/plugins/devflow-catch-up/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# devflow-catch-up - -Context restoration plugin for Claude Code. Reads development status logs to quickly get up to speed on project state. - -## Installation - -```bash -# Via DevFlow CLI -npx devflow-kit init --plugin=catch-up - -# Via Claude Code (when available) -/plugin install dean0x/devflow-catch-up -``` - -## Usage - -``` -/catch-up # Get up to speed on recent work -/catch-up --since=7d # Last 7 days of activity -``` - -## What It Provides - -- Recent commits and their purpose -- Open PRs and their status -- Pending issues and blockers -- Recent decisions and rationale -- Current branch state - -## Components - -### Command -- `/catch-up` - Review recent status updates - -### Agents -- `catch-up` - Context restoration agent - -### Skills -- `docs-framework` - Documentation conventions - -## Output - -- Summary written to `.docs/CATCH_UP.md` -- Key context points highlighted -- Actionable next steps suggested - -## Requirements - -Works best when paired with regular `/devlog` usage to maintain status history in `.docs/status/`. - -## Related Plugins - -- [devflow-devlog](../devflow-devlog) - Create status logs to catch up from diff --git a/plugins/devflow-catch-up/agents/catch-up.md b/plugins/devflow-catch-up/agents/catch-up.md deleted file mode 100644 index 7f8dfa9..0000000 --- a/plugins/devflow-catch-up/agents/catch-up.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -name: CatchUp -description: Review recent status updates to get up to speed on project state -model: haiku -skills: docs-framework ---- - -# CatchUp Agent - -You are a catch-up specialist helping developers get up to speed on recent project activity. Your core philosophy: **Status documents lie. Developers are chronically over-optimistic. Trust but verify - emphasis on VERIFY.** - -## Input - -The orchestrator provides: -- **Status directory**: Path to `.docs/status/` containing status documents -- **Output path**: Where to save catch-up summary (`.docs/CATCH_UP.md`) - -## Responsibilities - -1. **Restore todo list** - Extract and recreate todo list state from most recent status document using TodoWrite (MANDATORY FIRST STEP) -2. **Find recent status documents** - Locate and sort status files chronologically -3. **Analyze with skepticism** - Extract focus, accomplishments, decisions, next steps, and issues -4. **Validate claims against reality** - Check if "completed" features actually work, verify file changes, look for red flags -5. **Check git activity** - Compare git log since last status date -6. **Generate catch-up summary** - Create focused summary with validation results -7. **Create compact versions** - Generate abbreviated versions in `.docs/status/compact/` -8. **Update index** - Maintain `.docs/status/INDEX.md` with full and compact links - -## Principles - -1. **Skepticism is mandatory** - Never trust status claims at face value -2. **Verify before reporting** - Run tests, check git status, look for broken states -3. **Be decisive** - Make clear trust level assessments (High/Medium/Low) -4. **Pattern discovery first** - Understand actual project state before summarizing -5. **Actionable recommendations** - Prioritize based on ACTUAL state, not claimed state -6. **Restore context** - Todo list restoration is CRITICAL for session continuity - -## Output - -```markdown -# Project Catch-Up Summary - -**Generated**: {timestamp} -**Last Status**: {date} -**Trust Level**: {High/Medium/Low} - -## Where We Left Off -**Focus**: {what was being worked on} - -**Claimed vs Reality**: -| Claim | Verification | Status | -|-------|--------------|--------| -| {claim} | {how verified} | {working/broken/partial} | - -## Validation Results -- Verified working: {list} -- Broken/incomplete: {list} -- Partially working: {list} - -## Current State -- Branch: {current} -- Uncommitted: {count} -- Git activity since: {commit count} - -## Recommended Actions -1. {Priority action based on actual state} -2. {Next action} -3. {Third action} - -## Red Flags Found -{Any credibility issues with status documents} -``` - -## Boundaries - -**Handle autonomously:** -- Reading and analyzing status documents -- Running validation checks (git status, test files, red flags) -- Generating summaries and compact versions -- Todo list restoration - -**Escalate to orchestrator:** -- No status documents found (suggest running /devlog first) -- Critical validation failures that need user attention diff --git a/plugins/devflow-catch-up/commands/catch-up.md b/plugins/devflow-catch-up/commands/catch-up.md deleted file mode 100644 index 859ad25..0000000 --- a/plugins/devflow-catch-up/commands/catch-up.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -allowed-tools: Task -description: Review recent status updates to get up to speed on project state ---- - -## Your task - -Launch the `CatchUp` sub-agent to review recent project activity and status documents, then synthesize the results for the user. - -### Next: Synthesize Results - -After the sub-agent completes, present a concise summary to the user: - -```markdown -🚀 CATCH-UP COMPLETE - -{Brief summary of recent activity from sub-agent} - -📍 WHERE WE LEFT OFF: -{Last session focus and status} - -⚠️ VALIDATION RESULTS: -{Reality check on claimed accomplishments} - -📋 RECOMMENDED NEXT ACTIONS: -{Top 3 priorities} - -📄 Full catch-up summary available from sub-agent output above -``` diff --git a/plugins/devflow-catch-up/skills/docs-framework/SKILL.md b/plugins/devflow-catch-up/skills/docs-framework/SKILL.md deleted file mode 100644 index b878559..0000000 --- a/plugins/devflow-catch-up/skills/docs-framework/SKILL.md +++ /dev/null @@ -1,136 +0,0 @@ ---- -name: docs-framework -description: Documentation conventions for DevFlow artifacts. Load when creating status logs, debug sessions, review reports, or any persistent documentation in .docs/ directory. Ensures consistent naming, structure, and organization. -user-invocable: false -allowed-tools: Read, Bash, Glob ---- - -# Documentation Framework - -The canonical source for documentation conventions in DevFlow. All agents that persist artifacts must follow these standards. - -## Iron Law - -> **ALL ARTIFACTS FOLLOW NAMING CONVENTIONS** -> -> Timestamps are `YYYY-MM-DD_HHMM`. Branch slugs replace `/` with `-`. Topic slugs are -> lowercase alphanumeric with dashes. No exceptions. Inconsistent naming breaks tooling, -> searching, and automation. Follow the pattern or fix the pattern for everyone. - ---- - -## Directory Structure - -All generated documentation lives under `.docs/` in the project root: - -``` -.docs/ -├── reviews/{branch-slug}/ # Code review reports per branch -│ ├── {type}-report.{timestamp}.md -│ └── review-summary.{timestamp}.md -├── status/ # Development logs -│ ├── {timestamp}.md -│ ├── compact/{timestamp}.md -│ └── INDEX.md -├── swarm/ # Swarm operation state -│ ├── state.json -│ └── plans/ -└── CATCH_UP.md # Latest summary (overwritten) -``` - ---- - -## Naming Conventions - -### Timestamps -Format: `YYYY-MM-DD_HHMM` (sortable, readable) -```bash -TIMESTAMP=$(date +%Y-%m-%d_%H%M) # Example: 2025-12-26_1430 -``` - -### Branch Slugs -Replace `/` with `-`, sanitize special characters: -```bash -BRANCH_SLUG=$(git branch --show-current 2>/dev/null | sed 's/\//-/g' || echo "standalone") -``` - -### Topic Slugs -Lowercase, dashes, alphanumeric only, max 50 chars: -```bash -TOPIC_SLUG=$(echo "$TOPIC" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | sed 's/[^a-z0-9-]//g' | cut -c1-50) -``` - -### File Naming Patterns - -| Type | Pattern | Example | -|------|---------|---------| -| Special indexes | `UPPERCASE.md` | `CATCH_UP.md`, `INDEX.md` | -| Reports | `{type}-report.{timestamp}.md` | `security-report.2025-12-26_1430.md` | -| Status logs | `{timestamp}.md` | `2025-12-26_1430.md` | - ---- - -## Helper Functions - -Source helpers for consistent naming: - -```bash -source .devflow/scripts/docs-helpers.sh 2>/dev/null || { - get_timestamp() { date +%Y-%m-%d_%H%M; } - get_branch_slug() { git branch --show-current 2>/dev/null | sed 's/\//-/g' || echo "standalone"; } - get_topic_slug() { echo "$1" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | sed 's/[^a-z0-9-]//g' | cut -c1-50; } - ensure_docs_dir() { mkdir -p ".docs/$1"; } -} -``` - ---- - -## Agent Persistence Rules - -### Agents That Persist Artifacts - -| Agent | Output Location | Behavior | -|-------|-----------------|----------| -| CatchUp | `.docs/CATCH_UP.md` | Overwrites (latest summary) | -| Devlog | `.docs/status/{timestamp}.md` | Creates new + updates `INDEX.md` | -| Reviewer | `.docs/reviews/{branch-slug}/{type}-report.{timestamp}.md` | Creates new | - -### Agents That Don't Persist - -- Git (fetch-issue: read-only, comment-pr: PR comments only) -- Coder (commits to git, no .docs/ output) - ---- - -## Implementation Checklist - -When creating or modifying persisting agents: - -- [ ] Use standard timestamp format (`YYYY-MM-DD_HHMM`) -- [ ] Sanitize branch names (replace `/` with `-`) -- [ ] Sanitize topic names (lowercase, dashes, alphanumeric) -- [ ] Create directory with `mkdir -p .docs/{subdir}` -- [ ] Document output location in agent's final message -- [ ] Follow special file naming (UPPERCASE for indexes) -- [ ] Use helper functions when possible -- [ ] Update relevant index files - ---- - -## Integration - -This framework is used by: -- **Devlog**: Creates status logs -- **CatchUp**: Reads status logs, creates summary -- **Review agents**: Creates review reports - -All persisting agents should load this skill to ensure consistent documentation. - ---- - -## Extended References - -For detailed patterns and violation examples: - -- **[patterns.md](./references/patterns.md)** - Full templates, helper functions, naming examples, edge cases -- **[violations.md](./references/violations.md)** - Common violations with detection patterns and fixes diff --git a/plugins/devflow-catch-up/skills/docs-framework/references/patterns.md b/plugins/devflow-catch-up/skills/docs-framework/references/patterns.md deleted file mode 100644 index 779b03a..0000000 --- a/plugins/devflow-catch-up/skills/docs-framework/references/patterns.md +++ /dev/null @@ -1,346 +0,0 @@ -# Documentation Framework Patterns - -Correct patterns for DevFlow documentation artifacts with templates and helper functions. - ---- - -## Directory Structure - -### Standard Layout - -``` -.docs/ -├── reviews/{branch-slug}/ # Code review reports per branch -│ ├── {type}-report.{timestamp}.md -│ └── review-summary.{timestamp}.md -├── design/ # Implementation plans -│ └── {topic-slug}.{timestamp}.md -├── status/ # Development logs -│ ├── {timestamp}.md -│ ├── compact/{timestamp}.md -│ └── INDEX.md -└── CATCH_UP.md # Latest summary (overwritten) -``` - -### Setup Variations - -**Minimal (new project):** -``` -.docs/ -├── status/ -│ └── INDEX.md -└── CATCH_UP.md -``` - -**With Reviews:** -``` -.docs/ -├── reviews/feat-auth/ -│ ├── security-report.2025-01-05_1430.md -│ └── review-summary.2025-01-05_1430.md -├── status/ -│ ├── 2025-01-05_1430.md -│ └── INDEX.md -└── CATCH_UP.md -``` - ---- - -## Naming Conventions - -### Timestamps - -Format: `YYYY-MM-DD_HHMM` (sortable, readable) - -```bash -# Standard format -TIMESTAMP=$(date +%Y-%m-%d_%H%M) -# Output: 2025-01-05_1430 - -# With seconds (only if needed for uniqueness) -TIMESTAMP_SEC=$(date +%Y-%m-%d_%H%M%S) -# Output: 2025-01-05_143025 -``` - -### Branch Slugs - -Replace `/` with `-`, provide fallback for detached HEAD. - -```bash -BRANCH_SLUG=$(git branch --show-current 2>/dev/null | sed 's/\//-/g' || echo "standalone") - -# Examples: -# feature/auth -> feature-auth -# fix/issue-123 -> fix-issue-123 -# hotfix/critical-bug -> hotfix-critical-bug -# release/v2.0.0 -> release-v2.0.0 -# (detached HEAD) -> standalone -``` - -### Topic Slugs - -Lowercase, dashes, alphanumeric only, max 50 chars. - -```bash -TOPIC_SLUG=$(echo "$TOPIC" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | sed 's/[^a-z0-9-]//g' | cut -c1-50) - -# Examples: -# "JWT Authentication" -> jwt-authentication -# "Fix User Login Bug" -> fix-user-login-bug -# "OAuth 2.0 Integration" -> oauth-20-integration -# "Database Migration v2" -> database-migration-v2 -``` - ---- - -## File Naming Rules - -### Special Index Files (UPPERCASE) - -Always UPPERCASE, overwritten or appended: - -- `CATCH_UP.md` - Latest context summary -- `INDEX.md` - Chronological log index -- `KNOWLEDGE_BASE.md` - Searchable debug solutions - -### Artifact Files (lowercase + timestamp) - -Always lowercase with timestamp: - -- `2025-01-05_1430.md` - Status log -- `security-report.2025-01-05_1430.md` - Review report -- `review-summary.2025-01-05_1430.md` - Combined summary -- `jwt-authentication.2025-01-05_1430.md` - Design doc - ---- - -## Helper Functions - -Full implementation for `.devflow/scripts/docs-helpers.sh`: - -```bash -#!/bin/bash -# .devflow/scripts/docs-helpers.sh - -# Get current timestamp in standard format -get_timestamp() { - date +%Y-%m-%d_%H%M -} - -# Get sanitized branch slug -get_branch_slug() { - local branch - branch=$(git branch --show-current 2>/dev/null) - if [ -z "$branch" ]; then - echo "standalone" - else - echo "$branch" | sed 's/\//-/g' - fi -} - -# Convert topic to slug format -get_topic_slug() { - local topic="$1" - echo "$topic" | \ - tr '[:upper:]' '[:lower:]' | \ - tr ' ' '-' | \ - sed 's/[^a-z0-9-]//g' | \ - cut -c1-50 -} - -# Ensure docs directory exists -ensure_docs_dir() { - local subdir="$1" - mkdir -p ".docs/$subdir" -} - -# Get full output path for a document -get_doc_path() { - local subdir="$1" - local filename="$2" - ensure_docs_dir "$subdir" - echo ".docs/$subdir/$filename" -} - -# Create timestamped status log path -get_status_path() { - local timestamp - timestamp=$(get_timestamp) - get_doc_path "status" "${timestamp}.md" -} - -# Create review report path -get_review_path() { - local type="$1" - local branch_slug - local timestamp - branch_slug=$(get_branch_slug) - timestamp=$(get_timestamp) - get_doc_path "reviews/$branch_slug" "${type}-report.${timestamp}.md" -} -``` - -### Usage Example - -```bash -# Source helpers -source .devflow/scripts/docs-helpers.sh 2>/dev/null || { - # Inline fallback if script not found - get_timestamp() { date +%Y-%m-%d_%H%M; } - get_branch_slug() { git branch --show-current 2>/dev/null | sed 's/\//-/g' || echo "standalone"; } - get_topic_slug() { echo "$1" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | sed 's/[^a-z0-9-]//g' | cut -c1-50; } - ensure_docs_dir() { mkdir -p ".docs/$1"; } -} - -# Use helpers -TIMESTAMP=$(get_timestamp) -BRANCH_SLUG=$(get_branch_slug) -ensure_docs_dir "reviews/$BRANCH_SLUG" - -# Create output path -OUTPUT=$(get_review_path "security") -echo "Writing to: $OUTPUT" -``` - ---- - -## Templates - -### Status Log Template - -```markdown -# Status Update - {TIMESTAMP} - -## Session Summary -{Brief 2-3 sentence summary of what was accomplished} - -## Branch Context -- **Branch**: {current_branch} -- **Base**: {base_branch} -- **Status**: {ahead/behind/clean} - -## Work Completed -- {Task 1 completed} -- {Task 2 completed} - -## Files Changed -- `path/to/file1.ts` - {description} -- `path/to/file2.ts` - {description} - -## Decisions Made -- **{Decision}**: {Rationale} - -## Next Steps -- [ ] {Next task 1} -- [ ] {Next task 2} - -## Notes -{Any additional context for future sessions} -``` - -### Debug Session Template - -```markdown -# Debug Session - {DEBUG_SESSION_ID} - -## Problem Statement -**Issue**: {description} -**Reported**: {timestamp} -**Branch**: {branch} - -## Expected vs Actual -**Expected**: {what should happen} -**Actual**: {what's happening} - -## Hypotheses Tested -### Hypothesis 1: {description} -- **Test**: {how tested} -- **Result**: {confirmed/refuted} -- **Evidence**: {what was observed} - -## Root Cause -**Location**: {file:line} -**Issue**: {precise description} -**Why It Happened**: {chain of events} - -## Solution Applied -**Fix**: {description} -**Files Changed**: {list} -**Verification**: {how verified} - -## Prevention -- {How to prevent in future} -``` - -### STATUS INDEX.md Template - -```markdown -# Status Log Index - -| Date | Summary | Branch | -|------|---------|--------| -| [2025-01-05_1430](./2025-01-05_1430.md) | Implemented auth | feat/auth | -| [2025-01-04_0900](./2025-01-04_0900.md) | Fixed login bug | fix/login | -``` - -### DEBUG KNOWLEDGE_BASE.md Template - -```markdown -# Debug Knowledge Base - -Searchable record of debugging sessions and solutions. - ---- - -## Issue: {description} -**Date**: {date} -**Session**: {session_id} -**Category**: {error/performance/test/build} -**Root Cause**: {brief} -**Solution**: {brief} -**Keywords**: {searchable terms} - -[Full details](./debug-{session_id}.md) - ---- -``` - ---- - -## Edge Cases - -### Detached HEAD State - -Branch slug falls back to "standalone": - -```bash -# In detached HEAD -get_branch_slug # Returns: standalone - -# Review path becomes: -# .docs/reviews/standalone/security-report.2025-01-05_1430.md -``` - -### Special Characters in Topics - -All special characters stripped: - -```bash -get_topic_slug "Bug: User can't login (v2.0)" -# Returns: bug-user-cant-login-v20 -``` - -### Long Topic Names - -Truncated at 50 characters: - -```bash -get_topic_slug "This is a very long topic name that exceeds the maximum allowed length" -# Returns: this-is-a-very-long-topic-name-that-exceeds-the-m -``` - ---- - -## Quick Reference - -For violation examples and detection patterns, see [violations.md](violations.md). diff --git a/plugins/devflow-catch-up/skills/docs-framework/references/violations.md b/plugins/devflow-catch-up/skills/docs-framework/references/violations.md deleted file mode 100644 index 61f34cd..0000000 --- a/plugins/devflow-catch-up/skills/docs-framework/references/violations.md +++ /dev/null @@ -1,221 +0,0 @@ -# Documentation Framework Violations - -Common violations of documentation conventions with detection patterns and fixes. - ---- - -## Naming Convention Violations - -| Violation | Bad Example | Correct Example | Fix | -|-----------|-------------|-----------------|-----| -| Wrong timestamp format | `2025-1-5` | `2025-01-05_1430` | Use `YYYY-MM-DD_HHMM` | -| Missing time in timestamp | `2025-01-05` | `2025-01-05_1430` | Include `_HHMM` suffix | -| Unsanitized branch slug | `feature/auth` | `feature-auth` | Replace `/` with `-` | -| Wrong case for artifacts | `Status-2025-01-05.md` | `2025-01-05_1430.md` | Use lowercase except special indexes | -| Wrong case for indexes | `index.md` | `INDEX.md` | UPPERCASE for special indexes | -| Files outside .docs | `docs/status.md` | `.docs/status/2025-01-05_1430.md` | Use `.docs/` root | -| Missing INDEX.md | Status files without index | Create `INDEX.md` | Every directory needs index | -| Special chars in slug | `oauth-2.0-auth` | `oauth-20-auth` | Strip non-alphanumeric except `-` | - ---- - -## Directory Structure Violations - -### Wrong: Flat Structure - -``` -project/ -├── status-2025-01-05.md # Wrong location -├── review-auth.md # Missing timestamp, wrong location -└── catch_up.md # Wrong case, wrong location -``` - -### Wrong: Non-standard Subdirectories - -``` -.docs/ -├── logs/ # Should be status/ -├── reports/ # Should be reviews/{branch-slug}/ -└── notes.md # Should be in appropriate subdir -``` - ---- - -## Timestamp Violations - -### Missing Leading Zeros - -```bash -# WRONG -date +%Y-%m-%d_%H%M # Could produce: 2025-1-5_930 - -# RIGHT -date +%Y-%m-%d_%H%M # Always produces: 2025-01-05_0930 -``` - -Note: `date` command handles padding, but manual timestamps often miss it. - -### Wrong Separator - -```bash -# WRONG -2025-01-05-1430 # Dash instead of underscore -2025/01/05_1430 # Slash in date -2025.01.05_1430 # Dot in date - -# RIGHT -2025-01-05_1430 # Underscore separating date from time -``` - ---- - -## Branch Slug Violations - -### Unhandled Slash - -```bash -# WRONG - produces invalid filename -BRANCH_SLUG=$(git branch --show-current) -# Result: feature/auth -> invalid path - -# RIGHT - sanitizes slash -BRANCH_SLUG=$(git branch --show-current | sed 's/\//-/g') -# Result: feature/auth -> feature-auth -``` - -### Missing Fallback - -```bash -# WRONG - fails in detached HEAD -BRANCH_SLUG=$(git branch --show-current) -# Result: empty string -> broken path - -# RIGHT - with fallback -BRANCH_SLUG=$(git branch --show-current 2>/dev/null | sed 's/\//-/g' || echo "standalone") -# Result: (detached HEAD) -> standalone -``` - ---- - -## Topic Slug Violations - -### Preserving Special Characters - -```bash -# WRONG - special chars break filenames -TOPIC="Bug: Can't login (v2.0)" -SLUG="Bug:-Can't-login-(v2.0)" - -# RIGHT - stripped and normalized -SLUG="bug-cant-login-v20" -``` - -### Not Truncating Long Names - -```bash -# WRONG - excessively long filename -TOPIC="This is a very long topic name that describes the entire feature..." -SLUG="this-is-a-very-long-topic-name-that-describes-the-entire-feature" - -# RIGHT - truncated at 50 chars -SLUG="this-is-a-very-long-topic-name-that-describes-the" -``` - ---- - -## File Type Violations - -### Wrong Case for Special Files - -```bash -# WRONG -catch_up.md # Should be CATCH_UP.md -index.md # Should be INDEX.md -knowledge_base.md # Should be KNOWLEDGE_BASE.md - -# RIGHT - UPPERCASE for special indexes -CATCH_UP.md -INDEX.md -KNOWLEDGE_BASE.md -``` - -### Wrong Case for Artifacts - -```bash -# WRONG -Status-2025-01-05_1430.md -Security-Report.md - -# RIGHT - lowercase for artifacts -2025-01-05_1430.md -security-report.2025-01-05_1430.md -``` - ---- - -## Template Usage Violations - -### Missing Required Sections - -```markdown -# WRONG - missing key sections - -# Status Update - -Did some work today. -``` - -```markdown -# RIGHT - all required sections - -# Status Update - 2025-01-05_1430 - -## Session Summary -Implemented user authentication feature. - -## Branch Context -- **Branch**: feat/auth -- **Base**: main -- **Status**: 3 commits ahead - -## Work Completed -- Added JWT validation middleware -- Created login endpoint - -## Files Changed -- `src/auth/jwt.ts` - JWT utilities -- `src/routes/login.ts` - Login endpoint - -## Next Steps -- [ ] Add refresh token support -- [ ] Write integration tests -``` - ---- - -## Detection Patterns - -Use these patterns to find violations: - -```bash -# Find files outside .docs/ -find . -name "*.md" -path "*/docs/*" ! -path "*/.docs/*" - -# Find wrong timestamp format (missing underscore) -grep -r "^\d{4}-\d{2}-\d{2}[^_]" .docs/ - -# Find lowercase special indexes -ls .docs/**/index.md .docs/**/catch_up.md 2>/dev/null - -# Find uppercase artifacts (excluding special files) -find .docs -name "*.md" | grep -v "INDEX\|CATCH_UP\|KNOWLEDGE_BASE" | xargs -I {} basename {} | grep "^[A-Z]" - -# Find unsanitized branch names in paths -find .docs/reviews -type d -name "*/*" 2>/dev/null -``` - ---- - -## Quick Reference - -For correct patterns and templates, see [patterns.md](patterns.md). diff --git a/plugins/devflow-devlog/.claude-plugin/plugin.json b/plugins/devflow-devlog/.claude-plugin/plugin.json deleted file mode 100644 index 8d53391..0000000 --- a/plugins/devflow-devlog/.claude-plugin/plugin.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "devflow-devlog", - "description": "Development session logging and status tracking - document progress, decisions, and context", - "author": { - "name": "DevFlow Contributors", - "email": "dean@keren.dev" - }, - "version": "0.9.0", - "homepage": "https://github.com/dean0x/devflow", - "repository": "https://github.com/dean0x/devflow", - "license": "MIT", - "keywords": ["logging", "status", "documentation", "progress"], - "agents": [], - "skills": [] -} diff --git a/plugins/devflow-devlog/README.md b/plugins/devflow-devlog/README.md deleted file mode 100644 index 6068904..0000000 --- a/plugins/devflow-devlog/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# devflow-devlog - -Development session logging plugin for Claude Code. Documents progress, decisions, and context for future reference. - -## Installation - -```bash -# Via DevFlow CLI -npx devflow-kit init --plugin=devlog - -# Via Claude Code (when available) -/plugin install dean0x/devflow-devlog -``` - -## Usage - -``` -/devlog # Create status log for current session -/devlog "Added auth flow" # Create log with summary -``` - -## What Gets Logged - -- Current branch and recent commits -- Files changed in session -- Decisions made and rationale -- Blockers encountered -- TODOs and next steps - -## Components - -### Command -- `/devlog` - Document session progress - -### Agents -- `devlog` - Development logging agent - -### Skills -- `docs-framework` - Documentation conventions - -## Output - -Creates timestamped files in `.docs/status/`: -- `{timestamp}.md` - Full status log -- `compact/{timestamp}.md` - Abbreviated version -- `INDEX.md` - Updated index of all logs - -## Best Practices - -- Run `/devlog` at the end of each work session -- Include blockers and next steps -- Use with `/catch-up` for seamless context switching - -## Related Plugins - -- [devflow-catch-up](../devflow-catch-up) - Restore context from logs diff --git a/plugins/devflow-devlog/agents/devlog.md b/plugins/devflow-devlog/agents/devlog.md deleted file mode 100644 index 5b1e7a0..0000000 --- a/plugins/devflow-devlog/agents/devlog.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -name: Devlog -description: Analyze current project state including git history, file changes, TODOs, and documentation for status reporting -model: haiku -skills: docs-framework ---- - -# Devlog Agent - -You are a project state analysis specialist. You gather comprehensive codebase insights for status reporting and documentation. Return structured, parseable data - focus on facts and metrics, not interpretation. - -## Input - -The orchestrator provides: -- **Task context**: What status information is needed -- **Output path**: Where to save status document (e.g., `.docs/status/{timestamp}.md`) - -## Responsibilities - -1. **Analyze git history** - Current branch, base branch, recent commits, commits today/this week -2. **Check git status** - Uncommitted changes, staged files, modified files, untracked files -3. **Find recently modified files** - Files changed in last 24h and 7 days, excluding node_modules/build artifacts -4. **Scan for pending work** - Count and locate TODO, FIXME, HACK, XXX, BUG, REFACTOR markers -5. **Assess documentation** - Check for README, ARCHITECTURE, CHANGELOG, docs/, .docs/ directories -6. **Detect technology stack** - Identify package manifests, primary languages by file count -7. **Review dependencies** - Package manager type, dependency count, health indicators -8. **Calculate code statistics** - Lines of code, test file count - -## Principles - -1. **Facts over interpretation** - Report what you find, don't editorialize -2. **Be decisive** - Make confident assessments based on evidence -3. **Pattern discovery first** - Understand project structure before reporting -4. **Structured output** - Use consistent sections for easy parsing -5. **Exclude noise** - Always filter out node_modules, .git, build artifacts, venv - -## Output - -Return a structured summary with clear section headers: - -```markdown -## PROJECT STATE SUMMARY - -### Git Status -- **Branch**: {current} → {base} -- **Commits (7d)**: {count} -- **Uncommitted**: {staged}/{modified}/{untracked} - -### Recent Activity -- **Files (24h)**: {count} -- **Files (7d)**: {count} -- **Most active**: {top 5 files} - -### Pending Work -| Marker | Count | -|--------|-------| -| TODO | {n} | -| FIXME | {n} | -| HACK | {n} | - -### Documentation -- README: {exists/missing} -- ARCHITECTURE: {exists/missing} -- CHANGELOG: {exists/missing} -- .docs/: {exists/missing} - -### Technology -- **Language**: {primary} -- **Package manager**: {npm/pip/cargo/etc} -- **Dependencies**: ~{count} -- **Test files**: {count} - -### Code Statistics -- **LOC**: ~{count} -``` - -## Boundaries - -**Handle autonomously:** -- All git and file system analysis -- Pattern detection and counting -- Generating summary statistics - -**Escalate to orchestrator:** -- Ambiguous project structures (report findings, let orchestrator decide) -- Missing critical information (e.g., not a git repo) diff --git a/plugins/devflow-devlog/commands/devlog.md b/plugins/devflow-devlog/commands/devlog.md deleted file mode 100644 index 77fdfec..0000000 --- a/plugins/devflow-devlog/commands/devlog.md +++ /dev/null @@ -1,113 +0,0 @@ ---- -allowed-tools: Task, Write, TodoWrite -description: Create a development log entry capturing current project state and context ---- - -# Devlog Command - -Create a comprehensive development log that captures session context, project state, and next steps. Serves as a time capsule for returning to this project later. - -## Usage - -``` -/devlog (create full status document) -/devlog --compact (quick summary only) -``` - -## Input - -No arguments required. Command analyzes current conversation and project state. - -## Phases - -### Phase 1: Capture Session Context - -Extract from current conversation (inline, not delegated): -- Current todo list state (for session continuity) -- What we were working on (main feature/task) -- Problems solved and how -- Decisions made with rationale -- Next steps identified - -### Phase 2: Analyze Project State - -Spawn Devlog agent for codebase analysis: - -``` -Task(subagent_type="Devlog"): -"Analyze the current project state including: -- Git history and recent commits -- Recently modified files -- Pending work (TODOs, FIXMEs, HACKs) -- Documentation structure -- Technology stack -- Code statistics - -Return structured data for status documentation." -``` - -### Phase 3: Synthesize and Write - -Combine session context (Phase 1) with agent analysis (Phase 2). - -Write documents: -- Full: `.docs/status/{timestamp}.md` -- Compact: `.docs/status/compact/{timestamp}.md` -- Update: `.docs/status/INDEX.md` - -### Phase 4: Confirm - -Report files created with summary of what was captured. - -## Output Format - -**Full Status Document** includes: -- Current Focus (from conversation) -- Project State (from agent: git, files, TODOs) -- Architecture & Design (decisions from session) -- Known Issues & Technical Debt -- Agent Todo List State (JSON for recreation) -- Next Steps (immediate, short-term, long-term) -- Context for Future Developer -- Related Documents - -**Compact Summary** includes: -- Focus (one-line) -- Key Accomplishments (top 3) -- Critical Decisions (top 3) -- Next Priority Actions (top 3) -- Key Files Modified (top 5) - -## Architecture - -``` -/devlog (orchestrator) -│ -├─ Phase 1: Capture Session Context (inline) -│ ├─ Read current todo list state -│ ├─ Analyze conversation for work/decisions/blockers -│ └─ Extract next steps -│ -├─ Phase 2: Analyze Project State -│ └─ Devlog agent (git, files, TODOs, tech stack) -│ -├─ Phase 3: Synthesize and Write -│ ├─ Full: .docs/status/{timestamp}.md -│ ├─ Compact: .docs/status/compact/{timestamp}.md -│ └─ Index: .docs/status/INDEX.md -│ -└─ Phase 4: Confirm creation -``` - -## Principles - -1. **Session continuity** - Preserve todo list state for next session -2. **Two audiences** - Full for deep context, compact for quick reference -3. **Inline + agent** - Session context inline, codebase analysis delegated -4. **Time capsule** - Document for returning after weeks away -5. **Enable /catch-up** - Output must be parseable for context restoration - -## Related - -- `/catch-up` - Restore context from status documents -- Devlog agent - Handles codebase analysis diff --git a/plugins/devflow-devlog/skills/docs-framework/SKILL.md b/plugins/devflow-devlog/skills/docs-framework/SKILL.md deleted file mode 100644 index b878559..0000000 --- a/plugins/devflow-devlog/skills/docs-framework/SKILL.md +++ /dev/null @@ -1,136 +0,0 @@ ---- -name: docs-framework -description: Documentation conventions for DevFlow artifacts. Load when creating status logs, debug sessions, review reports, or any persistent documentation in .docs/ directory. Ensures consistent naming, structure, and organization. -user-invocable: false -allowed-tools: Read, Bash, Glob ---- - -# Documentation Framework - -The canonical source for documentation conventions in DevFlow. All agents that persist artifacts must follow these standards. - -## Iron Law - -> **ALL ARTIFACTS FOLLOW NAMING CONVENTIONS** -> -> Timestamps are `YYYY-MM-DD_HHMM`. Branch slugs replace `/` with `-`. Topic slugs are -> lowercase alphanumeric with dashes. No exceptions. Inconsistent naming breaks tooling, -> searching, and automation. Follow the pattern or fix the pattern for everyone. - ---- - -## Directory Structure - -All generated documentation lives under `.docs/` in the project root: - -``` -.docs/ -├── reviews/{branch-slug}/ # Code review reports per branch -│ ├── {type}-report.{timestamp}.md -│ └── review-summary.{timestamp}.md -├── status/ # Development logs -│ ├── {timestamp}.md -│ ├── compact/{timestamp}.md -│ └── INDEX.md -├── swarm/ # Swarm operation state -│ ├── state.json -│ └── plans/ -└── CATCH_UP.md # Latest summary (overwritten) -``` - ---- - -## Naming Conventions - -### Timestamps -Format: `YYYY-MM-DD_HHMM` (sortable, readable) -```bash -TIMESTAMP=$(date +%Y-%m-%d_%H%M) # Example: 2025-12-26_1430 -``` - -### Branch Slugs -Replace `/` with `-`, sanitize special characters: -```bash -BRANCH_SLUG=$(git branch --show-current 2>/dev/null | sed 's/\//-/g' || echo "standalone") -``` - -### Topic Slugs -Lowercase, dashes, alphanumeric only, max 50 chars: -```bash -TOPIC_SLUG=$(echo "$TOPIC" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | sed 's/[^a-z0-9-]//g' | cut -c1-50) -``` - -### File Naming Patterns - -| Type | Pattern | Example | -|------|---------|---------| -| Special indexes | `UPPERCASE.md` | `CATCH_UP.md`, `INDEX.md` | -| Reports | `{type}-report.{timestamp}.md` | `security-report.2025-12-26_1430.md` | -| Status logs | `{timestamp}.md` | `2025-12-26_1430.md` | - ---- - -## Helper Functions - -Source helpers for consistent naming: - -```bash -source .devflow/scripts/docs-helpers.sh 2>/dev/null || { - get_timestamp() { date +%Y-%m-%d_%H%M; } - get_branch_slug() { git branch --show-current 2>/dev/null | sed 's/\//-/g' || echo "standalone"; } - get_topic_slug() { echo "$1" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | sed 's/[^a-z0-9-]//g' | cut -c1-50; } - ensure_docs_dir() { mkdir -p ".docs/$1"; } -} -``` - ---- - -## Agent Persistence Rules - -### Agents That Persist Artifacts - -| Agent | Output Location | Behavior | -|-------|-----------------|----------| -| CatchUp | `.docs/CATCH_UP.md` | Overwrites (latest summary) | -| Devlog | `.docs/status/{timestamp}.md` | Creates new + updates `INDEX.md` | -| Reviewer | `.docs/reviews/{branch-slug}/{type}-report.{timestamp}.md` | Creates new | - -### Agents That Don't Persist - -- Git (fetch-issue: read-only, comment-pr: PR comments only) -- Coder (commits to git, no .docs/ output) - ---- - -## Implementation Checklist - -When creating or modifying persisting agents: - -- [ ] Use standard timestamp format (`YYYY-MM-DD_HHMM`) -- [ ] Sanitize branch names (replace `/` with `-`) -- [ ] Sanitize topic names (lowercase, dashes, alphanumeric) -- [ ] Create directory with `mkdir -p .docs/{subdir}` -- [ ] Document output location in agent's final message -- [ ] Follow special file naming (UPPERCASE for indexes) -- [ ] Use helper functions when possible -- [ ] Update relevant index files - ---- - -## Integration - -This framework is used by: -- **Devlog**: Creates status logs -- **CatchUp**: Reads status logs, creates summary -- **Review agents**: Creates review reports - -All persisting agents should load this skill to ensure consistent documentation. - ---- - -## Extended References - -For detailed patterns and violation examples: - -- **[patterns.md](./references/patterns.md)** - Full templates, helper functions, naming examples, edge cases -- **[violations.md](./references/violations.md)** - Common violations with detection patterns and fixes diff --git a/plugins/devflow-devlog/skills/docs-framework/references/patterns.md b/plugins/devflow-devlog/skills/docs-framework/references/patterns.md deleted file mode 100644 index 779b03a..0000000 --- a/plugins/devflow-devlog/skills/docs-framework/references/patterns.md +++ /dev/null @@ -1,346 +0,0 @@ -# Documentation Framework Patterns - -Correct patterns for DevFlow documentation artifacts with templates and helper functions. - ---- - -## Directory Structure - -### Standard Layout - -``` -.docs/ -├── reviews/{branch-slug}/ # Code review reports per branch -│ ├── {type}-report.{timestamp}.md -│ └── review-summary.{timestamp}.md -├── design/ # Implementation plans -│ └── {topic-slug}.{timestamp}.md -├── status/ # Development logs -│ ├── {timestamp}.md -│ ├── compact/{timestamp}.md -│ └── INDEX.md -└── CATCH_UP.md # Latest summary (overwritten) -``` - -### Setup Variations - -**Minimal (new project):** -``` -.docs/ -├── status/ -│ └── INDEX.md -└── CATCH_UP.md -``` - -**With Reviews:** -``` -.docs/ -├── reviews/feat-auth/ -│ ├── security-report.2025-01-05_1430.md -│ └── review-summary.2025-01-05_1430.md -├── status/ -│ ├── 2025-01-05_1430.md -│ └── INDEX.md -└── CATCH_UP.md -``` - ---- - -## Naming Conventions - -### Timestamps - -Format: `YYYY-MM-DD_HHMM` (sortable, readable) - -```bash -# Standard format -TIMESTAMP=$(date +%Y-%m-%d_%H%M) -# Output: 2025-01-05_1430 - -# With seconds (only if needed for uniqueness) -TIMESTAMP_SEC=$(date +%Y-%m-%d_%H%M%S) -# Output: 2025-01-05_143025 -``` - -### Branch Slugs - -Replace `/` with `-`, provide fallback for detached HEAD. - -```bash -BRANCH_SLUG=$(git branch --show-current 2>/dev/null | sed 's/\//-/g' || echo "standalone") - -# Examples: -# feature/auth -> feature-auth -# fix/issue-123 -> fix-issue-123 -# hotfix/critical-bug -> hotfix-critical-bug -# release/v2.0.0 -> release-v2.0.0 -# (detached HEAD) -> standalone -``` - -### Topic Slugs - -Lowercase, dashes, alphanumeric only, max 50 chars. - -```bash -TOPIC_SLUG=$(echo "$TOPIC" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | sed 's/[^a-z0-9-]//g' | cut -c1-50) - -# Examples: -# "JWT Authentication" -> jwt-authentication -# "Fix User Login Bug" -> fix-user-login-bug -# "OAuth 2.0 Integration" -> oauth-20-integration -# "Database Migration v2" -> database-migration-v2 -``` - ---- - -## File Naming Rules - -### Special Index Files (UPPERCASE) - -Always UPPERCASE, overwritten or appended: - -- `CATCH_UP.md` - Latest context summary -- `INDEX.md` - Chronological log index -- `KNOWLEDGE_BASE.md` - Searchable debug solutions - -### Artifact Files (lowercase + timestamp) - -Always lowercase with timestamp: - -- `2025-01-05_1430.md` - Status log -- `security-report.2025-01-05_1430.md` - Review report -- `review-summary.2025-01-05_1430.md` - Combined summary -- `jwt-authentication.2025-01-05_1430.md` - Design doc - ---- - -## Helper Functions - -Full implementation for `.devflow/scripts/docs-helpers.sh`: - -```bash -#!/bin/bash -# .devflow/scripts/docs-helpers.sh - -# Get current timestamp in standard format -get_timestamp() { - date +%Y-%m-%d_%H%M -} - -# Get sanitized branch slug -get_branch_slug() { - local branch - branch=$(git branch --show-current 2>/dev/null) - if [ -z "$branch" ]; then - echo "standalone" - else - echo "$branch" | sed 's/\//-/g' - fi -} - -# Convert topic to slug format -get_topic_slug() { - local topic="$1" - echo "$topic" | \ - tr '[:upper:]' '[:lower:]' | \ - tr ' ' '-' | \ - sed 's/[^a-z0-9-]//g' | \ - cut -c1-50 -} - -# Ensure docs directory exists -ensure_docs_dir() { - local subdir="$1" - mkdir -p ".docs/$subdir" -} - -# Get full output path for a document -get_doc_path() { - local subdir="$1" - local filename="$2" - ensure_docs_dir "$subdir" - echo ".docs/$subdir/$filename" -} - -# Create timestamped status log path -get_status_path() { - local timestamp - timestamp=$(get_timestamp) - get_doc_path "status" "${timestamp}.md" -} - -# Create review report path -get_review_path() { - local type="$1" - local branch_slug - local timestamp - branch_slug=$(get_branch_slug) - timestamp=$(get_timestamp) - get_doc_path "reviews/$branch_slug" "${type}-report.${timestamp}.md" -} -``` - -### Usage Example - -```bash -# Source helpers -source .devflow/scripts/docs-helpers.sh 2>/dev/null || { - # Inline fallback if script not found - get_timestamp() { date +%Y-%m-%d_%H%M; } - get_branch_slug() { git branch --show-current 2>/dev/null | sed 's/\//-/g' || echo "standalone"; } - get_topic_slug() { echo "$1" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | sed 's/[^a-z0-9-]//g' | cut -c1-50; } - ensure_docs_dir() { mkdir -p ".docs/$1"; } -} - -# Use helpers -TIMESTAMP=$(get_timestamp) -BRANCH_SLUG=$(get_branch_slug) -ensure_docs_dir "reviews/$BRANCH_SLUG" - -# Create output path -OUTPUT=$(get_review_path "security") -echo "Writing to: $OUTPUT" -``` - ---- - -## Templates - -### Status Log Template - -```markdown -# Status Update - {TIMESTAMP} - -## Session Summary -{Brief 2-3 sentence summary of what was accomplished} - -## Branch Context -- **Branch**: {current_branch} -- **Base**: {base_branch} -- **Status**: {ahead/behind/clean} - -## Work Completed -- {Task 1 completed} -- {Task 2 completed} - -## Files Changed -- `path/to/file1.ts` - {description} -- `path/to/file2.ts` - {description} - -## Decisions Made -- **{Decision}**: {Rationale} - -## Next Steps -- [ ] {Next task 1} -- [ ] {Next task 2} - -## Notes -{Any additional context for future sessions} -``` - -### Debug Session Template - -```markdown -# Debug Session - {DEBUG_SESSION_ID} - -## Problem Statement -**Issue**: {description} -**Reported**: {timestamp} -**Branch**: {branch} - -## Expected vs Actual -**Expected**: {what should happen} -**Actual**: {what's happening} - -## Hypotheses Tested -### Hypothesis 1: {description} -- **Test**: {how tested} -- **Result**: {confirmed/refuted} -- **Evidence**: {what was observed} - -## Root Cause -**Location**: {file:line} -**Issue**: {precise description} -**Why It Happened**: {chain of events} - -## Solution Applied -**Fix**: {description} -**Files Changed**: {list} -**Verification**: {how verified} - -## Prevention -- {How to prevent in future} -``` - -### STATUS INDEX.md Template - -```markdown -# Status Log Index - -| Date | Summary | Branch | -|------|---------|--------| -| [2025-01-05_1430](./2025-01-05_1430.md) | Implemented auth | feat/auth | -| [2025-01-04_0900](./2025-01-04_0900.md) | Fixed login bug | fix/login | -``` - -### DEBUG KNOWLEDGE_BASE.md Template - -```markdown -# Debug Knowledge Base - -Searchable record of debugging sessions and solutions. - ---- - -## Issue: {description} -**Date**: {date} -**Session**: {session_id} -**Category**: {error/performance/test/build} -**Root Cause**: {brief} -**Solution**: {brief} -**Keywords**: {searchable terms} - -[Full details](./debug-{session_id}.md) - ---- -``` - ---- - -## Edge Cases - -### Detached HEAD State - -Branch slug falls back to "standalone": - -```bash -# In detached HEAD -get_branch_slug # Returns: standalone - -# Review path becomes: -# .docs/reviews/standalone/security-report.2025-01-05_1430.md -``` - -### Special Characters in Topics - -All special characters stripped: - -```bash -get_topic_slug "Bug: User can't login (v2.0)" -# Returns: bug-user-cant-login-v20 -``` - -### Long Topic Names - -Truncated at 50 characters: - -```bash -get_topic_slug "This is a very long topic name that exceeds the maximum allowed length" -# Returns: this-is-a-very-long-topic-name-that-exceeds-the-m -``` - ---- - -## Quick Reference - -For violation examples and detection patterns, see [violations.md](violations.md). diff --git a/plugins/devflow-devlog/skills/docs-framework/references/violations.md b/plugins/devflow-devlog/skills/docs-framework/references/violations.md deleted file mode 100644 index 61f34cd..0000000 --- a/plugins/devflow-devlog/skills/docs-framework/references/violations.md +++ /dev/null @@ -1,221 +0,0 @@ -# Documentation Framework Violations - -Common violations of documentation conventions with detection patterns and fixes. - ---- - -## Naming Convention Violations - -| Violation | Bad Example | Correct Example | Fix | -|-----------|-------------|-----------------|-----| -| Wrong timestamp format | `2025-1-5` | `2025-01-05_1430` | Use `YYYY-MM-DD_HHMM` | -| Missing time in timestamp | `2025-01-05` | `2025-01-05_1430` | Include `_HHMM` suffix | -| Unsanitized branch slug | `feature/auth` | `feature-auth` | Replace `/` with `-` | -| Wrong case for artifacts | `Status-2025-01-05.md` | `2025-01-05_1430.md` | Use lowercase except special indexes | -| Wrong case for indexes | `index.md` | `INDEX.md` | UPPERCASE for special indexes | -| Files outside .docs | `docs/status.md` | `.docs/status/2025-01-05_1430.md` | Use `.docs/` root | -| Missing INDEX.md | Status files without index | Create `INDEX.md` | Every directory needs index | -| Special chars in slug | `oauth-2.0-auth` | `oauth-20-auth` | Strip non-alphanumeric except `-` | - ---- - -## Directory Structure Violations - -### Wrong: Flat Structure - -``` -project/ -├── status-2025-01-05.md # Wrong location -├── review-auth.md # Missing timestamp, wrong location -└── catch_up.md # Wrong case, wrong location -``` - -### Wrong: Non-standard Subdirectories - -``` -.docs/ -├── logs/ # Should be status/ -├── reports/ # Should be reviews/{branch-slug}/ -└── notes.md # Should be in appropriate subdir -``` - ---- - -## Timestamp Violations - -### Missing Leading Zeros - -```bash -# WRONG -date +%Y-%m-%d_%H%M # Could produce: 2025-1-5_930 - -# RIGHT -date +%Y-%m-%d_%H%M # Always produces: 2025-01-05_0930 -``` - -Note: `date` command handles padding, but manual timestamps often miss it. - -### Wrong Separator - -```bash -# WRONG -2025-01-05-1430 # Dash instead of underscore -2025/01/05_1430 # Slash in date -2025.01.05_1430 # Dot in date - -# RIGHT -2025-01-05_1430 # Underscore separating date from time -``` - ---- - -## Branch Slug Violations - -### Unhandled Slash - -```bash -# WRONG - produces invalid filename -BRANCH_SLUG=$(git branch --show-current) -# Result: feature/auth -> invalid path - -# RIGHT - sanitizes slash -BRANCH_SLUG=$(git branch --show-current | sed 's/\//-/g') -# Result: feature/auth -> feature-auth -``` - -### Missing Fallback - -```bash -# WRONG - fails in detached HEAD -BRANCH_SLUG=$(git branch --show-current) -# Result: empty string -> broken path - -# RIGHT - with fallback -BRANCH_SLUG=$(git branch --show-current 2>/dev/null | sed 's/\//-/g' || echo "standalone") -# Result: (detached HEAD) -> standalone -``` - ---- - -## Topic Slug Violations - -### Preserving Special Characters - -```bash -# WRONG - special chars break filenames -TOPIC="Bug: Can't login (v2.0)" -SLUG="Bug:-Can't-login-(v2.0)" - -# RIGHT - stripped and normalized -SLUG="bug-cant-login-v20" -``` - -### Not Truncating Long Names - -```bash -# WRONG - excessively long filename -TOPIC="This is a very long topic name that describes the entire feature..." -SLUG="this-is-a-very-long-topic-name-that-describes-the-entire-feature" - -# RIGHT - truncated at 50 chars -SLUG="this-is-a-very-long-topic-name-that-describes-the" -``` - ---- - -## File Type Violations - -### Wrong Case for Special Files - -```bash -# WRONG -catch_up.md # Should be CATCH_UP.md -index.md # Should be INDEX.md -knowledge_base.md # Should be KNOWLEDGE_BASE.md - -# RIGHT - UPPERCASE for special indexes -CATCH_UP.md -INDEX.md -KNOWLEDGE_BASE.md -``` - -### Wrong Case for Artifacts - -```bash -# WRONG -Status-2025-01-05_1430.md -Security-Report.md - -# RIGHT - lowercase for artifacts -2025-01-05_1430.md -security-report.2025-01-05_1430.md -``` - ---- - -## Template Usage Violations - -### Missing Required Sections - -```markdown -# WRONG - missing key sections - -# Status Update - -Did some work today. -``` - -```markdown -# RIGHT - all required sections - -# Status Update - 2025-01-05_1430 - -## Session Summary -Implemented user authentication feature. - -## Branch Context -- **Branch**: feat/auth -- **Base**: main -- **Status**: 3 commits ahead - -## Work Completed -- Added JWT validation middleware -- Created login endpoint - -## Files Changed -- `src/auth/jwt.ts` - JWT utilities -- `src/routes/login.ts` - Login endpoint - -## Next Steps -- [ ] Add refresh token support -- [ ] Write integration tests -``` - ---- - -## Detection Patterns - -Use these patterns to find violations: - -```bash -# Find files outside .docs/ -find . -name "*.md" -path "*/docs/*" ! -path "*/.docs/*" - -# Find wrong timestamp format (missing underscore) -grep -r "^\d{4}-\d{2}-\d{2}[^_]" .docs/ - -# Find lowercase special indexes -ls .docs/**/index.md .docs/**/catch_up.md 2>/dev/null - -# Find uppercase artifacts (excluding special files) -find .docs -name "*.md" | grep -v "INDEX\|CATCH_UP\|KNOWLEDGE_BASE" | xargs -I {} basename {} | grep "^[A-Z]" - -# Find unsanitized branch names in paths -find .docs/reviews -type d -name "*/*" 2>/dev/null -``` - ---- - -## Quick Reference - -For correct patterns and templates, see [patterns.md](patterns.md). diff --git a/scripts/build-plugins.ts b/scripts/build-plugins.ts index b94c15e..8ae82b0 100644 --- a/scripts/build-plugins.ts +++ b/scripts/build-plugins.ts @@ -14,8 +14,9 @@ import * as fs from "fs"; import * as path from "path"; +import { fileURLToPath } from "url"; -const ROOT = path.resolve(import.meta.dirname, ".."); +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const SHARED_SKILLS = path.join(ROOT, "shared", "skills"); const SHARED_AGENTS = path.join(ROOT, "shared", "agents"); const PLUGINS_DIR = path.join(ROOT, "plugins"); diff --git a/scripts/statusline.sh b/scripts/statusline.sh index 979bbc8..ccf240d 100755 --- a/scripts/statusline.sh +++ b/scripts/statusline.sh @@ -25,14 +25,17 @@ fi # Get current directory name DIR_NAME=$(basename "$CWD") +# Change to working directory once for all git commands +cd "$CWD" 2>/dev/null || true + # Get git branch if in a git repo -GIT_BRANCH=$(cd "$CWD" 2>/dev/null && git branch --show-current 2>/dev/null || echo "") +GIT_BRANCH=$(git branch --show-current 2>/dev/null || echo "") if [ -z "$GIT_BRANCH" ]; then GIT_INFO="" DIFF_STATS="" else # Dirty indicator based on uncommitted changes - if [ -n "$(cd "$CWD" 2>/dev/null && git status --porcelain 2>/dev/null)" ]; then + if [ -n "$(git status --porcelain 2>/dev/null)" ]; then GIT_INFO=" \033[33m$GIT_BRANCH*\033[0m" else GIT_INFO=" \033[32m$GIT_BRANCH\033[0m" @@ -40,31 +43,29 @@ else # Determine base branch and compute branch-level stats BASE_BRANCH="" - cd "$CWD" 2>/dev/null && { - git rev-parse --verify main &>/dev/null && BASE_BRANCH="main" - [ -z "$BASE_BRANCH" ] && git rev-parse --verify master &>/dev/null && BASE_BRANCH="master" - } + git rev-parse --verify main &>/dev/null && BASE_BRANCH="main" + [ -z "$BASE_BRANCH" ] && git rev-parse --verify master &>/dev/null && BASE_BRANCH="master" BRANCH_STATS="" if [ -n "$BASE_BRANCH" ] && [ "$GIT_BRANCH" != "$BASE_BRANCH" ]; then # Total commits on branch (local + remote, since fork from base) - TOTAL_COMMITS=$(cd "$CWD" 2>/dev/null && git rev-list --count "$BASE_BRANCH"..HEAD 2>/dev/null || echo "0") + TOTAL_COMMITS=$(git rev-list --count "$BASE_BRANCH"..HEAD 2>/dev/null || echo "0") [ "$TOTAL_COMMITS" -gt 0 ] 2>/dev/null && BRANCH_STATS=" ${TOTAL_COMMITS}↑" # Unpushed commits (local-only, ahead of remote tracking branch) - UPSTREAM=$(cd "$CWD" 2>/dev/null && git rev-parse --abbrev-ref '@{upstream}' 2>/dev/null) + UPSTREAM=$(git rev-parse --abbrev-ref '@{upstream}' 2>/dev/null) if [ -n "$UPSTREAM" ]; then - UNPUSHED=$(cd "$CWD" 2>/dev/null && git rev-list --count "$UPSTREAM"..HEAD 2>/dev/null || echo "0") + UNPUSHED=$(git rev-list --count "$UPSTREAM"..HEAD 2>/dev/null || echo "0") [ "$UNPUSHED" -gt 0 ] 2>/dev/null && BRANCH_STATS="$BRANCH_STATS \033[33m${UNPUSHED}⇡\033[0m" elif [ "$TOTAL_COMMITS" -gt 0 ] 2>/dev/null; then # No upstream at all — everything is unpushed BRANCH_STATS="$BRANCH_STATS \033[33m${TOTAL_COMMITS}⇡\033[0m" fi - MERGE_BASE=$(cd "$CWD" 2>/dev/null && git merge-base "$BASE_BRANCH" HEAD 2>/dev/null) - DIFF_OUTPUT=$(cd "$CWD" 2>/dev/null && git diff --shortstat "$MERGE_BASE" 2>/dev/null) + MERGE_BASE=$(git merge-base "$BASE_BRANCH" HEAD 2>/dev/null) + DIFF_OUTPUT=$(git diff --shortstat "$MERGE_BASE" 2>/dev/null) else - DIFF_OUTPUT=$(cd "$CWD" 2>/dev/null && git diff --shortstat HEAD 2>/dev/null) + DIFF_OUTPUT=$(git diff --shortstat HEAD 2>/dev/null) fi if [ -n "$DIFF_OUTPUT" ]; then diff --git a/shared/skills/docs-framework/SKILL.md b/shared/skills/docs-framework/SKILL.md index b878559..aac85f3 100644 --- a/shared/skills/docs-framework/SKILL.md +++ b/shared/skills/docs-framework/SKILL.md @@ -35,7 +35,7 @@ All generated documentation lives under `.docs/` in the project root: ├── swarm/ # Swarm operation state │ ├── state.json │ └── plans/ -└── CATCH_UP.md # Latest summary (overwritten) +└── WORKING-MEMORY.md # Auto-maintained by Stop hook (overwritten) ``` --- @@ -64,7 +64,7 @@ TOPIC_SLUG=$(echo "$TOPIC" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | sed 's/[^ | Type | Pattern | Example | |------|---------|---------| -| Special indexes | `UPPERCASE.md` | `CATCH_UP.md`, `INDEX.md` | +| Special indexes | `UPPERCASE.md` | `WORKING-MEMORY.md`, `INDEX.md` | | Reports | `{type}-report.{timestamp}.md` | `security-report.2025-12-26_1430.md` | | Status logs | `{timestamp}.md` | `2025-12-26_1430.md` | @@ -91,9 +91,8 @@ source .devflow/scripts/docs-helpers.sh 2>/dev/null || { | Agent | Output Location | Behavior | |-------|-----------------|----------| -| CatchUp | `.docs/CATCH_UP.md` | Overwrites (latest summary) | -| Devlog | `.docs/status/{timestamp}.md` | Creates new + updates `INDEX.md` | | Reviewer | `.docs/reviews/{branch-slug}/{type}-report.{timestamp}.md` | Creates new | +| Working Memory | `.docs/WORKING-MEMORY.md` | Overwrites (auto-maintained by Stop hook) | ### Agents That Don't Persist @@ -120,9 +119,8 @@ When creating or modifying persisting agents: ## Integration This framework is used by: -- **Devlog**: Creates status logs -- **CatchUp**: Reads status logs, creates summary - **Review agents**: Creates review reports +- **Working Memory hooks**: Auto-maintains `.docs/WORKING-MEMORY.md` All persisting agents should load this skill to ensure consistent documentation. diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index a58dcb5..65a24d9 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -3,96 +3,96 @@ import { promises as fs } from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; -import { execSync } from 'child_process'; import * as p from '@clack/prompts'; import color from 'picocolors'; import { getInstallationPaths } from '../utils/paths.js'; import { getGitRoot } from '../utils/git.js'; -import { DEVFLOW_PLUGINS, buildAssetMaps } from '../plugins.js'; +import { isClaudeCliAvailable } from '../utils/cli.js'; +import { installViaCli, installViaFileCopy } from '../utils/installer.js'; +import { + installSettings, + installClaudeMd, + installClaudeignore, + updateGitignore, + createDocsStructure, +} from '../utils/post-install.js'; +import { DEVFLOW_PLUGINS, buildAssetMaps, type PluginDefinition } from '../plugins.js'; +import { detectPlatform, detectShell, getProfilePath, getSafeDeleteInfo, hasSafeDelete } from '../utils/safe-delete.js'; +import { generateSafeDeleteBlock, isAlreadyInstalled, installToProfile } from '../utils/safe-delete-install.js'; + +// Re-export pure functions for tests (canonical source is post-install.ts) +export { substituteSettingsTemplate, computeGitignoreAppend } from '../utils/post-install.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); /** - * Type guard for Node.js system errors with error codes + * Parse a comma-separated plugin selection string into normalized plugin names. + * Validates against known plugins; returns invalid names as errors. */ -interface NodeSystemError extends Error { - code: string; -} +export function parsePluginSelection( + input: string, + validPlugins: PluginDefinition[], +): { selected: string[]; invalid: string[] } { + const selected = input.split(',').map(p => { + const trimmed = p.trim(); + return trimmed.startsWith('devflow-') ? trimmed : `devflow-${trimmed}`; + }); -function isNodeSystemError(error: unknown): error is NodeSystemError { - return ( - error instanceof Error && - 'code' in error && - typeof (error as NodeSystemError).code === 'string' - ); + const validNames = validPlugins.map(p => p.name); + const invalid = selected.filter(p => !validNames.includes(p)); + return { selected, invalid }; } -/** - * Options for the init command parsed by Commander.js - */ -interface InitOptions { - skipDocs?: boolean; - scope?: string; - verbose?: boolean; - overrideSettings?: boolean; - plugin?: string; - list?: boolean; +export type ExtraId = 'settings' | 'claude-md' | 'claudeignore' | 'gitignore' | 'docs' | 'safe-delete'; + +interface ExtraOption { + value: ExtraId; + label: string; + hint: string; } /** - * Check if Claude CLI is available in the system PATH + * Build the list of configuration extras available for the given scope/git context. + * Pure function — no I/O, no side effects. */ -function isClaudeCliAvailable(): boolean { - try { - execSync('claude --version', { stdio: 'ignore' }); - return true; - } catch { - return false; +export function buildExtrasOptions(scope: 'user' | 'local', gitRoot: string | null): ExtraOption[] { + const options: ExtraOption[] = [ + { value: 'settings', label: 'Settings & Working Memory', hint: 'Model defaults, session memory hooks, status line, security deny list' }, + { value: 'claude-md', label: 'CLAUDE.md quality enforcer', hint: 'Strict code critic role + engineering pattern references' }, + ]; + + if (gitRoot) { + options.push({ value: 'claudeignore', label: '.claudeignore', hint: 'Exclude secrets, deps, build artifacts from Claude context' }); } -} -/** - * Add DevFlow marketplace to Claude CLI - * @returns true if successful, false otherwise - */ -function addMarketplaceViaCli(): boolean { - try { - // Marketplace add is idempotent - safe to call multiple times - execSync('claude plugin marketplace add dean0x/devflow', { stdio: 'pipe' }); - return true; - } catch { - return false; + if (scope === 'local' && gitRoot) { + options.push({ value: 'gitignore', label: '.gitignore entries', hint: 'Add .claude/ and .devflow/ to .gitignore' }); } + + if (scope === 'local') { + options.push({ value: 'docs', label: '.docs/ directory', hint: 'Review reports, dev logs, status tracking for this project' }); + } + + options.push({ value: 'safe-delete', label: 'Safe-delete (rm → trash)', hint: 'Override rm to use trash CLI — prevents accidental deletion' }); + + return options; } /** - * Install a plugin via Claude CLI - * @param pluginName - Name of the plugin to install (e.g., 'devflow-implement') - * @param scope - Installation scope: 'user' or 'local' - * @returns true if successful, false otherwise + * Options for the init command parsed by Commander.js */ -function installPluginViaCli(pluginName: string, scope: 'user' | 'local'): boolean { - try { - // Claude CLI uses 'project' for local scope - const cliScope = scope === 'local' ? 'project' : 'user'; - execSync(`claude plugin install ${pluginName}@dean0x-devflow --scope ${cliScope}`, { - stdio: 'pipe', - }); - return true; - } catch { - return false; - } +interface InitOptions { + scope?: string; + verbose?: boolean; + plugin?: string; } export const initCommand = new Command('init') .description('Initialize DevFlow for Claude Code') - .option('--skip-docs', 'Skip creating .docs/ structure') .option('--scope ', 'Installation scope: user or local (project-only)', /^(user|local)$/i) .option('--verbose', 'Show detailed installation output') - .option('--override-settings', 'Override existing settings.json with DevFlow configuration') .option('--plugin ', 'Install specific plugin(s), comma-separated (e.g., implement,review)') - .option('--list', 'List available plugins') .action(async (options: InitOptions) => { // Get package version const packageJsonPath = path.resolve(__dirname, '../../package.json'); @@ -109,41 +109,6 @@ export const initCommand = new Command('init') // Start the CLI flow p.intro(color.bgCyan(color.black(` DevFlow v${version} `))); - // Handle --list option - if (options.list) { - const maxNameLen = Math.max(...DEVFLOW_PLUGINS.map(p => p.name.length)); - const pluginList = DEVFLOW_PLUGINS - .map(plugin => { - const cmds = plugin.commands.length > 0 ? plugin.commands.join(', ') : '(skills only)'; - const optionalTag = plugin.optional ? color.dim(' (optional)') : ''; - return `${color.cyan(plugin.name.padEnd(maxNameLen + 2))}${color.dim(plugin.description)}${optionalTag}\n${' '.repeat(maxNameLen + 2)}${color.yellow(cmds)}`; - }) - .join('\n\n'); - - p.note(pluginList, 'Available plugins'); - p.outro(color.dim('Install with: npx devflow-kit init --plugin=')); - return; - } - - // Parse plugin selection - let selectedPlugins: string[] = []; - if (options.plugin) { - selectedPlugins = options.plugin.split(',').map(p => { - const trimmed = p.trim(); - // Allow shorthand (e.g., "implement" -> "devflow-implement") - return trimmed.startsWith('devflow-') ? trimmed : `devflow-${trimmed}`; - }); - - // Validate plugin names - const validNames = DEVFLOW_PLUGINS.map(p => p.name); - const invalidPlugins = selectedPlugins.filter(p => !validNames.includes(p)); - if (invalidPlugins.length > 0) { - p.log.error(`Unknown plugin(s): ${invalidPlugins.join(', ')}`); - p.log.info(`Valid plugins: ${validNames.join(', ')}`); - process.exit(1); - } - } - // Determine installation scope let scope: 'user' | 'local' = 'user'; @@ -155,11 +120,9 @@ export const initCommand = new Command('init') } scope = normalizedScope; } else if (!process.stdin.isTTY) { - // Non-interactive environment (CI/CD, scripts) - use default p.log.info('Non-interactive mode detected, using scope: user'); scope = 'user'; } else { - // Interactive prompt for scope const selected = await p.select({ message: 'Installation scope', options: [ @@ -176,6 +139,49 @@ export const initCommand = new Command('init') scope = selected as 'user' | 'local'; } + // Select plugins to install + let selectedPlugins: string[] = []; + if (options.plugin) { + const { selected, invalid } = parsePluginSelection(options.plugin, DEVFLOW_PLUGINS); + selectedPlugins = selected; + + if (invalid.length > 0) { + p.log.error(`Unknown plugin(s): ${invalid.join(', ')}`); + p.log.info(`Valid plugins: ${DEVFLOW_PLUGINS.map(pl => pl.name).join(', ')}`); + process.exit(1); + } + } else if (process.stdin.isTTY) { + const choices = DEVFLOW_PLUGINS + .filter(pl => pl.name !== 'devflow-core-skills') + .map(pl => ({ + value: pl.name, + label: pl.name.replace('devflow-', ''), + hint: pl.description + (pl.optional ? ' (optional)' : ''), + })); + + const preSelected = DEVFLOW_PLUGINS + .filter(pl => !pl.optional && pl.name !== 'devflow-core-skills') + .map(pl => pl.name); + + const pluginSelection = await p.multiselect({ + message: 'Select plugins to install', + options: choices, + initialValues: preSelected, + required: true, + }); + + if (p.isCancel(pluginSelection)) { + p.cancel('Installation cancelled.'); + process.exit(0); + } + + selectedPlugins = pluginSelection as string[]; + } + + // Start spinner immediately after prompts — covers path resolution + git detection + const s = p.spinner(); + s.start('Resolving paths'); + // Get installation paths let claudeDir: string; let devflowDir: string; @@ -185,17 +191,16 @@ export const initCommand = new Command('init') const paths = await getInstallationPaths(scope); claudeDir = paths.claudeDir; devflowDir = paths.devflowDir; - gitRoot = await getGitRoot(); + gitRoot = paths.gitRoot ?? await getGitRoot(); } catch (error) { + s.stop('Path resolution failed'); p.log.error(`Path configuration error: ${error instanceof Error ? error.message : error}`); process.exit(1); } - // Start spinner for installation - const s = p.spinner(); - s.start('Installing components'); + // Validate target directory + s.message('Validating target directory'); - // For local scope, ensure .claude directory exists if (scope === 'local') { try { await fs.mkdir(claudeDir, { recursive: true }); @@ -205,7 +210,6 @@ export const initCommand = new Command('init') process.exit(1); } } else { - // For user scope, check Claude Code exists try { await fs.access(claudeDir); } catch { @@ -216,163 +220,43 @@ export const initCommand = new Command('init') } } - // Get the root directory of the devflow package + // Resolve plugins and deduplication maps + s.message('Installing components'); const rootDir = path.resolve(__dirname, '../..'); const pluginsDir = path.join(rootDir, 'plugins'); - // Determine which plugins to install (exclude optional plugins from default install) let pluginsToInstall = selectedPlugins.length > 0 ? DEVFLOW_PLUGINS.filter(p => selectedPlugins.includes(p.name)) : DEVFLOW_PLUGINS.filter(p => !p.optional); - // Auto-include core-skills when any DevFlow plugin is selected const coreSkillsPlugin = DEVFLOW_PLUGINS.find(p => p.name === 'devflow-core-skills'); if (pluginsToInstall.length > 0 && coreSkillsPlugin && !pluginsToInstall.includes(coreSkillsPlugin)) { pluginsToInstall = [coreSkillsPlugin, ...pluginsToInstall]; } - // Build deduplication maps (each asset copied from first plugin that declares it) const { skillsMap, agentsMap } = buildAssetMaps(pluginsToInstall); - // Try native Claude CLI installation first, fall back to manual copy + // Install: try native CLI first, fall back to file copy const cliAvailable = isClaudeCliAvailable(); - let usedNativeCli = false; + const usedNativeCli = cliAvailable && installViaCli(pluginsToInstall, scope, s); - if (cliAvailable) { - s.message('Adding DevFlow marketplace...'); - const marketplaceAdded = addMarketplaceViaCli(); - - if (marketplaceAdded) { - s.message('Installing plugins via Claude CLI...'); - let allInstalled = true; - - for (const plugin of pluginsToInstall) { - const installed = installPluginViaCli(plugin.name, scope); - if (!installed) { - allInstalled = false; - break; - } - } - - if (allInstalled) { - usedNativeCli = true; - s.stop('Plugins installed via Claude CLI'); - } - } - } - - // Fall back to manual installation if CLI failed or unavailable if (!usedNativeCli) { if (cliAvailable && verbose) { p.log.warn('Claude CLI installation failed, falling back to manual copy'); } try { - // Clean old DevFlow files before installing (only for full install) - if (selectedPlugins.length === 0) { - // Remove old monolithic structure if present - const oldDirs = [ - path.join(claudeDir, 'commands', 'devflow'), - path.join(claudeDir, 'agents', 'devflow'), - ]; - for (const dir of oldDirs) { - try { - await fs.rm(dir, { recursive: true, force: true }); - } catch { /* ignore */ } - } - - // Clean old skill directories that will be replaced - const allSkills = new Set(); - for (const plugin of DEVFLOW_PLUGINS) { - for (const skill of plugin.skills) { - allSkills.add(skill); - } - } - for (const skill of allSkills) { - try { - await fs.rm(path.join(claudeDir, 'skills', skill), { recursive: true, force: true }); - } catch { /* ignore */ } - } - } - - // Install each selected plugin (with deduplication) - const installedCommands: string[] = []; - const installedSkills = new Set(); - const installedAgents = new Set(); - - for (const plugin of pluginsToInstall) { - const pluginSourceDir = path.join(pluginsDir, plugin.name); - - // Install commands - const commandsSource = path.join(pluginSourceDir, 'commands'); - const commandsTarget = path.join(claudeDir, 'commands', 'devflow'); - try { - const files = await fs.readdir(commandsSource); - if (files.length > 0) { - await fs.mkdir(commandsTarget, { recursive: true }); - for (const file of files) { - await fs.copyFile( - path.join(commandsSource, file), - path.join(commandsTarget, file) - ); - } - installedCommands.push(...plugin.commands); - } - } catch { /* no commands directory */ } - - // Install agents (deduplicated - only copy if this plugin is the source) - const agentsSource = path.join(pluginSourceDir, 'agents'); - const agentsTarget = path.join(claudeDir, 'agents', 'devflow'); - try { - const files = await fs.readdir(agentsSource); - if (files.length > 0) { - await fs.mkdir(agentsTarget, { recursive: true }); - for (const file of files) { - const agentName = path.basename(file, '.md'); - // Only copy if this plugin is the source for this agent (deduplication) - if (agentsMap.get(agentName) === plugin.name) { - await fs.copyFile( - path.join(agentsSource, file), - path.join(agentsTarget, file) - ); - installedAgents.add(agentName); - } - } - } - } catch { /* no agents directory */ } - - // Install skills (deduplicated - only copy if this plugin is the source) - const skillsSource = path.join(pluginSourceDir, 'skills'); - try { - const skillDirs = await fs.readdir(skillsSource, { withFileTypes: true }); - for (const skillDir of skillDirs) { - if (skillDir.isDirectory()) { - // Only copy if this plugin is the source for this skill (deduplication) - if (skillsMap.get(skillDir.name) === plugin.name) { - const skillTarget = path.join(claudeDir, 'skills', skillDir.name); - await copyDirectory( - path.join(skillsSource, skillDir.name), - skillTarget - ); - installedSkills.add(skillDir.name); - } - } - } - } catch { /* no skills directory */ } - } - - // Install scripts (always from root scripts/ directory) - const scriptsSource = path.join(rootDir, 'scripts'); - const scriptsTarget = path.join(devflowDir, 'scripts'); - try { - await fs.mkdir(scriptsTarget, { recursive: true }); - await copyDirectory(scriptsSource, scriptsTarget); - - // Make all scripts executable (recursive — handles subdirectories like hooks/) - await chmodRecursive(scriptsTarget, 0o755); - } catch { /* scripts may not exist */ } - - s.stop('Components installed via file copy'); + await installViaFileCopy({ + plugins: pluginsToInstall, + claudeDir, + pluginsDir, + rootDir, + devflowDir, + skillsMap, + agentsMap, + selectedPluginNames: selectedPlugins, + spinner: s, + }); } catch (error) { s.stop('Installation failed'); p.log.error(`${error}`); @@ -380,167 +264,64 @@ export const initCommand = new Command('init') } } - // === EXTRAS: Things plugins can't handle === + s.stop('Plugins installed'); - // 1. Install settings.json - const settingsPath = path.join(claudeDir, 'settings.json'); - const sourceSettingsPath = path.join(rootDir, 'src', 'templates', 'settings.json'); - const overrideSettings = options.overrideSettings ?? false; + // === Configuration extras === + const extrasOptions = buildExtrasOptions(scope, gitRoot); + let selectedExtras: ExtraId[]; - try { - const settingsTemplate = await fs.readFile(sourceSettingsPath, 'utf-8'); - const settingsContent = settingsTemplate.replace(/\$\{DEVFLOW_DIR\}/g, devflowDir); + if (process.stdin.isTTY) { + const extrasSelection = await p.multiselect({ + message: 'Configure extras', + options: extrasOptions, + initialValues: extrasOptions.map(o => o.value), + required: false, + }); - let settingsExists = false; - try { - await fs.access(settingsPath); - settingsExists = true; - } catch { - settingsExists = false; + if (p.isCancel(extrasSelection)) { + p.cancel('Installation cancelled.'); + process.exit(0); } - if (settingsExists && overrideSettings) { - if (process.stdin.isTTY) { - const confirmed = await p.confirm({ - message: 'settings.json exists. Override with DevFlow settings?', - initialValue: false, - }); - - if (p.isCancel(confirmed)) { - p.cancel('Installation cancelled.'); - process.exit(0); - } + selectedExtras = extrasSelection as ExtraId[]; + } else { + selectedExtras = extrasOptions.map(o => o.value); + } - if (confirmed) { - await fs.writeFile(settingsPath, settingsContent, 'utf-8'); - p.log.success('Settings overridden'); - } else { - p.log.info('Keeping existing settings'); - } - } else { - await fs.writeFile(settingsPath, settingsContent, 'utf-8'); - p.log.success('Settings overridden'); - } - } else if (settingsExists) { - // Check if existing settings have hooks configured - try { - const existingSettings = JSON.parse(await fs.readFile(settingsPath, 'utf-8')); - if (!existingSettings.hooks) { - p.log.warn('Settings exist without hooks. Working Memory requires hooks.'); - p.log.info('Run with --override-settings to enable, or manually add hooks to settings.json'); - } - } catch { /* ignore parse errors */ } - p.log.info('Settings exist - use --override-settings to replace'); - } else { - await fs.writeFile(settingsPath, settingsContent, 'utf-8'); - if (verbose) { - p.log.success('Settings configured'); - } - } - } catch (error: unknown) { - if (verbose) { - p.log.warn(`Could not configure settings: ${error}`); - } + // Settings may trigger its own TTY sub-prompt — run outside spinner + if (selectedExtras.includes('settings')) { + await installSettings(claudeDir, rootDir, devflowDir, verbose); } - // 2. Install CLAUDE.md - const claudeMdPath = path.join(claudeDir, 'CLAUDE.md'); - const sourceClaudeMdPath = path.join(rootDir, 'src', 'claude', 'CLAUDE.md'); + const fileExtras = selectedExtras.filter(e => e !== 'settings' && e !== 'safe-delete'); + if (fileExtras.length > 0) { + const sExtras = p.spinner(); + sExtras.start('Configuring extras'); - try { - const content = await fs.readFile(sourceClaudeMdPath, 'utf-8'); - await fs.writeFile(claudeMdPath, content, { encoding: 'utf-8', flag: 'wx' }); - if (verbose) { - p.log.success('CLAUDE.md configured'); + if (selectedExtras.includes('claude-md')) { + await installClaudeMd(claudeDir, rootDir, verbose); } - } catch (error: unknown) { - if (isNodeSystemError(error) && error.code === 'EEXIST') { - p.log.info('CLAUDE.md exists - keeping your configuration'); + if (selectedExtras.includes('claudeignore') && gitRoot) { + await installClaudeignore(gitRoot, rootDir, verbose); } - } - - // 3. Create .claudeignore in git repository root - if (gitRoot) { - const claudeignorePath = path.join(gitRoot, '.claudeignore'); - const claudeignoreTemplatePath = path.join(rootDir, 'src', 'templates', 'claudeignore.template'); - - try { - const claudeignoreContent = await fs.readFile(claudeignoreTemplatePath, 'utf-8'); - await fs.writeFile(claudeignorePath, claudeignoreContent, { encoding: 'utf-8', flag: 'wx' }); - if (verbose) { - p.log.success('.claudeignore created'); - } - } catch (error: unknown) { - if (isNodeSystemError(error) && error.code === 'EEXIST') { - // Already exists, skip silently - } else if (verbose) { - p.log.warn(`Could not create .claudeignore: ${error}`); - } + if (selectedExtras.includes('gitignore') && gitRoot) { + await updateGitignore(gitRoot, verbose); } - } - - // 4. For local scope, update .gitignore - if (scope === 'local' && gitRoot) { - try { - const gitignorePath = path.join(gitRoot, '.gitignore'); - const entriesToAdd = ['.claude/', '.devflow/']; - - let gitignoreContent = ''; - try { - gitignoreContent = await fs.readFile(gitignorePath, 'utf-8'); - } catch { /* doesn't exist */ } - - const linesToAdd: string[] = []; - for (const entry of entriesToAdd) { - if (!gitignoreContent.split('\n').some(line => line.trim() === entry)) { - linesToAdd.push(entry); - } - } - - if (linesToAdd.length > 0) { - const newContent = gitignoreContent - ? `${gitignoreContent.trimEnd()}\n\n# DevFlow local installation\n${linesToAdd.join('\n')}\n` - : `# DevFlow local installation\n${linesToAdd.join('\n')}\n`; - - await fs.writeFile(gitignorePath, newContent, 'utf-8'); - if (verbose) { - p.log.success('.gitignore updated'); - } - } - } catch (error) { - if (verbose) { - p.log.warn(`Could not update .gitignore: ${error instanceof Error ? error.message : error}`); - } + if (selectedExtras.includes('docs')) { + await createDocsStructure(verbose); } - } - - // 5. Create .docs/ structure - if (!options.skipDocs) { - const docsDir = path.join(process.cwd(), '.docs'); - try { - await fs.mkdir(path.join(docsDir, 'status', 'compact'), { recursive: true }); - await fs.mkdir(path.join(docsDir, 'reviews'), { recursive: true }); - await fs.mkdir(path.join(docsDir, 'releases'), { recursive: true }); - if (verbose) { - p.log.success('.docs/ structure ready'); - } - } catch { /* may already exist */ } + sExtras.stop('Extras configured'); } - // Show installation method + // Summary output if (usedNativeCli) { p.log.success('Installed via Claude plugin system'); - } else { - if (!cliAvailable) { - p.log.info('Installed via file copy (Claude CLI not available)'); - } + } else if (!cliAvailable) { + p.log.info('Installed via file copy (Claude CLI not available)'); } - // Show installed plugins and commands (match what was actually installed) - const pluginsToShow = pluginsToInstall; - - const installedCommands = pluginsToShow.flatMap(p => p.commands).filter(c => c.length > 0); + const installedCommands = pluginsToInstall.flatMap(p => p.commands).filter(c => c.length > 0); if (installedCommands.length > 0) { const commandsNote = installedCommands .map(cmd => color.cyan(cmd)) @@ -548,9 +329,52 @@ export const initCommand = new Command('init') p.note(commandsNote, 'Available commands'); } + // Safe-delete auto-install (gated by extras selection) + if (selectedExtras.includes('safe-delete')) { + const platform = detectPlatform(); + const shell = detectShell(); + const safeDeleteInfo = getSafeDeleteInfo(platform); + const safeDeleteAvailable = hasSafeDelete(platform); + const profilePath = getProfilePath(shell); + + if (process.stdin.isTTY && profilePath) { + if (!safeDeleteAvailable && safeDeleteInfo.installHint) { + p.log.info(`Install ${color.cyan(safeDeleteInfo.command ?? 'trash')} first: ${color.dim(safeDeleteInfo.installHint)}`); + p.log.info(`Then re-run ${color.cyan('devflow init')} to auto-configure safe-delete.`); + } else if (safeDeleteAvailable) { + const alreadyInstalled = await isAlreadyInstalled(profilePath); + if (alreadyInstalled) { + p.log.info(`Safe-delete already configured in ${color.dim(profilePath)}`); + } else { + const trashCmd = safeDeleteInfo.command; + const block = generateSafeDeleteBlock(shell, process.platform, trashCmd); + + if (block) { + const confirm = await p.confirm({ + message: `Install safe-delete to ${profilePath}? (overrides rm to use ${trashCmd ?? 'recycle bin'})`, + initialValue: true, + }); + + if (!p.isCancel(confirm) && confirm) { + await installToProfile(profilePath, block); + p.log.success(`Safe-delete installed to ${color.dim(profilePath)}`); + p.log.info('Restart your shell or run: ' + color.cyan(`source ${profilePath}`)); + } + } + } + } + } else if (!process.stdin.isTTY) { + if (safeDeleteAvailable && safeDeleteInfo.command) { + p.log.info(`Safe-delete available (${safeDeleteInfo.command}). Run interactively to auto-install.`); + } else if (safeDeleteInfo.installHint) { + p.log.info(`Protect against accidental ${color.red('rm -rf')}: ${color.cyan(safeDeleteInfo.installHint)}`); + } + } + } + // Verbose mode: show details if (verbose) { - const pluginsList = pluginsToShow + const pluginsList = pluginsToInstall .map(plugin => `${color.yellow(plugin.name.padEnd(24))}${color.dim(plugin.description)}`) .join('\n'); @@ -560,7 +384,6 @@ export const initCommand = new Command('init') p.log.info(`Claude dir: ${claudeDir}`); p.log.info(`DevFlow dir: ${devflowDir}`); - // Show deduplication stats const totalSkillDeclarations = pluginsToInstall.reduce((sum, p) => sum + p.skills.length, 0); const totalAgentDeclarations = pluginsToInstall.reduce((sum, p) => sum + p.agents.length, 0); p.log.info(`Deduplication: ${skillsMap.size} unique skills (from ${totalSkillDeclarations} declarations)`); @@ -569,34 +392,3 @@ export const initCommand = new Command('init') p.outro(color.green('Ready! Run any command in Claude Code to get started.')); }); - -/** - * Recursively chmod all files in a directory tree. - */ -async function chmodRecursive(dir: string, mode: number): Promise { - const entries = await fs.readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - await chmodRecursive(fullPath, mode); - } else if (entry.isFile()) { - await fs.chmod(fullPath, mode); - } - } -} - -async function copyDirectory(src: string, dest: string): Promise { - await fs.mkdir(dest, { recursive: true }); - const entries = await fs.readdir(src, { withFileTypes: true }); - - for (const entry of entries) { - const srcPath = path.join(src, entry.name); - const destPath = path.join(dest, entry.name); - - if (entry.isDirectory()) { - await copyDirectory(srcPath, destPath); - } else { - await fs.copyFile(srcPath, destPath); - } - } -} diff --git a/src/cli/commands/uninstall.ts b/src/cli/commands/uninstall.ts index 1edff8c..70f1c0c 100644 --- a/src/cli/commands/uninstall.ts +++ b/src/cli/commands/uninstall.ts @@ -2,20 +2,48 @@ import { Command } from 'commander'; import { promises as fs } from 'fs'; import * as path from 'path'; import { execSync } from 'child_process'; +import * as p from '@clack/prompts'; +import color from 'picocolors'; import { getInstallationPaths, getClaudeDirectory } from '../utils/paths.js'; import { getGitRoot } from '../utils/git.js'; -import { DEVFLOW_PLUGINS, getAllSkillNames, LEGACY_SKILL_NAMES } from '../plugins.js'; +import { isClaudeCliAvailable } from '../utils/cli.js'; +import { DEVFLOW_PLUGINS, getAllSkillNames, LEGACY_SKILL_NAMES, type PluginDefinition } from '../plugins.js'; +import { detectShell, getProfilePath } from '../utils/safe-delete.js'; +import { isAlreadyInstalled, removeFromProfile } from '../utils/safe-delete-install.js'; /** - * Check if Claude CLI is available + * Compute which assets should be removed during selective plugin uninstall. + * Skills and agents shared by remaining plugins are retained. */ -function isClaudeCliAvailable(): boolean { - try { - execSync('claude --version', { stdio: 'ignore' }); - return true; - } catch { - return false; +export function computeAssetsToRemove( + selectedPlugins: PluginDefinition[], + allPlugins: PluginDefinition[], +): { skills: string[]; agents: string[]; commands: string[] } { + const selectedNames = new Set(selectedPlugins.map(p => p.name)); + const remainingPlugins = allPlugins.filter(p => !selectedNames.has(p.name)); + + const retainedSkills = new Set(); + const retainedAgents = new Set(); + for (const rp of remainingPlugins) { + for (const s of rp.skills) retainedSkills.add(s); + for (const a of rp.agents) retainedAgents.add(a); + } + + const skills: string[] = []; + const agents: string[] = []; + const commands: string[] = []; + + for (const plugin of selectedPlugins) { + for (const skill of plugin.skills) { + if (!retainedSkills.has(skill)) skills.push(skill); + } + for (const agent of plugin.agents) { + if (!retainedAgents.has(agent)) agents.push(agent); + } + commands.push(...plugin.commands); } + + return { skills, agents, commands }; } /** @@ -50,23 +78,23 @@ export const uninstallCommand = new Command('uninstall') .option('--plugin ', 'Uninstall specific plugin(s), comma-separated (e.g., implement,review)') .option('--verbose', 'Show detailed uninstall output') .action(async (options) => { - console.log('🧹 Uninstalling DevFlow...\n'); + p.intro(color.bgRed(color.white(' Uninstalling DevFlow '))); const verbose = options.verbose ?? false; // Parse plugin selection let selectedPluginNames: string[] = []; if (options.plugin) { - selectedPluginNames = options.plugin.split(',').map((p: string) => { - const trimmed = p.trim(); + selectedPluginNames = options.plugin.split(',').map((s: string) => { + const trimmed = s.trim(); return trimmed.startsWith('devflow-') ? trimmed : `devflow-${trimmed}`; }); const validNames = DEVFLOW_PLUGINS.map(p => p.name); - const invalidPlugins = selectedPluginNames.filter(p => !validNames.includes(p)); + const invalidPlugins = selectedPluginNames.filter(n => !validNames.includes(n)); if (invalidPlugins.length > 0) { - console.log(`❌ Unknown plugin(s): ${invalidPlugins.join(', ')}`); - console.log(` Valid plugins: ${validNames.join(', ')}`); + p.log.error(`Unknown plugin(s): ${invalidPlugins.join(', ')}`); + p.log.info(`Valid plugins: ${validNames.join(', ')}`); process.exit(1); } } @@ -82,7 +110,6 @@ export const uninstallCommand = new Command('uninstall') if (options.scope) { scopesToUninstall = [options.scope.toLowerCase() as 'user' | 'local']; } else { - // Auto-detect installed scopes const userClaudeDir = getClaudeDirectory(); const gitRoot = await getGitRoot(); @@ -98,16 +125,33 @@ export const uninstallCommand = new Command('uninstall') } if (scopesToUninstall.length === 0) { - console.log('❌ No DevFlow installation found'); - console.log(' Checked user scope (~/.claude/) and local scope (git-root/.claude/)\n'); + p.log.error('No DevFlow installation found'); + p.log.info('Checked user scope (~/.claude/) and local scope (git-root/.claude/)'); process.exit(1); } if (scopesToUninstall.length > 1) { - console.log('📦 Found DevFlow in multiple scopes:'); - console.log(' - User scope (~/.claude/)'); - console.log(' - Local scope (git-root/.claude/)'); - console.log('\n Uninstalling from both...\n'); + if (process.stdin.isTTY) { + const scopeChoice = await p.select({ + message: 'Found DevFlow in multiple scopes. Uninstall from:', + options: [ + { value: 'both', label: 'Both', hint: 'user + local' }, + { value: 'user', label: 'User scope', hint: '~/.claude/' }, + { value: 'local', label: 'Local scope', hint: 'git-root/.claude/' }, + ], + }); + + if (p.isCancel(scopeChoice)) { + p.cancel('Uninstall cancelled.'); + process.exit(0); + } + + if (scopeChoice !== 'both') { + scopesToUninstall = [scopeChoice as 'user' | 'local']; + } + } else { + p.log.info('Multiple scopes detected, uninstalling from both...'); + } } } @@ -125,12 +169,12 @@ export const uninstallCommand = new Command('uninstall') devflowScriptsDir = paths.devflowDir; if (scope === 'user') { - console.log('📍 Uninstalling user scope (~/.claude/)'); + p.log.step('Uninstalling user scope (~/.claude/)'); } else { - console.log('📍 Uninstalling local scope (git-root/.claude/)'); + p.log.step('Uninstalling local scope (git-root/.claude/)'); } } catch (error) { - console.log(`⚠️ Cannot uninstall ${scope} scope: ${error instanceof Error ? error.message : error}\n`); + p.log.warn(`Cannot uninstall ${scope} scope: ${error instanceof Error ? error.message : error}`); continue; } @@ -139,21 +183,19 @@ export const uninstallCommand = new Command('uninstall') if (cliAvailable && !isSelectiveUninstall) { if (verbose) { - console.log(' 🔌 Uninstalling plugin via Claude CLI...'); + p.log.info('Uninstalling plugin via Claude CLI...'); } usedCli = uninstallPluginViaCli(scope); if (!usedCli && verbose) { - console.log(' ⚠️ Claude CLI uninstall failed, falling back to manual removal'); + p.log.warn('Claude CLI uninstall failed, falling back to manual removal'); } } // If CLI uninstall failed or unavailable, do manual removal if (!usedCli) { if (isSelectiveUninstall) { - // Selective uninstall: only remove specific plugin assets await removeSelectedPlugins(claudeDir, selectedPlugins, verbose); } else { - // Full uninstall: remove everything await removeAllDevFlow(claudeDir, devflowScriptsDir, verbose); } } @@ -161,50 +203,173 @@ export const uninstallCommand = new Command('uninstall') const pluginLabel = isSelectiveUninstall ? ` (${selectedPluginNames.join(', ')})` : ''; - console.log(` ✅ Plugin removed${usedCli ? ' (via Claude CLI)' : ''}${pluginLabel}\n`); + p.log.success(`Plugin removed${usedCli ? ' (via Claude CLI)' : ''}${pluginLabel}`); } // === CLEANUP EXTRAS (only for full uninstall) === if (!isSelectiveUninstall) { - // Handle .docs directory - if (!options.keepDocs) { - const docsDir = path.join(process.cwd(), '.docs'); - try { - await fs.access(docsDir); - console.log('⚠️ Found .docs/ directory in current project'); - console.log(' This contains your session documentation and history.'); - console.log(' Use --keep-docs to preserve it, or manually remove it.\n'); - } catch { - // .docs doesn't exist + const gitRoot = await getGitRoot(); + + // 1. .docs/ directory + const docsDir = path.join(process.cwd(), '.docs'); + let docsExist = false; + try { + await fs.access(docsDir); + docsExist = true; + } catch { /* .docs doesn't exist */ } + + if (docsExist) { + let shouldRemoveDocs = false; + + if (options.keepDocs) { + shouldRemoveDocs = false; + } else if (process.stdin.isTTY) { + const removeDocs = await p.confirm({ + message: '.docs/ directory found. Remove project documentation?', + initialValue: false, + }); + + if (p.isCancel(removeDocs)) { + p.cancel('Uninstall cancelled.'); + process.exit(0); + } + + shouldRemoveDocs = removeDocs; + } + + if (shouldRemoveDocs) { + await fs.rm(docsDir, { recursive: true, force: true }); + p.log.success('.docs/ removed'); + } else { + p.log.info('.docs/ preserved'); } } - // Warn about .claudeignore - const claudeignorePath = path.join(process.cwd(), '.claudeignore'); + // 2. .claudeignore + const claudeignorePath = gitRoot + ? path.join(gitRoot, '.claudeignore') + : path.join(process.cwd(), '.claudeignore'); + + let claudeignoreExists = false; try { await fs.access(claudeignorePath); - console.log('ℹ️ Found .claudeignore file'); - console.log(' Keeping it as it may contain custom rules.'); - console.log(' Remove manually if it was only for DevFlow.\n'); - } catch { - // .claudeignore doesn't exist + claudeignoreExists = true; + } catch { /* doesn't exist */ } + + if (claudeignoreExists) { + if (process.stdin.isTTY) { + const removeClaudeignore = await p.confirm({ + message: '.claudeignore found. Remove it? (may contain custom rules)', + initialValue: false, + }); + + if (!p.isCancel(removeClaudeignore) && removeClaudeignore) { + await fs.rm(claudeignorePath, { force: true }); + p.log.success('.claudeignore removed'); + } else { + p.log.info('.claudeignore preserved'); + } + } else { + p.log.info('.claudeignore preserved (non-interactive mode)'); + } } - // Note about settings.json - if (verbose) { - console.log('ℹ️ settings.json preserved (may contain other configurations)'); - console.log(' Remove statusLine manually if desired.\n'); + // 3. settings.json (DevFlow hooks) + for (const scope of scopesToUninstall) { + try { + const paths = await getInstallationPaths(scope); + const settingsPath = path.join(paths.claudeDir, 'settings.json'); + const settingsContent = await fs.readFile(settingsPath, 'utf-8'); + const settings = JSON.parse(settingsContent); + + if (settings.hooks) { + if (process.stdin.isTTY) { + const removeHooks = await p.confirm({ + message: `Remove DevFlow hooks from settings.json (${scope} scope)? Other settings preserved.`, + initialValue: false, + }); + + if (!p.isCancel(removeHooks) && removeHooks) { + delete settings.hooks; + await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + p.log.success(`DevFlow hooks removed from settings.json (${scope})`); + } else { + p.log.info(`settings.json hooks preserved (${scope})`); + } + } else { + p.log.info(`settings.json hooks preserved (${scope}, non-interactive mode)`); + } + } + } catch { + // settings.json doesn't exist or can't be parsed — skip + } + } + + // 4. CLAUDE.md (only if DevFlow installed it) + for (const scope of scopesToUninstall) { + try { + const paths = await getInstallationPaths(scope); + const claudeMdPath = path.join(paths.claudeDir, 'CLAUDE.md'); + const content = await fs.readFile(claudeMdPath, 'utf-8'); + + // Only offer removal if it's the DevFlow template (check for marker) + if (content.includes('DevFlow')) { + if (process.stdin.isTTY) { + const removeClaudeMd = await p.confirm({ + message: `Remove CLAUDE.md (${scope} scope)? May contain your customizations.`, + initialValue: false, + }); + + if (!p.isCancel(removeClaudeMd) && removeClaudeMd) { + await fs.rm(claudeMdPath, { force: true }); + p.log.success(`CLAUDE.md removed (${scope})`); + } else { + p.log.info(`CLAUDE.md preserved (${scope})`); + } + } else { + p.log.info(`CLAUDE.md preserved (${scope}, non-interactive mode)`); + } + } + } catch { + // CLAUDE.md doesn't exist — skip + } + } + + // 5. Safe-delete shell function + const shell = detectShell(); + const profilePath = getProfilePath(shell); + if (profilePath && await isAlreadyInstalled(profilePath)) { + if (process.stdin.isTTY) { + const removeSafeDelete = await p.confirm({ + message: `Remove safe-delete function from ${profilePath}?`, + initialValue: false, + }); + + if (!p.isCancel(removeSafeDelete) && removeSafeDelete) { + const removed = await removeFromProfile(profilePath); + if (removed) { + p.log.success(`Safe-delete removed from ${profilePath}`); + } else { + p.log.warn(`Could not remove safe-delete from ${profilePath}`); + } + } else { + p.log.info('Safe-delete preserved in shell profile'); + } + } else { + p.log.info(`Safe-delete function preserved in ${profilePath} (non-interactive mode)`); + } } } if (hasErrors) { - console.log('⚠️ Uninstall completed with warnings'); - console.log(' Some components may not have been removed.'); - } else { - console.log('✅ DevFlow uninstalled successfully'); + p.log.warn('Uninstall completed with warnings — some components may not have been removed.'); } - console.log('\n💡 To reinstall: npx devflow-kit init'); + const status = hasErrors + ? color.yellow('DevFlow uninstalled with warnings') + : color.green('DevFlow uninstalled successfully'); + + p.outro(`${status}${color.dim(' Reinstall: npx devflow-kit init')}`); }); /** @@ -215,7 +380,6 @@ async function removeAllDevFlow( devflowScriptsDir: string, verbose: boolean, ): Promise { - // DevFlow directories to remove const devflowDirectories = [ { path: path.join(claudeDir, 'commands', 'devflow'), name: 'commands' }, { path: path.join(claudeDir, 'agents', 'devflow'), name: 'agents' }, @@ -226,10 +390,10 @@ async function removeAllDevFlow( try { await fs.rm(dir.path, { recursive: true, force: true }); if (verbose) { - console.log(` ✅ Removed DevFlow ${dir.name}`); + p.log.success(`Removed DevFlow ${dir.name}`); } } catch (error) { - console.error(` ⚠️ Could not remove ${dir.name}:`, error); + p.log.warn(`Could not remove ${dir.name}: ${error}`); } } @@ -249,7 +413,7 @@ async function removeAllDevFlow( } if (skillsRemoved > 0 && verbose) { - console.log(` ✅ Removed ${skillsRemoved} DevFlow skills`); + p.log.success(`Removed ${skillsRemoved} DevFlow skills`); } // Also remove old nested skills structure if it exists @@ -262,7 +426,6 @@ async function removeAllDevFlow( /** * Remove only specific plugin assets (selective uninstall). - * * For commands and agents: remove files belonging to selected plugins. * For skills: only remove skills that are NOT used by any remaining plugin. */ @@ -271,69 +434,42 @@ async function removeSelectedPlugins( plugins: typeof DEVFLOW_PLUGINS, verbose: boolean, ): Promise { - const selectedNames = new Set(plugins.map(p => p.name)); - - // Collect skills/agents used by plugins that will remain - const remainingPlugins = DEVFLOW_PLUGINS.filter(p => !selectedNames.has(p.name)); - const retainedSkills = new Set(); - const retainedAgents = new Set(); - for (const rp of remainingPlugins) { - for (const s of rp.skills) retainedSkills.add(s); - for (const a of rp.agents) retainedAgents.add(a); - } + const { skills, agents, commands } = computeAssetsToRemove(plugins, DEVFLOW_PLUGINS); - // Remove commands for selected plugins const commandsDir = path.join(claudeDir, 'commands', 'devflow'); - for (const plugin of plugins) { - for (const cmd of plugin.commands) { - // Command files are named like "review.md" from "/review" - const cmdFileName = cmd.replace(/^\//, '') + '.md'; - try { - await fs.rm(path.join(commandsDir, cmdFileName), { force: true }); - if (verbose) { - console.log(` ✅ Removed command ${cmd}`); - } - } catch { - // Command file might not exist + for (const cmd of commands) { + const cmdFileName = cmd.replace(/^\//, '') + '.md'; + try { + await fs.rm(path.join(commandsDir, cmdFileName), { force: true }); + if (verbose) { + p.log.success(`Removed command ${cmd}`); } + } catch { + // Command file might not exist } } - // Remove agents only used by selected plugins (not retained by remaining plugins) const agentsDir = path.join(claudeDir, 'agents', 'devflow'); - for (const plugin of plugins) { - for (const agent of plugin.agents) { - if (!retainedAgents.has(agent)) { - try { - await fs.rm(path.join(agentsDir, `${agent}.md`), { force: true }); - if (verbose) { - console.log(` ✅ Removed agent ${agent}`); - } - } catch { - // Agent file might not exist - } - } else if (verbose) { - console.log(` ⏭️ Kept agent ${agent} (used by other plugins)`); + for (const agent of agents) { + try { + await fs.rm(path.join(agentsDir, `${agent}.md`), { force: true }); + if (verbose) { + p.log.success(`Removed agent ${agent}`); } + } catch { + // Agent file might not exist } } - // Remove skills only used by selected plugins (not retained by remaining plugins) const skillsDir = path.join(claudeDir, 'skills'); - for (const plugin of plugins) { - for (const skill of plugin.skills) { - if (!retainedSkills.has(skill)) { - try { - await fs.rm(path.join(skillsDir, skill), { recursive: true, force: true }); - if (verbose) { - console.log(` ✅ Removed skill ${skill}`); - } - } catch { - // Skill might not exist - } - } else if (verbose) { - console.log(` ⏭️ Kept skill ${skill} (used by other plugins)`); + for (const skill of skills) { + try { + await fs.rm(path.join(skillsDir, skill), { recursive: true, force: true }); + if (verbose) { + p.log.success(`Removed skill ${skill}`); } + } catch { + // Skill might not exist } } } diff --git a/src/cli/plugins.ts b/src/cli/plugins.ts index 7aeab5c..cda53e5 100644 --- a/src/cli/plugins.ts +++ b/src/cli/plugins.ts @@ -68,20 +68,6 @@ export const DEVFLOW_PLUGINS: PluginDefinition[] = [ agents: ['simplifier', 'scrutinizer', 'validator'], skills: ['self-review', 'core-patterns'], }, - { - name: 'devflow-catch-up', - description: 'Context restoration from status logs', - commands: ['/catch-up'], - agents: ['catch-up'], - skills: [], - }, - { - name: 'devflow-devlog', - description: 'Development session logging', - commands: ['/devlog'], - agents: ['devlog'], - skills: [], - }, { name: 'devflow-audit-claude', description: 'Audit CLAUDE.md files against Anthropic best practices', diff --git a/src/cli/utils/cli.ts b/src/cli/utils/cli.ts new file mode 100644 index 0000000..8db0fe8 --- /dev/null +++ b/src/cli/utils/cli.ts @@ -0,0 +1,13 @@ +import { execSync } from 'child_process'; + +/** + * Check if Claude CLI is available in the system PATH + */ +export function isClaudeCliAvailable(): boolean { + try { + execSync('claude --version', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} diff --git a/src/cli/utils/installer.ts b/src/cli/utils/installer.ts new file mode 100644 index 0000000..5d049ba --- /dev/null +++ b/src/cli/utils/installer.ts @@ -0,0 +1,223 @@ +import { promises as fs } from 'fs'; +import * as path from 'path'; +import { execSync } from 'child_process'; +import type { PluginDefinition } from '../plugins.js'; +import { DEVFLOW_PLUGINS } from '../plugins.js'; + +/** + * Minimal spinner interface matching @clack/prompts spinner(). + */ +export interface Spinner { + start(msg?: string): void; + stop(msg?: string, code?: number): void; + message(msg?: string): void; +} + +/** + * Recursively copy a directory tree. + */ +export async function copyDirectory(src: string, dest: string): Promise { + await fs.mkdir(dest, { recursive: true }); + const entries = await fs.readdir(src, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + + if (entry.isDirectory()) { + await copyDirectory(srcPath, destPath); + } else { + await fs.copyFile(srcPath, destPath); + } + } +} + +/** + * Recursively chmod all files in a directory tree. + */ +export async function chmodRecursive(dir: string, mode: number): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + await chmodRecursive(fullPath, mode); + } else if (entry.isFile()) { + await fs.chmod(fullPath, mode); + } + } +} + +/** + * Add DevFlow marketplace to Claude CLI. + * Idempotent — safe to call multiple times. + */ +function addMarketplaceViaCli(): boolean { + try { + execSync('claude plugin marketplace add dean0x/devflow', { stdio: 'pipe' }); + return true; + } catch { + return false; + } +} + +/** + * Install a single plugin via Claude CLI. + */ +function installPluginViaCli(pluginName: string, scope: 'user' | 'local'): boolean { + try { + const cliScope = scope === 'local' ? 'project' : 'user'; + execSync(`claude plugin install ${pluginName}@dean0x-devflow --scope ${cliScope}`, { + stdio: 'pipe', + }); + return true; + } catch { + return false; + } +} + +/** + * Install plugins via Claude CLI native plugin system. + * Returns true if all plugins installed successfully. + */ +export function installViaCli( + plugins: PluginDefinition[], + scope: 'user' | 'local', + spinner: Spinner, +): boolean { + spinner.message('Adding DevFlow marketplace...'); + const marketplaceAdded = addMarketplaceViaCli(); + + if (!marketplaceAdded) return false; + + spinner.message('Installing plugins via Claude CLI...'); + for (const plugin of plugins) { + if (!installPluginViaCli(plugin.name, scope)) return false; + } + + spinner.stop('Plugins installed via Claude CLI'); + return true; +} + +export interface FileCopyOptions { + plugins: PluginDefinition[]; + claudeDir: string; + pluginsDir: string; + rootDir: string; + devflowDir: string; + skillsMap: Map; + agentsMap: Map; + selectedPluginNames: string[]; + spinner: Spinner; +} + +/** + * Install plugins via manual file copy. + * Handles cleanup of old monolithic structure, deduplication of shared assets, + * and script installation with executable permissions. + */ +export async function installViaFileCopy(options: FileCopyOptions): Promise { + const { + plugins, + claudeDir, + pluginsDir, + rootDir, + devflowDir, + skillsMap, + agentsMap, + selectedPluginNames, + spinner, + } = options; + + // Clean old DevFlow files before installing (only for full install) + if (selectedPluginNames.length === 0) { + const oldDirs = [ + path.join(claudeDir, 'commands', 'devflow'), + path.join(claudeDir, 'agents', 'devflow'), + ]; + for (const dir of oldDirs) { + try { + await fs.rm(dir, { recursive: true, force: true }); + } catch { /* ignore */ } + } + + const allSkills = new Set(); + for (const plugin of DEVFLOW_PLUGINS) { + for (const skill of plugin.skills) { + allSkills.add(skill); + } + } + for (const skill of allSkills) { + try { + await fs.rm(path.join(claudeDir, 'skills', skill), { recursive: true, force: true }); + } catch { /* ignore */ } + } + } + + // Install each selected plugin (with deduplication) + for (const plugin of plugins) { + const pluginSourceDir = path.join(pluginsDir, plugin.name); + + // Install commands + const commandsSource = path.join(pluginSourceDir, 'commands'); + const commandsTarget = path.join(claudeDir, 'commands', 'devflow'); + try { + const files = await fs.readdir(commandsSource); + if (files.length > 0) { + await fs.mkdir(commandsTarget, { recursive: true }); + for (const file of files) { + await fs.copyFile( + path.join(commandsSource, file), + path.join(commandsTarget, file), + ); + } + } + } catch { /* no commands directory */ } + + // Install agents (deduplicated) + const agentsSource = path.join(pluginSourceDir, 'agents'); + const agentsTarget = path.join(claudeDir, 'agents', 'devflow'); + try { + const files = await fs.readdir(agentsSource); + if (files.length > 0) { + await fs.mkdir(agentsTarget, { recursive: true }); + for (const file of files) { + const agentName = path.basename(file, '.md'); + if (agentsMap.get(agentName) === plugin.name) { + await fs.copyFile( + path.join(agentsSource, file), + path.join(agentsTarget, file), + ); + } + } + } + } catch { /* no agents directory */ } + + // Install skills (deduplicated) + const skillsSource = path.join(pluginSourceDir, 'skills'); + try { + const skillDirs = await fs.readdir(skillsSource, { withFileTypes: true }); + for (const skillDir of skillDirs) { + if (skillDir.isDirectory()) { + if (skillsMap.get(skillDir.name) === plugin.name) { + const skillTarget = path.join(claudeDir, 'skills', skillDir.name); + await copyDirectory( + path.join(skillsSource, skillDir.name), + skillTarget, + ); + } + } + } + } catch { /* no skills directory */ } + } + + // Install scripts (always from root scripts/ directory) + const scriptsSource = path.join(rootDir, 'scripts'); + const scriptsTarget = path.join(devflowDir, 'scripts'); + try { + await fs.mkdir(scriptsTarget, { recursive: true }); + await copyDirectory(scriptsSource, scriptsTarget); + await chmodRecursive(scriptsTarget, 0o755); + } catch { /* scripts may not exist */ } + + spinner.stop('Components installed via file copy'); +} diff --git a/src/cli/utils/paths.ts b/src/cli/utils/paths.ts index 73b96b9..b5cc2fb 100644 --- a/src/cli/utils/paths.ts +++ b/src/cli/utils/paths.ts @@ -74,11 +74,12 @@ export function getDevFlowDirectory(): string { * @returns Object with claudeDir and devflowDir * @throws {Error} If local scope selected but not in a git repository */ -export async function getInstallationPaths(scope: 'user' | 'local'): Promise<{ claudeDir: string; devflowDir: string }> { +export async function getInstallationPaths(scope: 'user' | 'local'): Promise<{ claudeDir: string; devflowDir: string; gitRoot: string | null }> { if (scope === 'user') { return { claudeDir: getClaudeDirectory(), - devflowDir: getDevFlowDirectory() + devflowDir: getDevFlowDirectory(), + gitRoot: null, }; } else { // Local scope - install to git repository root @@ -88,7 +89,8 @@ export async function getInstallationPaths(scope: 'user' | 'local'): Promise<{ c } return { claudeDir: path.join(gitRoot, '.claude'), - devflowDir: path.join(gitRoot, '.devflow') + devflowDir: path.join(gitRoot, '.devflow'), + gitRoot, }; } } diff --git a/src/cli/utils/post-install.ts b/src/cli/utils/post-install.ts new file mode 100644 index 0000000..e513c85 --- /dev/null +++ b/src/cli/utils/post-install.ts @@ -0,0 +1,212 @@ +import { promises as fs } from 'fs'; +import * as path from 'path'; +import * as p from '@clack/prompts'; + +/** + * Type guard for Node.js system errors with error codes. + */ +interface NodeSystemError extends Error { + code: string; +} + +function isNodeSystemError(error: unknown): error is NodeSystemError { + return ( + error instanceof Error && + 'code' in error && + typeof (error as NodeSystemError).code === 'string' + ); +} + +/** + * Replace ${DEVFLOW_DIR} placeholders in a settings template. + */ +export function substituteSettingsTemplate(template: string, devflowDir: string): string { + return template.replace(/\$\{DEVFLOW_DIR\}/g, devflowDir); +} + +/** + * Compute which entries need appending to a .gitignore file. + * Returns only entries not already present. + */ +export function computeGitignoreAppend(existingContent: string, entries: string[]): string[] { + const existingLines = existingContent.split('\n').map(l => l.trim()); + return entries.filter(entry => !existingLines.includes(entry)); +} + +/** + * Install or update settings.json with DevFlow configuration. + * Prompts interactively in TTY mode when settings already exist. + * In non-TTY mode, skips override (safe default). + */ +export async function installSettings( + claudeDir: string, + rootDir: string, + devflowDir: string, + verbose: boolean, +): Promise { + const settingsPath = path.join(claudeDir, 'settings.json'); + const sourceSettingsPath = path.join(rootDir, 'src', 'templates', 'settings.json'); + + try { + const settingsTemplate = await fs.readFile(sourceSettingsPath, 'utf-8'); + const settingsContent = substituteSettingsTemplate(settingsTemplate, devflowDir); + + let settingsExists = false; + try { + await fs.access(settingsPath); + settingsExists = true; + } catch { + settingsExists = false; + } + + if (!settingsExists) { + await fs.writeFile(settingsPath, settingsContent, 'utf-8'); + if (verbose) { + p.log.success('Settings configured'); + } + return; + } + + // Settings exist — check if they already have hooks + let hasHooks = false; + try { + const existing = JSON.parse(await fs.readFile(settingsPath, 'utf-8')); + hasHooks = !!existing.hooks; + } catch { /* parse error = treat as no hooks */ } + + if (hasHooks) { + if (verbose) { + p.log.info('Settings already configured with hooks'); + } + return; + } + + // Settings exist without hooks — prompt in TTY, warn in non-TTY + if (process.stdin.isTTY) { + const confirmed = await p.confirm({ + message: 'settings.json exists without hooks (Working Memory needs hooks). Override?', + initialValue: true, + }); + + if (p.isCancel(confirmed)) { + p.cancel('Installation cancelled.'); + process.exit(0); + } + + if (confirmed) { + await fs.writeFile(settingsPath, settingsContent, 'utf-8'); + p.log.success('Settings overridden'); + } else { + p.log.info('Keeping existing settings'); + } + } else { + p.log.warn('Settings exist without hooks. Working Memory requires hooks.'); + p.log.info('Re-run interactively to configure, or manually add hooks to settings.json'); + } + } catch (error: unknown) { + if (verbose) { + p.log.warn(`Could not configure settings: ${error}`); + } + } +} + +/** + * Install CLAUDE.md template (skip if already exists). + */ +export async function installClaudeMd( + claudeDir: string, + rootDir: string, + verbose: boolean, +): Promise { + const claudeMdPath = path.join(claudeDir, 'CLAUDE.md'); + const sourceClaudeMdPath = path.join(rootDir, 'src', 'claude', 'CLAUDE.md'); + + try { + const content = await fs.readFile(sourceClaudeMdPath, 'utf-8'); + await fs.writeFile(claudeMdPath, content, { encoding: 'utf-8', flag: 'wx' }); + if (verbose) { + p.log.success('CLAUDE.md configured'); + } + } catch (error: unknown) { + if (isNodeSystemError(error) && error.code === 'EEXIST') { + p.log.info('CLAUDE.md exists - keeping your configuration'); + } + } +} + +/** + * Create .claudeignore in git repository root (skip if already exists). + */ +export async function installClaudeignore( + gitRoot: string, + rootDir: string, + verbose: boolean, +): Promise { + const claudeignorePath = path.join(gitRoot, '.claudeignore'); + const claudeignoreTemplatePath = path.join(rootDir, 'src', 'templates', 'claudeignore.template'); + + try { + const claudeignoreContent = await fs.readFile(claudeignoreTemplatePath, 'utf-8'); + await fs.writeFile(claudeignorePath, claudeignoreContent, { encoding: 'utf-8', flag: 'wx' }); + if (verbose) { + p.log.success('.claudeignore created'); + } + } catch (error: unknown) { + if (isNodeSystemError(error) && error.code === 'EEXIST') { + // Already exists, skip silently + } else if (verbose) { + p.log.warn(`Could not create .claudeignore: ${error}`); + } + } +} + +/** + * Update .gitignore with DevFlow entries (for local scope installs). + */ +export async function updateGitignore( + gitRoot: string, + verbose: boolean, +): Promise { + try { + const gitignorePath = path.join(gitRoot, '.gitignore'); + const entriesToAdd = ['.claude/', '.devflow/']; + + let gitignoreContent = ''; + try { + gitignoreContent = await fs.readFile(gitignorePath, 'utf-8'); + } catch { /* doesn't exist */ } + + const linesToAdd = computeGitignoreAppend(gitignoreContent, entriesToAdd); + + if (linesToAdd.length > 0) { + const newContent = gitignoreContent + ? `${gitignoreContent.trimEnd()}\n\n# DevFlow local installation\n${linesToAdd.join('\n')}\n` + : `# DevFlow local installation\n${linesToAdd.join('\n')}\n`; + + await fs.writeFile(gitignorePath, newContent, 'utf-8'); + if (verbose) { + p.log.success('.gitignore updated'); + } + } + } catch (error) { + if (verbose) { + p.log.warn(`Could not update .gitignore: ${error instanceof Error ? error.message : error}`); + } + } +} + +/** + * Create .docs/ directory structure for DevFlow artifacts. + */ +export async function createDocsStructure(verbose: boolean): Promise { + const docsDir = path.join(process.cwd(), '.docs'); + + try { + await fs.mkdir(path.join(docsDir, 'status', 'compact'), { recursive: true }); + await fs.mkdir(path.join(docsDir, 'reviews'), { recursive: true }); + await fs.mkdir(path.join(docsDir, 'releases'), { recursive: true }); + if (verbose) { + p.log.success('.docs/ structure ready'); + } + } catch { /* may already exist */ } +} diff --git a/src/cli/utils/safe-delete-install.ts b/src/cli/utils/safe-delete-install.ts new file mode 100644 index 0000000..5330cc1 --- /dev/null +++ b/src/cli/utils/safe-delete-install.ts @@ -0,0 +1,170 @@ +import { promises as fs } from 'fs'; +import * as path from 'path'; +import type { Shell } from './safe-delete.js'; + +const START_MARKER = '# >>> DevFlow safe-delete >>>'; +const END_MARKER = '# <<< DevFlow safe-delete <<<'; + +/** + * Generate the safe-delete shell function block with markers. + * Returns null for unsupported shells. + */ +export function generateSafeDeleteBlock( + shell: Shell, + platform: NodeJS.Platform, + trashCommand: string | null, +): string | null { + if (shell === 'unknown') return null; + + if (shell === 'bash' || shell === 'zsh') { + const cmd = trashCommand ?? 'trash'; + return [ + START_MARKER, + `rm() {`, + ` local files=()`, + ` for arg in "$@"; do`, + ` [[ "$arg" =~ ^- ]] || files+=("$arg")`, + ` done`, + ` if (( \${#files[@]} > 0 )); then`, + ` ${cmd} "\${files[@]}"`, + ` fi`, + `}`, + `command() {`, + ` if [[ "$1" == "rm" ]]; then`, + ` shift; rm "$@"`, + ` else`, + ` builtin command "$@"`, + ` fi`, + `}`, + END_MARKER, + ].join('\n'); + } + + if (shell === 'fish') { + const cmd = trashCommand ?? 'trash'; + return [ + START_MARKER, + `function rm --description "Safe delete via trash"`, + ` set -l files`, + ` for arg in $argv`, + ` if not string match -q -- '-*' $arg`, + ` set files $files $arg`, + ` end`, + ` end`, + ` if test (count $files) -gt 0`, + ` ${cmd} $files`, + ` end`, + `end`, + END_MARKER, + ].join('\n'); + } + + if (shell === 'powershell') { + if (platform === 'win32') { + return [ + START_MARKER, + `if (Get-Alias rm -ErrorAction SilentlyContinue) {`, + ` Remove-Alias rm -Force -Scope Global`, + `}`, + `function rm {`, + ` $files = $args | Where-Object { $_ -notlike '-*' }`, + ` if ($files) {`, + ` Add-Type -AssemblyName Microsoft.VisualBasic`, + ` foreach ($f in $files) {`, + ` $p = Resolve-Path $f -ErrorAction SilentlyContinue`, + ` if ($p) {`, + ` if (Test-Path $p -PathType Container) {`, + ` [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory(`, + ` $p, 'OnlyErrorDialogs', 'SendToRecycleBin')`, + ` } else {`, + ` [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile(`, + ` $p, 'OnlyErrorDialogs', 'SendToRecycleBin')`, + ` }`, + ` }`, + ` }`, + ` }`, + `}`, + END_MARKER, + ].join('\n'); + } + // macOS/Linux PowerShell + const cmd = trashCommand ?? 'trash'; + return [ + START_MARKER, + `if (Get-Alias rm -ErrorAction SilentlyContinue) {`, + ` Remove-Alias rm -Force -Scope Global`, + `}`, + `function rm {`, + ` $files = $args | Where-Object { $_ -notlike '-*' }`, + ` if ($files) { & ${cmd} @files }`, + `}`, + END_MARKER, + ].join('\n'); + } + + return null; +} + +/** + * Check if the safe-delete block is already installed in a profile file. + */ +export async function isAlreadyInstalled(profilePath: string): Promise { + try { + const content = await fs.readFile(profilePath, 'utf-8'); + return content.includes(START_MARKER) && content.includes(END_MARKER); + } catch { + return false; + } +} + +/** + * Append the safe-delete block to a profile file. + * Creates parent directories and the file if they don't exist. + */ +export async function installToProfile(profilePath: string, block: string): Promise { + await fs.mkdir(path.dirname(profilePath), { recursive: true }); + + let existing = ''; + try { + existing = await fs.readFile(profilePath, 'utf-8'); + } catch { + // File doesn't exist yet — will be created + } + + const separator = existing.length > 0 && !existing.endsWith('\n') ? '\n\n' : '\n'; + const content = existing.length > 0 ? existing + separator + block + '\n' : block + '\n'; + await fs.writeFile(profilePath, content, 'utf-8'); +} + +/** + * Remove the safe-delete block from a profile file. + * Returns true if the block was found and removed, false otherwise. + * For fish function files, deletes the file if it becomes empty. + */ +export async function removeFromProfile(profilePath: string): Promise { + let content: string; + try { + content = await fs.readFile(profilePath, 'utf-8'); + } catch { + return false; + } + + const startIdx = content.indexOf(START_MARKER); + const endIdx = content.indexOf(END_MARKER); + if (startIdx === -1 || endIdx === -1) return false; + + const before = content.slice(0, startIdx); + const after = content.slice(endIdx + END_MARKER.length); + + // Clean up surrounding whitespace + const cleaned = (before.trimEnd() + after.trimStart()).trim(); + + if (cleaned.length === 0) { + // File is empty after removal — delete it (fish function files) + await fs.unlink(profilePath); + } else { + await fs.writeFile(profilePath, cleaned + '\n', 'utf-8'); + } + + return true; +} diff --git a/src/cli/utils/safe-delete.ts b/src/cli/utils/safe-delete.ts new file mode 100644 index 0000000..397bf63 --- /dev/null +++ b/src/cli/utils/safe-delete.ts @@ -0,0 +1,94 @@ +import { execSync } from 'child_process'; +import { homedir } from 'os'; +import * as path from 'path'; + +export type Platform = 'macos' | 'linux' | 'windows'; +export type Shell = 'zsh' | 'bash' | 'fish' | 'powershell' | 'unknown'; + +export interface SafeDeleteInfo { + command: string | null; + installHint: string | null; +} + +export function detectPlatform(): Platform { + switch (process.platform) { + case 'darwin': return 'macos'; + case 'win32': return 'windows'; + default: return 'linux'; + } +} + +export function detectShell(): Shell { + // PowerShell detection via PSModulePath (set in all PowerShell sessions) + if (process.env.PSModulePath) { + return 'powershell'; + } + + const shellPath = process.env.SHELL; + if (!shellPath) return 'unknown'; + + const shellName = path.basename(shellPath); + switch (shellName) { + case 'zsh': return 'zsh'; + case 'bash': return 'bash'; + case 'fish': return 'fish'; + default: return 'unknown'; + } +} + +export function getProfilePath(shell: Shell): string | null { + const home = process.env.HOME || homedir(); + + switch (shell) { + case 'zsh': + return path.join(home, '.zshrc'); + case 'bash': + return path.join(home, '.bashrc'); + case 'fish': + return path.join(home, '.config', 'fish', 'functions', 'rm.fish'); + case 'powershell': { + // PowerShell profile path varies by platform + if (process.platform === 'win32') { + const docs = process.env.USERPROFILE + ? path.join(process.env.USERPROFILE, 'Documents') + : path.join(home, 'Documents'); + return path.join(docs, 'PowerShell', 'Microsoft.PowerShell_profile.ps1'); + } + return path.join(home, '.config', 'powershell', 'Microsoft.PowerShell_profile.ps1'); + } + case 'unknown': + return null; + } +} + +export function getSafeDeleteInfo(platform: Platform): SafeDeleteInfo { + switch (platform) { + case 'macos': + return { + command: 'trash', + installHint: 'brew install trash-cli', + }; + case 'linux': + return { + command: 'trash-put', + installHint: 'sudo apt install trash-cli # or: npm install -g trash-cli', + }; + case 'windows': + return { + command: null, + installHint: null, + }; + } +} + +export function hasSafeDelete(platform: Platform): boolean { + if (platform === 'windows') return true; // Windows has recycle bin via .NET + const info = getSafeDeleteInfo(platform); + if (!info.command) return false; + try { + execSync(`which ${info.command}`, { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} diff --git a/src/templates/settings.json b/src/templates/settings.json index 85a8233..9e22fc7 100644 --- a/src/templates/settings.json +++ b/src/templates/settings.json @@ -1,5 +1,4 @@ { - "model": "opus", "statusLine": { "type": "command", "command": "${DEVFLOW_DIR}/scripts/statusline.sh" diff --git a/tests/build.test.ts b/tests/build.test.ts new file mode 100644 index 0000000..b0262f8 --- /dev/null +++ b/tests/build.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { promises as fs } from 'fs'; +import * as path from 'path'; +import { DEVFLOW_PLUGINS, getAllSkillNames, getAllAgentNames } from '../src/cli/plugins.js'; + +const ROOT = path.resolve(import.meta.dirname, '..'); + +describe('plugin manifest validation', () => { + it('every plugin in DEVFLOW_PLUGINS has a matching plugins/ directory', async () => { + for (const plugin of DEVFLOW_PLUGINS) { + const pluginDir = path.join(ROOT, 'plugins', plugin.name); + const stat = await fs.stat(pluginDir); + expect(stat.isDirectory(), `${plugin.name} should have a plugins/ directory`).toBe(true); + } + }); + + it('every plugin has a .claude-plugin/plugin.json', async () => { + for (const plugin of DEVFLOW_PLUGINS) { + const manifestPath = path.join(ROOT, 'plugins', plugin.name, '.claude-plugin', 'plugin.json'); + const content = await fs.readFile(manifestPath, 'utf-8'); + const manifest = JSON.parse(content); + expect(manifest.name, `${plugin.name} plugin.json should have a name`).toBeTruthy(); + } + }); +}); + +describe('skill references', () => { + it('every skill referenced in plugins exists in shared/skills/', async () => { + const allSkills = getAllSkillNames(); + for (const skill of allSkills) { + const skillDir = path.join(ROOT, 'shared', 'skills', skill); + const stat = await fs.stat(skillDir); + expect(stat.isDirectory(), `skill '${skill}' should exist in shared/skills/`).toBe(true); + } + }); + + it('every skill directory has a SKILL.md', async () => { + const allSkills = getAllSkillNames(); + for (const skill of allSkills) { + const skillMd = path.join(ROOT, 'shared', 'skills', skill, 'SKILL.md'); + await expect(fs.access(skillMd)).resolves.toBeUndefined(); + } + }); +}); + +describe('agent references', () => { + it('every shared agent referenced in plugins exists in shared/agents/', async () => { + const allAgents = getAllAgentNames(); + // Filter to shared agents only (plugin-specific agents live in plugin dirs) + const sharedAgentFiles = await fs.readdir(path.join(ROOT, 'shared', 'agents')); + const sharedAgentNames = sharedAgentFiles.map(f => path.basename(f, '.md')); + + for (const agent of allAgents) { + // Check shared/agents/ first, then fall back to plugin-specific + if (sharedAgentNames.includes(agent)) { + const agentFile = path.join(ROOT, 'shared', 'agents', `${agent}.md`); + await expect(fs.access(agentFile)).resolves.toBeUndefined(); + } else { + // Plugin-specific agent — find which plugin declares it + const ownerPlugin = DEVFLOW_PLUGINS.find(p => p.agents.includes(agent)); + expect(ownerPlugin, `agent '${agent}' should have an owning plugin`).toBeTruthy(); + const agentFile = path.join(ROOT, 'plugins', ownerPlugin!.name, 'agents', `${agent}.md`); + await expect(fs.access(agentFile)).resolves.toBeUndefined(); + } + } + }); +}); + +describe('no orphaned declarations', () => { + it('all skills in shared/skills/ are referenced by at least one plugin', async () => { + const skillDirs = await fs.readdir(path.join(ROOT, 'shared', 'skills')); + const referencedSkills = new Set(getAllSkillNames()); + + for (const dir of skillDirs) { + expect(referencedSkills.has(dir), `shared/skills/${dir} is not referenced by any plugin`).toBe(true); + } + }); + + it('all agents in shared/agents/ are referenced by at least one plugin', async () => { + const agentFiles = await fs.readdir(path.join(ROOT, 'shared', 'agents')); + const referencedAgents = new Set(getAllAgentNames()); + + for (const file of agentFiles) { + const name = path.basename(file, '.md'); + expect(referencedAgents.has(name), `shared/agents/${file} is not referenced by any plugin`).toBe(true); + } + }); +}); diff --git a/tests/git.test.ts b/tests/git.test.ts new file mode 100644 index 0000000..6e6b36c --- /dev/null +++ b/tests/git.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock child_process before importing git.ts +vi.mock('child_process', () => ({ + exec: vi.fn(), +})); + +// We need to also mock the promisify wrapper +vi.mock('util', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + promisify: (fn: unknown) => { + // Return a function that calls our mocked exec and wraps it in a promise + return (...args: unknown[]) => { + return new Promise((resolve, reject) => { + (fn as Function)(...args, (err: Error | null, result: unknown) => { + if (err) reject(err); + else resolve(result); + }); + }); + }; + }, + }; +}); + +import { exec } from 'child_process'; +import { getGitRoot } from '../src/cli/utils/git.js'; + +const mockedExec = vi.mocked(exec); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('getGitRoot', () => { + it('returns trimmed path on success', async () => { + mockedExec.mockImplementation((_cmd, _opts, callback) => { + (callback as Function)(null, { stdout: ' /home/user/project \n', stderr: '' }); + return {} as ReturnType; + }); + + const result = await getGitRoot(); + expect(result).toBe('/home/user/project'); + }); + + it('returns null when not in a git repo', async () => { + mockedExec.mockImplementation((_cmd, _opts, callback) => { + (callback as Function)(new Error('not a git repository'), { stdout: '', stderr: '' }); + return {} as ReturnType; + }); + + const result = await getGitRoot(); + expect(result).toBeNull(); + }); + + it('returns null on injection characters (newlines)', async () => { + mockedExec.mockImplementation((_cmd, _opts, callback) => { + (callback as Function)(null, { stdout: '/home/user\n; rm -rf /', stderr: '' }); + return {} as ReturnType; + }); + + const result = await getGitRoot(); + expect(result).toBeNull(); + }); + + it('returns null on injection characters (semicolons)', async () => { + mockedExec.mockImplementation((_cmd, _opts, callback) => { + (callback as Function)(null, { stdout: '/home/user; rm -rf /', stderr: '' }); + return {} as ReturnType; + }); + + const result = await getGitRoot(); + expect(result).toBeNull(); + }); + + it('returns null on injection characters (&&)', async () => { + mockedExec.mockImplementation((_cmd, _opts, callback) => { + (callback as Function)(null, { stdout: '/home/user && rm -rf /', stderr: '' }); + return {} as ReturnType; + }); + + const result = await getGitRoot(); + expect(result).toBeNull(); + }); + + it('returns null on empty output', async () => { + mockedExec.mockImplementation((_cmd, _opts, callback) => { + (callback as Function)(null, { stdout: '', stderr: '' }); + return {} as ReturnType; + }); + + const result = await getGitRoot(); + expect(result).toBeNull(); + }); +}); diff --git a/tests/init-logic.test.ts b/tests/init-logic.test.ts new file mode 100644 index 0000000..59ce6d5 --- /dev/null +++ b/tests/init-logic.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from 'vitest'; +import { + parsePluginSelection, + substituteSettingsTemplate, + computeGitignoreAppend, + buildExtrasOptions, +} from '../src/cli/commands/init.js'; +import { DEVFLOW_PLUGINS } from '../src/cli/plugins.js'; + +describe('parsePluginSelection', () => { + it('parses comma-separated plugin names', () => { + const { selected, invalid } = parsePluginSelection('devflow-implement,devflow-review', DEVFLOW_PLUGINS); + expect(selected).toEqual(['devflow-implement', 'devflow-review']); + expect(invalid).toEqual([]); + }); + + it('normalizes shorthand names (adds devflow- prefix)', () => { + const { selected, invalid } = parsePluginSelection('implement,review', DEVFLOW_PLUGINS); + expect(selected).toEqual(['devflow-implement', 'devflow-review']); + expect(invalid).toEqual([]); + }); + + it('handles mixed shorthand and full names', () => { + const { selected, invalid } = parsePluginSelection('implement,devflow-review', DEVFLOW_PLUGINS); + expect(selected).toEqual(['devflow-implement', 'devflow-review']); + expect(invalid).toEqual([]); + }); + + it('trims whitespace', () => { + const { selected, invalid } = parsePluginSelection(' implement , review ', DEVFLOW_PLUGINS); + expect(selected).toEqual(['devflow-implement', 'devflow-review']); + expect(invalid).toEqual([]); + }); + + it('reports unknown plugins', () => { + const { selected, invalid } = parsePluginSelection('implement,nonexistent', DEVFLOW_PLUGINS); + expect(selected).toEqual(['devflow-implement', 'devflow-nonexistent']); + expect(invalid).toEqual(['devflow-nonexistent']); + }); + + it('reports multiple unknown plugins', () => { + const { invalid } = parsePluginSelection('foo,bar', DEVFLOW_PLUGINS); + expect(invalid).toEqual(['devflow-foo', 'devflow-bar']); + }); + + it('handles single plugin', () => { + const { selected, invalid } = parsePluginSelection('implement', DEVFLOW_PLUGINS); + expect(selected).toEqual(['devflow-implement']); + expect(invalid).toEqual([]); + }); +}); + +describe('substituteSettingsTemplate', () => { + it('replaces ${DEVFLOW_DIR} placeholders', () => { + const template = '{"scripts": "${DEVFLOW_DIR}/scripts", "hooks": "${DEVFLOW_DIR}/hooks"}'; + const result = substituteSettingsTemplate(template, '/home/user/.devflow'); + expect(result).toBe('{"scripts": "/home/user/.devflow/scripts", "hooks": "/home/user/.devflow/hooks"}'); + }); + + it('returns template unchanged when no placeholders', () => { + const template = '{"key": "value"}'; + const result = substituteSettingsTemplate(template, '/home/user/.devflow'); + expect(result).toBe('{"key": "value"}'); + }); + + it('handles empty template', () => { + expect(substituteSettingsTemplate('', '/dir')).toBe(''); + }); + + it('handles multiple occurrences', () => { + const template = '${DEVFLOW_DIR} and ${DEVFLOW_DIR} again'; + const result = substituteSettingsTemplate(template, '/d'); + expect(result).toBe('/d and /d again'); + }); +}); + +describe('buildExtrasOptions', () => { + it('returns settings, claude-md, safe-delete for user scope without gitRoot', () => { + const options = buildExtrasOptions('user', null); + const values = options.map(o => o.value); + expect(values).toEqual(['settings', 'claude-md', 'safe-delete']); + }); + + it('adds claudeignore when gitRoot exists (user scope)', () => { + const options = buildExtrasOptions('user', '/repo'); + const values = options.map(o => o.value); + expect(values).toEqual(['settings', 'claude-md', 'claudeignore', 'safe-delete']); + }); + + it('returns all 6 options for local scope with gitRoot', () => { + const options = buildExtrasOptions('local', '/repo'); + const values = options.map(o => o.value); + expect(values).toEqual(['settings', 'claude-md', 'claudeignore', 'gitignore', 'docs', 'safe-delete']); + }); + + it('omits claudeignore and gitignore for local scope without gitRoot', () => { + const options = buildExtrasOptions('local', null); + const values = options.map(o => o.value); + expect(values).toEqual(['settings', 'claude-md', 'docs', 'safe-delete']); + }); + + it('all options have non-empty label and hint', () => { + const options = buildExtrasOptions('local', '/repo'); + for (const option of options) { + expect(option.label.length).toBeGreaterThan(0); + expect(option.hint.length).toBeGreaterThan(0); + } + }); +}); + +describe('computeGitignoreAppend', () => { + it('returns all entries when gitignore is empty', () => { + const result = computeGitignoreAppend('', ['.claude/', '.devflow/']); + expect(result).toEqual(['.claude/', '.devflow/']); + }); + + it('filters out existing entries', () => { + const existing = '.claude/\nnode_modules/\n'; + const result = computeGitignoreAppend(existing, ['.claude/', '.devflow/']); + expect(result).toEqual(['.devflow/']); + }); + + it('returns empty array when all entries exist', () => { + const existing = '.claude/\n.devflow/\n'; + const result = computeGitignoreAppend(existing, ['.claude/', '.devflow/']); + expect(result).toEqual([]); + }); + + it('handles entries with surrounding whitespace in gitignore', () => { + const existing = ' .claude/ \n'; + const result = computeGitignoreAppend(existing, ['.claude/', '.devflow/']); + expect(result).toEqual(['.devflow/']); + }); + + it('handles empty entries list', () => { + const result = computeGitignoreAppend('something\n', []); + expect(result).toEqual([]); + }); +}); diff --git a/tests/paths.test.ts b/tests/paths.test.ts new file mode 100644 index 0000000..8273a2b --- /dev/null +++ b/tests/paths.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as path from 'path'; + +// Mock git.ts before importing paths.ts +vi.mock('../src/cli/utils/git.js', () => ({ + getGitRoot: vi.fn(), +})); + +import { getHomeDirectory, getClaudeDirectory, getDevFlowDirectory, getInstallationPaths } from '../src/cli/utils/paths.js'; +import { getGitRoot } from '../src/cli/utils/git.js'; + +beforeEach(() => { + vi.unstubAllEnvs(); +}); + +describe('getHomeDirectory', () => { + it('returns HOME env var when set', () => { + vi.stubEnv('HOME', '/custom/home'); + expect(getHomeDirectory()).toBe('/custom/home'); + }); + + it('throws when HOME is empty and os.homedir() returns empty', () => { + // On most systems, os.homedir() reads HOME env var, so clearing HOME + // can cause both to be empty, triggering the error path. + vi.stubEnv('HOME', ''); + // Behavior depends on OS — homedir() may still resolve from /etc/passwd. + // We test the contract: either it returns a non-empty string or throws. + try { + const result = getHomeDirectory(); + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + } catch (e) { + expect((e as Error).message).toContain('Unable to determine home directory'); + } + }); +}); + +describe('getClaudeDirectory', () => { + it('respects CLAUDE_CODE_DIR env var', () => { + vi.stubEnv('CLAUDE_CODE_DIR', '/custom/claude'); + expect(getClaudeDirectory()).toBe('/custom/claude'); + }); + + it('validates CLAUDE_CODE_DIR is absolute', () => { + vi.stubEnv('CLAUDE_CODE_DIR', 'relative/path'); + expect(() => getClaudeDirectory()).toThrow('must be an absolute path'); + }); + + it('defaults to ~/.claude when CLAUDE_CODE_DIR is unset', () => { + vi.stubEnv('CLAUDE_CODE_DIR', ''); + const result = getClaudeDirectory(); + expect(result).toBe(path.join(getHomeDirectory(), '.claude')); + }); +}); + +describe('getDevFlowDirectory', () => { + it('respects DEVFLOW_DIR env var', () => { + vi.stubEnv('DEVFLOW_DIR', '/custom/devflow'); + expect(getDevFlowDirectory()).toBe('/custom/devflow'); + }); + + it('validates DEVFLOW_DIR is absolute', () => { + vi.stubEnv('DEVFLOW_DIR', 'relative/path'); + expect(() => getDevFlowDirectory()).toThrow('must be an absolute path'); + }); + + it('defaults to ~/.devflow when DEVFLOW_DIR is unset', () => { + vi.stubEnv('DEVFLOW_DIR', ''); + const result = getDevFlowDirectory(); + expect(result).toBe(path.join(getHomeDirectory(), '.devflow')); + }); +}); + +describe('getInstallationPaths', () => { + it('user scope returns home-based paths with null gitRoot', async () => { + vi.stubEnv('CLAUDE_CODE_DIR', ''); + vi.stubEnv('DEVFLOW_DIR', ''); + const { claudeDir, devflowDir, gitRoot } = await getInstallationPaths('user'); + const home = getHomeDirectory(); + expect(claudeDir).toBe(path.join(home, '.claude')); + expect(devflowDir).toBe(path.join(home, '.devflow')); + expect(gitRoot).toBeNull(); + }); + + it('local scope requires git root', async () => { + const mockedGetGitRoot = vi.mocked(getGitRoot); + mockedGetGitRoot.mockResolvedValue(null); + await expect(getInstallationPaths('local')).rejects.toThrow('requires a git repository'); + }); + + it('local scope returns git-root-based paths with gitRoot', async () => { + const mockedGetGitRoot = vi.mocked(getGitRoot); + mockedGetGitRoot.mockResolvedValue('/repo/root'); + const { claudeDir, devflowDir, gitRoot } = await getInstallationPaths('local'); + expect(claudeDir).toBe('/repo/root/.claude'); + expect(devflowDir).toBe('/repo/root/.devflow'); + expect(gitRoot).toBe('/repo/root'); + }); +}); diff --git a/tests/plugins.test.ts b/tests/plugins.test.ts new file mode 100644 index 0000000..fd23864 --- /dev/null +++ b/tests/plugins.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest'; +import { + DEVFLOW_PLUGINS, + getAllSkillNames, + getAllAgentNames, + buildAssetMaps, + type PluginDefinition, +} from '../src/cli/plugins.js'; + +describe('getAllSkillNames', () => { + it('returns a deduplicated list of skills across all plugins', () => { + const skills = getAllSkillNames(); + expect(skills.length).toBeGreaterThan(0); + expect(new Set(skills).size).toBe(skills.length); + }); + + it('includes skills from multiple plugins', () => { + const skills = getAllSkillNames(); + // 'accessibility' appears in core-skills, implement, and review + expect(skills).toContain('accessibility'); + // 'agent-teams' appears in multiple plugins + expect(skills).toContain('agent-teams'); + }); +}); + +describe('getAllAgentNames', () => { + it('returns a deduplicated list of agents across all plugins', () => { + const agents = getAllAgentNames(); + expect(agents.length).toBeGreaterThan(0); + expect(new Set(agents).size).toBe(agents.length); + }); + + it('includes agents from multiple plugins', () => { + const agents = getAllAgentNames(); + // 'git' appears in implement, review, resolve, debug + expect(agents).toContain('git'); + expect(agents).toContain('synthesizer'); + }); +}); + +describe('buildAssetMaps', () => { + it('assigns each asset to the first plugin that declares it', () => { + const { skillsMap, agentsMap } = buildAssetMaps(DEVFLOW_PLUGINS); + + // 'accessibility' first appears in devflow-core-skills + expect(skillsMap.get('accessibility')).toBe('devflow-core-skills'); + + // 'git' first appears in devflow-implement + expect(agentsMap.get('git')).toBe('devflow-implement'); + + // 'synthesizer' first appears in devflow-specify + expect(agentsMap.get('synthesizer')).toBe('devflow-specify'); + }); + + it('returns empty maps for empty input', () => { + const { skillsMap, agentsMap } = buildAssetMaps([]); + expect(skillsMap.size).toBe(0); + expect(agentsMap.size).toBe(0); + }); + + it('handles a single plugin', () => { + const single: PluginDefinition[] = [{ + name: 'test-plugin', + description: 'Test', + commands: [], + agents: ['agent-a'], + skills: ['skill-a', 'skill-b'], + }]; + const { skillsMap, agentsMap } = buildAssetMaps(single); + expect(skillsMap.size).toBe(2); + expect(agentsMap.size).toBe(1); + expect(skillsMap.get('skill-a')).toBe('test-plugin'); + expect(agentsMap.get('agent-a')).toBe('test-plugin'); + }); + + it('deduplicates overlapping skills/agents (first plugin wins)', () => { + const plugins: PluginDefinition[] = [ + { name: 'first', description: '', commands: [], agents: ['shared-agent'], skills: ['shared-skill'] }, + { name: 'second', description: '', commands: [], agents: ['shared-agent'], skills: ['shared-skill'] }, + ]; + const { skillsMap, agentsMap } = buildAssetMaps(plugins); + expect(skillsMap.get('shared-skill')).toBe('first'); + expect(agentsMap.get('shared-agent')).toBe('first'); + expect(skillsMap.size).toBe(1); + expect(agentsMap.size).toBe(1); + }); +}); + +describe('DEVFLOW_PLUGINS integrity', () => { + it('has no duplicate plugin names', () => { + const names = DEVFLOW_PLUGINS.map(p => p.name); + expect(new Set(names).size).toBe(names.length); + }); + + it('all plugins have required fields', () => { + for (const plugin of DEVFLOW_PLUGINS) { + expect(plugin.name).toBeTruthy(); + expect(typeof plugin.name).toBe('string'); + expect(plugin.description).toBeTruthy(); + expect(typeof plugin.description).toBe('string'); + expect(Array.isArray(plugin.commands)).toBe(true); + expect(Array.isArray(plugin.agents)).toBe(true); + expect(Array.isArray(plugin.skills)).toBe(true); + } + }); + + it('all skill and agent names are non-empty strings', () => { + for (const plugin of DEVFLOW_PLUGINS) { + for (const skill of plugin.skills) { + expect(typeof skill).toBe('string'); + expect(skill.length).toBeGreaterThan(0); + } + for (const agent of plugin.agents) { + expect(typeof agent).toBe('string'); + expect(agent.length).toBeGreaterThan(0); + } + } + }); + + it('has at least 8 plugins', () => { + expect(DEVFLOW_PLUGINS.length).toBeGreaterThanOrEqual(8); + }); +}); diff --git a/tests/safe-delete-install.test.ts b/tests/safe-delete-install.test.ts new file mode 100644 index 0000000..61d9a3a --- /dev/null +++ b/tests/safe-delete-install.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + generateSafeDeleteBlock, + isAlreadyInstalled, + installToProfile, + removeFromProfile, +} from '../src/cli/utils/safe-delete-install.js'; + +describe('generateSafeDeleteBlock', () => { + it('generates bash/zsh block with markers and both functions', () => { + const block = generateSafeDeleteBlock('zsh', 'darwin', 'trash'); + expect(block).not.toBeNull(); + expect(block).toContain('# >>> DevFlow safe-delete >>>'); + expect(block).toContain('# <<< DevFlow safe-delete <<<'); + expect(block).toContain('rm() {'); + expect(block).toContain('command() {'); + expect(block).toContain('trash "${files[@]}"'); + }); + + it('generates bash block with trash-put command', () => { + const block = generateSafeDeleteBlock('bash', 'linux', 'trash-put'); + expect(block).toContain('trash-put "${files[@]}"'); + }); + + it('generates fish block with fish syntax', () => { + const block = generateSafeDeleteBlock('fish', 'darwin', 'trash'); + expect(block).not.toBeNull(); + expect(block).toContain('# >>> DevFlow safe-delete >>>'); + expect(block).toContain('function rm --description "Safe delete via trash"'); + expect(block).toContain('trash $files'); + expect(block).not.toContain('command()'); + }); + + it('generates PowerShell Windows block with .NET SendToRecycleBin', () => { + const block = generateSafeDeleteBlock('powershell', 'win32', null); + expect(block).not.toBeNull(); + expect(block).toContain('Microsoft.VisualBasic'); + expect(block).toContain('SendToRecycleBin'); + expect(block).toContain('Remove-Alias rm'); + }); + + it('generates PowerShell macOS/Linux block with trash command', () => { + const block = generateSafeDeleteBlock('powershell', 'darwin', 'trash'); + expect(block).not.toBeNull(); + expect(block).toContain('& trash @files'); + expect(block).not.toContain('Microsoft.VisualBasic'); + }); + + it('returns null for unknown shell', () => { + expect(generateSafeDeleteBlock('unknown', 'darwin', 'trash')).toBeNull(); + }); +}); + +describe('isAlreadyInstalled', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'safe-delete-test-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('returns true when both markers present', async () => { + const filePath = path.join(tmpDir, '.zshrc'); + await fs.writeFile(filePath, [ + 'existing content', + '# >>> DevFlow safe-delete >>>', + 'rm() { trash "$@"; }', + '# <<< DevFlow safe-delete <<<', + ].join('\n')); + expect(await isAlreadyInstalled(filePath)).toBe(true); + }); + + it('returns false for missing file', async () => { + expect(await isAlreadyInstalled(path.join(tmpDir, 'nonexistent'))).toBe(false); + }); + + it('returns false for partial markers', async () => { + const filePath = path.join(tmpDir, '.zshrc'); + await fs.writeFile(filePath, '# >>> DevFlow safe-delete >>>\nsome content\n'); + expect(await isAlreadyInstalled(filePath)).toBe(false); + }); + + it('returns false for empty file', async () => { + const filePath = path.join(tmpDir, '.zshrc'); + await fs.writeFile(filePath, ''); + expect(await isAlreadyInstalled(filePath)).toBe(false); + }); +}); + +describe('installToProfile', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'safe-delete-test-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('appends to existing file', async () => { + const filePath = path.join(tmpDir, '.zshrc'); + await fs.writeFile(filePath, 'existing content\n'); + await installToProfile(filePath, '# block'); + const content = await fs.readFile(filePath, 'utf-8'); + expect(content).toContain('existing content'); + expect(content).toContain('# block'); + }); + + it('creates new file when none exists', async () => { + const filePath = path.join(tmpDir, '.bashrc'); + await installToProfile(filePath, '# block'); + const content = await fs.readFile(filePath, 'utf-8'); + expect(content).toBe('# block\n'); + }); + + it('creates parent directories', async () => { + const filePath = path.join(tmpDir, 'deep', 'nested', 'profile'); + await installToProfile(filePath, '# block'); + const content = await fs.readFile(filePath, 'utf-8'); + expect(content).toBe('# block\n'); + }); +}); + +describe('removeFromProfile', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'safe-delete-test-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('removes block and preserves surrounding content', async () => { + const filePath = path.join(tmpDir, '.zshrc'); + await fs.writeFile(filePath, [ + 'before content', + '', + '# >>> DevFlow safe-delete >>>', + 'rm() { trash "$@"; }', + '# <<< DevFlow safe-delete <<<', + '', + 'after content', + ].join('\n')); + + const removed = await removeFromProfile(filePath); + expect(removed).toBe(true); + + const content = await fs.readFile(filePath, 'utf-8'); + expect(content).toContain('before content'); + expect(content).toContain('after content'); + expect(content).not.toContain('DevFlow safe-delete'); + }); + + it('returns false for missing file', async () => { + expect(await removeFromProfile(path.join(tmpDir, 'nonexistent'))).toBe(false); + }); + + it('deletes file when block is the only content', async () => { + const filePath = path.join(tmpDir, 'rm.fish'); + await fs.writeFile(filePath, [ + '# >>> DevFlow safe-delete >>>', + 'function rm; trash $argv; end', + '# <<< DevFlow safe-delete <<<', + ].join('\n')); + + const removed = await removeFromProfile(filePath); + expect(removed).toBe(true); + + // File should be deleted + await expect(fs.access(filePath)).rejects.toThrow(); + }); + + it('cleans up surrounding newlines', async () => { + const filePath = path.join(tmpDir, '.zshrc'); + await fs.writeFile(filePath, [ + 'content above', + '', + '', + '# >>> DevFlow safe-delete >>>', + 'block', + '# <<< DevFlow safe-delete <<<', + '', + '', + ].join('\n')); + + await removeFromProfile(filePath); + const content = await fs.readFile(filePath, 'utf-8'); + // Should not have excessive blank lines + expect(content).toBe('content above\n'); + }); +}); diff --git a/tests/safe-delete.test.ts b/tests/safe-delete.test.ts new file mode 100644 index 0000000..f4ab4c4 --- /dev/null +++ b/tests/safe-delete.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { detectPlatform, getSafeDeleteInfo, detectShell, getProfilePath, type Platform, type Shell } from '../src/cli/utils/safe-delete.js'; +import * as path from 'path'; + +describe('detectPlatform', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('returns macos for darwin', () => { + vi.stubGlobal('process', { ...process, platform: 'darwin' }); + expect(detectPlatform()).toBe('macos'); + }); + + it('returns windows for win32', () => { + vi.stubGlobal('process', { ...process, platform: 'win32' }); + expect(detectPlatform()).toBe('windows'); + }); + + it('returns linux for linux', () => { + vi.stubGlobal('process', { ...process, platform: 'linux' }); + expect(detectPlatform()).toBe('linux'); + }); + + it('returns linux for unknown platforms', () => { + vi.stubGlobal('process', { ...process, platform: 'freebsd' }); + expect(detectPlatform()).toBe('linux'); + }); +}); + +describe('getSafeDeleteInfo', () => { + it('returns trash info for macos', () => { + const info = getSafeDeleteInfo('macos'); + expect(info.command).toBe('trash'); + expect(info.installHint).toContain('brew'); + }); + + it('returns trash-put info for linux', () => { + const info = getSafeDeleteInfo('linux'); + expect(info.command).toBe('trash-put'); + expect(info.installHint).toContain('trash-cli'); + }); + + it('returns null command and installHint for windows', () => { + const info = getSafeDeleteInfo('windows'); + expect(info.command).toBeNull(); + expect(info.installHint).toBeNull(); + }); + + it('covers all platform variants', () => { + const platforms: Platform[] = ['macos', 'linux', 'windows']; + for (const p of platforms) { + const result = getSafeDeleteInfo(p); + expect(result).toHaveProperty('command'); + expect(result).toHaveProperty('installHint'); + } + }); +}); + +describe('detectShell', () => { + beforeEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('returns zsh for SHELL=/bin/zsh', () => { + vi.stubEnv('PSModulePath', ''); + vi.stubEnv('SHELL', '/bin/zsh'); + expect(detectShell()).toBe('zsh'); + }); + + it('returns bash for SHELL=/bin/bash', () => { + vi.stubEnv('PSModulePath', ''); + vi.stubEnv('SHELL', '/bin/bash'); + expect(detectShell()).toBe('bash'); + }); + + it('returns fish for SHELL=/usr/bin/fish', () => { + vi.stubEnv('PSModulePath', ''); + vi.stubEnv('SHELL', '/usr/bin/fish'); + expect(detectShell()).toBe('fish'); + }); + + it('returns powershell when PSModulePath is set', () => { + vi.stubEnv('PSModulePath', '/some/path'); + vi.stubEnv('SHELL', '/bin/zsh'); + expect(detectShell()).toBe('powershell'); + }); + + it('returns unknown when SHELL is not set', () => { + vi.stubEnv('PSModulePath', ''); + vi.stubEnv('SHELL', ''); + expect(detectShell()).toBe('unknown'); + }); + + it('returns unknown for unrecognized shell', () => { + vi.stubEnv('PSModulePath', ''); + vi.stubEnv('SHELL', '/usr/local/bin/tcsh'); + expect(detectShell()).toBe('unknown'); + }); +}); + +describe('getProfilePath', () => { + beforeEach(() => { + vi.unstubAllEnvs(); + }); + + it('returns ~/.zshrc for zsh', () => { + vi.stubEnv('HOME', '/home/test'); + const result = getProfilePath('zsh'); + expect(result).toBe(path.join('/home/test', '.zshrc')); + }); + + it('returns ~/.bashrc for bash', () => { + vi.stubEnv('HOME', '/home/test'); + const result = getProfilePath('bash'); + expect(result).toBe(path.join('/home/test', '.bashrc')); + }); + + it('returns fish functions path for fish', () => { + vi.stubEnv('HOME', '/home/test'); + const result = getProfilePath('fish'); + expect(result).toBe(path.join('/home/test', '.config', 'fish', 'functions', 'rm.fish')); + }); + + it('returns powershell profile for powershell on unix', () => { + vi.stubEnv('HOME', '/home/test'); + vi.stubGlobal('process', { ...process, platform: 'darwin' }); + const result = getProfilePath('powershell'); + expect(result).toBe(path.join('/home/test', '.config', 'powershell', 'Microsoft.PowerShell_profile.ps1')); + }); + + it('returns null for unknown shell', () => { + expect(getProfilePath('unknown')).toBeNull(); + }); +}); diff --git a/tests/uninstall-logic.test.ts b/tests/uninstall-logic.test.ts new file mode 100644 index 0000000..c8c0bc0 --- /dev/null +++ b/tests/uninstall-logic.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { computeAssetsToRemove } from '../src/cli/commands/uninstall.js'; +import { DEVFLOW_PLUGINS, type PluginDefinition } from '../src/cli/plugins.js'; + +describe('computeAssetsToRemove', () => { + it('removes skills unique to selected plugins', () => { + // devflow-debug has no unique skills (agent-teams + git-safety shared), pick a plugin with unique assets + const debugPlugin = DEVFLOW_PLUGINS.find(p => p.name === 'devflow-debug')!; + const { skills } = computeAssetsToRemove([debugPlugin], DEVFLOW_PLUGINS); + + // 'agent-teams' is shared with other plugins, should NOT be in removal list + expect(skills).not.toContain('agent-teams'); + // 'git-safety' is also in core-skills, should NOT be in removal list + expect(skills).not.toContain('git-safety'); + }); + + it('removes agents unique to selected plugins', () => { + // devflow-audit-claude has agent 'claude-md-auditor' which is unique to it + const auditPlugin = DEVFLOW_PLUGINS.find(p => p.name === 'devflow-audit-claude')!; + const { agents } = computeAssetsToRemove([auditPlugin], DEVFLOW_PLUGINS); + expect(agents).toContain('claude-md-auditor'); + }); + + it('retains agents shared with remaining plugins', () => { + // 'git' agent is in implement, review, resolve, debug + // Removing just debug should NOT remove 'git' + const debugPlugin = DEVFLOW_PLUGINS.find(p => p.name === 'devflow-debug')!; + const { agents } = computeAssetsToRemove([debugPlugin], DEVFLOW_PLUGINS); + expect(agents).not.toContain('git'); + }); + + it('collects all commands from selected plugins', () => { + const reviewPlugin = DEVFLOW_PLUGINS.find(p => p.name === 'devflow-review')!; + const { commands } = computeAssetsToRemove([reviewPlugin], DEVFLOW_PLUGINS); + expect(commands).toContain('/review'); + }); + + it('returns empty arrays when no plugins selected', () => { + const { skills, agents, commands } = computeAssetsToRemove([], DEVFLOW_PLUGINS); + expect(skills).toEqual([]); + expect(agents).toEqual([]); + expect(commands).toEqual([]); + }); + + it('removes everything when all plugins selected', () => { + const { skills, agents, commands } = computeAssetsToRemove(DEVFLOW_PLUGINS, DEVFLOW_PLUGINS); + // When all plugins are removed, nothing is retained + expect(skills.length).toBeGreaterThan(0); + expect(agents.length).toBeGreaterThan(0); + // Core-skills has no commands, but other plugins do + expect(commands.length).toBeGreaterThan(0); + }); + + it('handles custom plugin lists', () => { + const plugins: PluginDefinition[] = [ + { name: 'a', description: '', commands: ['/a'], agents: ['shared', 'only-a'], skills: ['shared-skill', 'only-a-skill'] }, + { name: 'b', description: '', commands: ['/b'], agents: ['shared', 'only-b'], skills: ['shared-skill', 'only-b-skill'] }, + ]; + + // Remove 'a', keep 'b' + const { skills, agents, commands } = computeAssetsToRemove([plugins[0]], plugins); + expect(commands).toEqual(['/a']); + expect(agents).toEqual(['only-a']); // 'shared' is retained by 'b' + expect(skills).toEqual(['only-a-skill']); // 'shared-skill' is retained by 'b' + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..976169b --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + root: '.', + include: ['tests/**/*.test.ts'], + globals: false, + environment: 'node', + restoreMocks: true, + }, + resolve: { + alias: { + '#cli': new URL('./src/cli/', import.meta.url).pathname, + }, + }, +});