From 092d8f5456199d2e1f202e43e005b3a12cea3755 Mon Sep 17 00:00:00 2001 From: Jaehyun Yeom Date: Mon, 4 May 2026 23:16:18 -0700 Subject: [PATCH 01/21] Add claude-toolbox integration design spec Phase 1 design for additive claude-toolbox integration: stack-aware bundle resolution, project-scoped settings.json merge, init/add surfacing, and overlap-skip default with opt-in drop-replace path deferred to Phase 2. --- ...05-04-claude-toolbox-integration-design.md | 320 ++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-04-claude-toolbox-integration-design.md diff --git a/docs/superpowers/specs/2026-05-04-claude-toolbox-integration-design.md b/docs/superpowers/specs/2026-05-04-claude-toolbox-integration-design.md new file mode 100644 index 0000000..ae486b3 --- /dev/null +++ b/docs/superpowers/specs/2026-05-04-claude-toolbox-integration-design.md @@ -0,0 +1,320 @@ +# claude-toolbox Integration Design + +**Date:** 2026-05-04 +**Status:** Spec — pending implementation plan +**Scope:** Phase 1 — additive expansion. Phase 2 (replacement of overlapping ROBOCO outputs) is out of scope for this spec. + +## Goal + +Make `jaeyeom/claude-toolbox` plugins available through `roboco init` and `roboco add`, with stack-aware bundle selection and team-consistent installation. + +## Decisions + +| Axis | Choice | +|---|---| +| Direction | Additive expansion. Replacement of overlapping ROBOCO outputs deferred to Phase 2. | +| Granularity | Curated bundles, not per-plugin entries. | +| Install mechanism | Project-scoped `.claude/settings.json` (team consistency) + `claude plugin install` subprocess on initiating machine (immediate availability). | +| Bundle shape | Stack-aware: `CORE_BUNDLE` + `STACK_OVERLAYS` + `SIGNAL_OVERLAYS`. | +| Surfacing | Both interview-time (`roboco init`) and post-init (`roboco add`). | +| Overlap handling | Skip overlapping plugins from default bundle. Opt-in only via `roboco add toolbox:` with drop-replace prompt. | + +## Non-Goals + +- Replacing ROBOCO's existing CLAUDE.md generation, husky hooks, deny list, or CI workflow with toolbox equivalents (Phase 2). +- Per-plugin granularity in the default bundle. Users who want a single plugin use `roboco add toolbox:`. +- Installing every toolbox plugin. Niche plugins (`apply-figma-make`, `cloudflare-macos-fix`, `create-lang-dev-skill`, Jira plugins) are not in defaults. + +## Architecture + +Three new code units; no new architectural concepts. Matches the existing `installer.ts` + `KNOWN_TOOLS` + interview pattern that OMC and MCP servers already use. + +``` +src/ + core/ + toolbox-bundles.ts # NEW: bundle definitions, resolution, overlap remediation + installer.ts # +installToolbox(), +installSingleToolboxPlugin() + generator.ts # +mergeToolboxSettings() into deepMergeSettings + commands/ + add.ts # +"toolbox" entry, +"toolbox:" parsing + types/ + index.ts # +toolbox flag in ToolSelection +``` + +## Bundle Resolution + +Pure function in `core/toolbox-bundles.ts`: + +```ts +export const MARKETPLACE = { + name: 'claude-toolbox', + source: { source: 'github', repo: 'jaeyeom/claude-toolbox' }, +}; + +export const CORE_BUNDLE = [ + 'next-action', + 'todo', + 'gh-issue-resolver', + 'semgrep-review', + 'sandbox-helpers', + 'makefile-workflow', +]; + +export const STACK_OVERLAYS: Record = { + Go: ['go-dev'], + TypeScript: ['biome-vcs-integration'], + JavaScript: ['biome-vcs-integration'], + // Python, Rust, Java: empty until toolbox ships *-dev plugins +}; + +export const SIGNAL_OVERLAYS: Record = { + hasProto: ['protobuf-dev'], +}; + +export const OVERLAPPING_PLUGINS = new Set([ + 'claude-md', + 'gabyx-githooks-setup', + 'git-guardrails', + 'ci-workflow', +]); + +export function resolveBundle( + languages: string[], + signals: { hasProto?: boolean }, +): string[] { + const stackOverlay = languages.flatMap((l) => STACK_OVERLAYS[l] ?? []); + const signalOverlay = Object.entries(signals) + .filter(([, on]) => on) + .flatMap(([key]) => SIGNAL_OVERLAYS[key] ?? []); + return [...new Set([...CORE_BUNDLE, ...stackOverlay, ...signalOverlay])] + .filter((p) => !OVERLAPPING_PLUGINS.has(p)); +} +``` + +Resolved examples: + +| Stack / signal | Plugins installed | +|---|---| +| TypeScript | core 6 + `biome-vcs-integration` | +| Go | core 6 + `go-dev` | +| TypeScript + `.proto` files | core 6 + `biome-vcs-integration` + `protobuf-dev` | +| Python / Rust / Java | core 6 | + +**Note:** Until the toolbox ships `*-dev` plugins for non-Go/TS stacks, those stacks get the same 6 core plugins. The stack-aware structure exists to absorb future toolbox additions without code changes to the bundle shape. + +## Settings.json Merge Schema + +Two keys land in the project's `.claude/settings.json`: + +```json +{ + "extraKnownMarketplaces": { + "claude-toolbox": { + "source": { "source": "github", "repo": "jaeyeom/claude-toolbox" } + } + }, + "enabledPlugins": { + "next-action@claude-toolbox": true, + "todo@claude-toolbox": true, + "gh-issue-resolver@claude-toolbox": true, + "semgrep-review@claude-toolbox": true, + "sandbox-helpers@claude-toolbox": true, + "makefile-workflow@claude-toolbox": true + } +} +``` + +`generator.ts:172` `deepMergeSettings` currently special-cases only `permissions` (union arrays). Extend with two keys: + +| Key | Merge rule | +|---|---| +| `extraKnownMarketplaces` | Add `claude-toolbox` entry if absent. If present with matching `source`, no-op. If present with **different** source, log warning and skip (user has a custom marketplace fork). | +| `enabledPlugins` | For each `X@claude-toolbox`: set `true` if absent, no-op if already `true`, **respect explicit `false`** (never overwrite a user disable). | + +```ts +function mergeToolboxSettings( + existing: Record, + bundle: string[], +): Record { + const result = { ...existing }; + + const marketplaces = (result.extraKnownMarketplaces ?? {}) as Record; + if (!marketplaces['claude-toolbox']) { + marketplaces['claude-toolbox'] = { + source: { source: 'github', repo: 'jaeyeom/claude-toolbox' }, + }; + } + result.extraKnownMarketplaces = marketplaces; + + const plugins = (result.enabledPlugins ?? {}) as Record; + for (const name of bundle) { + const key = `${name}@claude-toolbox`; + if (plugins[key] !== false) plugins[key] = true; + } + result.enabledPlugins = plugins; + + return result; +} +``` + +## Install Flow during `roboco init` + +New `ToolSelection` field: + +```ts +// types/index.ts +export interface ToolSelection { + // ... existing + toolbox: boolean; +} +``` + +Interview adds one question: + +> "Install the claude-toolbox bundle? Adds task workflow (next-action, todo, gh-issue-resolver), security review (semgrep-review), and check orchestration (makefile-workflow). +> Detected stack: → also installs ``. [Y/n]" + +Default: `yes`. The detected-stack line is omitted when no overlay applies. + +`installToolbox(analysis)` runs alongside the existing `installTools()`: + +``` +installToolbox(analysis): + 1. bundle = resolveBundle(analysis.stack.languages, analysis.signals) + 2. mergeToolboxSettings → write project .claude/settings.json + 3. subprocess: claude plugin marketplace add jaeyeom/claude-toolbox + ↳ on failure: warn, skip step 4, return InstallResult{success: false} + 4. for each plugin in bundle: + subprocess: claude plugin install @claude-toolbox + ↳ on failure: warn for that plugin, continue with rest + 5. return InstallResult{ + tool: 'claude-toolbox', + success: marketplaceOk && atLeastOnePluginInstalled, + message: "Installed N/M plugins. Teammates: run `roboco install` to enable." + } +``` + +**Order matters:** settings.json edit happens *first* — it is the source of truth for team consistency and survives subprocess failure. Subprocess install is a convenience for the initiating user (skip Claude Code restart). If subprocess fails entirely, the project settings.json still records intent and `roboco install` can retry. + +## `roboco add toolbox` and `roboco add toolbox:` + +`add.ts:8` `KNOWN_TOOLS` gets one entry: + +```ts +toolbox: { key: 'toolbox', description: 'claude-toolbox bundle (stack-aware)' } +``` + +Two parsing paths in `addCommand`: + +``` +roboco add toolbox → installToolbox(analysis) # same as init +roboco add toolbox: → installSingleToolboxPlugin() # opt-in single +``` + +`installSingleToolboxPlugin` enables Phase 2: + +``` +installSingleToolboxPlugin(name): + 1. if name not in known toolbox catalog → error + 2. if name in OVERLAPPING_PLUGINS: + prompt: "This replaces ROBOCO's . Drop ROBOCO's version? [y/N]" + on yes: + run OVERLAP_REMEDIATION[name].cleanup() + record override in .roboco/config.json overrides[] (future generator runs skip it) + on no: + warn: "Both will coexist — manual cleanup may be needed" + 3. read existing .claude/settings.json; mergeToolboxSettings(existing, [name]) → write back + 4. subprocess: claude plugin install @claude-toolbox +``` + +Overlap remediation registry in `core/toolbox-bundles.ts`: + +```ts +export const OVERLAP_REMEDIATION: Record Promise }> = { + 'gabyx-githooks-setup': { description: '.husky/pre-commit', cleanup: removeHuskyHook }, + 'ci-workflow': { description: '.github/workflows/vibe-coding-check.yml', cleanup: removeCiWorkflow }, + 'git-guardrails': { description: '.claude/settings.json deny entries', cleanup: removeRobocoDenyList }, + 'claude-md': { description: ' block in CLAUDE.md', cleanup: removeRobocoBlock }, +}; +``` + +Phase 2 work later: when an overlap drop becomes the default, that lives in updating `generator.ts` to skip the corresponding output when `.roboco/config.json` records the override — this spec only defines the override-recording mechanism, not the swap. + +`.roboco/config.json` override schema: + +```ts +// types/index.ts — extend RobocoConfig +export interface RobocoConfig { + // ... existing + overrides?: { + /** Generator outputs the user has opted out of in favor of toolbox plugins. */ + skipGeneratorOutputs?: Array< + | 'husky-pre-commit' + | 'ci-workflow-vibe-coding-check' + | 'claude-deny-list' + | 'claude-md-roboco-block' + >; + }; +} +``` + +`OVERLAP_REMEDIATION[name]` declares which `skipGeneratorOutputs` token to add when the user accepts the drop. `generator.ts` consults `config.overrides?.skipGeneratorOutputs` and skips matching emit branches on subsequent runs. + +## Error Handling + +| Source | Handling | +|---|---| +| `claude` CLI absent | Same as `installOMC` — warn with manual install command, return `success: false`, do not abort `roboco init` | +| `claude plugin install` timeout (60s/plugin) | Continue with rest of bundle | +| Marketplace add fails | Skip plugin installs (they would all fail), settings.json still written | +| Settings.json write fails | Abort `installToolbox` — settings is source of truth, silent corruption is worse than visible failure | +| Overlap drop-replace cleanup fails | Log warning, leave settings.json change intact, surface in final report | + +User-facing messages follow CLAUDE.md's "friendly with actionable next steps" rule: + +``` +✗ claude-toolbox: marketplace add failed + → Run manually: claude plugin marketplace add jaeyeom/claude-toolbox + → Then re-run: roboco add toolbox +``` + +`InstallResult` shape unchanged — no new error type required. + +## Testing + +Tests mirror `src/` structure per CLAUDE.md. + +| Test file | Coverage | +|---|---| +| `tests/core/toolbox-bundles.test.ts` (new) | `resolveBundle` pure function — fixture inputs (TS / Go / Python / multi-stack / hasProto), assert exact plugin lists, assert overlap exclusion | +| `tests/core/installer.test.ts` (extend) | `installToolbox` — mock `execa`; assert correct `claude plugin install` invocations per resolved bundle; assert settings.json write content; assert partial-failure behavior (marketplace ok + one plugin fails) | +| `tests/commands/add.test.ts` (extend) | `roboco add toolbox` invokes bundle installer; `roboco add toolbox:` parses and dispatches single-plugin path; overlap prompt path with mocked user input (yes runs cleanup, no warns) | + +Existing framework: vitest + execa mocks. No new framework or fixture style. + +## Verification Gate + +The install mechanism (project-scoped `.claude/settings.json` + subprocess) assumes Claude Code honors project-level `enabledPlugins` and auto-fetches the marketplace on first run for teammates who clone the repo. **This must be verified before implementation lands.** + +Smoke test: + +1. Write a minimal project `.claude/settings.json` containing only `extraKnownMarketplaces` and `enabledPlugins`. +2. Launch Claude Code in a clean home directory (no `~/.claude/settings.json` entries for claude-toolbox). +3. Confirm the marketplace is fetched and the listed plugins activate. + +**If verified:** proceed with the design as specified. +**If not:** fall back to subprocess-only install (option A from the install-mechanism question). Document teammate workflow in `.roboco/config.json`-driven `roboco install` flow: `install` runs `claude plugin install` for every plugin recorded in `installedTools`. Update this spec to reflect the fallback before implementation. + +This gate must be a task in the implementation plan. + +## Out of Scope (Phase 2) + +These are documented here so future-us remembers what *not* to bundle into the Phase 1 implementation: + +- Switching ROBOCO's husky pre-commit to `gabyx-githooks-setup` by default. +- Switching ROBOCO's `.github/workflows/vibe-coding-check.yml` to `ci-workflow` by default. +- Auto-invoking the `claude-md` skill after `claude /init`. +- Adding `git-guardrails` deny entries to ROBOCO's default deny list. +- Per-overlap interview questions ("ROBOCO or toolbox for hooks?"). + +Each Phase 2 swap is its own decision and its own PR. From e197ab36429c1183a03f3d5512a9a15d43a55b5d Mon Sep 17 00:00:00 2001 From: Jaehyun Yeom Date: Mon, 4 May 2026 23:29:26 -0700 Subject: [PATCH 02/21] Add claude-toolbox integration implementation plan Bite-sized TDD-shaped tasks covering type changes, bundle resolution, settings merge, installer, interview integration, add command (bundle and single-plugin paths), overlap remediation, generator override hook, README, and verification smoke test. --- .../2026-05-04-claude-toolbox-integration.md | 1755 +++++++++++++++++ 1 file changed, 1755 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-04-claude-toolbox-integration.md diff --git a/docs/superpowers/plans/2026-05-04-claude-toolbox-integration.md b/docs/superpowers/plans/2026-05-04-claude-toolbox-integration.md new file mode 100644 index 0000000..33fc32b --- /dev/null +++ b/docs/superpowers/plans/2026-05-04-claude-toolbox-integration.md @@ -0,0 +1,1755 @@ +# claude-toolbox Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add Phase 1 claude-toolbox marketplace integration to ROBOCO CLI — stack-aware bundle resolution, project-scoped settings.json merge, init/add surfacing, and overlap-skip default with opt-in drop-replace. + +**Architecture:** New `core/toolbox-bundles.ts` defines bundle data and a pure `resolveBundle` function. New `installToolbox` / `installSingleToolboxPlugin` in `core/installer.ts` follow the existing `installOMC` pattern (subprocess via `execa`, return `InstallResult`). New `mergeToolboxSettings` extends `core/generator.ts`'s settings merge. `commands/add.ts` parses `toolbox` and `toolbox:` forms. Types extended in `types/{interview,config,analysis}.ts`. + +**Tech Stack:** TypeScript (strict ESM), Node 24, Commander, execa, vitest. + +**Spec:** [docs/superpowers/specs/2026-05-04-claude-toolbox-integration-design.md](../specs/2026-05-04-claude-toolbox-integration-design.md) + +--- + +## File Map + +**New files:** +- `src/core/toolbox-bundles.ts` — bundle data, `resolveBundle`, `OVERLAPPING_PLUGINS`, `OVERLAP_REMEDIATION` registry +- `tests/unit/toolbox-bundles.test.ts` — unit tests for `resolveBundle` +- `tests/unit/toolbox-install.test.ts` — unit tests for `installToolbox` and `installSingleToolboxPlugin` (subprocess mocked) +- `tests/unit/toolbox-add.test.ts` — unit tests for `roboco add toolbox` and `roboco add toolbox:` parsing/dispatch + +**Modified files:** +- `src/types/interview.ts` — add `toolbox: boolean` to `ToolSelection` +- `src/types/config.ts` — add `overrides?: { skipGeneratorOutputs?: OverrideKey[] }` to `RobocoConfig` +- `src/types/analysis.ts` — add `signals: RepoSignals` to `AnalysisResult` +- `src/core/analyzer.ts` — detect `.proto` files, populate `signals.hasProto` +- `src/core/generator.ts` — add `mergeToolboxSettings`, consult `config.overrides` to skip emit branches +- `src/core/installer.ts` — add `installToolbox`, `installSingleToolboxPlugin`; call `installToolbox` when `tools.toolbox` is true +- `src/core/interviewer.ts` — add toolbox question to interactive/auto/AI paths +- `src/commands/add.ts` — add `toolbox` to `KNOWN_TOOLS`; parse `toolbox:` form + +--- + +## Task 1: Add type definitions + +**Files:** +- Modify: `src/types/interview.ts` +- Modify: `src/types/config.ts` +- Modify: `src/types/analysis.ts` + +- [ ] **Step 1: Extend `ToolSelection` with `toolbox`** + +Edit `src/types/interview.ts`: + +```ts +export interface ToolSelection { + omc: true; + openspec: boolean; + exaAi: boolean; + perplexityAsk: boolean; + githubMcp: boolean; + context7: boolean; + harness: boolean; + toolbox: boolean; +} +``` + +- [ ] **Step 2: Add `RepoSignals` and extend `AnalysisResult`** + +Edit `src/types/analysis.ts` — add the interface and the field: + +```ts +export interface RepoSignals { + hasProto: boolean; +} + +export interface AnalysisResult { + path: string; + stack: StackInfo; + structure: RepoStructure; + existing: ExistingConfig; + git: GitInfo; + signals: RepoSignals; +} +``` + +- [ ] **Step 3: Re-export `RepoSignals` from `types/index.ts`** + +Edit `src/types/index.ts`: + +```ts +export type { + StackInfo, + RepoStructure, + ExistingConfig, + GitInfo, + AnalysisResult, + RepoSignals, +} from './analysis.js'; +``` + +- [ ] **Step 4: Add overrides schema to `RobocoConfig`** + +Edit `src/types/config.ts`: + +```ts +import type { AnalysisResult } from './analysis.js'; +import type { InterviewResult } from './interview.js'; + +export type OverrideKey = + | 'husky-pre-commit' + | 'ci-workflow-vibe-coding-check' + | 'claude-deny-list' + | 'claude-md-roboco-block'; + +export interface RobocoConfig { + version: string; + createdAt: string; + updatedAt: string; + analysis: AnalysisResult; + interview: InterviewResult; + installedTools: string[]; + overrides?: { + skipGeneratorOutputs?: OverrideKey[]; + }; +} +``` + +- [ ] **Step 5: Re-export `OverrideKey` from `types/index.ts`** + +```ts +export type { RobocoConfig, GlobalConfig, OverrideKey } from './config.js'; +``` + +- [ ] **Step 6: Run typecheck — expect failures in callers** + +Run: `npm run typecheck` +Expected: TypeScript reports missing `toolbox` and `signals` properties in `interviewer.ts`, `commands/init.ts`, test fixtures, etc. Note the failures — Tasks 2–8 fix them. + +- [ ] **Step 7: Commit** + +```bash +git add src/types/ +git commit -m "Add types for toolbox integration + +ToolSelection.toolbox, RepoSignals.hasProto, RobocoConfig.overrides." +``` + +--- + +## Task 2: Bundle resolution module + +**Files:** +- Create: `src/core/toolbox-bundles.ts` +- Create: `tests/unit/toolbox-bundles.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `tests/unit/toolbox-bundles.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { resolveBundle, MARKETPLACE, CORE_BUNDLE, OVERLAPPING_PLUGINS } from '../../src/core/toolbox-bundles.js'; + +describe('resolveBundle', () => { + it('returns core bundle for stacks with no overlay', () => { + expect(resolveBundle(['Python'], { hasProto: false })).toEqual(CORE_BUNDLE); + }); + + it('adds go-dev for Go stack', () => { + const result = resolveBundle(['Go'], { hasProto: false }); + expect(result).toEqual([...CORE_BUNDLE, 'go-dev']); + }); + + it('adds biome-vcs-integration for TypeScript', () => { + const result = resolveBundle(['TypeScript'], { hasProto: false }); + expect(result).toEqual([...CORE_BUNDLE, 'biome-vcs-integration']); + }); + + it('adds biome-vcs-integration for JavaScript', () => { + const result = resolveBundle(['JavaScript'], { hasProto: false }); + expect(result).toEqual([...CORE_BUNDLE, 'biome-vcs-integration']); + }); + + it('does not duplicate when both TypeScript and JavaScript detected', () => { + const result = resolveBundle(['TypeScript', 'JavaScript'], { hasProto: false }); + expect(result.filter((p) => p === 'biome-vcs-integration')).toHaveLength(1); + }); + + it('adds protobuf-dev when hasProto signal is true', () => { + const result = resolveBundle(['Go'], { hasProto: true }); + expect(result).toContain('protobuf-dev'); + expect(result).toContain('go-dev'); + }); + + it('excludes overlapping plugins from defaults', () => { + const result = resolveBundle(['Go', 'TypeScript'], { hasProto: true }); + for (const overlap of OVERLAPPING_PLUGINS) { + expect(result).not.toContain(overlap); + } + }); + + it('returns marketplace metadata pointing at jaeyeom/claude-toolbox', () => { + expect(MARKETPLACE.name).toBe('claude-toolbox'); + expect(MARKETPLACE.source).toEqual({ source: 'github', repo: 'jaeyeom/claude-toolbox' }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/unit/toolbox-bundles.test.ts` +Expected: FAIL with "Cannot find module '../../src/core/toolbox-bundles.js'" + +- [ ] **Step 3: Implement the module** + +Create `src/core/toolbox-bundles.ts`: + +```ts +export const MARKETPLACE = { + name: 'claude-toolbox', + source: { source: 'github', repo: 'jaeyeom/claude-toolbox' }, +} as const; + +export const CORE_BUNDLE: readonly string[] = [ + 'next-action', + 'todo', + 'gh-issue-resolver', + 'semgrep-review', + 'sandbox-helpers', + 'makefile-workflow', +]; + +export const STACK_OVERLAYS: Record = { + Go: ['go-dev'], + TypeScript: ['biome-vcs-integration'], + JavaScript: ['biome-vcs-integration'], +}; + +export const SIGNAL_OVERLAYS: Record = { + hasProto: ['protobuf-dev'], +}; + +export const OVERLAPPING_PLUGINS = new Set([ + 'claude-md', + 'gabyx-githooks-setup', + 'git-guardrails', + 'ci-workflow', +]); + +export interface ResolveSignals { + hasProto: boolean; +} + +export function resolveBundle(languages: string[], signals: ResolveSignals): string[] { + const stackOverlay = languages.flatMap((l) => STACK_OVERLAYS[l] ?? []); + const signalOverlay = (Object.entries(signals) as Array<[keyof ResolveSignals, boolean]>) + .filter(([, on]) => on) + .flatMap(([key]) => SIGNAL_OVERLAYS[key] ?? []); + return [...new Set([...CORE_BUNDLE, ...stackOverlay, ...signalOverlay])].filter( + (p) => !OVERLAPPING_PLUGINS.has(p), + ); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/unit/toolbox-bundles.test.ts` +Expected: PASS — all 8 tests green. + +- [ ] **Step 5: Commit** + +```bash +git add src/core/toolbox-bundles.ts tests/unit/toolbox-bundles.test.ts +git commit -m "Add toolbox bundle resolution + +Stack-aware resolveBundle with core/stack/signal overlays and overlap exclusion." +``` + +--- + +## Task 3: Detect protobuf signal in analyzer + +**Files:** +- Modify: `src/core/analyzer.ts` +- Modify: `tests/unit/analyzer.test.ts` + +- [ ] **Step 1: Read existing analyzer test for style** + +Run: `head -40 tests/unit/analyzer.test.ts` and note the fixture-temp-dir pattern. + +- [ ] **Step 2: Write the failing test** + +Add to `tests/unit/analyzer.test.ts` (in the appropriate describe block — match existing style; if unsure, add a new describe block at the bottom): + +```ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { analyze } from '../../src/core/analyzer.js'; + +describe('analyzer signals', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'roboco-signal-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('hasProto is false when no .proto files exist', async () => { + const result = await analyze(tempDir); + expect(result.signals.hasProto).toBe(false); + }); + + it('hasProto is true when a .proto file exists at root', async () => { + await writeFile(join(tempDir, 'service.proto'), 'syntax = "proto3";\n'); + const result = await analyze(tempDir); + expect(result.signals.hasProto).toBe(true); + }); + + it('hasProto is true when a .proto file exists in a subdirectory', async () => { + await mkdir(join(tempDir, 'proto')); + await writeFile(join(tempDir, 'proto', 'service.proto'), 'syntax = "proto3";\n'); + const result = await analyze(tempDir); + expect(result.signals.hasProto).toBe(true); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npx vitest run tests/unit/analyzer.test.ts -t "analyzer signals"` +Expected: FAIL — `result.signals` is undefined. + +- [ ] **Step 4: Implement signal detection** + +Edit `src/core/analyzer.ts`. Add a `detectSignals` function and wire it into `analyze`: + +```ts +import type { + AnalysisResult, + StackInfo, + RepoStructure, + ExistingConfig, + GitInfo, + RepoSignals, +} from '../types/index.js'; + +export async function analyze(targetPath: string): Promise { + try { + await access(targetPath); + } catch { + throw new Error(`Directory not found: ${targetPath}`); + } + const [stack, structure, existing, git, signals] = await Promise.all([ + detectStack(targetPath), + scanStructure(targetPath), + checkExisting(targetPath), + getGitInfo(targetPath), + detectSignals(targetPath), + ]); + return { path: targetPath, stack, structure, existing, git, signals }; +} + +async function detectSignals(rootPath: string): Promise { + const hasProto = await containsExtension(rootPath, '.proto', 4); + return { hasProto }; +} + +async function containsExtension( + rootPath: string, + ext: string, + maxDepth: number, +): Promise { + const ignore = new Set(['.git', 'node_modules', 'dist', 'build', '.next', 'target', 'vendor']); + async function walk(dir: string, depth: number): Promise { + if (depth > maxDepth) return false; + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return false; + } + for (const e of entries) { + if (e.isFile() && e.name.endsWith(ext)) return true; + } + for (const e of entries) { + if (e.isDirectory() && !ignore.has(e.name) && !e.name.startsWith('.')) { + if (await walk(join(dir, e.name), depth + 1)) return true; + } + } + return false; + } + return walk(rootPath, 0); +} +``` + +- [ ] **Step 5: Run new tests + existing tests** + +Run: `npx vitest run tests/unit/analyzer.test.ts` +Expected: PASS — both new "analyzer signals" tests and all pre-existing analyzer tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/core/analyzer.ts tests/unit/analyzer.test.ts +git commit -m "Detect .proto files in analyzer signals + +Adds AnalysisResult.signals.hasProto used by toolbox bundle resolution." +``` + +--- + +## Task 4: Settings.json merge for toolbox marketplace + plugins + +**Files:** +- Modify: `src/core/generator.ts` +- Modify: `tests/unit/settings-merge.test.ts` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/unit/settings-merge.test.ts` (extend the existing describe block): + +```ts +import { mergeToolboxSettings } from '../../src/core/generator.js'; + +describe('mergeToolboxSettings', () => { + it('writes marketplace + enabledPlugins on empty settings', () => { + const result = mergeToolboxSettings({}, ['next-action', 'todo']); + expect(result['extraKnownMarketplaces']).toEqual({ + 'claude-toolbox': { source: { source: 'github', repo: 'jaeyeom/claude-toolbox' } }, + }); + expect(result['enabledPlugins']).toEqual({ + 'next-action@claude-toolbox': true, + 'todo@claude-toolbox': true, + }); + }); + + it('preserves unrelated existing keys', () => { + const existing = { permissions: { allow: ['Read'], deny: [] } }; + const result = mergeToolboxSettings(existing, ['next-action']); + expect(result['permissions']).toEqual({ allow: ['Read'], deny: [] }); + }); + + it('does not overwrite explicit false in enabledPlugins', () => { + const existing = { enabledPlugins: { 'next-action@claude-toolbox': false } }; + const result = mergeToolboxSettings(existing, ['next-action', 'todo']); + expect(result['enabledPlugins']).toEqual({ + 'next-action@claude-toolbox': false, + 'todo@claude-toolbox': true, + }); + }); + + it('is idempotent on re-run with same bundle', () => { + const first = mergeToolboxSettings({}, ['next-action']); + const second = mergeToolboxSettings(first, ['next-action']); + expect(second).toEqual(first); + }); + + it('preserves an existing matching marketplace entry', () => { + const existing = { + extraKnownMarketplaces: { + 'claude-toolbox': { source: { source: 'github', repo: 'jaeyeom/claude-toolbox' } }, + }, + }; + const result = mergeToolboxSettings(existing, []); + expect(result['extraKnownMarketplaces']).toEqual(existing.extraKnownMarketplaces); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/unit/settings-merge.test.ts -t "mergeToolboxSettings"` +Expected: FAIL — `mergeToolboxSettings` is not exported. + +- [ ] **Step 3: Implement and export `mergeToolboxSettings`** + +Edit `src/core/generator.ts`. Add the export at module scope: + +```ts +import { MARKETPLACE } from './toolbox-bundles.js'; + +export function mergeToolboxSettings( + existing: Record, + bundle: string[], +): Record { + const result = { ...existing }; + + const marketplaces = ((result['extraKnownMarketplaces'] ?? {}) as Record); + const newMarketplaces = { ...marketplaces }; + if (!newMarketplaces[MARKETPLACE.name]) { + newMarketplaces[MARKETPLACE.name] = { source: MARKETPLACE.source }; + } + result['extraKnownMarketplaces'] = newMarketplaces; + + const plugins = ((result['enabledPlugins'] ?? {}) as Record); + const newPlugins = { ...plugins }; + for (const name of bundle) { + const key = `${name}@${MARKETPLACE.name}`; + if (newPlugins[key] !== false) newPlugins[key] = true; + } + result['enabledPlugins'] = newPlugins; + + return result; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/unit/settings-merge.test.ts` +Expected: PASS — new tests + all pre-existing tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/core/generator.ts tests/unit/settings-merge.test.ts +git commit -m "Add mergeToolboxSettings to generator + +Writes extraKnownMarketplaces + enabledPlugins respecting explicit false." +``` + +--- + +## Task 5: installToolbox subprocess flow + +**Files:** +- Modify: `src/core/installer.ts` +- Create: `tests/unit/toolbox-install.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `tests/unit/toolbox-install.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mkdtemp, rm, readFile, mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +vi.mock('execa', () => ({ + execa: vi.fn(), +})); + +import { execa } from 'execa'; +import { installToolbox } from '../../src/core/installer.js'; +import type { AnalysisResult } from '../../src/types/index.js'; + +const execaMock = execa as unknown as ReturnType; + +function makeAnalysis(tempDir: string, languages: string[] = ['TypeScript']): AnalysisResult { + return { + path: tempDir, + stack: { languages, frameworks: [], buildTools: [], packageManager: 'npm', hasTypeScript: languages.includes('TypeScript') }, + structure: { rootFiles: [], rootDirs: [], sourceDir: 'src', testDir: 'tests', hasMonorepo: false }, + existing: { hasClaude: false, hasClaudeMd: false, hasOmc: false, hasRoboco: false, hasOpenSpec: false, claudeSettings: null, claudeSkills: [], claudeCommands: [], globalSettings: null, globalSkills: [] }, + git: { isRepo: true, remoteUrl: null, repoName: 'test', branch: 'main' }, + signals: { hasProto: false }, + }; +} + +describe('installToolbox', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'roboco-toolbox-')); + execaMock.mockReset(); + execaMock.mockResolvedValue({ stdout: '', stderr: '' }); + }); + + it('writes project .claude/settings.json with marketplace + plugins for TypeScript', async () => { + await installToolbox(makeAnalysis(tempDir)); + const raw = await readFile(join(tempDir, '.claude', 'settings.json'), 'utf-8'); + const parsed = JSON.parse(raw); + expect(parsed.extraKnownMarketplaces['claude-toolbox']).toBeDefined(); + expect(parsed.enabledPlugins['biome-vcs-integration@claude-toolbox']).toBe(true); + expect(parsed.enabledPlugins['next-action@claude-toolbox']).toBe(true); + }); + + it('runs marketplace add then plugin install per bundle entry', async () => { + await installToolbox(makeAnalysis(tempDir, ['Go'])); + const calls = execaMock.mock.calls.map((c) => c[1]); + expect(calls[0]).toEqual(['plugin', 'marketplace', 'add', 'jaeyeom/claude-toolbox']); + const installArgs = calls.slice(1).map((args) => args[2]); + expect(installArgs).toContain('next-action@claude-toolbox'); + expect(installArgs).toContain('go-dev@claude-toolbox'); + }); + + it('skips plugin installs when marketplace add fails', async () => { + execaMock.mockReset(); + execaMock.mockRejectedValueOnce(new Error('marketplace failed')); + const result = await installToolbox(makeAnalysis(tempDir)); + expect(execaMock).toHaveBeenCalledTimes(1); + expect(result.success).toBe(false); + }); + + it('continues installing remaining plugins when one install fails', async () => { + execaMock.mockReset(); + execaMock.mockResolvedValueOnce({ stdout: '', stderr: '' }); // marketplace add ok + execaMock.mockRejectedValueOnce(new Error('plugin install failed')); // first plugin fails + execaMock.mockResolvedValue({ stdout: '', stderr: '' }); // rest pass + const result = await installToolbox(makeAnalysis(tempDir)); + expect(execaMock.mock.calls.length).toBeGreaterThan(2); + expect(result.success).toBe(true); + expect(result.message).toMatch(/Installed \d+\/\d+/); + }); + + it('preserves existing .claude/settings.json content', async () => { + await mkdir(join(tempDir, '.claude'), { recursive: true }); + await writeFile( + join(tempDir, '.claude', 'settings.json'), + JSON.stringify({ permissions: { allow: ['Read'], deny: [] } }), + ); + await installToolbox(makeAnalysis(tempDir)); + const parsed = JSON.parse(await readFile(join(tempDir, '.claude', 'settings.json'), 'utf-8')); + expect(parsed.permissions.allow).toEqual(['Read']); + expect(parsed.extraKnownMarketplaces['claude-toolbox']).toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/unit/toolbox-install.test.ts` +Expected: FAIL — `installToolbox` is not exported from `installer.ts`. + +- [ ] **Step 3: Implement `installToolbox`** + +Edit `src/core/installer.ts`. Add at module scope: + +```ts +import { join } from 'node:path'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import type { AnalysisResult, ToolSelection } from '../types/index.js'; +import { resolveBundle, MARKETPLACE } from './toolbox-bundles.js'; +import { mergeToolboxSettings } from './generator.js'; + +export async function installToolbox(analysis: AnalysisResult): Promise { + const bundle = resolveBundle(analysis.stack.languages, analysis.signals); + + // 1. Write project-scoped settings.json (source of truth — survives subprocess failure) + await writeProjectSettings(analysis.path, bundle); + + // 2. Subprocess: marketplace add + try { + await execa('claude', ['plugin', 'marketplace', 'add', `${MARKETPLACE.source.repo}`], { + timeout: 30000, + }); + } catch { + logger.warn( + `claude-toolbox: marketplace add failed. Run manually: claude plugin marketplace add ${MARKETPLACE.source.repo}`, + ); + return { + tool: 'claude-toolbox', + success: false, + message: 'Marketplace add failed — settings.json written, install skipped', + }; + } + + // 3. Subprocess: per-plugin install + let installed = 0; + for (const plugin of bundle) { + try { + await execa('claude', ['plugin', 'install', `${plugin}@${MARKETPLACE.name}`], { + timeout: 60000, + }); + installed++; + } catch { + logger.warn(`claude-toolbox: install of ${plugin} failed`); + } + } + + return { + tool: 'claude-toolbox', + success: installed > 0, + message: `Installed ${installed}/${bundle.length} plugins. Teammates: run \`roboco install\` to enable.`, + }; +} + +async function writeProjectSettings(targetPath: string, bundle: string[]): Promise { + const settingsPath = join(targetPath, '.claude', 'settings.json'); + await mkdir(join(targetPath, '.claude'), { recursive: true }); + let existing: Record = {}; + try { + existing = JSON.parse(await readFile(settingsPath, 'utf-8')) as Record; + } catch { + // file does not exist or is invalid — start fresh + } + const merged = mergeToolboxSettings(existing, bundle); + await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n'); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/unit/toolbox-install.test.ts` +Expected: PASS — all 5 tests green. + +- [ ] **Step 5: Commit** + +```bash +git add src/core/installer.ts tests/unit/toolbox-install.test.ts +git commit -m "Add installToolbox: settings-first, marketplace add, per-plugin install + +Settings.json written first as source of truth; subprocess install is +convenience for the initiating user." +``` + +--- + +## Task 6: Wire `installToolbox` into the install router + +**Files:** +- Modify: `src/core/installer.ts` + +- [ ] **Step 1: Read current `installTools` signature** + +Run: `head -30 src/core/installer.ts` +Note that `installTools` currently takes only `tools: ToolSelection`. We need to thread `analysis` through so `installToolbox` can read `signals` and `path`. + +- [ ] **Step 2: Update `installTools` signature** + +Edit `src/core/installer.ts`. Change the signature and dispatch: + +```ts +export async function installTools( + tools: ToolSelection, + analysis: AnalysisResult, +): Promise { + const results: InstallResult[] = []; + results.push(await installOMC()); + if (tools.exaAi) results.push(await installMCP('exa', 'npx -y exa-mcp-server', 'EXA_API_KEY')); + if (tools.perplexityAsk) + results.push( + await installMCP('perplexity-ask', 'npx -y @anthropic-ai/perplexity-ask', 'PERPLEXITY_API_KEY'), + ); + if (tools.githubMcp) + results.push( + await installMCP('github', 'npx -y @modelcontextprotocol/server-github', 'GITHUB_TOKEN'), + ); + if (tools.context7) + results.push(await installMCPSimple('context7', 'npx -y @upstash/context7-mcp@latest')); + if (tools.openspec) results.push(await installOpenSpec()); + if (tools.harness) results.push(await installHarness()); + if (tools.toolbox) results.push(await installToolbox(analysis)); + return results; +} +``` + +- [ ] **Step 3: Update all callers of `installTools`** + +Find every caller. Run: `grep -rn "installTools(" src/ tests/` + +For each caller, pass `analysis` as second argument. Likely callers: +- `src/commands/init.ts` — pass `analysis` from `analyze(targetPath)` +- `src/commands/install.ts` — read analysis from `.roboco/config.json` +- `src/commands/add.ts` — needs `analysis`; update Task 8 will handle this fully — for now, `await analyze(targetPath)` to obtain it +- `src/commands/update.ts` — pass `analysis` + +For each, edit the relevant lines accordingly. Show diff per file: + +```ts +// Before +await installTools(interview.tools); +// After +await installTools(interview.tools, analysis); +``` + +- [ ] **Step 4: Run typecheck** + +Run: `npm run typecheck` +Expected: PASS — no TS errors. + +- [ ] **Step 5: Run all tests** + +Run: `npm test` +Expected: PASS — existing tests still pass; toolbox tests still pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/ +git commit -m "Wire installToolbox into installTools router + +Threads analysis through installTools so toolbox can read signals and path." +``` + +--- + +## Task 7: Interview integration (interactive + auto + AI) + +**Files:** +- Modify: `src/core/interviewer.ts` +- Modify: `tests/unit/interviewer.test.ts` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/unit/interviewer.test.ts`: + +```ts +describe('interviewer toolbox integration', () => { + it('autoInterview defaults toolbox to true', async () => { + const { interview } = await import('../../src/core/interviewer.js'); + const result = await interview( + { + path: '/tmp/x', + stack: { languages: ['TypeScript'], frameworks: [], buildTools: [], packageManager: 'npm', hasTypeScript: true }, + structure: { rootFiles: [], rootDirs: [], sourceDir: 'src', testDir: 'tests', hasMonorepo: false }, + existing: { hasClaude: false, hasClaudeMd: false, hasOmc: false, hasRoboco: false, hasOpenSpec: false, claudeSettings: null, claudeSkills: [], claudeCommands: [], globalSettings: null, globalSkills: [] }, + git: { isRepo: false, remoteUrl: null, repoName: null, branch: null }, + signals: { hasProto: false }, + }, + { auto: true }, + ); + expect(result.tools.toolbox).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/unit/interviewer.test.ts -t "toolbox integration"` +Expected: FAIL — `result.tools.toolbox` is undefined. + +- [ ] **Step 3: Update `autoInterview`** + +Edit `src/core/interviewer.ts` `autoInterview` return: + +```ts +return { + setupDomains: { claudeEnv: true, processDocs: true, cicd: analysis.git.isRepo }, + tools: { + omc: true, + openspec: true, + exaAi: false, + perplexityAsk: false, + githubMcp: analysis.git.remoteUrl?.includes('github.com') ?? false, + context7: analysis.stack.languages.length > 0, + harness: hasWebProject || analysis.structure.hasMonorepo, + toolbox: true, + }, + preferences: { autoGenerated: true, stack: analysis.stack }, +}; +``` + +- [ ] **Step 4: Update `interactiveInterview`** + +In `src/core/interviewer.ts`, add a question after the existing `harness` question. Compose the prompt with the detected overlay so the user sees what will be installed: + +```ts +const overlayHints: string[] = []; +if (analysis.stack.languages.includes('Go')) overlayHints.push('go-dev'); +if (analysis.stack.languages.includes('TypeScript') || analysis.stack.languages.includes('JavaScript')) { + overlayHints.push('biome-vcs-integration'); +} +if (analysis.signals.hasProto) overlayHints.push('protobuf-dev'); +const overlayMsg = overlayHints.length > 0 ? ` (also: ${overlayHints.join(', ')})` : ''; +const toolbox = await confirm( + ` claude-toolbox bundle${overlayMsg} (next-action, todo, gh-issue-resolver, semgrep-review, sandbox-helpers, makefile-workflow)?`, +); +``` + +Add `toolbox` to the returned `tools` object. + +- [ ] **Step 5: Update `parseAiResult`** + +Edit the JSON parse block to read `parsed.tools?.toolbox`: + +```ts +tools: { + omc: true, + openspec: parsed.tools?.openspec ?? false, + exaAi: parsed.tools?.exaAi ?? false, + perplexityAsk: parsed.tools?.perplexityAsk ?? false, + githubMcp: parsed.tools?.githubMcp ?? false, + context7: parsed.tools?.context7 ?? false, + harness: parsed.tools?.harness ?? false, + toolbox: parsed.tools?.toolbox ?? true, +}, +``` + +- [ ] **Step 6: Update the AI system prompt JSON schema** + +In `aiInterview`, edit the `systemPrompt` template to include `toolbox`: + +```ts +{ + "setupDomains": { "claudeEnv": true, "processDocs": boolean, "cicd": boolean }, + "tools": { "omc": true, "openspec": boolean, "exaAi": boolean, "perplexityAsk": boolean, "githubMcp": boolean, "context7": boolean, "harness": boolean, "toolbox": boolean }, + "preferences": {} +} +``` + +- [ ] **Step 7: Run all tests** + +Run: `npm test` +Expected: PASS — new interviewer test green; existing pass. + +- [ ] **Step 8: Commit** + +```bash +git add src/core/interviewer.ts tests/unit/interviewer.test.ts +git commit -m "Add toolbox to interview (interactive, auto, AI) + +Default true in auto/AI; interactive prompt shows detected stack overlay." +``` + +--- + +## Task 8: `roboco add toolbox` bundle path + +**Files:** +- Modify: `src/commands/add.ts` +- Create: `tests/unit/toolbox-add.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `tests/unit/toolbox-add.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mkdtemp, rm, mkdir, writeFile, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +vi.mock('execa', () => ({ execa: vi.fn() })); +import { execa } from 'execa'; +import { addCommand } from '../../src/commands/add.js'; + +const execaMock = execa as unknown as ReturnType; + +async function fixture(): Promise { + const tempDir = await mkdtemp(join(tmpdir(), 'roboco-add-')); + await mkdir(join(tempDir, '.roboco')); + await writeFile( + join(tempDir, '.roboco', 'config.json'), + JSON.stringify({ + version: '0.1.0', + createdAt: '2026-05-04T00:00:00.000Z', + updatedAt: '2026-05-04T00:00:00.000Z', + analysis: { + path: tempDir, + stack: { languages: ['TypeScript'], frameworks: [], buildTools: [], packageManager: 'npm', hasTypeScript: true }, + structure: { rootFiles: [], rootDirs: [], sourceDir: 'src', testDir: 'tests', hasMonorepo: false }, + existing: { hasClaude: false, hasClaudeMd: false, hasOmc: false, hasRoboco: false, hasOpenSpec: false, claudeSettings: null, claudeSkills: [], claudeCommands: [], globalSettings: null, globalSkills: [] }, + git: { isRepo: false, remoteUrl: null, repoName: null, branch: null }, + signals: { hasProto: false }, + }, + interview: { + setupDomains: { claudeEnv: true, processDocs: false, cicd: false }, + tools: { omc: true, openspec: false, exaAi: false, perplexityAsk: false, githubMcp: false, context7: false, harness: false, toolbox: false }, + preferences: {}, + }, + installedTools: ['omc'], + }), + ); + return tempDir; +} + +describe('roboco add toolbox', () => { + beforeEach(() => { + execaMock.mockReset(); + execaMock.mockResolvedValue({ stdout: '', stderr: '' }); + }); + + it('installs the bundle and writes settings.json', async () => { + const tempDir = await fixture(); + await addCommand('toolbox', { path: tempDir }); + const settings = JSON.parse( + await readFile(join(tempDir, '.claude', 'settings.json'), 'utf-8'), + ); + expect(settings.enabledPlugins['biome-vcs-integration@claude-toolbox']).toBe(true); + await rm(tempDir, { recursive: true, force: true }); + }); + + it('marks toolbox enabled in .roboco/config.json', async () => { + const tempDir = await fixture(); + await addCommand('toolbox', { path: tempDir }); + const cfg = JSON.parse(await readFile(join(tempDir, '.roboco', 'config.json'), 'utf-8')); + expect(cfg.interview.tools.toolbox).toBe(true); + expect(cfg.installedTools).toContain('toolbox'); + await rm(tempDir, { recursive: true, force: true }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/unit/toolbox-add.test.ts -t "roboco add toolbox"` +Expected: FAIL — toolbox is not in `KNOWN_TOOLS`. + +- [ ] **Step 3: Add `toolbox` to `KNOWN_TOOLS` and dispatch path** + +Edit `src/commands/add.ts`: + +```ts +import { resolve } from 'node:path'; +import ora from 'ora'; +import { fileExists, readJson, writeJson } from '../utils/fs.js'; +import { installToolbox } from '../core/installer.js'; +import { logger } from '../utils/logger.js'; +import type { RobocoConfig, ToolSelection } from '../types/index.js'; + +const KNOWN_TOOLS: Record = { + // ... existing entries ... + toolbox: { key: 'toolbox', description: 'claude-toolbox bundle (stack-aware)' }, +}; +``` + +Then in `addCommand`, add a branch *before* the existing `installTools` call: + +```ts +if (integration.toLowerCase() === 'toolbox') { + const spinner = ora('Installing claude-toolbox bundle...').start(); + const result = await installToolbox(config.analysis); + spinner.succeed('claude-toolbox bundle install complete'); + if (result.success) logger.success(`${result.tool}: ${result.message}`); + else logger.warn(`${result.tool}: ${result.message}`); + + config.interview.tools.toolbox = true; + config.updatedAt = new Date().toISOString(); + if (!config.installedTools.includes('toolbox')) config.installedTools.push('toolbox'); + await writeJson(configPath, config); + logger.success('Configuration updated.'); + return; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/unit/toolbox-add.test.ts` +Expected: PASS — both tests green. + +- [ ] **Step 5: Commit** + +```bash +git add src/commands/add.ts tests/unit/toolbox-add.test.ts +git commit -m "Add 'roboco add toolbox' bundle install path + +Reads analysis from .roboco/config.json, installs bundle, updates config." +``` + +--- + +## Task 9: `roboco add toolbox:` parsing and non-overlap install + +**Files:** +- Modify: `src/commands/add.ts` +- Modify: `src/core/installer.ts` +- Modify: `tests/unit/toolbox-add.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/unit/toolbox-add.test.ts`: + +```ts +describe('roboco add toolbox:', () => { + beforeEach(() => { + execaMock.mockReset(); + execaMock.mockResolvedValue({ stdout: '', stderr: '' }); + }); + + it('installs a single non-overlapping plugin', async () => { + const tempDir = await fixture(); + await addCommand('toolbox:next-action', { path: tempDir }); + const calls = execaMock.mock.calls.map((c) => c[1]); + expect(calls).toContainEqual(['plugin', 'install', 'next-action@claude-toolbox']); + const settings = JSON.parse( + await readFile(join(tempDir, '.claude', 'settings.json'), 'utf-8'), + ); + expect(settings.enabledPlugins['next-action@claude-toolbox']).toBe(true); + await rm(tempDir, { recursive: true, force: true }); + }); + + it('rejects unknown plugin name', async () => { + const tempDir = await fixture(); + await addCommand('toolbox:not-a-real-plugin', { path: tempDir }); + expect(process.exitCode).toBe(1); + process.exitCode = 0; + await rm(tempDir, { recursive: true, force: true }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/unit/toolbox-add.test.ts -t "toolbox:"` +Expected: FAIL — parsing for the colon form not implemented. + +- [ ] **Step 3: Add a known-plugins catalog to `toolbox-bundles.ts`** + +Edit `src/core/toolbox-bundles.ts`. Add at the bottom: + +```ts +export const KNOWN_PLUGINS = new Set([ + ...CORE_BUNDLE, + ...Object.values(STACK_OVERLAYS).flat(), + ...Object.values(SIGNAL_OVERLAYS).flat(), + ...OVERLAPPING_PLUGINS, + // Catalog-only entries (in marketplace but not in any default bundle) + 'apply-figma-make', + 'cloudflare-macos-fix', + 'create-lang-dev-skill', + 'jira-commands', + 'jira-edit-description', +]); +``` + +- [ ] **Step 4: Implement `installSingleToolboxPlugin` (non-overlap path only)** + +Edit `src/core/installer.ts`. Add: + +```ts +import { KNOWN_PLUGINS, OVERLAPPING_PLUGINS } from './toolbox-bundles.js'; + +export async function installSingleToolboxPlugin( + name: string, + targetPath: string, +): Promise { + if (!KNOWN_PLUGINS.has(name)) { + return { tool: `claude-toolbox:${name}`, success: false, message: `Unknown plugin: ${name}` }; + } + + if (OVERLAPPING_PLUGINS.has(name)) { + // Overlap path filled in by Task 10 + return { tool: `claude-toolbox:${name}`, success: false, message: 'Overlap path not yet implemented' }; + } + + await writeProjectSettings(targetPath, [name]); + + try { + await execa('claude', ['plugin', 'install', `${name}@${MARKETPLACE.name}`], { timeout: 60000 }); + return { tool: `claude-toolbox:${name}`, success: true, message: 'Installed' }; + } catch { + return { tool: `claude-toolbox:${name}`, success: false, message: 'Install failed' }; + } +} +``` + +- [ ] **Step 5: Add the parsing branch in `addCommand`** + +Edit `src/commands/add.ts`. Before the `KNOWN_TOOLS` lookup, add: + +```ts +if (integration.toLowerCase().startsWith('toolbox:')) { + const pluginName = integration.slice('toolbox:'.length); + if (!(await fileExists(configPath))) { + logger.error('This repository has not been initialized with ROBOCO.'); + logger.info('Run "roboco init" first.'); + process.exitCode = 1; + return; + } + const config = await readJson(configPath); + const result = await installSingleToolboxPlugin(pluginName, targetPath); + if (result.success) { + logger.success(`${result.tool}: ${result.message}`); + config.updatedAt = new Date().toISOString(); + if (!config.installedTools.includes(`toolbox:${pluginName}`)) { + config.installedTools.push(`toolbox:${pluginName}`); + } + await writeJson(configPath, config); + } else { + logger.error(`${result.tool}: ${result.message}`); + process.exitCode = 1; + } + return; +} +``` + +(Note: this branch must run **before** the existing `targetPath`/`configPath` setup — adjust the function structure so `targetPath` and `configPath` are computed before this branch.) + +- [ ] **Step 6: Refactor for branch ordering** + +Move `targetPath` and `configPath` initialization to the top of `addCommand`, immediately after the empty-integration check: + +```ts +export async function addCommand( + integration: string | undefined, + options: { path?: string }, +): Promise { + if (!integration) { + /* existing list-print logic */ + return; + } + + const targetPath = resolve(options.path ?? '.'); + const configPath = resolve(targetPath, '.roboco', 'config.json'); + + if (integration.toLowerCase().startsWith('toolbox:')) { /* see Step 5 */ } + if (integration.toLowerCase() === 'toolbox') { /* Task 8 */ } + + // existing KNOWN_TOOLS dispatch follows +} +``` + +- [ ] **Step 7: Run tests** + +Run: `npx vitest run tests/unit/toolbox-add.test.ts` +Expected: PASS — both new tests green; pre-existing `roboco add toolbox` tests still pass. + +- [ ] **Step 8: Commit** + +```bash +git add src/commands/add.ts src/core/installer.ts src/core/toolbox-bundles.ts tests/unit/toolbox-add.test.ts +git commit -m "Add 'roboco add toolbox:' single-plugin install (non-overlap) + +Recognizes toolbox: prefix, validates against KNOWN_PLUGINS catalog, +installs a single plugin via subprocess. Overlap path is Task 10." +``` + +--- + +## Task 10: Overlap remediation registry + drop-replace prompt + +**Files:** +- Modify: `src/core/toolbox-bundles.ts` +- Modify: `src/core/installer.ts` +- Modify: `src/commands/add.ts` +- Modify: `tests/unit/toolbox-add.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/unit/toolbox-add.test.ts`: + +```ts +describe('roboco add toolbox:', () => { + beforeEach(() => { + execaMock.mockReset(); + execaMock.mockResolvedValue({ stdout: '', stderr: '' }); + }); + + it('records skipGeneratorOutputs override when user accepts drop-replace', async () => { + const tempDir = await fixture(); + // simulate user accepting the prompt + const promptModule = await import('../../src/utils/prompt.js'); + vi.spyOn(promptModule, 'confirm').mockResolvedValue(true); + + await addCommand('toolbox:gabyx-githooks-setup', { path: tempDir }); + + const cfg = JSON.parse(await readFile(join(tempDir, '.roboco', 'config.json'), 'utf-8')); + expect(cfg.overrides?.skipGeneratorOutputs).toContain('husky-pre-commit'); + expect(cfg.installedTools).toContain('toolbox:gabyx-githooks-setup'); + + vi.restoreAllMocks(); + await rm(tempDir, { recursive: true, force: true }); + }); + + it('does not record override when user declines drop-replace', async () => { + const tempDir = await fixture(); + const promptModule = await import('../../src/utils/prompt.js'); + vi.spyOn(promptModule, 'confirm').mockResolvedValue(false); + + await addCommand('toolbox:gabyx-githooks-setup', { path: tempDir }); + + const cfg = JSON.parse(await readFile(join(tempDir, '.roboco', 'config.json'), 'utf-8')); + expect(cfg.overrides?.skipGeneratorOutputs ?? []).not.toContain('husky-pre-commit'); + + vi.restoreAllMocks(); + await rm(tempDir, { recursive: true, force: true }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/unit/toolbox-add.test.ts -t "overlap-plugin"` +Expected: FAIL — currently returns "Overlap path not yet implemented". + +- [ ] **Step 3: Add overlap remediation registry** + +Edit `src/core/toolbox-bundles.ts`. Append: + +```ts +import { rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { OverrideKey } from '../types/index.js'; + +export interface OverlapRemediation { + description: string; // shown in the prompt + overrideKey: OverrideKey; // recorded in .roboco/config.json + cleanup: (targetPath: string) => Promise; +} + +export const OVERLAP_REMEDIATION: Record = { + 'gabyx-githooks-setup': { + description: '.husky/pre-commit', + overrideKey: 'husky-pre-commit', + cleanup: async (targetPath: string) => { + await rm(join(targetPath, '.husky', 'pre-commit'), { force: true }); + }, + }, + 'ci-workflow': { + description: '.github/workflows/vibe-coding-check.yml', + overrideKey: 'ci-workflow-vibe-coding-check', + cleanup: async (targetPath: string) => { + await rm(join(targetPath, '.github', 'workflows', 'vibe-coding-check.yml'), { force: true }); + }, + }, + 'git-guardrails': { + description: '.claude/settings.json deny entries', + overrideKey: 'claude-deny-list', + // Cleanup is non-trivial (in-place edit of settings.json deny list). + // For Phase 1, we record the override and let the user clean manually. + // Generator skip-on-override (Task 11) prevents future re-emission. + cleanup: async () => { /* no-op; future-runs respect override */ }, + }, + 'claude-md': { + description: ' block in CLAUDE.md', + overrideKey: 'claude-md-roboco-block', + cleanup: async () => { /* no-op; future-runs respect override */ }, + }, +}; +``` + +- [ ] **Step 4: Implement overlap path in `installSingleToolboxPlugin`** + +Edit `src/core/installer.ts`. Replace the overlap stub: + +```ts +import { KNOWN_PLUGINS, OVERLAPPING_PLUGINS, OVERLAP_REMEDIATION } from './toolbox-bundles.js'; +import { confirm } from '../utils/prompt.js'; +import type { RobocoConfig } from '../types/index.js'; + +export async function installSingleToolboxPlugin( + name: string, + targetPath: string, + config: RobocoConfig, +): Promise<{ result: InstallResult; configChanged: boolean }> { + if (!KNOWN_PLUGINS.has(name)) { + return { + result: { tool: `claude-toolbox:${name}`, success: false, message: `Unknown plugin: ${name}` }, + configChanged: false, + }; + } + + let configChanged = false; + + if (OVERLAPPING_PLUGINS.has(name)) { + const remediation = OVERLAP_REMEDIATION[name]; + if (!remediation) { + return { + result: { tool: `claude-toolbox:${name}`, success: false, message: `No remediation for overlap: ${name}` }, + configChanged: false, + }; + } + const accepted = await confirm( + `This replaces ROBOCO's ${remediation.description}. Drop ROBOCO's version?`, + false, + ); + if (accepted) { + try { + await remediation.cleanup(targetPath); + } catch { + logger.warn(`Cleanup of ${remediation.description} failed — continuing`); + } + config.overrides ??= {}; + config.overrides.skipGeneratorOutputs ??= []; + if (!config.overrides.skipGeneratorOutputs.includes(remediation.overrideKey)) { + config.overrides.skipGeneratorOutputs.push(remediation.overrideKey); + } + configChanged = true; + } else { + logger.warn('Both will coexist — manual cleanup may be needed.'); + } + } + + await writeProjectSettings(targetPath, [name]); + + try { + await execa('claude', ['plugin', 'install', `${name}@${MARKETPLACE.name}`], { timeout: 60000 }); + return { + result: { tool: `claude-toolbox:${name}`, success: true, message: 'Installed' }, + configChanged, + }; + } catch { + return { + result: { tool: `claude-toolbox:${name}`, success: false, message: 'Install failed' }, + configChanged, + }; + } +} +``` + +- [ ] **Step 5: Update `addCommand` to use the new return shape** + +Edit `src/commands/add.ts`. Update the toolbox: branch: + +```ts +if (integration.toLowerCase().startsWith('toolbox:')) { + const pluginName = integration.slice('toolbox:'.length); + if (!(await fileExists(configPath))) { + logger.error('This repository has not been initialized with ROBOCO.'); + logger.info('Run "roboco init" first.'); + process.exitCode = 1; + return; + } + const config = await readJson(configPath); + const { result, configChanged } = await installSingleToolboxPlugin(pluginName, targetPath, config); + if (result.success) { + logger.success(`${result.tool}: ${result.message}`); + config.updatedAt = new Date().toISOString(); + if (!config.installedTools.includes(`toolbox:${pluginName}`)) { + config.installedTools.push(`toolbox:${pluginName}`); + } + await writeJson(configPath, config); + } else { + logger.error(`${result.tool}: ${result.message}`); + if (configChanged) await writeJson(configPath, config); // persist override even if install fails + process.exitCode = 1; + } + return; +} +``` + +- [ ] **Step 6: Run tests** + +Run: `npx vitest run tests/unit/toolbox-add.test.ts` +Expected: PASS — overlap-plugin accept/decline tests green; non-overlap tests still green. + +- [ ] **Step 7: Commit** + +```bash +git add src/core/toolbox-bundles.ts src/core/installer.ts src/commands/add.ts tests/unit/toolbox-add.test.ts +git commit -m "Add overlap remediation for opt-in toolbox plugins + +Drop-replace prompt records skipGeneratorOutputs override; cleanup +handlers per overlapping plugin run filesystem removal where applicable." +``` + +--- + +## Task 11: Generator respects `skipGeneratorOutputs` + +**Files:** +- Modify: `src/core/generator.ts` +- Modify: `tests/unit/settings-merge.test.ts` (or `tests/unit/generator.test.ts` if a separate generator test file exists — check first) + +- [ ] **Step 1: Write the failing test** + +Add to `tests/unit/settings-merge.test.ts`: + +```ts +describe('generator skipGeneratorOutputs', () => { + let tempDir: string; + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'roboco-skip-')); + }); + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('skips husky pre-commit when override is set', async () => { + const interview: InterviewResult = { + ...defaultInterview, + setupDomains: { claudeEnv: true, processDocs: false, cicd: true }, + }; + await generate(tempDir, makeAnalysis(tempDir), interview, { + overrides: { skipGeneratorOutputs: ['husky-pre-commit'] }, + }); + let huskyExists = true; + try { + await readFile(join(tempDir, '.husky', 'pre-commit'), 'utf-8'); + } catch { + huskyExists = false; + } + expect(huskyExists).toBe(false); + }); + + it('writes husky pre-commit when override is absent', async () => { + const interview: InterviewResult = { + ...defaultInterview, + setupDomains: { claudeEnv: true, processDocs: false, cicd: true }, + }; + await generate(tempDir, makeAnalysis(tempDir), interview); + const content = await readFile(join(tempDir, '.husky', 'pre-commit'), 'utf-8'); + expect(content.length).toBeGreaterThan(0); + }); +}); +``` + +(Note: this requires `generate` to accept an optional fourth argument carrying overrides. If the existing `defaultInterview` fixture lacks the overrides field — it does — pass overrides as a separate param rather than threading via interview.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/unit/settings-merge.test.ts -t "skipGeneratorOutputs"` +Expected: FAIL — `generate` only takes 3 args; husky is always written when `cicd` is true. + +- [ ] **Step 3: Extend `generate` signature** + +Edit `src/core/generator.ts`. Update the export: + +```ts +import type { OverrideKey } from '../types/index.js'; + +interface GenerateOptions { + overrides?: { skipGeneratorOutputs?: OverrideKey[] }; +} + +export async function generate( + targetPath: string, + analysis: AnalysisResult, + interviewResult: InterviewResult, + options: GenerateOptions = {}, +): Promise { + const skip = new Set(options.overrides?.skipGeneratorOutputs ?? []); + // ... existing setup +} +``` + +Then guard the four overlap emit branches with `skip.has(...)` checks. Each branch corresponds to one overlap key: + +```ts +// CI/CD generation — guard the husky branch +if (interviewResult.setupDomains.cicd) { + files.push(...generateCicd(targetPath, analysis, skip)); +} +``` + +Update `generateCicd` to accept and respect `skip`: + +```ts +function generateCicd( + targetPath: string, + analysis: AnalysisResult, + skip: Set, +): FileOperation[] { + const files: FileOperation[] = []; + if (!skip.has('ci-workflow-vibe-coding-check')) { + files.push({ + path: join(targetPath, '.github', 'workflows', 'vibe-coding-check.yml'), + content: `...existing content...`, + description: 'GitHub Actions workflow', + }); + } + if (!skip.has('husky-pre-commit')) { + const lintCmd = getLintCommand(analysis.stack.languages); + files.push({ + path: join(targetPath, '.husky', 'pre-commit'), + content: `${lintCmd}\n`, + description: 'Pre-commit hook', + }); + } + return files; +} +``` + +For `claude-deny-list` and `claude-md-roboco-block` — these are emitted by `generateClaudeSettings` and `appendRobocoContext` respectively. Update both to check `skip` and short-circuit when set. + +In `generateClaudeSettings`: + +```ts +function generateClaudeSettings( + targetPath: string, + analysis: AnalysisResult, + skip: Set, +): FileOperation[] { + const hooks = generateHooksForStack(analysis.stack.languages); + const robocoDefaults: Record = { + permissions: skip.has('claude-deny-list') + ? { allow: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash(npm run *)', 'Bash(git status*)', 'Bash(git diff*)', 'Bash(git log*)'] } + : { + allow: [/* existing */], + deny: [/* existing */], + }, + ...(Object.keys(hooks).length > 0 ? { hooks } : {}), + }; + // ... rest unchanged +} +``` + +In `appendRobocoContext`: + +```ts +async function appendRobocoContext( + targetPath: string, + analysis: AnalysisResult, + interviewResult: InterviewResult, + skip: Set, +): Promise { + if (skip.has('claude-md-roboco-block')) return; + // ... rest unchanged +} +``` + +Thread `skip` through all callsites in `generate()`. + +- [ ] **Step 4: Update callers of `generate`** + +Find every caller. Run: `grep -rn "generate(" src/ tests/ | grep -v "// "` + +For each caller, pass overrides from `config.overrides` when available. Update `src/commands/init.ts`, `src/commands/install.ts`, `src/commands/update.ts`. + +```ts +// install.ts / update.ts read existing config +await generate(targetPath, analysis, interview, { overrides: existingConfig.overrides }); +``` + +- [ ] **Step 5: Run all tests** + +Run: `npm test` +Expected: PASS — new skip tests green; all existing tests still pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/core/generator.ts tests/unit/settings-merge.test.ts src/commands/ +git commit -m "Honor skipGeneratorOutputs overrides in generator + +generate() consults config.overrides and skips husky, ci-workflow, +claude-deny-list, or roboco-block emit branches when overridden." +``` + +--- + +## Task 12: README + verification gate documentation + +**Files:** +- Modify: `README.md` +- Modify: `docs/superpowers/specs/2026-05-04-claude-toolbox-integration-design.md` (only if smoke test fails — see Step 4) + +- [ ] **Step 1: Add toolbox row to "What Gets Generated" / "Optional" table in README.md** + +Find the "Optional (selected during interview)" table in `README.md` and add: + +```markdown +| claude-toolbox | Stack-aware bundle (next-action, todo, gh-issue-resolver, semgrep-review, sandbox-helpers, makefile-workflow + stack overlay) | +``` + +Add to the Commands table: + +```markdown +| `roboco add toolbox` | Install full claude-toolbox bundle (stack-aware) | +| `roboco add toolbox:` | Install a single claude-toolbox plugin (with overlap drop-replace prompt) | +``` + +- [ ] **Step 2: Run the verification smoke test** + +Manual verification of project-level `enabledPlugins` honoring (per spec verification gate): + +1. Create a clean tempdir: `mkdir /tmp/roboco-smoke && cd /tmp/roboco-smoke` +2. Build CLI: `npm run build` (in roboco-cli repo) +3. Run: `node /Users/jaehyun/go/src/github.com/roboco-io/roboco-cli/dist/index.js init --auto .` +4. Verify `.claude/settings.json` contains `extraKnownMarketplaces` and `enabledPlugins` keys. +5. Move `~/.claude/settings.json` aside temporarily: `mv ~/.claude/settings.json ~/.claude/settings.json.bak` +6. Launch Claude Code in `/tmp/roboco-smoke`: `claude` +7. Inside Claude Code, check active plugins: `/plugin list` +8. Confirm the toolbox plugins listed in the project settings are active. +9. Restore: `mv ~/.claude/settings.json.bak ~/.claude/settings.json` + +Expected: project-scoped settings activate the bundle. Document the exact result (pass/fail/partial) in the PR description. + +- [ ] **Step 3: If smoke test passes — proceed** + +If verified, no further changes needed. The design as implemented is correct. + +- [ ] **Step 4: If smoke test fails — document fallback** + +If project-level `enabledPlugins` does NOT activate plugins for fresh users: + +a. Edit `docs/superpowers/specs/2026-05-04-claude-toolbox-integration-design.md`. In the "Verification Gate" section, replace the "If verified" branch with the observed behavior, and the "If not" branch with the chosen mitigation. + +b. Update `installToolbox` to log a teammate-facing instruction when project settings are written: + +```ts +logger.info( + `claude-toolbox: project settings recorded. Teammates run \`roboco install\` to enable plugins on their machine.`, +); +``` + +c. Ensure `roboco install` (`src/commands/install.ts`) calls `installToolbox(analysis)` when `config.installedTools.includes('toolbox')`. Add a test for this. (If this requires significant refactor, file as a follow-up issue and ship with manual install instructions.) + +- [ ] **Step 5: Commit** + +```bash +git add README.md docs/ +git commit -m "Document claude-toolbox integration and verification result" +``` + +--- + +## Task 13: Final integration smoke + release prep + +**Files:** None — verification only. + +- [ ] **Step 1: Run full test suite** + +Run: `npm test` +Expected: All tests pass (existing + new). + +- [ ] **Step 2: Run typecheck** + +Run: `npm run typecheck` +Expected: No errors. + +- [ ] **Step 3: Run lint** + +Run: `npm run lint` +Expected: No errors. + +- [ ] **Step 4: Run build** + +Run: `npm run build` +Expected: tsup completes successfully. + +- [ ] **Step 5: End-to-end smoke** + +Run a full `roboco init` cycle on a fresh tempdir (Python repo so toolbox shows core-only): + +```bash +mkdir /tmp/roboco-e2e && cd /tmp/roboco-e2e +echo "print('hi')" > main.py +node /path/to/roboco-cli/dist/index.js init --auto . +``` + +Expected: +- `.claude/settings.json` written with `extraKnownMarketplaces` and `enabledPlugins` +- `.roboco/config.json` records `installedTools: [..., 'toolbox']` and `interview.tools.toolbox: true` +- Subprocess output shows marketplace add + per-plugin installs + +Then: + +```bash +node /path/to/roboco-cli/dist/index.js add toolbox:claude-md --path . +``` + +Decline the prompt; verify both husky and the no-cleanup-needed claude-md@claude-toolbox entry coexist (manual cleanup expected). + +- [ ] **Step 6: Mark plan complete** + +If all tasks pass, the plan is implemented. Open a PR referencing the spec and this plan. + +--- + +## Self-Review + +**Spec coverage:** + +| Spec section | Implementing task | +|---|---| +| Architecture | Task 1 (types), Task 2 (bundles), Task 4 (settings), Task 5 (installer), Task 7 (interview), Task 8/9/10 (add) | +| Bundle Resolution | Task 2 | +| Settings.json Merge Schema | Task 4 | +| Install Flow during `roboco init` | Tasks 5, 6, 7 | +| `roboco add toolbox` and `:` | Tasks 8, 9, 10 | +| Error Handling | Task 5 (timeout, marketplace failure, partial install), Task 10 (cleanup failure) | +| Testing | Each implementation task includes its TDD test pair | +| Verification Gate | Task 12 | +| `.roboco/config.json` overrides schema | Task 1, Task 10 | +| Phase 2 hand-off (skipGeneratorOutputs) | Task 11 | + +No spec section is unaddressed. + +**Placeholder scan:** No "TBD"/"TODO"/"implement later" entries. Each step shows the actual code to write or the exact command to run. + +**Type consistency check:** +- `installToolbox(analysis)` — same signature in Task 5 (definition), Task 6 (router call), Task 8 (add command call). ✓ +- `installSingleToolboxPlugin` — Task 9 returns `InstallResult`, Task 10 changes return shape to `{ result, configChanged }`. The Task 10 step explicitly updates the caller. ✓ +- `mergeToolboxSettings(existing, bundle)` — same signature in Task 4 (definition), Task 5 (called via `writeProjectSettings`). ✓ +- `RepoSignals.hasProto` — defined in Task 1, populated in Task 3, consumed in Tasks 2 and 7. ✓ +- `OverrideKey` — defined in Task 1, used in Tasks 10, 11. Same union members in both. ✓ From efc9a171781c01aa848a854017052a5455a7a0d8 Mon Sep 17 00:00:00 2001 From: Jaehyun Yeom Date: Mon, 4 May 2026 23:39:51 -0700 Subject: [PATCH 03/21] Add types for toolbox integration ToolSelection.toolbox, RepoSignals.hasProto, RobocoConfig.overrides. --- src/types/analysis.ts | 5 +++++ src/types/config.ts | 9 +++++++++ src/types/index.ts | 3 ++- src/types/interview.ts | 1 + 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/types/analysis.ts b/src/types/analysis.ts index cdf0410..5d5ea35 100644 --- a/src/types/analysis.ts +++ b/src/types/analysis.ts @@ -34,10 +34,15 @@ export interface GitInfo { branch: string | null; } +export interface RepoSignals { + hasProto: boolean; +} + export interface AnalysisResult { path: string; stack: StackInfo; structure: RepoStructure; existing: ExistingConfig; git: GitInfo; + signals: RepoSignals; } diff --git a/src/types/config.ts b/src/types/config.ts index 868006c..2047c49 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1,6 +1,12 @@ import type { AnalysisResult } from './analysis.js'; import type { InterviewResult } from './interview.js'; +export type OverrideKey = + | 'husky-pre-commit' + | 'ci-workflow-vibe-coding-check' + | 'claude-deny-list' + | 'claude-md-roboco-block'; + export interface RobocoConfig { version: string; createdAt: string; @@ -8,6 +14,9 @@ export interface RobocoConfig { analysis: AnalysisResult; interview: InterviewResult; installedTools: string[]; + overrides?: { + skipGeneratorOutputs?: OverrideKey[]; + }; } export interface GlobalConfig { diff --git a/src/types/index.ts b/src/types/index.ts index 464ed09..c2b31bd 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,9 +4,10 @@ export type { ExistingConfig, GitInfo, AnalysisResult, + RepoSignals, } from './analysis.js'; export type { SetupDomains, ToolSelection, InterviewResult } from './interview.js'; -export type { RobocoConfig, GlobalConfig } from './config.js'; +export type { RobocoConfig, GlobalConfig, OverrideKey } from './config.js'; export { DEFAULT_GLOBAL_CONFIG } from './config.js'; diff --git a/src/types/interview.ts b/src/types/interview.ts index 8246bda..30ba932 100644 --- a/src/types/interview.ts +++ b/src/types/interview.ts @@ -12,6 +12,7 @@ export interface ToolSelection { githubMcp: boolean; context7: boolean; harness: boolean; + toolbox: boolean; } export interface InterviewResult { From 86529e825c27baf31d63f5338bf2ef1721bf9bf5 Mon Sep 17 00:00:00 2001 From: Jaehyun Yeom Date: Mon, 4 May 2026 23:48:09 -0700 Subject: [PATCH 04/21] Add toolbox bundle resolution Stack-aware resolveBundle with core/stack/signal overlays and overlap exclusion. --- src/core/toolbox-bundles.ts | 44 ++++++++++++++++++++++++++ tests/unit/toolbox-bundles.test.ts | 51 ++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 src/core/toolbox-bundles.ts create mode 100644 tests/unit/toolbox-bundles.test.ts diff --git a/src/core/toolbox-bundles.ts b/src/core/toolbox-bundles.ts new file mode 100644 index 0000000..215cbd3 --- /dev/null +++ b/src/core/toolbox-bundles.ts @@ -0,0 +1,44 @@ +export const MARKETPLACE = { + name: 'claude-toolbox', + source: { source: 'github', repo: 'jaeyeom/claude-toolbox' }, +} as const; + +export const CORE_BUNDLE: readonly string[] = [ + 'next-action', + 'todo', + 'gh-issue-resolver', + 'semgrep-review', + 'sandbox-helpers', + 'makefile-workflow', +]; + +export const STACK_OVERLAYS: Record = { + Go: ['go-dev'], + TypeScript: ['biome-vcs-integration'], + JavaScript: ['biome-vcs-integration'], +}; + +export const SIGNAL_OVERLAYS: Record = { + hasProto: ['protobuf-dev'], +}; + +export const OVERLAPPING_PLUGINS = new Set([ + 'claude-md', + 'gabyx-githooks-setup', + 'git-guardrails', + 'ci-workflow', +]); + +export interface ResolveSignals { + hasProto: boolean; +} + +export function resolveBundle(languages: string[], signals: ResolveSignals): string[] { + const stackOverlay = languages.flatMap((l) => STACK_OVERLAYS[l] ?? []); + const signalOverlay = (Object.entries(signals) as Array<[keyof ResolveSignals, boolean]>) + .filter(([, on]) => on) + .flatMap(([key]) => SIGNAL_OVERLAYS[key] ?? []); + return [...new Set([...CORE_BUNDLE, ...stackOverlay, ...signalOverlay])].filter( + (p) => !OVERLAPPING_PLUGINS.has(p), + ); +} diff --git a/tests/unit/toolbox-bundles.test.ts b/tests/unit/toolbox-bundles.test.ts new file mode 100644 index 0000000..5afcdc9 --- /dev/null +++ b/tests/unit/toolbox-bundles.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { + resolveBundle, + MARKETPLACE, + CORE_BUNDLE, + OVERLAPPING_PLUGINS, +} from '../../src/core/toolbox-bundles.js'; + +describe('resolveBundle', () => { + it('returns core bundle for stacks with no overlay', () => { + expect(resolveBundle(['Python'], { hasProto: false })).toEqual(CORE_BUNDLE); + }); + + it('adds go-dev for Go stack', () => { + const result = resolveBundle(['Go'], { hasProto: false }); + expect(result).toEqual([...CORE_BUNDLE, 'go-dev']); + }); + + it('adds biome-vcs-integration for TypeScript', () => { + const result = resolveBundle(['TypeScript'], { hasProto: false }); + expect(result).toEqual([...CORE_BUNDLE, 'biome-vcs-integration']); + }); + + it('adds biome-vcs-integration for JavaScript', () => { + const result = resolveBundle(['JavaScript'], { hasProto: false }); + expect(result).toEqual([...CORE_BUNDLE, 'biome-vcs-integration']); + }); + + it('does not duplicate when both TypeScript and JavaScript detected', () => { + const result = resolveBundle(['TypeScript', 'JavaScript'], { hasProto: false }); + expect(result.filter((p) => p === 'biome-vcs-integration')).toHaveLength(1); + }); + + it('adds protobuf-dev when hasProto signal is true', () => { + const result = resolveBundle(['Go'], { hasProto: true }); + expect(result).toContain('protobuf-dev'); + expect(result).toContain('go-dev'); + }); + + it('excludes overlapping plugins from defaults', () => { + const result = resolveBundle(['Go', 'TypeScript'], { hasProto: true }); + for (const overlap of OVERLAPPING_PLUGINS) { + expect(result).not.toContain(overlap); + } + }); + + it('returns marketplace metadata pointing at jaeyeom/claude-toolbox', () => { + expect(MARKETPLACE.name).toBe('claude-toolbox'); + expect(MARKETPLACE.source).toEqual({ source: 'github', repo: 'jaeyeom/claude-toolbox' }); + }); +}); From d44aaf97803a943ef98358333d6956082c084d0e Mon Sep 17 00:00:00 2001 From: Jaehyun Yeom Date: Tue, 5 May 2026 10:40:09 -0700 Subject: [PATCH 05/21] Detect .proto files in analyzer signals Adds AnalysisResult.signals.hasProto used by toolbox bundle resolution. --- src/core/analyzer.ts | 38 +++++++++++++++++++++++++++++++++++-- tests/unit/analyzer.test.ts | 30 +++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/core/analyzer.ts b/src/core/analyzer.ts index f01cd89..7a6840c 100644 --- a/src/core/analyzer.ts +++ b/src/core/analyzer.ts @@ -9,6 +9,7 @@ import type { RepoStructure, ExistingConfig, GitInfo, + RepoSignals, } from '../types/index.js'; export async function analyze(targetPath: string): Promise { @@ -17,13 +18,14 @@ export async function analyze(targetPath: string): Promise { } catch { throw new Error(`Directory not found: ${targetPath}`); } - const [stack, structure, existing, git] = await Promise.all([ + const [stack, structure, existing, git, signals] = await Promise.all([ detectStack(targetPath), scanStructure(targetPath), checkExisting(targetPath), getGitInfo(targetPath), + detectSignals(targetPath), ]); - return { path: targetPath, stack, structure, existing, git }; + return { path: targetPath, stack, structure, existing, git, signals }; } async function detectStack(path: string): Promise { @@ -171,3 +173,35 @@ async function getGitInfo(path: string): Promise { ]); return { isRepo, remoteUrl, repoName, branch }; } + +async function detectSignals(rootPath: string): Promise { + const hasProto = await containsExtension(rootPath, '.proto', 4); + return { hasProto }; +} + +async function containsExtension( + rootPath: string, + ext: string, + maxDepth: number, +): Promise { + const ignore = new Set(['.git', 'node_modules', 'dist', 'build', '.next', 'target', 'vendor']); + async function walk(dir: string, depth: number): Promise { + if (depth > maxDepth) return false; + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return false; + } + for (const e of entries) { + if (e.isFile() && e.name.endsWith(ext)) return true; + } + for (const e of entries) { + if (e.isDirectory() && !ignore.has(e.name) && !e.name.startsWith('.')) { + if (await walk(join(dir, e.name), depth + 1)) return true; + } + } + return false; + } + return walk(rootPath, 0); +} diff --git a/tests/unit/analyzer.test.ts b/tests/unit/analyzer.test.ts index dac944a..6cc902c 100644 --- a/tests/unit/analyzer.test.ts +++ b/tests/unit/analyzer.test.ts @@ -126,3 +126,33 @@ describe('analyzer', () => { expect(result.existing.claudeSettings).toBeNull(); }); }); + +describe('analyzer signals', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'roboco-signal-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('hasProto is false when no .proto files exist', async () => { + const result = await analyze(tempDir); + expect(result.signals.hasProto).toBe(false); + }); + + it('hasProto is true when a .proto file exists at root', async () => { + await writeFile(join(tempDir, 'service.proto'), 'syntax = "proto3";\n'); + const result = await analyze(tempDir); + expect(result.signals.hasProto).toBe(true); + }); + + it('hasProto is true when a .proto file exists in a subdirectory', async () => { + await mkdir(join(tempDir, 'proto')); + await writeFile(join(tempDir, 'proto', 'service.proto'), 'syntax = "proto3";\n'); + const result = await analyze(tempDir); + expect(result.signals.hasProto).toBe(true); + }); +}); From 83daa14caea910f1f08ac958b9b1f1b2537e3d32 Mon Sep 17 00:00:00 2001 From: Jaehyun Yeom Date: Tue, 5 May 2026 11:41:11 -0700 Subject: [PATCH 06/21] Add mergeToolboxSettings to generator Writes extraKnownMarketplaces + enabledPlugins respecting explicit false. --- src/core/generator.ts | 25 +++++++++++++++++ tests/unit/settings-merge.test.ts | 46 ++++++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/core/generator.ts b/src/core/generator.ts index 4730c98..4c36c9c 100644 --- a/src/core/generator.ts +++ b/src/core/generator.ts @@ -3,6 +3,7 @@ import { execa } from 'execa'; import type { AnalysisResult, InterviewResult, RobocoConfig } from '../types/index.js'; import { ensureDir, fileExists, writeText, readText } from '../utils/fs.js'; import { logger } from '../utils/logger.js'; +import { MARKETPLACE } from './toolbox-bundles.js'; interface FileOperation { path: string; @@ -11,6 +12,30 @@ interface FileOperation { overwrite?: boolean; } +export function mergeToolboxSettings( + existing: Record, + bundle: string[], +): Record { + const result = { ...existing }; + + const marketplaces = (result['extraKnownMarketplaces'] ?? {}) as Record; + const newMarketplaces = { ...marketplaces }; + if (!newMarketplaces[MARKETPLACE.name]) { + newMarketplaces[MARKETPLACE.name] = { source: MARKETPLACE.source }; + } + result['extraKnownMarketplaces'] = newMarketplaces; + + const plugins = (result['enabledPlugins'] ?? {}) as Record; + const newPlugins = { ...plugins }; + for (const name of bundle) { + const key = `${name}@${MARKETPLACE.name}`; + if (newPlugins[key] !== false) newPlugins[key] = true; + } + result['enabledPlugins'] = newPlugins; + + return result; +} + export async function generate( targetPath: string, analysis: AnalysisResult, diff --git a/tests/unit/settings-merge.test.ts b/tests/unit/settings-merge.test.ts index e73fd63..fe2d4c4 100644 --- a/tests/unit/settings-merge.test.ts +++ b/tests/unit/settings-merge.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { generate } from '../../src/core/generator.js'; +import { generate, mergeToolboxSettings } from '../../src/core/generator.js'; import type { AnalysisResult, InterviewResult } from '../../src/types/index.js'; function makeAnalysis( @@ -143,3 +143,47 @@ describe('settings.json merge', () => { expect(settings.mcpServers.myServer.command).toBe('npx my-server'); }); }); + +describe('mergeToolboxSettings', () => { + it('writes marketplace + enabledPlugins on empty settings', () => { + const result = mergeToolboxSettings({}, ['next-action', 'todo']); + expect(result['extraKnownMarketplaces']).toEqual({ + 'claude-toolbox': { source: { source: 'github', repo: 'jaeyeom/claude-toolbox' } }, + }); + expect(result['enabledPlugins']).toEqual({ + 'next-action@claude-toolbox': true, + 'todo@claude-toolbox': true, + }); + }); + + it('preserves unrelated existing keys', () => { + const existing = { permissions: { allow: ['Read'], deny: [] } }; + const result = mergeToolboxSettings(existing, ['next-action']); + expect(result['permissions']).toEqual({ allow: ['Read'], deny: [] }); + }); + + it('does not overwrite explicit false in enabledPlugins', () => { + const existing = { enabledPlugins: { 'next-action@claude-toolbox': false } }; + const result = mergeToolboxSettings(existing, ['next-action', 'todo']); + expect(result['enabledPlugins']).toEqual({ + 'next-action@claude-toolbox': false, + 'todo@claude-toolbox': true, + }); + }); + + it('is idempotent on re-run with same bundle', () => { + const first = mergeToolboxSettings({}, ['next-action']); + const second = mergeToolboxSettings(first, ['next-action']); + expect(second).toEqual(first); + }); + + it('preserves an existing matching marketplace entry', () => { + const existing = { + extraKnownMarketplaces: { + 'claude-toolbox': { source: { source: 'github', repo: 'jaeyeom/claude-toolbox' } }, + }, + }; + const result = mergeToolboxSettings(existing, []); + expect(result['extraKnownMarketplaces']).toEqual(existing.extraKnownMarketplaces); + }); +}); From ef8e20429baaceee3806112d399b534bd4b2270a Mon Sep 17 00:00:00 2001 From: Jaehyun Yeom Date: Tue, 5 May 2026 16:35:09 -0700 Subject: [PATCH 07/21] Add installToolbox: settings-first, marketplace add, per-plugin install Settings.json written first as source of truth; subprocess install is convenience for the initiating user. --- src/core/installer.ts | 61 +++++++++++++++- tests/unit/toolbox-install.test.ts | 107 +++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 tests/unit/toolbox-install.test.ts diff --git a/src/core/installer.ts b/src/core/installer.ts index 39b7c14..4d3d5c1 100644 --- a/src/core/installer.ts +++ b/src/core/installer.ts @@ -1,6 +1,10 @@ +import { join } from 'node:path'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { execa } from 'execa'; -import type { ToolSelection } from '../types/index.js'; +import type { AnalysisResult, ToolSelection } from '../types/index.js'; import { logger } from '../utils/logger.js'; +import { resolveBundle, MARKETPLACE } from './toolbox-bundles.js'; +import { mergeToolboxSettings } from './generator.js'; interface InstallResult { tool: string; @@ -110,3 +114,58 @@ export function printInstallReport(results: InstallResult[]): void { } } } + +export async function installToolbox(analysis: AnalysisResult): Promise { + const bundle = resolveBundle(analysis.stack.languages, analysis.signals); + + // 1. Write project-scoped settings.json (source of truth — survives subprocess failure) + await writeProjectSettings(analysis.path, bundle); + + // 2. Subprocess: marketplace add + try { + await execa('claude', ['plugin', 'marketplace', 'add', `${MARKETPLACE.source.repo}`], { + timeout: 30000, + }); + } catch { + logger.warn( + `claude-toolbox: marketplace add failed. Run manually: claude plugin marketplace add ${MARKETPLACE.source.repo}`, + ); + return { + tool: 'claude-toolbox', + success: false, + message: 'Marketplace add failed — settings.json written, install skipped', + }; + } + + // 3. Subprocess: per-plugin install + let installed = 0; + for (const plugin of bundle) { + try { + await execa('claude', ['plugin', 'install', `${plugin}@${MARKETPLACE.name}`], { + timeout: 60000, + }); + installed++; + } catch { + logger.warn(`claude-toolbox: install of ${plugin} failed`); + } + } + + return { + tool: 'claude-toolbox', + success: installed > 0, + message: `Installed ${installed}/${bundle.length} plugins. Teammates: run \`roboco install\` to enable.`, + }; +} + +async function writeProjectSettings(targetPath: string, bundle: string[]): Promise { + const settingsPath = join(targetPath, '.claude', 'settings.json'); + await mkdir(join(targetPath, '.claude'), { recursive: true }); + let existing: Record = {}; + try { + existing = JSON.parse(await readFile(settingsPath, 'utf-8')) as Record; + } catch { + // file does not exist or is invalid — start fresh + } + const merged = mergeToolboxSettings(existing, bundle); + await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n'); +} diff --git a/tests/unit/toolbox-install.test.ts b/tests/unit/toolbox-install.test.ts new file mode 100644 index 0000000..3c86685 --- /dev/null +++ b/tests/unit/toolbox-install.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mkdtemp, rm, readFile, mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +vi.mock('execa', () => ({ + execa: vi.fn(), +})); + +import { execa } from 'execa'; +import { installToolbox } from '../../src/core/installer.js'; +import type { AnalysisResult } from '../../src/types/index.js'; + +const execaMock = execa as unknown as ReturnType; + +function makeAnalysis(tempDir: string, languages: string[] = ['TypeScript']): AnalysisResult { + return { + path: tempDir, + stack: { + languages, + frameworks: [], + buildTools: [], + packageManager: 'npm', + hasTypeScript: languages.includes('TypeScript'), + }, + structure: { + rootFiles: [], + rootDirs: [], + sourceDir: 'src', + testDir: 'tests', + hasMonorepo: false, + }, + existing: { + hasClaude: false, + hasClaudeMd: false, + hasOmc: false, + hasRoboco: false, + hasOpenSpec: false, + claudeSettings: null, + claudeSkills: [], + claudeCommands: [], + globalSettings: null, + globalSkills: [], + }, + git: { isRepo: true, remoteUrl: null, repoName: 'test', branch: 'main' }, + signals: { hasProto: false }, + }; +} + +describe('installToolbox', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'roboco-toolbox-')); + execaMock.mockReset(); + execaMock.mockResolvedValue({ stdout: '', stderr: '' }); + }); + + it('writes project .claude/settings.json with marketplace + plugins for TypeScript', async () => { + await installToolbox(makeAnalysis(tempDir)); + const raw = await readFile(join(tempDir, '.claude', 'settings.json'), 'utf-8'); + const parsed = JSON.parse(raw); + expect(parsed.extraKnownMarketplaces['claude-toolbox']).toBeDefined(); + expect(parsed.enabledPlugins['biome-vcs-integration@claude-toolbox']).toBe(true); + expect(parsed.enabledPlugins['next-action@claude-toolbox']).toBe(true); + }); + + it('runs marketplace add then plugin install per bundle entry', async () => { + await installToolbox(makeAnalysis(tempDir, ['Go'])); + const calls = execaMock.mock.calls.map((c) => c[1]); + expect(calls[0]).toEqual(['plugin', 'marketplace', 'add', 'jaeyeom/claude-toolbox']); + const installArgs = calls.slice(1).map((args) => args[2]); + expect(installArgs).toContain('next-action@claude-toolbox'); + expect(installArgs).toContain('go-dev@claude-toolbox'); + }); + + it('skips plugin installs when marketplace add fails', async () => { + execaMock.mockReset(); + execaMock.mockRejectedValueOnce(new Error('marketplace failed')); + const result = await installToolbox(makeAnalysis(tempDir)); + expect(execaMock).toHaveBeenCalledTimes(1); + expect(result.success).toBe(false); + }); + + it('continues installing remaining plugins when one install fails', async () => { + execaMock.mockReset(); + execaMock.mockResolvedValueOnce({ stdout: '', stderr: '' }); // marketplace add ok + execaMock.mockRejectedValueOnce(new Error('plugin install failed')); // first plugin fails + execaMock.mockResolvedValue({ stdout: '', stderr: '' }); // rest pass + const result = await installToolbox(makeAnalysis(tempDir)); + expect(execaMock.mock.calls.length).toBeGreaterThan(2); + expect(result.success).toBe(true); + expect(result.message).toMatch(/Installed \d+\/\d+/); + }); + + it('preserves existing .claude/settings.json content', async () => { + await mkdir(join(tempDir, '.claude'), { recursive: true }); + await writeFile( + join(tempDir, '.claude', 'settings.json'), + JSON.stringify({ permissions: { allow: ['Read'], deny: [] } }), + ); + await installToolbox(makeAnalysis(tempDir)); + const parsed = JSON.parse(await readFile(join(tempDir, '.claude', 'settings.json'), 'utf-8')); + expect(parsed.permissions.allow).toEqual(['Read']); + expect(parsed.extraKnownMarketplaces['claude-toolbox']).toBeDefined(); + }); +}); From c80e778f7d6abe6b2677a3b94706350d04ad57e7 Mon Sep 17 00:00:00 2001 From: Jaehyun Yeom Date: Tue, 5 May 2026 17:43:06 -0700 Subject: [PATCH 08/21] Wire installToolbox into installTools router Threads analysis through installTools so toolbox can read signals and path. --- src/commands/add.ts | 2 +- src/commands/init.ts | 2 +- src/commands/install.ts | 2 +- src/commands/update.ts | 2 +- src/core/installer.ts | 8 +++++++- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/commands/add.ts b/src/commands/add.ts index a3b102d..46935ae 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -57,7 +57,7 @@ export async function addCommand( (config.interview.tools as unknown as Record)[tool.key] = true; const spinner = ora(`Installing ${integration}...`).start(); - const results = await installTools(config.interview.tools); + const results = await installTools(config.interview.tools, config.analysis); const thisResult = results.find((r) => r.tool.toLowerCase().includes(integration.toLowerCase())); spinner.succeed(`${integration} installation complete`); diff --git a/src/commands/init.ts b/src/commands/init.ts index 6bffc60..795ce7e 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -72,7 +72,7 @@ export async function initCommand(path: string | undefined, options: InitOptions // Phase 4: Install tools logger.blank(); const installSpinner = ora('Installing tools...').start(); - const installResults = await installTools(interviewResult.tools); + const installResults = await installTools(interviewResult.tools, analysis); installSpinner.succeed('Tool installation complete'); printInstallReport(installResults); diff --git a/src/commands/install.ts b/src/commands/install.ts index c2d1be6..b64986f 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -24,7 +24,7 @@ export async function installCommand(path: string | undefined): Promise { logger.info(`Initialized: ${config.createdAt}`); const installSpinner = ora('Installing tools from configuration...').start(); - const results = await installTools(config.interview.tools); + const results = await installTools(config.interview.tools, config.analysis); installSpinner.succeed('Installation complete'); printInstallReport(results); diff --git a/src/commands/update.ts b/src/commands/update.ts index 8083570..5b84bdd 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -44,7 +44,7 @@ export async function updateCommand( await writeJson(configPath, existing); const installSpinner = ora('Updating tools...').start(); - const results = await installTools(interviewResult.tools); + const results = await installTools(interviewResult.tools, analysis); installSpinner.succeed('Tools updated'); printInstallReport(results); diff --git a/src/core/installer.ts b/src/core/installer.ts index 4d3d5c1..cd965a2 100644 --- a/src/core/installer.ts +++ b/src/core/installer.ts @@ -12,7 +12,10 @@ interface InstallResult { message: string; } -export async function installTools(tools: ToolSelection): Promise { +export async function installTools( + tools: ToolSelection, + analysis: AnalysisResult, +): Promise { const results: InstallResult[] = []; // OMC (required) @@ -41,6 +44,9 @@ export async function installTools(tools: ToolSelection): Promise Date: Tue, 5 May 2026 17:48:07 -0700 Subject: [PATCH 09/21] Add toolbox to interview (interactive, auto, AI) Default true in auto/AI; interactive prompt shows detected stack overlay. --- src/core/interviewer.ts | 19 +++++++++++++++- tests/unit/interviewer.test.ts | 41 ++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/core/interviewer.ts b/src/core/interviewer.ts index d5b8de5..e9f05a0 100644 --- a/src/core/interviewer.ts +++ b/src/core/interviewer.ts @@ -40,6 +40,7 @@ function autoInterview(analysis: AnalysisResult): InterviewResult { githubMcp: analysis.git.remoteUrl?.includes('github.com') ?? false, context7: analysis.stack.languages.length > 0, harness: hasWebProject || analysis.structure.hasMonorepo, + toolbox: true, }, preferences: { autoGenerated: true, @@ -71,7 +72,7 @@ IMPORTANT: When you have enough information, output your final recommendation as \`\`\`json { "setupDomains": { "claudeEnv": true, "processDocs": boolean, "cicd": boolean }, - "tools": { "omc": true, "openspec": boolean, "exaAi": boolean, "perplexityAsk": boolean, "githubMcp": boolean, "context7": boolean, "harness": boolean }, + "tools": { "omc": true, "openspec": boolean, "exaAi": boolean, "perplexityAsk": boolean, "githubMcp": boolean, "context7": boolean, "harness": boolean, "toolbox": boolean }, "preferences": {} } \`\`\``, @@ -136,6 +137,7 @@ function parseAiResult(result: string, analysis: AnalysisResult): InterviewResul githubMcp: parsed.tools?.githubMcp ?? false, context7: parsed.tools?.context7 ?? false, harness: parsed.tools?.harness ?? false, + toolbox: parsed.tools?.toolbox ?? true, }, preferences: parsed.preferences ?? { aiGenerated: true }, }; @@ -172,6 +174,20 @@ async function interactiveInterview(analysis: AnalysisResult): Promise 0 ? ` (also: ${overlayHints.join(', ')})` : ''; + const toolbox = await confirm( + ` claude-toolbox bundle${overlayMsg} (next-action, todo, gh-issue-resolver, semgrep-review, sandbox-helpers, makefile-workflow)?`, + ); + return { setupDomains: { claudeEnv: true, @@ -186,6 +202,7 @@ async function interactiveInterview(analysis: AnalysisResult): Promise { expect(result.preferences).toHaveProperty('autoGenerated', true); }); }); + +describe('interviewer toolbox integration', () => { + it('autoInterview defaults toolbox to true', async () => { + const { interview } = await import('../../src/core/interviewer.js'); + const result = await interview( + { + path: '/tmp/x', + stack: { + languages: ['TypeScript'], + frameworks: [], + buildTools: [], + packageManager: 'npm', + hasTypeScript: true, + }, + structure: { + rootFiles: [], + rootDirs: [], + sourceDir: 'src', + testDir: 'tests', + hasMonorepo: false, + }, + existing: { + hasClaude: false, + hasClaudeMd: false, + hasOmc: false, + hasRoboco: false, + hasOpenSpec: false, + claudeSettings: null, + claudeSkills: [], + claudeCommands: [], + globalSettings: null, + globalSkills: [], + }, + git: { isRepo: false, remoteUrl: null, repoName: null, branch: null }, + signals: { hasProto: false }, + }, + { auto: true }, + ); + expect(result.tools.toolbox).toBe(true); + }); +}); From 02818b248db11249452a3f5ebba418a4057248a5 Mon Sep 17 00:00:00 2001 From: Jaehyun Yeom Date: Tue, 5 May 2026 17:53:19 -0700 Subject: [PATCH 10/21] T7 follow-up: dedupe overlay hints, improve prompt copy, test parseAiResult Issues raised in code review: - overlay hints derived from resolveBundle/CORE_BUNDLE (single source of truth) - prompt groups plugins by capability per design spec - parseAiResult exported and tested for toolbox default-true and explicit-false --- src/core/interviewer.ts | 27 +++++++++--------- tests/unit/interviewer.test.ts | 50 +++++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 14 deletions(-) diff --git a/src/core/interviewer.ts b/src/core/interviewer.ts index e9f05a0..dc6352b 100644 --- a/src/core/interviewer.ts +++ b/src/core/interviewer.ts @@ -1,6 +1,7 @@ import type { AnalysisResult, InterviewResult, ToolSelection } from '../types/index.js'; import { logger } from '../utils/logger.js'; import { confirm } from '../utils/prompt.js'; +import { CORE_BUNDLE, resolveBundle } from './toolbox-bundles.js'; export async function interview( analysis: AnalysisResult, @@ -113,7 +114,7 @@ function buildInterviewPrompt(analysis: AnalysisResult): string { return parts.join('\n'); } -function parseAiResult(result: string, analysis: AnalysisResult): InterviewResult { +export function parseAiResult(result: string, analysis: AnalysisResult): InterviewResult { // Extract JSON from markdown code block const jsonMatch = result.match(/```json\s*([\s\S]*?)```/); if (jsonMatch?.[1]) { @@ -174,19 +175,19 @@ async function interactiveInterview(analysis: AnalysisResult): Promise !CORE_BUNDLE.includes(p)); + + logger.blank(); + logger.info(' claude-toolbox bundle adds:'); + logger.plain(' • task workflow (next-action, todo, gh-issue-resolver)'); + logger.plain(' • security review (semgrep-review)'); + logger.plain(' • check orchestration (makefile-workflow)'); + logger.plain(' • sandbox helpers'); + if (overlayHints.length > 0) { + logger.plain(` • stack-specific: ${overlayHints.join(', ')}`); } - if (analysis.signals.hasProto) overlayHints.push('protobuf-dev'); - const overlayMsg = overlayHints.length > 0 ? ` (also: ${overlayHints.join(', ')})` : ''; - const toolbox = await confirm( - ` claude-toolbox bundle${overlayMsg} (next-action, todo, gh-issue-resolver, semgrep-review, sandbox-helpers, makefile-workflow)?`, - ); + const toolbox = await confirm(' Install claude-toolbox bundle?'); return { setupDomains: { diff --git a/tests/unit/interviewer.test.ts b/tests/unit/interviewer.test.ts index 792a842..7aedf35 100644 --- a/tests/unit/interviewer.test.ts +++ b/tests/unit/interviewer.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { interview } from '../../src/core/interviewer.js'; +import { interview, parseAiResult } from '../../src/core/interviewer.js'; import type { AnalysisResult } from '../../src/types/index.js'; function makeAnalysis(): AnalysisResult { @@ -111,3 +111,51 @@ describe('interviewer toolbox integration', () => { expect(result.tools.toolbox).toBe(true); }); }); + +const stubAnalysis: AnalysisResult = { + path: '/tmp/x', + stack: { + languages: ['TypeScript'], + frameworks: [], + buildTools: [], + packageManager: 'npm', + hasTypeScript: true, + }, + structure: { + rootFiles: [], + rootDirs: [], + sourceDir: 'src', + testDir: 'tests', + hasMonorepo: false, + }, + existing: { + hasClaude: false, + hasClaudeMd: false, + hasOmc: false, + hasRoboco: false, + hasOpenSpec: false, + claudeSettings: null, + claudeSkills: [], + claudeCommands: [], + globalSettings: null, + globalSkills: [], + }, + git: { isRepo: false, remoteUrl: null, repoName: null, branch: null }, + signals: { hasProto: false }, +}; + +describe('parseAiResult toolbox fallback', () => { + it('defaults toolbox to true when AI omits the field', () => { + const aiOutput = + '```json\n{"setupDomains":{"claudeEnv":true,"processDocs":true,"cicd":true},"tools":{"omc":true,"openspec":true,"exaAi":false,"perplexityAsk":false,"githubMcp":false,"context7":true,"harness":false}}\n```'; + const result = parseAiResult(aiOutput, stubAnalysis); + expect(result.tools.toolbox).toBe(true); + }); + + it('honors toolbox: false when AI explicitly opts out', () => { + const aiOutput = + '```json\n{"tools":{"omc":true,"openspec":false,"exaAi":false,"perplexityAsk":false,"githubMcp":false,"context7":false,"harness":false,"toolbox":false}}\n```'; + const result = parseAiResult(aiOutput, stubAnalysis); + expect(result.tools.toolbox).toBe(false); + }); +}); From 8c8d26a2ebf6d4698efd4c6a1cb9309cdb755ff4 Mon Sep 17 00:00:00 2001 From: Jaehyun Yeom Date: Tue, 5 May 2026 17:58:38 -0700 Subject: [PATCH 11/21] Add 'roboco add toolbox' bundle install path Reads analysis from .roboco/config.json, installs bundle, updates config. --- src/commands/add.ts | 18 ++++++- tests/unit/toolbox-add.test.ts | 94 ++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 tests/unit/toolbox-add.test.ts diff --git a/src/commands/add.ts b/src/commands/add.ts index 46935ae..fab772e 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -1,7 +1,7 @@ import { resolve } from 'node:path'; import ora from 'ora'; import { fileExists, readJson, writeJson } from '../utils/fs.js'; -import { installTools } from '../core/installer.js'; +import { installTools, installToolbox } from '../core/installer.js'; import { logger } from '../utils/logger.js'; import type { RobocoConfig, ToolSelection } from '../types/index.js'; @@ -12,6 +12,7 @@ const KNOWN_TOOLS: Record(configPath); + if (integration.toLowerCase() === 'toolbox') { + const spinner = ora('Installing claude-toolbox bundle...').start(); + const result = await installToolbox(config.analysis); + spinner.succeed('claude-toolbox bundle install complete'); + if (result.success) logger.success(`${result.tool}: ${result.message}`); + else logger.warn(`${result.tool}: ${result.message}`); + + config.interview.tools.toolbox = true; + config.updatedAt = new Date().toISOString(); + if (!config.installedTools.includes('toolbox')) config.installedTools.push('toolbox'); + await writeJson(configPath, config); + logger.success('Configuration updated.'); + return; + } + if (config.interview.tools[tool.key]) { logger.warn(`${integration} is already enabled.`); return; diff --git a/tests/unit/toolbox-add.test.ts b/tests/unit/toolbox-add.test.ts new file mode 100644 index 0000000..4e1f5fe --- /dev/null +++ b/tests/unit/toolbox-add.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mkdtemp, rm, mkdir, writeFile, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +vi.mock('execa', () => ({ execa: vi.fn() })); +import { execa } from 'execa'; +import { addCommand } from '../../src/commands/add.js'; + +const execaMock = execa as unknown as ReturnType; + +async function fixture(): Promise { + const tempDir = await mkdtemp(join(tmpdir(), 'roboco-add-')); + await mkdir(join(tempDir, '.roboco')); + await writeFile( + join(tempDir, '.roboco', 'config.json'), + JSON.stringify({ + version: '0.1.0', + createdAt: '2026-05-04T00:00:00.000Z', + updatedAt: '2026-05-04T00:00:00.000Z', + analysis: { + path: tempDir, + stack: { + languages: ['TypeScript'], + frameworks: [], + buildTools: [], + packageManager: 'npm', + hasTypeScript: true, + }, + structure: { + rootFiles: [], + rootDirs: [], + sourceDir: 'src', + testDir: 'tests', + hasMonorepo: false, + }, + existing: { + hasClaude: false, + hasClaudeMd: false, + hasOmc: false, + hasRoboco: false, + hasOpenSpec: false, + claudeSettings: null, + claudeSkills: [], + claudeCommands: [], + globalSettings: null, + globalSkills: [], + }, + git: { isRepo: false, remoteUrl: null, repoName: null, branch: null }, + signals: { hasProto: false }, + }, + interview: { + setupDomains: { claudeEnv: true, processDocs: false, cicd: false }, + tools: { + omc: true, + openspec: false, + exaAi: false, + perplexityAsk: false, + githubMcp: false, + context7: false, + harness: false, + toolbox: false, + }, + preferences: {}, + }, + installedTools: ['omc'], + }), + ); + return tempDir; +} + +describe('roboco add toolbox', () => { + beforeEach(() => { + execaMock.mockReset(); + execaMock.mockResolvedValue({ stdout: '', stderr: '' }); + }); + + it('installs the bundle and writes settings.json', async () => { + const tempDir = await fixture(); + await addCommand('toolbox', { path: tempDir }); + const settings = JSON.parse(await readFile(join(tempDir, '.claude', 'settings.json'), 'utf-8')); + expect(settings.enabledPlugins['biome-vcs-integration@claude-toolbox']).toBe(true); + await rm(tempDir, { recursive: true, force: true }); + }); + + it('marks toolbox enabled in .roboco/config.json', async () => { + const tempDir = await fixture(); + await addCommand('toolbox', { path: tempDir }); + const cfg = JSON.parse(await readFile(join(tempDir, '.roboco', 'config.json'), 'utf-8')); + expect(cfg.interview.tools.toolbox).toBe(true); + expect(cfg.installedTools).toContain('toolbox'); + await rm(tempDir, { recursive: true, force: true }); + }); +}); From 9d8f21df138e7229f8eac3b1bbfd8407198076d0 Mon Sep 17 00:00:00 2001 From: Jaehyun Yeom Date: Tue, 5 May 2026 18:13:22 -0700 Subject: [PATCH 12/21] T8 follow-up: spinner cleanup on failure paths Wrap installToolbox in try/catch/finally so filesystem errors fail the spinner instead of leaking. Distinguish full-success (spinner.succeed) from partial-success (spinner.warn) based on result.success. Adds negative-path test for marketplace-add failure. --- src/commands/add.ts | 21 +++++++++++++++++---- tests/unit/toolbox-add.test.ts | 14 ++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/commands/add.ts b/src/commands/add.ts index fab772e..6b82067 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -51,10 +51,23 @@ export async function addCommand( if (integration.toLowerCase() === 'toolbox') { const spinner = ora('Installing claude-toolbox bundle...').start(); - const result = await installToolbox(config.analysis); - spinner.succeed('claude-toolbox bundle install complete'); - if (result.success) logger.success(`${result.tool}: ${result.message}`); - else logger.warn(`${result.tool}: ${result.message}`); + let result; + try { + result = await installToolbox(config.analysis); + } catch (err) { + spinner.fail('claude-toolbox bundle install failed'); + const reason = err instanceof Error ? err.message : String(err); + logger.error(`claude-toolbox: ${reason}`); + process.exitCode = 1; + return; + } + if (result.success) { + spinner.succeed('claude-toolbox bundle install complete'); + logger.success(`${result.tool}: ${result.message}`); + } else { + spinner.warn('claude-toolbox bundle install partially complete'); + logger.warn(`${result.tool}: ${result.message}`); + } config.interview.tools.toolbox = true; config.updatedAt = new Date().toISOString(); diff --git a/tests/unit/toolbox-add.test.ts b/tests/unit/toolbox-add.test.ts index 4e1f5fe..fbd8732 100644 --- a/tests/unit/toolbox-add.test.ts +++ b/tests/unit/toolbox-add.test.ts @@ -91,4 +91,18 @@ describe('roboco add toolbox', () => { expect(cfg.installedTools).toContain('toolbox'); await rm(tempDir, { recursive: true, force: true }); }); + + it('reports failure cleanly when installToolbox returns result.success === false', async () => { + const tempDir = await fixture(); + execaMock.mockReset(); + // First call (marketplace add) throws — installToolbox catches it and returns success:false + execaMock.mockRejectedValueOnce(new Error('marketplace failed')); + await addCommand('toolbox', { path: tempDir }); + // Even with marketplace add failure, settings.json is written and config.json is updated + // (per design: settings is source of truth, persists user intent) + const cfg = JSON.parse(await readFile(join(tempDir, '.roboco', 'config.json'), 'utf-8')); + expect(cfg.interview.tools.toolbox).toBe(true); + expect(cfg.installedTools).toContain('toolbox'); + await rm(tempDir, { recursive: true, force: true }); + }); }); From 4210d6e521469d38c269750f9ce0a04f666464a9 Mon Sep 17 00:00:00 2001 From: Jaehyun Yeom Date: Tue, 5 May 2026 18:15:46 -0700 Subject: [PATCH 13/21] Add 'roboco add toolbox:' single-plugin install (non-overlap) Recognizes toolbox: prefix, validates against KNOWN_PLUGINS catalog, installs a single plugin via subprocess. Overlap path is Task 10. --- src/commands/add.ts | 48 +++++++++++++++++++++++++++------- src/core/installer.ts | 36 ++++++++++++++++++++++++- src/core/toolbox-bundles.ts | 13 +++++++++ tests/unit/toolbox-add.test.ts | 25 ++++++++++++++++++ 4 files changed, 112 insertions(+), 10 deletions(-) diff --git a/src/commands/add.ts b/src/commands/add.ts index 6b82067..17091b4 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -1,7 +1,7 @@ import { resolve } from 'node:path'; import ora from 'ora'; import { fileExists, readJson, writeJson } from '../utils/fs.js'; -import { installTools, installToolbox } from '../core/installer.js'; +import { installTools, installToolbox, installSingleToolboxPlugin } from '../core/installer.js'; import { logger } from '../utils/logger.js'; import type { RobocoConfig, ToolSelection } from '../types/index.js'; @@ -29,14 +29,6 @@ export async function addCommand( return; } - const tool = KNOWN_TOOLS[integration.toLowerCase()]; - if (!tool) { - logger.error(`Unknown integration: "${integration}"`); - logger.info(`Available: ${Object.keys(KNOWN_TOOLS).join(', ')}`); - process.exitCode = 1; - return; - } - const targetPath = resolve(options.path ?? '.'); const configPath = resolve(targetPath, '.roboco', 'config.json'); @@ -49,6 +41,36 @@ export async function addCommand( const config = await readJson(configPath); + // Single-plugin toolbox install: `roboco add toolbox:` + if (integration.toLowerCase().startsWith('toolbox:')) { + const pluginName = integration.slice('toolbox:'.length); + const spinner = ora(`Installing ${integration}...`).start(); + let result; + try { + result = await installSingleToolboxPlugin(pluginName, targetPath); + } catch (err) { + spinner.fail(`Install of ${integration} failed`); + const reason = err instanceof Error ? err.message : String(err); + logger.error(`${integration}: ${reason}`); + process.exitCode = 1; + return; + } + if (result.success) { + spinner.succeed(`${integration} installed`); + logger.success(`${result.tool}: ${result.message}`); + config.updatedAt = new Date().toISOString(); + if (!config.installedTools.includes(`toolbox:${pluginName}`)) { + config.installedTools.push(`toolbox:${pluginName}`); + } + await writeJson(configPath, config); + } else { + spinner.fail(`${integration} install failed`); + logger.error(`${result.tool}: ${result.message}`); + process.exitCode = 1; + } + return; + } + if (integration.toLowerCase() === 'toolbox') { const spinner = ora('Installing claude-toolbox bundle...').start(); let result; @@ -77,6 +99,14 @@ export async function addCommand( return; } + const tool = KNOWN_TOOLS[integration.toLowerCase()]; + if (!tool) { + logger.error(`Unknown integration: "${integration}"`); + logger.info(`Available: ${Object.keys(KNOWN_TOOLS).join(', ')}`); + process.exitCode = 1; + return; + } + if (config.interview.tools[tool.key]) { logger.warn(`${integration} is already enabled.`); return; diff --git a/src/core/installer.ts b/src/core/installer.ts index cd965a2..0177085 100644 --- a/src/core/installer.ts +++ b/src/core/installer.ts @@ -3,7 +3,12 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { execa } from 'execa'; import type { AnalysisResult, ToolSelection } from '../types/index.js'; import { logger } from '../utils/logger.js'; -import { resolveBundle, MARKETPLACE } from './toolbox-bundles.js'; +import { + resolveBundle, + MARKETPLACE, + KNOWN_PLUGINS, + OVERLAPPING_PLUGINS, +} from './toolbox-bundles.js'; import { mergeToolboxSettings } from './generator.js'; interface InstallResult { @@ -163,6 +168,35 @@ export async function installToolbox(analysis: AnalysisResult): Promise { + if (!KNOWN_PLUGINS.has(name)) { + return { tool: `claude-toolbox:${name}`, success: false, message: `Unknown plugin: ${name}` }; + } + + if (OVERLAPPING_PLUGINS.has(name)) { + // Overlap path filled in by Task 10 + return { + tool: `claude-toolbox:${name}`, + success: false, + message: 'Overlap path not yet implemented', + }; + } + + await writeProjectSettings(targetPath, [name]); + + try { + await execa('claude', ['plugin', 'install', `${name}@${MARKETPLACE.name}`], { + timeout: 60000, + }); + return { tool: `claude-toolbox:${name}`, success: true, message: 'Installed' }; + } catch { + return { tool: `claude-toolbox:${name}`, success: false, message: 'Install failed' }; + } +} + async function writeProjectSettings(targetPath: string, bundle: string[]): Promise { const settingsPath = join(targetPath, '.claude', 'settings.json'); await mkdir(join(targetPath, '.claude'), { recursive: true }); diff --git a/src/core/toolbox-bundles.ts b/src/core/toolbox-bundles.ts index 215cbd3..e702890 100644 --- a/src/core/toolbox-bundles.ts +++ b/src/core/toolbox-bundles.ts @@ -42,3 +42,16 @@ export function resolveBundle(languages: string[], signals: ResolveSignals): str (p) => !OVERLAPPING_PLUGINS.has(p), ); } + +export const KNOWN_PLUGINS = new Set([ + ...CORE_BUNDLE, + ...Object.values(STACK_OVERLAYS).flat(), + ...Object.values(SIGNAL_OVERLAYS).flat(), + ...OVERLAPPING_PLUGINS, + // Catalog-only entries (in marketplace but not in any default bundle) + 'apply-figma-make', + 'cloudflare-macos-fix', + 'create-lang-dev-skill', + 'jira-commands', + 'jira-edit-description', +]); diff --git a/tests/unit/toolbox-add.test.ts b/tests/unit/toolbox-add.test.ts index fbd8732..ef1dda3 100644 --- a/tests/unit/toolbox-add.test.ts +++ b/tests/unit/toolbox-add.test.ts @@ -106,3 +106,28 @@ describe('roboco add toolbox', () => { await rm(tempDir, { recursive: true, force: true }); }); }); + +describe('roboco add toolbox:', () => { + beforeEach(() => { + execaMock.mockReset(); + execaMock.mockResolvedValue({ stdout: '', stderr: '' }); + }); + + it('installs a single non-overlapping plugin', async () => { + const tempDir = await fixture(); + await addCommand('toolbox:next-action', { path: tempDir }); + const calls = execaMock.mock.calls.map((c) => c[1]); + expect(calls).toContainEqual(['plugin', 'install', 'next-action@claude-toolbox']); + const settings = JSON.parse(await readFile(join(tempDir, '.claude', 'settings.json'), 'utf-8')); + expect(settings.enabledPlugins['next-action@claude-toolbox']).toBe(true); + await rm(tempDir, { recursive: true, force: true }); + }); + + it('rejects unknown plugin name', async () => { + const tempDir = await fixture(); + await addCommand('toolbox:not-a-real-plugin', { path: tempDir }); + expect(process.exitCode).toBe(1); + process.exitCode = 0; + await rm(tempDir, { recursive: true, force: true }); + }); +}); From 0434749025ff6954f2fdbf286152668118c4510b Mon Sep 17 00:00:00 2001 From: Jaehyun Yeom Date: Tue, 5 May 2026 20:00:57 -0700 Subject: [PATCH 14/21] Add overlap remediation for opt-in toolbox plugins Drop-replace prompt records skipGeneratorOutputs override; cleanup handlers per overlapping plugin run filesystem removal where applicable. --- src/commands/add.ts | 16 ++++++-- src/core/installer.ts | 70 ++++++++++++++++++++++++++-------- src/core/toolbox-bundles.ts | 44 +++++++++++++++++++++ tests/unit/toolbox-add.test.ts | 37 ++++++++++++++++++ 4 files changed, 149 insertions(+), 18 deletions(-) diff --git a/src/commands/add.ts b/src/commands/add.ts index 17091b4..5816a59 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -1,7 +1,12 @@ import { resolve } from 'node:path'; import ora from 'ora'; import { fileExists, readJson, writeJson } from '../utils/fs.js'; -import { installTools, installToolbox, installSingleToolboxPlugin } from '../core/installer.js'; +import { + installTools, + installToolbox, + installSingleToolboxPlugin, + type InstallResult, +} from '../core/installer.js'; import { logger } from '../utils/logger.js'; import type { RobocoConfig, ToolSelection } from '../types/index.js'; @@ -45,13 +50,17 @@ export async function addCommand( if (integration.toLowerCase().startsWith('toolbox:')) { const pluginName = integration.slice('toolbox:'.length); const spinner = ora(`Installing ${integration}...`).start(); - let result; + let result: InstallResult; + let configChanged = false; try { - result = await installSingleToolboxPlugin(pluginName, targetPath); + const outcome = await installSingleToolboxPlugin(pluginName, targetPath, config); + result = outcome.result; + configChanged = outcome.configChanged; } catch (err) { spinner.fail(`Install of ${integration} failed`); const reason = err instanceof Error ? err.message : String(err); logger.error(`${integration}: ${reason}`); + if (configChanged) await writeJson(configPath, config); // persist override even on error process.exitCode = 1; return; } @@ -66,6 +75,7 @@ export async function addCommand( } else { spinner.fail(`${integration} install failed`); logger.error(`${result.tool}: ${result.message}`); + if (configChanged) await writeJson(configPath, config); // persist override even on failure process.exitCode = 1; } return; diff --git a/src/core/installer.ts b/src/core/installer.ts index 0177085..63b96fa 100644 --- a/src/core/installer.ts +++ b/src/core/installer.ts @@ -1,17 +1,19 @@ import { join } from 'node:path'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { execa } from 'execa'; -import type { AnalysisResult, ToolSelection } from '../types/index.js'; +import type { AnalysisResult, RobocoConfig, ToolSelection } from '../types/index.js'; import { logger } from '../utils/logger.js'; import { resolveBundle, MARKETPLACE, KNOWN_PLUGINS, OVERLAPPING_PLUGINS, + OVERLAP_REMEDIATION, } from './toolbox-bundles.js'; +import { confirm } from '../utils/prompt.js'; import { mergeToolboxSettings } from './generator.js'; -interface InstallResult { +export interface InstallResult { tool: string; success: boolean; message: string; @@ -171,29 +173,67 @@ export async function installToolbox(analysis: AnalysisResult): Promise { + config: RobocoConfig, +): Promise<{ result: InstallResult; configChanged: boolean }> { if (!KNOWN_PLUGINS.has(name)) { - return { tool: `claude-toolbox:${name}`, success: false, message: `Unknown plugin: ${name}` }; + return { + result: { + tool: `claude-toolbox:${name}`, + success: false, + message: `Unknown plugin: ${name}`, + }, + configChanged: false, + }; } + let configChanged = false; + if (OVERLAPPING_PLUGINS.has(name)) { - // Overlap path filled in by Task 10 - return { - tool: `claude-toolbox:${name}`, - success: false, - message: 'Overlap path not yet implemented', - }; + const remediation = OVERLAP_REMEDIATION[name]; + if (!remediation) { + return { + result: { + tool: `claude-toolbox:${name}`, + success: false, + message: `No remediation for overlap: ${name}`, + }, + configChanged: false, + }; + } + const accepted = await confirm( + `This replaces ROBOCO's ${remediation.description}. Drop ROBOCO's version?`, + false, + ); + if (accepted) { + try { + await remediation.cleanup(targetPath); + } catch { + logger.warn(`Cleanup of ${remediation.description} failed — continuing`); + } + config.overrides ??= {}; + config.overrides.skipGeneratorOutputs ??= []; + if (!config.overrides.skipGeneratorOutputs.includes(remediation.overrideKey)) { + config.overrides.skipGeneratorOutputs.push(remediation.overrideKey); + } + configChanged = true; + } else { + logger.warn('Both will coexist — manual cleanup may be needed.'); + } } await writeProjectSettings(targetPath, [name]); try { - await execa('claude', ['plugin', 'install', `${name}@${MARKETPLACE.name}`], { - timeout: 60000, - }); - return { tool: `claude-toolbox:${name}`, success: true, message: 'Installed' }; + await execa('claude', ['plugin', 'install', `${name}@${MARKETPLACE.name}`], { timeout: 60000 }); + return { + result: { tool: `claude-toolbox:${name}`, success: true, message: 'Installed' }, + configChanged, + }; } catch { - return { tool: `claude-toolbox:${name}`, success: false, message: 'Install failed' }; + return { + result: { tool: `claude-toolbox:${name}`, success: false, message: 'Install failed' }, + configChanged, + }; } } diff --git a/src/core/toolbox-bundles.ts b/src/core/toolbox-bundles.ts index e702890..0e94824 100644 --- a/src/core/toolbox-bundles.ts +++ b/src/core/toolbox-bundles.ts @@ -1,3 +1,7 @@ +import { rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { OverrideKey } from '../types/index.js'; + export const MARKETPLACE = { name: 'claude-toolbox', source: { source: 'github', repo: 'jaeyeom/claude-toolbox' }, @@ -55,3 +59,43 @@ export const KNOWN_PLUGINS = new Set([ 'jira-commands', 'jira-edit-description', ]); + +export interface OverlapRemediation { + description: string; // shown in the prompt + overrideKey: OverrideKey; // recorded in .roboco/config.json + cleanup: (targetPath: string) => Promise; +} + +export const OVERLAP_REMEDIATION: Record = { + 'gabyx-githooks-setup': { + description: '.husky/pre-commit', + overrideKey: 'husky-pre-commit', + cleanup: async (targetPath: string) => { + await rm(join(targetPath, '.husky', 'pre-commit'), { force: true }); + }, + }, + 'ci-workflow': { + description: '.github/workflows/vibe-coding-check.yml', + overrideKey: 'ci-workflow-vibe-coding-check', + cleanup: async (targetPath: string) => { + await rm(join(targetPath, '.github', 'workflows', 'vibe-coding-check.yml'), { force: true }); + }, + }, + 'git-guardrails': { + description: '.claude/settings.json deny entries', + overrideKey: 'claude-deny-list', + // Cleanup is non-trivial (in-place edit of settings.json deny list). + // For Phase 1, we record the override and let the user clean manually. + // Generator skip-on-override (Task 11) prevents future re-emission. + cleanup: async () => { + /* no-op; future-runs respect override */ + }, + }, + 'claude-md': { + description: ' block in CLAUDE.md', + overrideKey: 'claude-md-roboco-block', + cleanup: async () => { + /* no-op; future-runs respect override */ + }, + }, +}; diff --git a/tests/unit/toolbox-add.test.ts b/tests/unit/toolbox-add.test.ts index ef1dda3..6a5719e 100644 --- a/tests/unit/toolbox-add.test.ts +++ b/tests/unit/toolbox-add.test.ts @@ -131,3 +131,40 @@ describe('roboco add toolbox:', () => { await rm(tempDir, { recursive: true, force: true }); }); }); + +describe('roboco add toolbox:', () => { + beforeEach(() => { + execaMock.mockReset(); + execaMock.mockResolvedValue({ stdout: '', stderr: '' }); + }); + + it('records skipGeneratorOutputs override when user accepts drop-replace', async () => { + const tempDir = await fixture(); + // simulate user accepting the prompt + const promptModule = await import('../../src/utils/prompt.js'); + vi.spyOn(promptModule, 'confirm').mockResolvedValue(true); + + await addCommand('toolbox:gabyx-githooks-setup', { path: tempDir }); + + const cfg = JSON.parse(await readFile(join(tempDir, '.roboco', 'config.json'), 'utf-8')); + expect(cfg.overrides?.skipGeneratorOutputs).toContain('husky-pre-commit'); + expect(cfg.installedTools).toContain('toolbox:gabyx-githooks-setup'); + + vi.restoreAllMocks(); + await rm(tempDir, { recursive: true, force: true }); + }); + + it('does not record override when user declines drop-replace', async () => { + const tempDir = await fixture(); + const promptModule = await import('../../src/utils/prompt.js'); + vi.spyOn(promptModule, 'confirm').mockResolvedValue(false); + + await addCommand('toolbox:gabyx-githooks-setup', { path: tempDir }); + + const cfg = JSON.parse(await readFile(join(tempDir, '.roboco', 'config.json'), 'utf-8')); + expect(cfg.overrides?.skipGeneratorOutputs ?? []).not.toContain('husky-pre-commit'); + + vi.restoreAllMocks(); + await rm(tempDir, { recursive: true, force: true }); + }); +}); From 3f7c41fcb63778a8842572a1b6f411f66f6838f3 Mon Sep 17 00:00:00 2001 From: Jaehyun Yeom Date: Wed, 6 May 2026 09:33:18 -0700 Subject: [PATCH 15/21] T10 follow-up: defer override recording until settings.json write succeeds - Reorder so config.overrides mutation happens after writeProjectSettings, preventing half-migrated state on filesystem error. - Surface deferred-cleanup intent for no-op remediation entries. - Add regression test asserting override is not persisted when settings write fails. --- src/core/installer.ts | 58 ++++++++++++++++++++++------------ tests/unit/toolbox-add.test.ts | 17 ++++++++++ 2 files changed, 55 insertions(+), 20 deletions(-) diff --git a/src/core/installer.ts b/src/core/installer.ts index 63b96fa..3345c6a 100644 --- a/src/core/installer.ts +++ b/src/core/installer.ts @@ -187,42 +187,60 @@ export async function installSingleToolboxPlugin( } let configChanged = false; + let acceptedOverlap = false; + const remediation = OVERLAPPING_PLUGINS.has(name) ? OVERLAP_REMEDIATION[name] : undefined; - if (OVERLAPPING_PLUGINS.has(name)) { - const remediation = OVERLAP_REMEDIATION[name]; - if (!remediation) { - return { - result: { - tool: `claude-toolbox:${name}`, - success: false, - message: `No remediation for overlap: ${name}`, - }, - configChanged: false, - }; - } - const accepted = await confirm( + if (OVERLAPPING_PLUGINS.has(name) && !remediation) { + return { + result: { + tool: `claude-toolbox:${name}`, + success: false, + message: `No remediation for overlap: ${name}`, + }, + configChanged: false, + }; + } + + if (remediation) { + acceptedOverlap = await confirm( `This replaces ROBOCO's ${remediation.description}. Drop ROBOCO's version?`, false, ); - if (accepted) { + if (acceptedOverlap) { try { await remediation.cleanup(targetPath); } catch { logger.warn(`Cleanup of ${remediation.description} failed — continuing`); } - config.overrides ??= {}; - config.overrides.skipGeneratorOutputs ??= []; - if (!config.overrides.skipGeneratorOutputs.includes(remediation.overrideKey)) { - config.overrides.skipGeneratorOutputs.push(remediation.overrideKey); - } - configChanged = true; } else { logger.warn('Both will coexist — manual cleanup may be needed.'); } } + // writeProjectSettings must succeed before we mutate in-memory config — + // prevents half-migrated state on filesystem errors (EROFS, EACCES). await writeProjectSettings(targetPath, [name]); + // Override recording: only after writeProjectSettings succeeds, only if user accepted. + if (remediation && acceptedOverlap) { + config.overrides ??= {}; + config.overrides.skipGeneratorOutputs ??= []; + if (!config.overrides.skipGeneratorOutputs.includes(remediation.overrideKey)) { + config.overrides.skipGeneratorOutputs.push(remediation.overrideKey); + } + configChanged = true; + + // Surface deferred-cleanup intent to the user for no-op remediation entries. + if ( + remediation.overrideKey === 'claude-deny-list' || + remediation.overrideKey === 'claude-md-roboco-block' + ) { + logger.info( + `Recorded override; existing ${remediation.description} will be replaced on next "roboco update".`, + ); + } + } + try { await execa('claude', ['plugin', 'install', `${name}@${MARKETPLACE.name}`], { timeout: 60000 }); return { diff --git a/tests/unit/toolbox-add.test.ts b/tests/unit/toolbox-add.test.ts index 6a5719e..1f4a709 100644 --- a/tests/unit/toolbox-add.test.ts +++ b/tests/unit/toolbox-add.test.ts @@ -167,4 +167,21 @@ describe('roboco add toolbox:', () => { vi.restoreAllMocks(); await rm(tempDir, { recursive: true, force: true }); }); + + it('does not record override if writeProjectSettings throws', async () => { + const tempDir = await fixture(); + const promptModule = await import('../../src/utils/prompt.js'); + vi.spyOn(promptModule, 'confirm').mockResolvedValue(true); + + // Make .claude into a file (not directory) so mkdir(.claude) fails + await writeFile(join(tempDir, '.claude'), 'blocking file'); + + await addCommand('toolbox:gabyx-githooks-setup', { path: tempDir }); + + const cfg = JSON.parse(await readFile(join(tempDir, '.roboco', 'config.json'), 'utf-8')); + expect(cfg.overrides?.skipGeneratorOutputs ?? []).not.toContain('husky-pre-commit'); + + vi.restoreAllMocks(); + await rm(tempDir, { recursive: true, force: true }); + }); }); From c9a85026d5c7c7643e6f754f0e0e8d83f0c5a23a Mon Sep 17 00:00:00 2001 From: Jaehyun Yeom Date: Wed, 6 May 2026 09:37:52 -0700 Subject: [PATCH 16/21] Honor skipGeneratorOutputs overrides in generator generate() consults config.overrides and skips husky, ci-workflow, claude-deny-list, or roboco-block emit branches when overridden. --- src/commands/update.ts | 7 ++- src/core/generator.ts | 85 +++++++++++++++++++++++------------- tests/unit/generator.test.ts | 80 +++++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 33 deletions(-) diff --git a/src/commands/update.ts b/src/commands/update.ts index 5b84bdd..d0bbfe6 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -32,12 +32,15 @@ export async function updateCommand( const interviewResult = await interview(analysis, { auto: options.auto ?? false }); + const existing = await readJson(configPath); + const genSpinner = ora('Updating configuration...').start(); - const created = await generate(targetPath, analysis, interviewResult); + const created = await generate(targetPath, analysis, interviewResult, { + overrides: existing.overrides, + }); genSpinner.succeed(`Updated ${created.length} files`); // Update config - const existing = await readJson(configPath); existing.updatedAt = new Date().toISOString(); existing.analysis = analysis; existing.interview = interviewResult; diff --git a/src/core/generator.ts b/src/core/generator.ts index 4c36c9c..edde6f6 100644 --- a/src/core/generator.ts +++ b/src/core/generator.ts @@ -1,10 +1,14 @@ import { join } from 'node:path'; import { execa } from 'execa'; -import type { AnalysisResult, InterviewResult, RobocoConfig } from '../types/index.js'; +import type { AnalysisResult, InterviewResult, OverrideKey, RobocoConfig } from '../types/index.js'; import { ensureDir, fileExists, writeText, readText } from '../utils/fs.js'; import { logger } from '../utils/logger.js'; import { MARKETPLACE } from './toolbox-bundles.js'; +interface GenerateOptions { + overrides?: { skipGeneratorOutputs?: OverrideKey[] }; +} + interface FileOperation { path: string; content: string; @@ -40,16 +44,18 @@ export async function generate( targetPath: string, analysis: AnalysisResult, interviewResult: InterviewResult, + options: GenerateOptions = {}, ): Promise { + const skip = new Set(options.overrides?.skipGeneratorOutputs ?? []); const files: FileOperation[] = []; // Claude Code Environment (required) // Step 1: Run claude /init to generate base CLAUDE.md await runClaudeInit(targetPath); // Step 2: Append ROBOCO context + generate settings/hooks - await appendRobocoContext(targetPath, analysis, interviewResult); + await appendRobocoContext(targetPath, analysis, interviewResult, skip); // Step 3: Generate .claude/settings.json and hooks - files.push(...generateClaudeSettings(targetPath, analysis)); + files.push(...generateClaudeSettings(targetPath, analysis, skip)); // Process Documents (optional) if (interviewResult.setupDomains.processDocs) { @@ -58,7 +64,7 @@ export async function generate( // CI/CD (optional) if (interviewResult.setupDomains.cicd) { - files.push(...generateCicd(targetPath, analysis)); + files.push(...generateCicd(targetPath, analysis, skip)); } // ROBOCO config @@ -118,7 +124,9 @@ async function appendRobocoContext( targetPath: string, analysis: AnalysisResult, interviewResult: InterviewResult, + skip: Set, ): Promise { + if (skip.has('claude-md-roboco-block')) return; const claudeMdPath = join(targetPath, 'CLAUDE.md'); if (!(await fileExists(claudeMdPath))) return; @@ -160,23 +168,30 @@ Run \`roboco status\` to check setup, \`roboco audit\` for maturity scoring. logger.success('ROBOCO context appended to CLAUDE.md'); } -function generateClaudeSettings(targetPath: string, analysis: AnalysisResult): FileOperation[] { +function generateClaudeSettings( + targetPath: string, + analysis: AnalysisResult, + skip: Set, +): FileOperation[] { const hooks = generateHooksForStack(analysis.stack.languages); + const allowList = [ + 'Read', + 'Write', + 'Edit', + 'Glob', + 'Grep', + 'Bash(npm run *)', + 'Bash(git status*)', + 'Bash(git diff*)', + 'Bash(git log*)', + ]; const robocoDefaults: Record = { - permissions: { - allow: [ - 'Read', - 'Write', - 'Edit', - 'Glob', - 'Grep', - 'Bash(npm run *)', - 'Bash(git status*)', - 'Bash(git diff*)', - 'Bash(git log*)', - ], - deny: ['Bash(rm -rf *)', 'Bash(git push --force*)', 'Bash(git reset --hard*)'], - }, + permissions: skip.has('claude-deny-list') + ? { allow: allowList } + : { + allow: allowList, + deny: ['Bash(rm -rf *)', 'Bash(git push --force*)', 'Bash(git reset --hard*)'], + }, ...(Object.keys(hooks).length > 0 ? { hooks } : {}), }; @@ -296,13 +311,18 @@ function generateProcessDocs(targetPath: string): FileOperation[] { })); } -function generateCicd(targetPath: string, analysis: AnalysisResult): FileOperation[] { +function generateCicd( + targetPath: string, + analysis: AnalysisResult, + skip: Set, +): FileOperation[] { const files: FileOperation[] = []; // GitHub Actions workflow - files.push({ - path: join(targetPath, '.github', 'workflows', 'vibe-coding-check.yml'), - content: `name: Vibe Coding Check + if (!skip.has('ci-workflow-vibe-coding-check')) { + files.push({ + path: join(targetPath, '.github', 'workflows', 'vibe-coding-check.yml'), + content: `name: Vibe Coding Check on: pull_request: @@ -318,16 +338,19 @@ jobs: - name: Check .claude directory run: test -d .claude `, - description: 'GitHub Actions workflow', - }); + description: 'GitHub Actions workflow', + }); + } // Pre-commit hook via husky - const lintCmd = getLintCommand(analysis.stack.languages); - files.push({ - path: join(targetPath, '.husky', 'pre-commit'), - content: `${lintCmd}\n`, - description: 'Pre-commit hook', - }); + if (!skip.has('husky-pre-commit')) { + const lintCmd = getLintCommand(analysis.stack.languages); + files.push({ + path: join(targetPath, '.husky', 'pre-commit'), + content: `${lintCmd}\n`, + description: 'Pre-commit hook', + }); + } return files; } diff --git a/tests/unit/generator.test.ts b/tests/unit/generator.test.ts index e939cf7..85fe846 100644 --- a/tests/unit/generator.test.ts +++ b/tests/unit/generator.test.ts @@ -160,3 +160,83 @@ describe('generator', () => { expect(count).toBe(1); }); }); + +describe('generator skipGeneratorOutputs', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'roboco-skip-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('skips husky pre-commit when override is set', async () => { + await seedClaudeMd(tempDir); + const interview = makeInterview({ + setupDomains: { claudeEnv: true, processDocs: false, cicd: true }, + }); + await generate(tempDir, makeAnalysis({ path: tempDir }), interview, { + overrides: { skipGeneratorOutputs: ['husky-pre-commit'] }, + }); + let huskyExists = true; + try { + await readFile(join(tempDir, '.husky', 'pre-commit'), 'utf-8'); + } catch { + huskyExists = false; + } + expect(huskyExists).toBe(false); + }); + + it('writes husky pre-commit when override is absent', async () => { + await seedClaudeMd(tempDir); + const interview = makeInterview({ + setupDomains: { claudeEnv: true, processDocs: false, cicd: true }, + }); + await generate(tempDir, makeAnalysis({ path: tempDir }), interview); + const content = await readFile(join(tempDir, '.husky', 'pre-commit'), 'utf-8'); + expect(content.length).toBeGreaterThan(0); + }); + + it('skips ci-workflow when override is set', async () => { + await seedClaudeMd(tempDir); + const interview = makeInterview({ + setupDomains: { claudeEnv: true, processDocs: false, cicd: true }, + }); + await generate(tempDir, makeAnalysis({ path: tempDir }), interview, { + overrides: { skipGeneratorOutputs: ['ci-workflow-vibe-coding-check'] }, + }); + let ciExists = true; + try { + await readFile(join(tempDir, '.github', 'workflows', 'vibe-coding-check.yml'), 'utf-8'); + } catch { + ciExists = false; + } + expect(ciExists).toBe(false); + }); + + it('skips claude deny list entries when override is set', async () => { + await seedClaudeMd(tempDir); + const interview = makeInterview({ + setupDomains: { claudeEnv: true, processDocs: false, cicd: false }, + }); + await generate(tempDir, makeAnalysis({ path: tempDir }), interview, { + overrides: { skipGeneratorOutputs: ['claude-deny-list'] }, + }); + const settings = JSON.parse(await readFile(join(tempDir, '.claude', 'settings.json'), 'utf-8')); + expect(settings.permissions.deny ?? []).toEqual([]); + }); + + it('skips block in CLAUDE.md when override is set', async () => { + await writeFile(join(tempDir, 'CLAUDE.md'), '# Test project\n\nProject overview.\n'); + const interview = makeInterview({ + setupDomains: { claudeEnv: true, processDocs: false, cicd: false }, + }); + await generate(tempDir, makeAnalysis({ path: tempDir }), interview, { + overrides: { skipGeneratorOutputs: ['claude-md-roboco-block'] }, + }); + const claudeMd = await readFile(join(tempDir, 'CLAUDE.md'), 'utf-8'); + expect(claudeMd).not.toContain(''); + }); +}); From 3466e829dd0ae633697fa266b6d8193652e70faa Mon Sep 17 00:00:00 2001 From: Jaehyun Yeom Date: Wed, 6 May 2026 09:57:38 -0700 Subject: [PATCH 17/21] T11 follow-up: align installer message with generator semantics; complete test helper - Soften the deferred-cleanup user message to reflect Phase 1's passive don't-emit behavior (existing entries are not removed). - Add missing signals field to generator.test.ts makeAnalysis helper. --- src/core/installer.ts | 2 +- tests/unit/generator.test.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/core/installer.ts b/src/core/installer.ts index 3345c6a..7d01691 100644 --- a/src/core/installer.ts +++ b/src/core/installer.ts @@ -236,7 +236,7 @@ export async function installSingleToolboxPlugin( remediation.overrideKey === 'claude-md-roboco-block' ) { logger.info( - `Recorded override; existing ${remediation.description} will be replaced on next "roboco update".`, + `Recorded override. ROBOCO will not re-emit ${remediation.description} on future runs, but you must remove the existing entry manually if present.`, ); } } diff --git a/tests/unit/generator.test.ts b/tests/unit/generator.test.ts index 85fe846..6f2cbff 100644 --- a/tests/unit/generator.test.ts +++ b/tests/unit/generator.test.ts @@ -28,6 +28,11 @@ function makeAnalysis(overrides: Partial = {}): AnalysisResult { hasOmc: false, hasRoboco: false, hasOpenSpec: false, + claudeSettings: null, + claudeSkills: [], + claudeCommands: [], + globalSettings: null, + globalSkills: [], }, git: { isRepo: true, @@ -35,6 +40,7 @@ function makeAnalysis(overrides: Partial = {}): AnalysisResult { repoName: 'repo', branch: 'main', }, + signals: { hasProto: false }, ...overrides, }; } From fadff23eb57041bdb3b0da139a56e8bd39912a05 Mon Sep 17 00:00:00 2001 From: Jaehyun Yeom Date: Wed, 6 May 2026 10:59:52 -0700 Subject: [PATCH 18/21] Document claude-toolbox integration in README Add toolbox to Optional artifacts table and Commands table. Document the verification gate as a manual pre-merge smoke test. --- README.md | 3 +++ .../2026-05-04-claude-toolbox-integration.md | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/README.md b/README.md index 6da9d49..8c79686 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,8 @@ Analyze and show recommendations without making any changes. | `roboco doctor` | Diagnose ROBOCO CLI health (version, SDK, dependencies) | | `roboco config` | View and modify global ROBOCO configuration | | `roboco add ` | Add a tool post-init (openspec, exa, github, context7, harness) | +| `roboco add toolbox` | Install full claude-toolbox bundle (stack-aware) | +| `roboco add toolbox:` | Install a single claude-toolbox plugin (with overlap drop-replace prompt) | | `roboco sync [--check]` | Detect configuration drift (CI-friendly with `--check`) | | `roboco validate [--fix]` | End-to-end setup validation | | `roboco audit [--format]` | Vibe coding maturity scoring (100 points, 5 categories) | @@ -178,6 +180,7 @@ Analyze and show recommendations without making any changes. | Harness | Domain-specific agent team design (6 architecture patterns) | | CI/CD pipeline | Pre-commit hooks + GitHub Actions workflows | | Process templates | 5-stage vibe coding document templates | +| claude-toolbox | Stack-aware bundle (next-action, todo, gh-issue-resolver, semgrep-review, sandbox-helpers, makefile-workflow + stack overlay) | ## Tech Stack diff --git a/docs/superpowers/plans/2026-05-04-claude-toolbox-integration.md b/docs/superpowers/plans/2026-05-04-claude-toolbox-integration.md index 33fc32b..9a79d81 100644 --- a/docs/superpowers/plans/2026-05-04-claude-toolbox-integration.md +++ b/docs/superpowers/plans/2026-05-04-claude-toolbox-integration.md @@ -1752,4 +1752,28 @@ No spec section is unaddressed. - `installSingleToolboxPlugin` — Task 9 returns `InstallResult`, Task 10 changes return shape to `{ result, configChanged }`. The Task 10 step explicitly updates the caller. ✓ - `mergeToolboxSettings(existing, bundle)` — same signature in Task 4 (definition), Task 5 (called via `writeProjectSettings`). ✓ - `RepoSignals.hasProto` — defined in Task 1, populated in Task 3, consumed in Tasks 2 and 7. ✓ + +--- + +## Verification Gate Status + +The implementation assumes Claude Code honors project-level `.claude/settings.json` `enabledPlugins` and auto-fetches the `extraKnownMarketplaces` entry on first run for teammates. + +**Manual smoke test required before merge:** + +1. Build CLI: `npm run build` +2. Create a clean tempdir: `mkdir /tmp/roboco-smoke && cd /tmp/roboco-smoke && echo "print('hi')" > main.py` (Python repo so toolbox shows core-only) +3. Run: `node /Users/jaehyun/go/src/github.com/roboco-io/roboco-cli/dist/index.js init --auto .` +4. Verify `.claude/settings.json` contains `extraKnownMarketplaces` and `enabledPlugins` keys. +5. Move user-global Claude settings aside: `mv ~/.claude/settings.json ~/.claude/settings.json.bak` +6. Launch Claude Code in `/tmp/roboco-smoke`: `claude` +7. Inside Claude Code, check active plugins: `/plugin list` +8. Confirm the toolbox plugins listed in the project settings are active. +9. Restore: `mv ~/.claude/settings.json.bak ~/.claude/settings.json` + +**If verified:** the design as implemented is correct. + +**If not verified:** the spec's verification gate documents a fallback to subprocess-only install. Update `installToolbox` to also drive `roboco install` on the teammate's machine. File a follow-up issue. + +This smoke test is intentionally NOT automated because it modifies user-global state. - `OverrideKey` — defined in Task 1, used in Tasks 10, 11. Same union members in both. ✓ From de48a48ae9ed22041fbc016de996f4c16e250c9b Mon Sep 17 00:00:00 2001 From: Jaehyun Yeom Date: Wed, 6 May 2026 11:04:35 -0700 Subject: [PATCH 19/21] Remove unused rm import from toolbox-install test --- tests/unit/toolbox-install.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/toolbox-install.test.ts b/tests/unit/toolbox-install.test.ts index 3c86685..f507562 100644 --- a/tests/unit/toolbox-install.test.ts +++ b/tests/unit/toolbox-install.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { mkdtemp, rm, readFile, mkdir, writeFile } from 'node:fs/promises'; +import { mkdtemp, readFile, mkdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; From 84b563a98d6ec0db393e42bf92677c1e81fe28e7 Mon Sep 17 00:00:00 2001 From: Jaehyun Yeom Date: Wed, 6 May 2026 11:10:41 -0700 Subject: [PATCH 20/21] Final review fixes: safer overlap ordering and marketplace conflict warning - Reorder installSingleToolboxPlugin so writeProjectSettings runs before destructive cleanup. Prevents the user being left with husky/CI files removed but no record of the toolbox plugin if filesystem write fails. - mergeToolboxSettings now warns when an existing marketplace entry has a non-matching source instead of silently leaving it in place. --- src/core/generator.ts | 7 ++++++- src/core/installer.ts | 21 +++++++++++---------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/core/generator.ts b/src/core/generator.ts index edde6f6..97d82b9 100644 --- a/src/core/generator.ts +++ b/src/core/generator.ts @@ -24,8 +24,13 @@ export function mergeToolboxSettings( const marketplaces = (result['extraKnownMarketplaces'] ?? {}) as Record; const newMarketplaces = { ...marketplaces }; - if (!newMarketplaces[MARKETPLACE.name]) { + const existingEntry = newMarketplaces[MARKETPLACE.name] as { source?: unknown } | undefined; + if (!existingEntry) { newMarketplaces[MARKETPLACE.name] = { source: MARKETPLACE.source }; + } else if (JSON.stringify(existingEntry.source) !== JSON.stringify(MARKETPLACE.source)) { + logger.warn( + `extraKnownMarketplaces['${MARKETPLACE.name}'] has a custom source — leaving it unchanged.`, + ); } result['extraKnownMarketplaces'] = newMarketplaces; diff --git a/src/core/installer.ts b/src/core/installer.ts index 7d01691..e5c72c4 100644 --- a/src/core/installer.ts +++ b/src/core/installer.ts @@ -206,23 +206,24 @@ export async function installSingleToolboxPlugin( `This replaces ROBOCO's ${remediation.description}. Drop ROBOCO's version?`, false, ); - if (acceptedOverlap) { - try { - await remediation.cleanup(targetPath); - } catch { - logger.warn(`Cleanup of ${remediation.description} failed — continuing`); - } - } else { + if (!acceptedOverlap) { logger.warn('Both will coexist — manual cleanup may be needed.'); } } - // writeProjectSettings must succeed before we mutate in-memory config — - // prevents half-migrated state on filesystem errors (EROFS, EACCES). + // writeProjectSettings must succeed before destructive cleanup or in-memory + // config mutation — prevents the user ending up with files removed and no + // record of the toolbox plugin on filesystem errors (EROFS, EACCES). await writeProjectSettings(targetPath, [name]); - // Override recording: only after writeProjectSettings succeeds, only if user accepted. + // Cleanup runs only after settings have been persisted; a cleanup failure + // is tolerated (warn, continue) since settings already record intent. if (remediation && acceptedOverlap) { + try { + await remediation.cleanup(targetPath); + } catch { + logger.warn(`Cleanup of ${remediation.description} failed — continuing`); + } config.overrides ??= {}; config.overrides.skipGeneratorOutputs ??= []; if (!config.overrides.skipGeneratorOutputs.includes(remediation.overrideKey)) { From e48f1c8e922b809df5db29dd825d397ff828562c Mon Sep 17 00:00:00 2001 From: Jaehyun Yeom Date: Wed, 6 May 2026 16:21:34 -0700 Subject: [PATCH 21/21] Mark verification gate as passed --- .../plans/2026-05-04-claude-toolbox-integration.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/plans/2026-05-04-claude-toolbox-integration.md b/docs/superpowers/plans/2026-05-04-claude-toolbox-integration.md index 9a79d81..e6180cf 100644 --- a/docs/superpowers/plans/2026-05-04-claude-toolbox-integration.md +++ b/docs/superpowers/plans/2026-05-04-claude-toolbox-integration.md @@ -1759,21 +1759,19 @@ No spec section is unaddressed. The implementation assumes Claude Code honors project-level `.claude/settings.json` `enabledPlugins` and auto-fetches the `extraKnownMarketplaces` entry on first run for teammates. -**Manual smoke test required before merge:** +**Status: VERIFIED 2026-05-04** — manual smoke test passed. Project-level `enabledPlugins` activates plugins for fresh users without modification to `~/.claude/settings.json`. The team-consistency design holds; no fallback needed. + +The smoke test procedure (kept here for future regression checks): 1. Build CLI: `npm run build` 2. Create a clean tempdir: `mkdir /tmp/roboco-smoke && cd /tmp/roboco-smoke && echo "print('hi')" > main.py` (Python repo so toolbox shows core-only) -3. Run: `node /Users/jaehyun/go/src/github.com/roboco-io/roboco-cli/dist/index.js init --auto .` +3. Run: `node /path/to/roboco-cli/dist/index.js init --auto .` 4. Verify `.claude/settings.json` contains `extraKnownMarketplaces` and `enabledPlugins` keys. 5. Move user-global Claude settings aside: `mv ~/.claude/settings.json ~/.claude/settings.json.bak` -6. Launch Claude Code in `/tmp/roboco-smoke`: `claude` +6. Launch Claude Code in the tempdir: `claude` 7. Inside Claude Code, check active plugins: `/plugin list` 8. Confirm the toolbox plugins listed in the project settings are active. 9. Restore: `mv ~/.claude/settings.json.bak ~/.claude/settings.json` -**If verified:** the design as implemented is correct. - -**If not verified:** the spec's verification gate documents a fallback to subprocess-only install. Update `installToolbox` to also drive `roboco install` on the teammate's machine. File a follow-up issue. - This smoke test is intentionally NOT automated because it modifies user-global state. - `OverrideKey` — defined in Task 1, used in Tasks 10, 11. Same union members in both. ✓