diff --git a/README.md b/README.md index 525e3f2d..2343088b 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,25 @@ the lower-level setup command: ```bash productize setup --agent codex productize setup --agent claude --agent codex --yes +productize setup --all-agents --yes ``` +Interactive setup chooses exactly one agent. Selecting OpenCode or Codex writes +skills only to `.agents/skills`; selecting Claude Code writes only to +`.claude/skills`. Repeat `--agent` to target several agents. Installing into every +supported agent directory requires the explicit `--all-agents` flag. Before +confirmation, Productize shows the exact unique directories it will modify. + +If an older setup populated unwanted agent directories, preview and remove only +Productize-managed copies or symlinks while retaining the agent you use: + +```bash +productize setup cleanup --keep-agent opencode --dry-run +productize setup cleanup --keep-agent opencode --yes +``` + +Cleanup preserves drifted, unknown, and user-owned files for manual review. + ### 3. Run the lifecycle Inside your AI agent (e.g. Claude Code), invoke the lifecycle skills in order. Each writes its output under `.productize/tasks//`: @@ -117,7 +134,7 @@ That's the whole loop. The rest of this README is reference. ### Skills & the catalog -Skills are self-contained Markdown bundles (`SKILL.md` + optional `references/`, `templates/`, `agents/`). Productize ships hundreds of them, embedded in the binary. `setup` resolves an **effective catalog** (bundled skills + any extensions, with conflicts resolved core-first) and writes it to `~/.productize/catalog/skills.json` (or `.productize/catalog/skills.json` for a single project). +Skills are self-contained Markdown bundles (`SKILL.md` + optional `references/`, `templates/`, `agents/`). Productize ships hundreds of them, embedded in the binary. `setup` resolves an **effective catalog** (bundled skills + any extensions, with conflicts resolved core-first), installs it into one selected agent target by default, and writes it to `~/.productize/catalog/skills.json` (or `.productize/catalog/skills.json` for a single project). Cleanup-only ownership checksums live beside it in `setup-ownership.json`; that manifest is metadata, not another skill store. Skills are organized by a `tier:` frontmatter field: @@ -202,8 +219,9 @@ productize runs watch # stream a running job |---------|--------------| | `productize onboard existing` | Inventory an existing repo, generate knowledge, install skills, and register the workspace. | | `productize init existing` | Create or refresh only `.productize/project/` knowledge docs. | -| `productize setup` | Install the skill catalog and reusable agents into your AI agent(s). | +| `productize setup` | Install the skill catalog and reusable agents into one selected agent target. | | `productize setup --doctor` | Inspect setup targets, paths, and drift without installing. | +| `productize setup cleanup` | Safely remove old Productize-managed agent targets while retaining selected agents. | | `productize exec [prompt]` | Run one ad-hoc prompt through an ACP runtime (headless). | | `productize tasks run ` | Execute a task workflow via the daemon. | | `productize tasks validate` | Validate task-workflow metadata. | @@ -275,10 +293,10 @@ See [docs/configuration.md](docs/configuration.md) for every key. ├── project/ ├── daemon/daemon.sock │ ├── context.md ├── db/global.db │ ├── conventions.md ├── catalog/skills.json -│ ├── architecture.md ├── runs/ -│ ├── decisions.md ├── logs/ -│ ├── constraints.md └── agents/ -│ └── manual.md (optional) +│ ├── architecture.md ├── catalog/setup-ownership.json +│ ├── decisions.md ├── runs/ +│ ├── constraints.md ├── logs/ +│ └── manual.md (optional) └── agents/ ├── tasks// │ ├── _prd.md │ ├── _techspec.md @@ -286,6 +304,7 @@ See [docs/configuration.md](docs/configuration.md) for every key. │ ├── reviews-001/ │ └── adrs/ ├── catalog/skills.json +├── catalog/setup-ownership.json └── tasks/_archived/ ``` diff --git a/agents/productize-operator/AGENT.md b/agents/productize-operator/AGENT.md index 7b942348..e8ddc61b 100644 --- a/agents/productize-operator/AGENT.md +++ b/agents/productize-operator/AGENT.md @@ -31,6 +31,13 @@ For `/productize build ` or `build `: - If `.productize/project/context.md` is missing, degraded, stale, or reports unresolved Knowledge Coverage findings, route first to `productize onboard existing`. +- During setup or onboarding, select one agent target by default. Use one + `--agent ` flag per additional explicit target, and use `--all-agents` + only when the user explicitly wants every supported target. +- If old Productize-managed agent directories remain after a targeted install, + preview conservative cleanup with + `productize setup cleanup --keep-agent --dry-run`. Ask for approval + before applying cleanup; drifted, unknown, and user-owned files must remain. - If project context exists but PRD, TechSpec, or task files are missing, route through `/create-prd`, `/create-techspec`, then `/create-tasks`. - If task files exist, route to `productize tasks run `. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 8fbc2673..87e62eb6 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -18,8 +18,8 @@ not invoke an AI model. | Flag | Default | Description | | ---- | ------- | ----------- | -| `--agent`, `-a` | | Target agent/editor name; repeatable | -| `--all-agents` | `false` | Install into every supported agent/editor destination | +| `--agent`, `-a` | | Target one agent/editor name per occurrence; repeat the flag for several | +| `--all-agents` | `false` | Explicitly install into every supported agent/editor destination | | `--global`, `-g` | `false` | Install skills in user scope instead of project scope | | `--copy` | `false` | Copy installed assets instead of symlinking | | `--core-only` | `false` | Install only core workflow skills | @@ -38,6 +38,11 @@ includes root-resolution evidence, inventory coverage, per-step results, structured diagnostics, and exact next actions. Use `productize init existing` when only the generated knowledge needs to be refreshed. +Interactive onboarding selects one agent. `--agent` and `--all-agents` are +mutually exclusive, and comma-separated agent lists are rejected. The combined +plan and JSON `setup.directory_summary` report the exact unique agent skill +directories and shared `.productize` metadata before application. + JSON uses `schema_version: 1` with `workspace_root`, `root_resolution`, `inventory`, `knowledge`, `setup`, `workspace_registration`, `diagnostics`, and `next_actions`. Step statuses are `planned`, `current`, `changed`, `skipped`, @@ -86,7 +91,7 @@ productize setup [flags] | Flag | Default | Description | | ---- | ------- | ----------- | -| `--agent`, `-a` | | Target agent name, repeatable | +| `--agent`, `-a` | | Target one agent name per occurrence; repeat for several | | `--skill`, `-s` | | Skill name to install, repeatable | | `--global`, `-g` | `false` | Install to user directory instead of project | | `--copy` | `false` | Copy files instead of symlinking | @@ -96,7 +101,7 @@ productize setup [flags] | `--yes`, `-y` | `false` | Skip confirmation prompts | | `--core-only` | `false` | Install only core workflow, lifecycle, and gate skills | | `--no-tactical` | `false` | Alias for `--core-only` | -| `--all-agents` | `false` | Install to every supported agent/editor destination | +| `--all-agents` | `false` | Explicitly install to every supported agent/editor destination | | `--all` | `false` | Deprecated: setup installs all skills by default; also skips prompts | Non-interactive installs require an explicit target: @@ -107,10 +112,46 @@ productize setup --all-agents --yes productize setup --doctor --agent opencode --format json ``` +Interactive setup uses a single-choice prompt. Exactly one detected agent is +preselected; if several or none are detected, a choice is required with no +default. `--agent` accepts one name per occurrence, cannot be combined with +`--all-agents`, and never creates another agent's skill directory. OpenCode and +Codex intentionally share `.agents/skills`; Claude Code uses `.claude/skills`. +The confirmation summary lists every unique directory, overwrite count, install +mode, skill count, and affected reusable-agent count. + Use `--doctor` to see the detected agents, selected targets, install paths, catalog path, stale or missing assets, and extension conflicts before changing anything. +## `productize setup cleanup` + +Remove old Productize-managed skill installations without touching the retained +agent targets or shared `.productize` project state. + +```bash +productize setup cleanup --keep-agent opencode --dry-run +productize setup cleanup --keep-agent opencode --yes +productize setup cleanup --keep-agent codex --keep-agent claude-code --yes +``` + +| Flag | Default | Description | +| ---- | ------- | ----------- | +| `--keep-agent` | | Desired agent target to retain; repeatable | +| `--global`, `-g` | `false` | Clean user-scoped instead of project-scoped targets | +| `--dry-run` | `false` | Preview without filesystem mutations | +| `--yes`, `-y` | `false` | Skip the one cleanup confirmation | +| `--format` | `text` | Stable output format: `text` or `json` | + +Cleanup uses setup ownership metadata when available. Legacy paths are removable +only when they are Productize symlinks to the expected canonical skill path or +byte-identical copies of the current effective skill source. Drifted, unknown, +and user-owned content is preserved and reported as `needs_review`. Cleanup never +removes `.productize/agents`, the setup catalog, project knowledge, or workflows. +JSON uses `schema_version: 1`; exit codes are `0` for clean/unchanged/feasible +dry-run, `1` for invalid selection, cancellation, or review items, and `2` for +filesystem or output failure. + ## `productize upgrade` Upgrade Productize to the latest release. diff --git a/docs/skills.md b/docs/skills.md index 08a21072..07f27f1a 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -39,7 +39,9 @@ Skills are split into two tiers so you can choose how much to install: - **Core** — the spine of the workflow. The actual core set (from `SkillCatalogTier` in `internal/setup/catalog_export.go`) is: the lifecycle skills `create-prd`, `create-techspec`, `create-tasks`, `execute-task`, `review-round`, `fix-reviews`, `final-verify`, and `workflow-memory`; the review gates `productize-product-review`, `productize-design-review`, `productize-eng-review`, `productize-qa`, `productize-dx-review`, `productize-comms-review`, `productize-release`, and `productize-docs`; and the orchestration skills `productize`, `productize-0-1`, `productize-operate`, `productize-grow`, `productize-autoplan`, and `productize-thesis-review`. Always installed, including with `--core-only`. - **Tactical** — everything else: the broad library of domain advisors and analyses across product, design, research, QA, growth, metrics, finance, and engineering. Installed by default; skip them with `--no-tactical` (an alias for `--core-only`). -By default, `productize setup` installs **everything** (the full-power default). Narrow it with: +By default, `productize setup` installs the **full skill catalog** into one +interactively selected agent target. It does not populate every detected agent +directory. Narrow the skills or explicitly broaden the agent targets with: ```bash productize setup --agent codex # explicit target for non-interactive installs @@ -47,8 +49,15 @@ productize setup --core-only # core tier only productize setup --skill create-prd --skill create-tasks # specific skills productize setup --list # preview the resolved catalog productize setup --doctor --agent codex --format json +productize setup --agent codex --agent claude-code # exactly two agent targets +productize setup --all-agents --yes # every target, explicit only ``` +OpenCode and Codex use the shared `.agents/skills` discovery directory; Claude +Code uses `.claude/skills`. Repeated targets that share a physical directory are +deduplicated. Use `productize setup cleanup --keep-agent --dry-run` to +inspect old Productize-managed targets before removing them. + ## The effective catalog At setup time Productize resolves an **effective catalog** — the bundled skills plus any installed [extensions](extensibility/index.md), with conflicts resolved deterministically (core skills and higher-precedence sources win). The result is written to: @@ -56,6 +65,11 @@ At setup time Productize resolves an **effective catalog** — the bundled skill - `~/.productize/catalog/skills.json` — global install - `.productize/catalog/skills.json` — project install +Setup also records cleanup-only ownership metadata in +`setup-ownership.json` beside that catalog. It contains scope, agent, skill, +relative target, materialization mode, and installed-content checksum; it is not +a second skill store. + This catalog is what your agents read to discover available skills. It's plain JSON; inspect it any time to see exactly what's installed and where each skill came from (bundled vs. extension). ## How agents use skills @@ -72,6 +86,6 @@ the exact lifecycle skill you need. 1. Create `skills//SKILL.md` (or put it in an extension) with `name`, `description`, and `tier` frontmatter. 2. Add `references/` or `templates/` if the skill needs supporting material. 3. Run `productize setup --list` to confirm it resolves into the catalog. -4. Run `productize setup` to install it into your agents. +4. Run `productize setup` and select one agent, or pass explicit repeated `--agent` flags. To distribute skills to others, package them as an [extension](extensibility/index.md). diff --git a/docs/workflow.md b/docs/workflow.md index 8a42fc9d..9f2c466c 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -65,7 +65,14 @@ productize setup ``` This lower-level command is useful for new repositories or when setup must be -managed separately. `onboard existing` already performs project-scoped setup. +managed separately. Interactive setup selects one agent and shows the exact +directories before confirmation. Use repeated `--agent` flags for several +targets or explicit `--all-agents` for every supported target. `onboard existing` +uses the same selection contract and already performs project-scoped setup. + +To remove old Productize-managed agent directories without deleting drifted or +user-owned content, run `productize setup cleanup --keep-agent --dry-run`, +then rerun with `--yes` after reviewing the plan. --- diff --git a/internal/cli/onboard_command.go b/internal/cli/onboard_command.go index 66928f8c..a7a9a2e7 100644 --- a/internal/cli/onboard_command.go +++ b/internal/cli/onboard_command.go @@ -47,6 +47,7 @@ type onboardCommandOptions struct { force bool yes bool format string + interactive bool } type onboardInventorySummary struct { @@ -92,14 +93,16 @@ type onboardKnowledgeStep struct { } type onboardSetupStep struct { - Status onboardStepStatus `json:"status"` - Scope string `json:"scope,omitempty"` - Mode string `json:"mode,omitempty"` - SelectedAgents []string `json:"selected_agents"` - SkillTargets int `json:"skill_targets"` - ReusableAgentTargets int `json:"reusable_agent_targets"` - Changes int `json:"changes"` - OverwriteTargets []string `json:"overwrite_targets"` + Status onboardStepStatus `json:"status"` + Scope string `json:"scope,omitempty"` + Mode string `json:"mode,omitempty"` + SelectedAgents []string `json:"selected_agents"` + SkillTargets int `json:"skill_targets"` + ReusableAgentTargets int `json:"reusable_agent_targets"` + Changes int `json:"changes"` + OverwriteTargets []string `json:"overwrite_targets"` + DirectorySummary setupDirectorySummary `json:"directory_summary"` + CleanupCommand string `json:"cleanup_command,omitempty"` } type onboardRegistrationStep struct { @@ -196,7 +199,8 @@ func newOnboardExistingCommandWithState(state *onboardCommandState) *cobra.Comma SilenceUsage: true, Args: cobra.MaximumNArgs(1), Long: `Deterministically inspect an existing repository, generate Productize project -knowledge, install Productize setup assets for selected agents, and register the workspace.`, +knowledge, install Productize setup assets for one interactively selected agent (or explicit +repeated --agent targets), and register the workspace.`, Example: ` productize onboard existing productize onboard existing ../my-app --agent codex --yes productize onboard existing --all-agents --yes @@ -205,8 +209,13 @@ knowledge, install Productize setup assets for selected agents, and register the RunE: state.run, } flags := cmd.Flags() - flags.StringSliceVarP(&state.options.agentNames, "agent", "a", nil, "Target agent/editor name (repeatable)") - flags.BoolVar(&state.options.allAgents, "all-agents", false, "Install to every supported agent/editor destination") + flags.StringArrayVarP(&state.options.agentNames, "agent", "a", nil, "Target one agent/editor name per occurrence") + flags.BoolVar( + &state.options.allAgents, + "all-agents", + false, + "Explicitly install to every supported agent/editor destination", + ) flags.BoolVarP(&state.options.global, "global", "g", false, "Install setup assets in the user scope") flags.BoolVar(&state.options.copy, "copy", false, "Copy setup assets instead of symlinking them") flags.BoolVar(&state.options.coreOnly, "core-only", false, "Install only core Productize skills") @@ -277,6 +286,7 @@ func onboardPreflightExitCode(err error) int { func (s *onboardCommandState) validate() error { interactive := s.isInteractive != nil && s.isInteractive() + s.options.interactive = interactive if err := s.validateFlagCombinations(); err != nil { return err } @@ -284,11 +294,12 @@ func (s *onboardCommandState) validate() error { } func (s *onboardCommandState) validateFlagCombinations() error { + if err := validateAgentTargetFlags("--agent", s.options.agentNames, s.options.allAgents); err != nil { + return err + } switch { case s.options.coreOnly && s.options.noTactical: return errors.New("use only one of --core-only or --no-tactical") - case s.options.allAgents && len(s.options.agentNames) > 0: - return errors.New("use only one of --agent or --all-agents") case s.options.skipSetup && (len(s.options.agentNames) > 0 || s.options.allAgents): return errors.New("--skip-setup cannot be combined with --agent or --all-agents") case s.options.skipSetup && (s.options.global || s.options.copy || s.options.coreOnly || s.options.noTactical): @@ -664,11 +675,19 @@ func reviewStatus(result *onboardResult) onboardStatus { func nextOnboardActions(result *onboardResult) []onboardNextAction { if result.Status == onboardStatusReady && !result.DryRun { - return []onboardNextAction{{ + actions := []onboardNextAction{{ Type: "skill", Command: "/create-prd ", Description: "Create the first Productize PRD", }} + if result.Setup.CleanupCommand != "" { + actions = append(actions, onboardNextAction{ + Type: "command", + Command: result.Setup.CleanupCommand, + Description: "Optionally remove old Productize-managed agent targets", + }) + } + return actions } if result.Status == onboardStatusReady { return []onboardNextAction{{ @@ -708,6 +727,14 @@ func nextOnboardActions(result *onboardResult) []onboardNextAction { } actions = append(actions, action) } + if !result.DryRun && result.Setup.CleanupCommand != "" && + result.Setup.Status != onboardStepFailed && result.Setup.Status != onboardStepSkipped { + actions = append(actions, onboardNextAction{ + Type: "command", + Command: result.Setup.CleanupCommand, + Description: "Optionally remove old Productize-managed agent targets", + }) + } sort.Slice(actions, func(i, j int) bool { left := strings.Join( []string{actions[i].Type, actions[i].Path, actions[i].Command, actions[i].Description}, diff --git a/internal/cli/onboard_command_test.go b/internal/cli/onboard_command_test.go index 0cb16d35..d99e22dd 100644 --- a/internal/cli/onboard_command_test.go +++ b/internal/cli/onboard_command_test.go @@ -6,6 +6,8 @@ import ( "errors" "os" "path/filepath" + "reflect" + "slices" "strings" "testing" @@ -145,6 +147,32 @@ func TestApplyAdoptionResultPreservesAutomationSummary(t *testing.T) { } } +func TestOnboardNeedsReviewStillReportsOptionalSetupCleanup(t *testing.T) { + t.Parallel() + + cleanupCommand := "productize setup cleanup --keep-agent codex --yes" + result := &onboardResult{ + Status: onboardStatusNeedsReview, + Setup: onboardSetupStep{ + Status: onboardStepChanged, + CleanupCommand: cleanupCommand, + }, + Diagnostics: []onboardDiagnostic{{ + Code: "knowledge_review", + Severity: "warning", + Message: "Review imported knowledge", + Remediation: "Review generated project knowledge", + }}, + } + actions := nextOnboardActions(result) + if !slices.ContainsFunc(actions, func(action onboardNextAction) bool { + return action.Command == cleanupCommand && action.Description == + "Optionally remove old Productize-managed agent targets" + }) { + t.Fatalf("optional cleanup action missing from needs-review result: %#v", actions) + } +} + func TestOnboardExistingAppliesKnowledgeAndEndsWithCreatePRD(t *testing.T) { t.Parallel() @@ -595,6 +623,16 @@ func TestOnboardExistingSetupDryRunApplyAndIdempotentRerun(t *testing.T) { if dryRunResult.Setup.Status != onboardStepPlanned || dryRunResult.Setup.Changes == 0 { t.Fatalf("unexpected setup dry-run result: %#v", dryRunResult.Setup) } + summary := dryRunResult.Setup.DirectorySummary + if !reflect.DeepEqual(summary.SelectedAgents, []string{"Codex"}) || + !reflect.DeepEqual( + summary.AgentSkillDirectories, + []setupDirectoryEntry{{Path: ".agents/skills", Mode: "copy"}}, + ) || + !slices.Contains(summary.SharedProductizeMetadata, ".productize/catalog") || + len(summary.OtherAgentDirectories) != 0 || summary.SkillCount == 0 { + t.Fatalf("unexpected onboarding directory summary: %#v", summary) + } wantFlags := " --agent 'codex' --copy --core-only --skip-register --format json --yes" if len(dryRunResult.NextActions) != 1 || !strings.HasSuffix(dryRunResult.NextActions[0].Command, wantFlags) { diff --git a/internal/cli/onboard_output.go b/internal/cli/onboard_output.go index a957c042..8425d50a 100644 --- a/internal/cli/onboard_output.go +++ b/internal/cli/onboard_output.go @@ -58,6 +58,9 @@ func writeOnboardText(out io.Writer, result *onboardResult) error { } } } + if err := printSetupDirectorySummary(out, result.Setup.DirectorySummary, ""); err != nil { + return fmt.Errorf("write onboarding setup directory summary: %w", err) + } if err := writeOnboardDiagnostics(out, result.Diagnostics); err != nil { return err } @@ -142,6 +145,9 @@ func printOnboardPlan(cmd *cobra.Command, result *onboardResult) error { return fmt.Errorf("write onboarding plan: %w", err) } } + if err := printSetupDirectorySummary(out, result.Setup.DirectorySummary, " "); err != nil { + return fmt.Errorf("write onboarding setup directory summary: %w", err) + } if len(result.Setup.OverwriteTargets) > 0 { if _, err := fmt.Fprintln(out, " Setup overwrite targets:"); err != nil { return fmt.Errorf("write onboarding plan: %w", err) diff --git a/internal/cli/onboard_setup.go b/internal/cli/onboard_setup.go index c2b15c62..c4cccc18 100644 --- a/internal/cli/onboard_setup.go +++ b/internal/cli/onboard_setup.go @@ -20,6 +20,8 @@ type onboardSetupApplyPlan struct { catalog setup.EffectiveCatalog config setup.InstallConfig doctor setupDoctorReport + summary setupDirectorySummary + cleanupCommand string catalogNeedsWrite bool } @@ -36,7 +38,7 @@ func buildOnboardSetupPlan( state.allAgents = options.allAgents state.coreOnly = options.coreOnly state.noTactical = options.noTactical - state.yes = options.yes || options.dryRun || options.format == operatorOutputFormatJSON + state.yes = options.yes || !options.interactive || options.format == operatorOutputFormatJSON resolver := currentResolverOptions() resolver.CWD = root @@ -48,7 +50,7 @@ func buildOnboardSetupPlan( if err != nil { return onboardSetupPlan{}, fmt.Errorf("load setup agents: %w", err) } - cfg, _, _, err := state.buildInstallPlan(cmd, catalog, resolver, supported, detected) + cfg, previews, reusablePreviews, err := state.buildInstallPlan(cmd, catalog, resolver, supported, detected) if err != nil { return onboardSetupPlan{}, &onboardSelectionError{err: err} } @@ -68,6 +70,12 @@ func buildOnboardSetupPlan( if err != nil { return onboardSetupPlan{}, fmt.Errorf("inspect setup targets: %w", err) } + summary, err := buildSetupDirectorySummary(resolver, cfg, supported, previews, reusablePreviews) + if err != nil { + return onboardSetupPlan{}, fmt.Errorf("summarize setup directories: %w", err) + } + cleanupCommand, cleanupDiagnostics := inspectOnboardSetupCleanup(resolver, catalog.Skills, cfg) + diagnostics := append(onboardSetupDiagnostics(doctor.Warnings), cleanupDiagnostics...) catalogCurrent, err := onboardSetupCatalogCurrent(doctor.CatalogPath, catalog.Skills) if err != nil { return onboardSetupPlan{}, fmt.Errorf("inspect setup catalog: %w", err) @@ -79,16 +87,44 @@ func buildOnboardSetupPlan( catalog: catalog, config: cfg, doctor: doctor, + summary: summary, + cleanupCommand: cleanupCommand, catalogNeedsWrite: !catalogCurrent, } step := applyPlan.step(true) return onboardSetupPlan{ Step: step, - Diagnostics: onboardSetupDiagnostics(doctor.Warnings), + Diagnostics: diagnostics, apply: applyPlan.apply, }, nil } +func inspectOnboardSetupCleanup( + resolver setup.ResolverOptions, + skills []setup.Skill, + cfg setup.InstallConfig, +) (string, []onboardDiagnostic) { + plan, err := setup.PlanCleanup(setup.CleanupConfig{ + ResolverOptions: resolver, + Skills: skills, + KeepAgentNames: cfg.AgentNames, + Global: cfg.Global, + DryRun: true, + }) + if err != nil { + return "", []onboardDiagnostic{{ + Code: "setup_cleanup_inspection_failed", + Severity: "info", + Message: err.Error(), + Remediation: "Run productize setup cleanup separately after onboarding", + }} + } + if plan.HasManagedTargets { + return setupCleanupApplyCommand(cfg.AgentNames, cfg.Global), nil + } + return "", nil +} + func (p onboardSetupApplyPlan) step(preflight bool) onboardSetupStep { changes := setupTargetChanges(p.doctor) + boolCount(p.catalogNeedsWrite) overwriteTargets := setupOverwriteTargets(p.doctor) @@ -114,6 +150,8 @@ func (p onboardSetupApplyPlan) step(preflight bool) onboardSetupStep { ReusableAgentTargets: len(p.doctor.ReusableAgentTargets), Changes: changes, OverwriteTargets: overwriteTargets, + DirectorySummary: p.summary, + CleanupCommand: p.cleanupCommand, } } @@ -151,7 +189,11 @@ func (p onboardSetupApplyPlan) installChangedSkills() error { if target.State == string(setup.VerifyStateCurrent) { continue } - agentsBySkill[target.Name] = append(agentsBySkill[target.Name], target.AgentName) + agentNames := target.AgentNames + if len(agentNames) == 0 { + agentNames = []string{target.AgentName} + } + agentsBySkill[target.Name] = append(agentsBySkill[target.Name], agentNames...) } names := sortedMapKeys(agentsBySkill) for _, name := range names { @@ -161,6 +203,7 @@ func (p onboardSetupApplyPlan) installChangedSkills() error { } agents := agentsBySkill[name] sort.Strings(agents) + agents = slices.Compact(agents) _, failures, err := p.state.installSkills( p.resolver, []setup.Skill{skill}, diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 9e7b7a09..cdbd57f2 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -517,6 +517,45 @@ func TestExecHelpMatchesGolden(t *testing.T) { } } +func TestSetupAndOnboardingHelpMatchGoldens(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + golden string + }{ + {name: "setup", args: []string{"setup", "--help"}, golden: "setup_help.golden"}, + { + name: "setup cleanup", + args: []string{"setup", "cleanup", "--help"}, + golden: "setup_cleanup_help.golden", + }, + { + name: "onboard existing", + args: []string{"onboard", "existing", "--help"}, + golden: "onboard_existing_help.golden", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + output, err := executeRootCommand(tt.args...) + if err != nil { + t.Fatalf("execute help: %v", err) + } + goldenPath := mustCLITestDataPath(t, tt.golden) + want, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("read golden file %s: %v", goldenPath, err) + } + if output != string(want) { + t.Fatalf("help output mismatch\nwant:\n%s\n\ngot:\n%s", string(want), output) + } + }) + } +} + func TestHiddenMCPServeCommandIsRegisteredButHidden(t *testing.T) { t.Parallel() @@ -590,6 +629,9 @@ func TestActiveDocsAndHelpFixturesOmitLegacyArtifactRoot(t *testing.T) { mustCLIRepoRootPath(t, "README.md"), mustCLITestDataPath(t, "exec_help.golden"), mustCLITestDataPath(t, "tasks_run_help.golden"), + mustCLITestDataPath(t, "setup_help.golden"), + mustCLITestDataPath(t, "setup_cleanup_help.golden"), + mustCLITestDataPath(t, "onboard_existing_help.golden"), } for _, path := range paths { body, err := os.ReadFile(path) diff --git a/internal/cli/setup.go b/internal/cli/setup.go index 3213cfc2..30aa43ff 100644 --- a/internal/cli/setup.go +++ b/internal/cli/setup.go @@ -78,7 +78,8 @@ func newSetupCommand(_ *kernel.Dispatcher) *cobra.Command { Long: `Install Productize's full bundled skill catalog, any additional skills shipped by enabled extensions, and reusable agents in either the project or user scope selected during setup. -The command can run interactively or entirely from flags.`, +Interactive setup selects one agent target. Use repeated --agent flags for multiple targets, +or --all-agents to install into every supported target.`, Example: ` productize setup productize setup --list productize setup --doctor --agent codex --format json @@ -89,7 +90,7 @@ The command can run interactively or entirely from flags.`, RunE: state.run, } - cmd.Flags().StringSliceVarP(&state.agentNames, "agent", "a", nil, "Target agent/editor name (repeatable)") + cmd.Flags().StringArrayVarP(&state.agentNames, "agent", "a", nil, "Target one agent/editor name per occurrence") cmd.Flags().StringSliceVarP(&state.skillNames, "skill", "s", nil, "Setup skill name to install (repeatable)") cmd.Flags().BoolVarP(&state.global, "global", "g", false, "Install to the user directory instead of the project") cmd.Flags().BoolVar(&state.copy, "copy", false, "Copy files instead of symlinking to agent directories") @@ -98,10 +99,12 @@ The command can run interactively or entirely from flags.`, cmd.Flags().BoolVarP(&state.yes, "yes", "y", false, "Skip confirmation prompts") cmd.Flags().BoolVar(&state.coreOnly, "core-only", false, "Install only core workflow, lifecycle, and gate skills") cmd.Flags().BoolVar(&state.noTactical, "no-tactical", false, "Alias for --core-only") - cmd.Flags().BoolVar(&state.allAgents, "all-agents", false, "Install to every supported agent/editor destination") + cmd.Flags(). + BoolVar(&state.allAgents, "all-agents", false, "Explicitly install to every supported agent/editor destination") cmd.Flags().StringVar(&state.format, "format", "text", "Output format for --doctor: text or json") cmd.Flags(). BoolVar(&state.all, "all", false, "Deprecated: setup installs all skills by default; also skips prompts") + cmd.AddCommand(newSetupCleanupCommand()) return cmd } @@ -121,6 +124,9 @@ func newSetupCommandState() *setupCommandState { func (s *setupCommandState) run(cmd *cobra.Command, _ []string) error { ctx, stop := signalCommandContext(cmd) defer stop() + if err := validateAgentTargetFlags("--agent", s.agentNames, s.allAgents); err != nil { + return err + } resolver := s.resolverOptions() catalog, err := s.loadCatalog(ctx, resolver) @@ -159,7 +165,11 @@ func (s *setupCommandState) run(cmd *cobra.Command, _ []string) error { return err } printSetupWarnings(cmd, catalog.Conflicts) - if err := s.confirmPlan(cmd, previews, reusableAgentPreviews, cfg.Global, cfg.Mode); err != nil { + summary, err := buildSetupDirectorySummary(resolver, cfg, supportedAgents, previews, reusableAgentPreviews) + if err != nil { + return err + } + if err := s.confirmPlan(cmd, summary); err != nil { return err } @@ -176,9 +186,37 @@ func (s *setupCommandState) run(cmd *cobra.Command, _ []string) error { if _, err := setup.WriteSkillsCatalog(resolver, cfg.Global, catalog.Skills); err != nil { return fmt.Errorf("write setup skills catalog: %w", err) } + s.printOptionalCleanupAction(cmd, resolver, catalog.Skills, cfg) return nil } +func (s *setupCommandState) printOptionalCleanupAction( + cmd *cobra.Command, + resolver setup.ResolverOptions, + skills []setup.Skill, + cfg setup.InstallConfig, +) { + plan, err := setup.PlanCleanup(setup.CleanupConfig{ + ResolverOptions: resolver, + Skills: skills, + KeepAgentNames: cfg.AgentNames, + Global: cfg.Global, + DryRun: true, + }) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "warning: inspect old setup targets: %v\n", err) + return + } + if !plan.HasManagedTargets { + return + } + fmt.Fprintf( + cmd.OutOrStdout(), + "Optional cleanup: %s\n", + setupCleanupApplyCommand(cfg.AgentNames, cfg.Global), + ) +} + func (s *setupCommandState) prepareRunMode() error { if s.all { s.yes = true @@ -188,6 +226,9 @@ func (s *setupCommandState) prepareRunMode() error { "productize setup --all no longer selects every agent; pass --agent or --all-agents explicitly", ) } + if s.allAgents && len(s.agentNames) > 0 { + return errors.New("use only one of --agent or --all-agents") + } if s.yes && len(s.agentNames) == 0 && !s.allAgents { return errors.New( "productize setup requires an explicit target in non-interactive mode; pass --agent or --all-agents", @@ -279,16 +320,15 @@ func (s *setupCommandState) buildInstallPlan( func (s *setupCommandState) confirmPlan( cmd *cobra.Command, - previews []setup.PreviewItem, - reusableAgentPreviews []setup.ReusableAgentPreviewItem, - global bool, - mode setup.InstallMode, + summary setupDirectorySummary, ) error { if s.yes { return nil } - printPreviewSummary(cmd, previews, reusableAgentPreviews, global, mode) + if err := printSetupDirectorySummary(cmd.OutOrStdout(), summary, ""); err != nil { + return fmt.Errorf("write setup directory summary: %w", err) + } confirmed, err := confirmSetup(cmd) if err != nil { return err @@ -366,13 +406,33 @@ func (s *setupCommandState) resolveAgentSelection( return agentNames(supported), nil } if s.yes { - if len(detected) == 0 { - return nil, errors.New("no agents detected; rerun with --agent or use interactive mode") - } - return agentNames(detected), nil + return nil, errors.New("no agent target selected; rerun with --agent or --all-agents") + } + + selected, err := selectInteractiveAgent( + cmd, + supported, + detected, + "Target Agent", + "Choose one editor/agent. Use repeated --agent flags for multiple targets", + ) + if err != nil { + return nil, fmt.Errorf("select target agent: %w", err) } + return []string{selected}, nil +} - preselected := defaultAgentSelection(supported, detected) +func selectInteractiveAgent( + cmd *cobra.Command, + supported []setup.Agent, + detected []setup.Agent, + title string, + description string, +) (string, error) { + preselected := "" + if len(detected) == 1 { + preselected = detected[0].Name + } options := make([]promptOption, 0, len(supported)) for _, agent := range supported { scopeHint := agent.ProjectRootDir @@ -383,17 +443,17 @@ func (s *setupCommandState) resolveAgentSelection( options = append(options, promptOption{Label: label, Value: agent.Name}) } - selected, err := newPromptSession(cmd).selectMany( - "Target Agents", - "Select the editors/agents where Productize should install skills", + selected, err := newPromptSession(cmd).selectOne( + title, + description, options, preselected, ) if err != nil { - return nil, fmt.Errorf("select target agents: %w", err) + return "", err } - if len(selected) == 0 { - return nil, errors.New("select at least one agent") + if selected == "" { + return "", errors.New("select one agent") } return selected, nil } @@ -560,87 +620,6 @@ func printSetupWarnings(cmd *cobra.Command, conflicts []setup.CatalogConflict) { } } -func printPreviewSummary( - cmd *cobra.Command, - previews []setup.PreviewItem, - reusableAgentPreviews []setup.ReusableAgentPreviewItem, - global bool, - mode setup.InstallMode, -) { - if len(previews) == 0 && len(reusableAgentPreviews) == 0 { - return - } - styles := newCLIChromeStyles() - - cwd, homeDir := displayRoots() - - w := cmd.OutOrStdout() - fmt.Fprintln(w, styles.sectionTitle.Render("Installation Summary")) - fmt.Fprintln(w) - - fmt.Fprintf(w, " %s %s\n", styles.label.Render("Scope "), styles.value.Render(scopeLabel(global))) - fmt.Fprintf(w, " %s %s\n", styles.label.Render("Method"), styles.value.Render(string(mode))) - fmt.Fprintln(w) - fmt.Fprintln(w, styles.separator.Render(" "+strings.Repeat("─", 50))) - fmt.Fprintln(w) - - maxSkillLen := 0 - maxAgentLen := 0 - for i := range previews { - if len(previews[i].Skill.Name) > maxSkillLen { - maxSkillLen = len(previews[i].Skill.Name) - } - if len(previews[i].Agent.DisplayName) > maxAgentLen { - maxAgentLen = len(previews[i].Agent.DisplayName) - } - } - - for i := range previews { - preview := &previews[i] - name := styles.skill.Render(padRight(preview.Skill.Name, maxSkillLen)) - arrow := styles.arrow.Render("->") - agent := styles.agent.Render(padRight(preview.Agent.DisplayName, maxAgentLen)) - path := styles.path.Render(shortenPath(preview.TargetPath, cwd, homeDir)) - - line := fmt.Sprintf(" %s %s %s %s", name, arrow, agent, path) - - if mode == setup.InstallModeSymlink && !sameInstallPath(preview.CanonicalPath, preview.TargetPath) { - via := styles.path.Render("via " + shortenPath(preview.CanonicalPath, cwd, homeDir)) - line += " " + via - } - if preview.WillOverwrite { - line += " " + styles.warn.Render("[overwrite]") - } - fmt.Fprintln(w, line) - } - - if len(reusableAgentPreviews) > 0 { - fmt.Fprintln(w) - fmt.Fprintln(w, styles.sectionTitle.Render(reusableAgentSectionTitle(global))) - fmt.Fprintln(w) - - maxReusableAgentLen := 0 - for i := range reusableAgentPreviews { - if len(reusableAgentPreviews[i].ReusableAgent.Name) > maxReusableAgentLen { - maxReusableAgentLen = len(reusableAgentPreviews[i].ReusableAgent.Name) - } - } - - for i := range reusableAgentPreviews { - preview := &reusableAgentPreviews[i] - name := styles.agent.Render(padRight(preview.ReusableAgent.Name, maxReusableAgentLen)) - path := styles.path.Render(shortenPath(preview.TargetPath, cwd, homeDir)) - - line := fmt.Sprintf(" %s %s", name, path) - if preview.WillOverwrite { - line += " " + styles.warn.Render("[overwrite]") - } - fmt.Fprintln(w, line) - } - } - fmt.Fprintln(w) -} - func printInstallResult(cmd *cobra.Command, result *setup.Result) { if result == nil { return @@ -953,24 +932,20 @@ func setupAssetSourceLabel(origin setup.AssetOrigin, extensionSource string, ext return strings.Join(parts, ":") } -func defaultAgentSelection(supported []setup.Agent, detected []setup.Agent) []string { - if len(detected) > 0 { - return agentNames(detected) +func validateAgentTargetFlags(flagName string, names []string, allAgents bool) error { + if allAgents && len(names) > 0 { + return fmt.Errorf("use only one of %s or --all-agents", flagName) } - - defaults := []string{"codex", "claude-code", "cursor", "droid"} - selected := make([]string, 0, len(defaults)) - for _, name := range defaults { - for _, agent := range supported { - if agent.Name == name { - selected = append(selected, name) - break - } + for _, name := range names { + if strings.Contains(name, ",") { + return fmt.Errorf( + "%s accepts one agent per occurrence; use %s codex %s claude-code", + flagName, + flagName, + flagName, + ) } } - if len(selected) > 0 { - return selected - } return nil } diff --git a/internal/cli/setup_built_command_test.go b/internal/cli/setup_built_command_test.go new file mode 100644 index 00000000..df0412a0 --- /dev/null +++ b/internal/cli/setup_built_command_test.go @@ -0,0 +1,497 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestBuiltSetupTargetsOnlySelectedAgentDirectories(t *testing.T) { + tests := []struct { + name string + agents []string + wantRoots []string + forbidRoots []string + }{ + { + name: "opencode only", + agents: []string{"opencode"}, + wantRoots: []string{".agents/skills"}, + forbidRoots: []string{".claude/skills", ".factory/skills", ".opencode/skills"}, + }, + { + name: "codex only", + agents: []string{"codex"}, + wantRoots: []string{".agents/skills"}, + forbidRoots: []string{".claude/skills", ".factory/skills", ".codex/skills"}, + }, + { + name: "claude only", + agents: []string{"claude-code"}, + wantRoots: []string{".claude/skills"}, + forbidRoots: []string{".agents/skills", ".factory/skills"}, + }, + { + name: "repeated agents", + agents: []string{"codex", "claude-code"}, + wantRoots: []string{".agents/skills", ".claude/skills"}, + forbidRoots: []string{".factory/skills", ".opencode/skills"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + workspaceRoot := t.TempDir() + homeDir := t.TempDir() + args := []string{"setup", "--skill", "productize", "--copy", "--yes"} + for _, agentName := range tt.agents { + args = append(args, "--agent", agentName) + } + stdout, stderr, exitCode := runSetupCLICommand(t, workspaceRoot, homeDir, args...) + if exitCode != 0 { + t.Fatalf("setup exit=%d\nstdout:\n%s\nstderr:\n%s", exitCode, stdout, stderr) + } + for _, root := range tt.wantRoots { + assertBuiltSetupSkill(t, workspaceRoot, root, "productize") + } + for _, root := range tt.forbidRoots { + assertBuiltSetupPathMissing(t, filepath.Join(workspaceRoot, filepath.FromSlash(root))) + } + assertBuiltSetupFile(t, filepath.Join(workspaceRoot, ".productize", "catalog", "skills.json")) + assertBuiltSetupFile(t, filepath.Join(workspaceRoot, ".productize", "catalog", "setup-ownership.json")) + assertBuiltSetupOnlyAllowedRoots(t, workspaceRoot, append(tt.wantRoots, ".productize")...) + if homePaths := walkBuiltSetupPaths(t, homeDir); len(homePaths) != 0 { + t.Fatalf("project setup modified isolated home: %#v", homePaths) + } + }) + } +} + +func TestBuiltSetupAllAgentsIsOnlyAllTargetShortcut(t *testing.T) { + workspaceRoot := t.TempDir() + homeDir := t.TempDir() + stdout, stderr, exitCode := runSetupCLICommand( + t, + workspaceRoot, + homeDir, + "setup", + "--skill", + "productize", + "--copy", + "--all-agents", + "--yes", + ) + if exitCode != 0 { + t.Fatalf("setup --all-agents exit=%d\nstdout:\n%s\nstderr:\n%s", exitCode, stdout, stderr) + } + for _, root := range []string{".agents/skills", ".claude/skills", ".factory/skills", ".pi/skills"} { + assertBuiltSetupSkill(t, workspaceRoot, root, "productize") + } +} + +func TestBuiltSetupRejectsAmbiguousAgentFlagsBeforeMutation(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + { + name: "comma-separated agents", + args: []string{"setup", "--agent", "codex,claude-code", "--yes"}, + want: "one agent per occurrence", + }, + { + name: "agent and all agents", + args: []string{"setup", "--agent", "codex", "--all-agents", "--yes"}, + want: "use only one of --agent or --all-agents", + }, + { + name: "onboarding comma-separated agents", + args: []string{"onboard", "existing", "--agent", "codex,claude-code", "--yes", "--skip-register"}, + want: "one agent per occurrence", + }, + { + name: "onboarding agent and all agents", + args: []string{"onboard", "existing", "--agent", "codex", "--all-agents", "--yes", "--skip-register"}, + want: "use only one of --agent or --all-agents", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + workspaceRoot := t.TempDir() + homeDir := t.TempDir() + before := snapshotBuiltSetupState(t, workspaceRoot, homeDir) + stdout, stderr, exitCode := runSetupCLICommand(t, workspaceRoot, homeDir, tt.args...) + if exitCode != 1 { + t.Fatalf("invalid setup exit=%d, want 1\nstdout:\n%s\nstderr:\n%s", exitCode, stdout, stderr) + } + if stdout != "" || !strings.Contains(stderr, tt.want) { + t.Fatalf("unexpected invalid setup output\nstdout:\n%s\nstderr:\n%s", stdout, stderr) + } + after := snapshotBuiltSetupState(t, workspaceRoot, homeDir) + if !reflect.DeepEqual(after, before) { + t.Fatalf("invalid setup mutated state\nbefore=%#v\nafter=%#v", before, after) + } + }) + } +} + +func TestBuiltSetupCleanupJSONRemovesOnlyNonRetainedManagedTarget(t *testing.T) { + workspaceRoot := t.TempDir() + homeDir := t.TempDir() + for _, agentName := range []string{"codex", "claude-code"} { + stdout, stderr, exitCode := runSetupCLICommand( + t, + workspaceRoot, + homeDir, + "setup", + "--agent", + agentName, + "--skill", + "productize", + "--copy", + "--yes", + ) + if exitCode != 0 { + t.Fatalf("setup %s exit=%d\nstdout:\n%s\nstderr:\n%s", agentName, exitCode, stdout, stderr) + } + } + + stdout, stderr, exitCode := runSetupCLICommand( + t, + workspaceRoot, + homeDir, + "setup", + "cleanup", + "--keep-agent", + "claude-code", + "--yes", + "--format", + "json", + ) + if exitCode != 0 { + t.Fatalf("setup cleanup exit=%d\nstdout:\n%s\nstderr:\n%s", exitCode, stdout, stderr) + } + var result setupCleanupResult + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("decode setup cleanup JSON: %v\nstdout:\n%s", err, stdout) + } + if result.SchemaVersion != 1 || result.Status != setupCleanupStatusReady || result.Scope != "project" { + t.Fatalf("unexpected cleanup result: %#v", result) + } + if !reflect.DeepEqual(result.KeepAgents, []string{"claude-code"}) || len(result.Removed) != 1 { + t.Fatalf("unexpected cleanup selection/removal: %#v", result) + } + if result.Removed[0].Path != ".agents/skills/productize" { + t.Fatalf("unexpected removed path: %#v", result.Removed) + } + assertBuiltSetupPathMissing(t, filepath.Join(workspaceRoot, ".agents", "skills", "productize")) + assertBuiltSetupSkill(t, workspaceRoot, ".claude/skills", "productize") +} + +func TestBuiltSetupCleanupDryRunIsPure(t *testing.T) { + workspaceRoot := t.TempDir() + homeDir := t.TempDir() + stdout, stderr, exitCode := runSetupCLICommand( + t, + workspaceRoot, + homeDir, + "setup", + "--agent", + "codex", + "--skill", + "productize", + "--copy", + "--yes", + ) + if exitCode != 0 { + t.Fatalf("setup exit=%d\nstdout:\n%s\nstderr:\n%s", exitCode, stdout, stderr) + } + before := snapshotBuiltSetupState(t, workspaceRoot, homeDir) + stdout, stderr, exitCode = runSetupCLICommand( + t, + workspaceRoot, + homeDir, + "setup", + "cleanup", + "--keep-agent", + "claude-code", + "--dry-run", + "--format", + "json", + ) + if exitCode != 0 { + t.Fatalf("cleanup dry-run exit=%d\nstdout:\n%s\nstderr:\n%s", exitCode, stdout, stderr) + } + var result setupCleanupResult + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("decode cleanup dry-run JSON: %v\nstdout:\n%s", err, stdout) + } + if !result.DryRun || len(result.RemovalCandidates) != 1 || len(result.Removed) != 0 { + t.Fatalf("unexpected cleanup dry-run result: %#v", result) + } + after := snapshotBuiltSetupState(t, workspaceRoot, homeDir) + if !reflect.DeepEqual(after, before) { + t.Fatalf("cleanup dry-run mutated state\nbefore=%#v\nafter=%#v", before, after) + } +} + +func TestBuiltSetupCleanupRejectsInvalidSelectionBeforeMutation(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + { + name: "missing non-interactive retained agent", + args: []string{"setup", "cleanup", "--dry-run"}, + want: "requires --keep-agent ", + }, + { + name: "comma-separated retained agents", + args: []string{"setup", "cleanup", "--keep-agent", "codex,claude-code", "--dry-run"}, + want: "one agent per occurrence", + }, + { + name: "invalid format", + args: []string{"setup", "cleanup", "--keep-agent", "codex", "--dry-run", "--format", "yaml"}, + want: "output format must be one of text or json", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + workspaceRoot := t.TempDir() + homeDir := t.TempDir() + before := snapshotBuiltSetupState(t, workspaceRoot, homeDir) + stdout, stderr, exitCode := runSetupCLICommand(t, workspaceRoot, homeDir, tt.args...) + if exitCode != 1 { + t.Fatalf("invalid cleanup exit=%d, want 1\nstdout:\n%s\nstderr:\n%s", exitCode, stdout, stderr) + } + if stdout != "" || !strings.Contains(stderr, tt.want) { + t.Fatalf("unexpected invalid cleanup output\nstdout:\n%s\nstderr:\n%s", stdout, stderr) + } + after := snapshotBuiltSetupState(t, workspaceRoot, homeDir) + if !reflect.DeepEqual(after, before) { + t.Fatalf("invalid cleanup mutated state\nbefore=%#v\nafter=%#v", before, after) + } + }) + } +} + +func TestBuiltSetupCleanupPreservesDriftedManagedCopy(t *testing.T) { + workspaceRoot := t.TempDir() + homeDir := t.TempDir() + stdout, stderr, exitCode := runSetupCLICommand( + t, + workspaceRoot, + homeDir, + "setup", + "--agent", + "codex", + "--skill", + "productize", + "--copy", + "--yes", + ) + if exitCode != 0 { + t.Fatalf("setup exit=%d\nstdout:\n%s\nstderr:\n%s", exitCode, stdout, stderr) + } + driftedPath := filepath.Join(workspaceRoot, ".agents", "skills", "productize", "SKILL.md") + if err := os.WriteFile(driftedPath, []byte("user-owned drift\n"), 0o600); err != nil { + t.Fatalf("drift managed skill: %v", err) + } + + stdout, stderr, exitCode = runSetupCLICommand( + t, + workspaceRoot, + homeDir, + "setup", + "cleanup", + "--keep-agent", + "claude-code", + "--yes", + "--format", + "json", + ) + if exitCode != 1 { + t.Fatalf("cleanup drift exit=%d, want 1\nstdout:\n%s\nstderr:\n%s", exitCode, stdout, stderr) + } + if stderr != "" { + t.Fatalf("JSON cleanup emitted human stderr: %s", stderr) + } + var result setupCleanupResult + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("decode drift cleanup JSON: %v\nstdout:\n%s", err, stdout) + } + if result.Status != setupCleanupStatusNeedsReview || len(result.Skipped) != 1 || len(result.Removed) != 0 { + t.Fatalf("unexpected drift cleanup result: %#v", result) + } + contents, err := os.ReadFile(driftedPath) + if err != nil { + t.Fatalf("read preserved drift: %v", err) + } + if string(contents) != "user-owned drift\n" { + t.Fatalf("drifted content changed: %q", contents) + } +} + +func TestBuiltTargetedSetupReportsOptionalCleanupCommand(t *testing.T) { + workspaceRoot := t.TempDir() + homeDir := t.TempDir() + for _, agentName := range []string{"codex", "claude-code"} { + stdout, stderr, exitCode := runSetupCLICommand( + t, + workspaceRoot, + homeDir, + "setup", + "--agent", + agentName, + "--skill", + "productize", + "--copy", + "--yes", + ) + if exitCode != 0 { + t.Fatalf("setup %s exit=%d\nstdout:\n%s\nstderr:\n%s", agentName, exitCode, stdout, stderr) + } + if agentName == "claude-code" { + want := "productize setup cleanup --keep-agent claude-code --yes" + if !strings.Contains(stdout, want) { + t.Fatalf("targeted setup did not report cleanup command %q\nstdout:\n%s", want, stdout) + } + } + } +} + +type builtSetupSnapshot struct { + Workspace []string + Home []string +} + +func snapshotBuiltSetupState(t *testing.T, workspaceRoot, homeDir string) builtSetupSnapshot { + t.Helper() + return builtSetupSnapshot{ + Workspace: walkBuiltSetupPaths(t, workspaceRoot), + Home: walkBuiltSetupPaths(t, homeDir), + } +} + +func walkBuiltSetupPaths(t *testing.T, root string) []string { + t.Helper() + paths := []string{} + err := filepath.WalkDir(root, func(path string, _ os.DirEntry, err error) error { + if err != nil { + return err + } + if path == root { + return nil + } + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + paths = append(paths, filepath.ToSlash(relative)) + return nil + }) + if err != nil { + t.Fatalf("snapshot %s: %v", root, err) + } + return paths +} + +func runSetupCLICommand( + t *testing.T, + dir string, + homeDir string, + args ...string, +) (string, string, int) { + t.Helper() + cmd := exec.CommandContext(context.Background(), validateTasksBinary(t), args...) + cmd.Dir = dir + cmd.Env = setupCLICommandEnv(homeDir) + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if err == nil { + return stdout.String(), stderr.String(), 0 + } + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("run setup command: %v", err) + } + return stdout.String(), stderr.String(), exitErr.ExitCode() +} + +func setupCLICommandEnv(homeDir string) []string { + values := map[string]string{ + "HOME": homeDir, + "CODEX_HOME": filepath.Join(homeDir, ".codex"), + "CLAUDE_CONFIG_DIR": filepath.Join(homeDir, ".claude"), + "XDG_CONFIG_HOME": filepath.Join(homeDir, ".config"), + } + env := make([]string, 0, len(os.Environ())+len(values)) + for _, entry := range os.Environ() { + key, _, ok := strings.Cut(entry, "=") + if ok { + if _, replaced := values[key]; replaced { + continue + } + } + env = append(env, entry) + } + for key, value := range values { + env = append(env, key+"="+value) + } + return env +} + +func assertBuiltSetupSkill(t *testing.T, workspaceRoot, root, skill string) { + t.Helper() + assertBuiltSetupFile(t, filepath.Join(workspaceRoot, filepath.FromSlash(root), skill, "SKILL.md")) +} + +func assertBuiltSetupFile(t *testing.T, path string) { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatalf("expected file %s: %v", path, err) + } + if info.IsDir() { + t.Fatalf("expected file %s, got directory", path) + } +} + +func assertBuiltSetupPathMissing(t *testing.T, path string) { + t.Helper() + if _, err := os.Lstat(path); !os.IsNotExist(err) { + t.Fatalf("expected %s to be absent, got %v", path, err) + } +} + +func assertBuiltSetupOnlyAllowedRoots(t *testing.T, workspaceRoot string, allowedRoots ...string) { + t.Helper() + for _, path := range walkBuiltSetupPaths(t, workspaceRoot) { + allowed := false + for _, root := range allowedRoots { + root = filepath.ToSlash(filepath.Clean(filepath.FromSlash(root))) + if path == root || strings.HasPrefix(path, root+"/") || strings.HasPrefix(root, path+"/") { + allowed = true + break + } + } + if !allowed { + t.Fatalf("setup modified unselected directory %q; allowed roots: %#v", path, allowedRoots) + } + } +} diff --git a/internal/cli/setup_cleanup.go b/internal/cli/setup_cleanup.go new file mode 100644 index 00000000..7badc344 --- /dev/null +++ b/internal/cli/setup_cleanup.go @@ -0,0 +1,520 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "path/filepath" + "slices" + "strings" + + "github.com/itseffi/productize/internal/setup" + "github.com/spf13/cobra" +) + +type setupCleanupStatus string + +const ( + setupCleanupStatusReady setupCleanupStatus = "ready" + setupCleanupStatusNeedsReview setupCleanupStatus = "needs_review" + setupCleanupStatusBlocked setupCleanupStatus = "blocked" +) + +type setupCleanupDiagnostic struct { + Code string `json:"code"` + Severity string `json:"severity"` + Path string `json:"path,omitempty"` + Message string `json:"message"` + Remediation string `json:"remediation,omitempty"` +} + +type setupCleanupNextAction struct { + Type string `json:"type"` + Command string `json:"command,omitempty"` + Path string `json:"path,omitempty"` + Description string `json:"description"` +} + +type setupCleanupResult struct { + SchemaVersion int `json:"schema_version"` + Status setupCleanupStatus `json:"status"` + DryRun bool `json:"dry_run"` + Scope string `json:"scope"` + KeepAgents []string `json:"keep_agents"` + RetainedDirectories []string `json:"retained_directories"` + RemovalCandidates []setup.CleanupCandidate `json:"removal_candidates"` + Removed []setup.CleanupCandidate `json:"removed"` + Skipped []setup.CleanupSkipped `json:"skipped"` + Diagnostics []setupCleanupDiagnostic `json:"diagnostics"` + NextActions []setupCleanupNextAction `json:"next_actions"` +} + +type setupCleanupCommandState struct { + keepAgentNames []string + global bool + dryRun bool + yes bool + format string + + loadCatalog func(context.Context, setup.ResolverOptions) (setup.EffectiveCatalog, error) + listAgents func(setup.ResolverOptions) ([]setup.Agent, error) + detectAgents func(setup.ResolverOptions) ([]setup.Agent, error) + planCleanup func(setup.CleanupConfig) (setup.CleanupPlan, error) + applyCleanup func(context.Context, setup.CleanupPlan) (setup.CleanupApplyResult, error) + isInteractive func() bool +} + +type preparedSetupCleanup struct { + resolver setup.ResolverOptions + plan setup.CleanupPlan + result setupCleanupResult +} + +func newSetupCleanupCommand() *cobra.Command { + state := newSetupCleanupCommandState() + cmd := &cobra.Command{ + Use: "cleanup", + Short: "Remove old Productize-managed agent skill installations safely", + SilenceUsage: true, + Args: cobra.NoArgs, + Long: `Inspect every non-retained agent skill directory and remove only Productize-managed +symlinks or byte-identical copies. Drifted, unknown, and user-owned content is preserved for review.`, + Example: ` productize setup cleanup --keep-agent opencode + productize setup cleanup --keep-agent codex --keep-agent claude-code --yes + productize setup cleanup --keep-agent codex --dry-run --format json`, + RunE: state.run, + } + flags := cmd.Flags() + flags.StringArrayVar(&state.keepAgentNames, "keep-agent", nil, "Agent target to retain (repeatable)") + flags.BoolVarP(&state.global, "global", "g", false, "Clean user-scoped agent skill directories") + flags.BoolVar(&state.dryRun, "dry-run", false, "Preview cleanup without removing files") + flags.BoolVarP(&state.yes, "yes", "y", false, "Skip the cleanup confirmation prompt") + flags.StringVar(&state.format, "format", operatorOutputFormatText, "Output format: text or json") + return cmd +} + +func newSetupCleanupCommandState() *setupCleanupCommandState { + return &setupCleanupCommandState{ + format: operatorOutputFormatText, + loadCatalog: loadEffectiveSetupCatalog, + listAgents: setup.SupportedAgents, + detectAgents: setup.DetectInstalledAgents, + planCleanup: setup.PlanCleanup, + applyCleanup: setup.ApplyCleanup, + isInteractive: isInteractiveTerminal, + } +} + +func (s *setupCleanupCommandState) run(cmd *cobra.Command, _ []string) error { + ctx, stop := signalCommandContext(cmd) + defer stop() + format, err := s.validateInvocation() + if err != nil { + return err + } + prepared, err := s.prepareCleanup(ctx, cmd) + if err != nil { + return err + } + if err := s.confirmCleanup(cmd, prepared.result); err != nil { + return err + } + return s.applyAndReportCleanup(ctx, cmd, format, prepared) +} + +func (s *setupCleanupCommandState) validateInvocation() (string, error) { + format, err := normalizeOperatorOutputFormat(s.format) + if err != nil { + return "", withExitCode(1, err) + } + s.format = format + if err := validateAgentTargetFlags("--keep-agent", s.keepAgentNames, false); err != nil { + return "", withExitCode(1, err) + } + interactive := s.isInteractive != nil && s.isInteractive() + if len(s.keepAgentNames) == 0 && (!interactive || format == operatorOutputFormatJSON) { + return "", withExitCode(1, errors.New( + "setup cleanup requires --keep-agent in non-interactive or JSON mode", + )) + } + if format == operatorOutputFormatJSON && !s.dryRun && !s.yes { + return "", withExitCode(1, errors.New("--format json requires --yes when applying setup cleanup")) + } + return format, nil +} + +func (s *setupCleanupCommandState) prepareCleanup( + ctx context.Context, + cmd *cobra.Command, +) (preparedSetupCleanup, error) { + resolver := currentResolverOptions() + supported, err := s.listAgents(resolver) + if err != nil { + return preparedSetupCleanup{}, withExitCode(2, err) + } + detected, err := s.detectAgents(resolver) + if err != nil { + return preparedSetupCleanup{}, withExitCode(2, err) + } + keepAgents, err := s.resolveKeepAgents(cmd, supported, detected) + if err != nil { + return preparedSetupCleanup{}, withExitCode(1, err) + } + catalog, err := s.loadCatalog(ctx, resolver) + if err != nil { + return preparedSetupCleanup{}, withExitCode(2, err) + } + plan, err := s.planCleanup(setup.CleanupConfig{ + ResolverOptions: resolver, + Skills: catalog.Skills, + KeepAgentNames: keepAgents, + Global: s.global, + DryRun: s.dryRun, + }) + if err != nil { + return preparedSetupCleanup{}, withExitCode(2, err) + } + return preparedSetupCleanup{ + resolver: resolver, + plan: plan, + result: newSetupCleanupResult(plan, resolver), + }, nil +} + +func (s *setupCleanupCommandState) confirmCleanup(cmd *cobra.Command, result setupCleanupResult) error { + if s.dryRun || s.yes { + return nil + } + if err := writeSetupCleanupPlan(cmd.OutOrStdout(), result); err != nil { + return withExitCode(2, err) + } + confirmed, err := newPromptSession(cmd).confirm("Apply this setup cleanup plan?", "", false) + if err != nil { + return withExitCode(2, fmt.Errorf("confirm setup cleanup: %w", err)) + } + if !confirmed { + return withExitCode(1, errors.New("setup cleanup canceled")) + } + return nil +} + +func (s *setupCleanupCommandState) applyAndReportCleanup( + ctx context.Context, + cmd *cobra.Command, + format string, + prepared preparedSetupCleanup, +) error { + applyErr := s.applyPreparedCleanup(ctx, &prepared) + finalizeSetupCleanupResult(&prepared.result, prepared.plan) + if err := writeSetupCleanupOutput(cmd.OutOrStdout(), format, prepared.result); err != nil { + return withExitCode(2, err) + } + silenceSetupCleanupJSONError(cmd, format) + if applyErr != nil { + return withExitCode(2, applyErr) + } + if prepared.result.Status == setupCleanupStatusNeedsReview { + return withExitCode(1, errors.New("setup cleanup preserved items that require review")) + } + return nil +} + +func silenceSetupCleanupJSONError(cmd *cobra.Command, format string) { + if format == operatorOutputFormatJSON { + cmd.Root().SilenceErrors = true + } +} + +func (s *setupCleanupCommandState) applyPreparedCleanup( + ctx context.Context, + prepared *preparedSetupCleanup, +) error { + if s.dryRun { + return nil + } + applied, err := s.applyCleanup(ctx, prepared.plan) + prepared.result.Removed = displayCleanupCandidates(applied.Removed, prepared.resolver, s.global) + if err == nil { + return nil + } + prepared.result.Status = setupCleanupStatusBlocked + for i := range applied.Failed { + prepared.result.Diagnostics = append(prepared.result.Diagnostics, setupCleanupDiagnostic{ + Code: "cleanup_remove_failed", + Severity: "error", + Path: displayCleanupPath(applied.Failed[i].Path, prepared.resolver, s.global), + Message: applied.Failed[i].Message, + Remediation: "Resolve the filesystem error and rerun setup cleanup", + }) + } + return err +} + +func (s *setupCleanupCommandState) resolveKeepAgents( + cmd *cobra.Command, + supported []setup.Agent, + detected []setup.Agent, +) ([]string, error) { + if len(s.keepAgentNames) > 0 { + selected, err := setup.SelectAgents(supported, s.keepAgentNames) + if err != nil { + return nil, err + } + return agentNames(selected), nil + } + selected, err := selectInteractiveAgent( + cmd, + supported, + detected, + "Agent to keep", + "Choose the one agent target to retain; all other managed targets will be inspected", + ) + if err != nil { + return nil, fmt.Errorf("select retained agent: %w", err) + } + return []string{selected}, nil +} + +func newSetupCleanupResult(plan setup.CleanupPlan, resolver setup.ResolverOptions) setupCleanupResult { + result := setupCleanupResult{ + SchemaVersion: 1, + Status: setupCleanupStatusReady, + DryRun: plan.DryRun, + Scope: string(plan.Scope), + KeepAgents: []string{}, + RetainedDirectories: displayCleanupPaths( + plan.RetainedDirectories, + resolver, + plan.Scope == setup.InstallScopeGlobal, + ), + RemovalCandidates: displayCleanupCandidates( + plan.RemovalCandidates, + resolver, + plan.Scope == setup.InstallScopeGlobal, + ), + Removed: []setup.CleanupCandidate{}, + Skipped: displayCleanupSkipped(plan.Skipped, resolver, plan.Scope == setup.InstallScopeGlobal), + Diagnostics: []setupCleanupDiagnostic{}, + NextActions: []setupCleanupNextAction{}, + } + for i := range plan.KeepAgents { + result.KeepAgents = append(result.KeepAgents, plan.KeepAgents[i].Name) + } + return result +} + +func finalizeSetupCleanupResult(result *setupCleanupResult, plan setup.CleanupPlan) { + if result.Status != setupCleanupStatusBlocked && plan.NeedsReview { + result.Status = setupCleanupStatusNeedsReview + } + for i := range result.Skipped { + result.Diagnostics = append(result.Diagnostics, setupCleanupDiagnostic{ + Code: result.Skipped[i].Code, + Severity: "warning", + Path: result.Skipped[i].Path, + Message: result.Skipped[i].Message, + Remediation: result.Skipped[i].Remediation, + }) + result.NextActions = append(result.NextActions, setupCleanupNextAction{ + Type: "edit", + Path: result.Skipped[i].Path, + Description: result.Skipped[i].Remediation, + }) + } + if result.DryRun && result.Status == setupCleanupStatusReady { + result.NextActions = append(result.NextActions, setupCleanupNextAction{ + Type: "command", + Command: setupCleanupApplyCommand(result.KeepAgents, result.Scope == string(setup.InstallScopeGlobal)), + Description: "Apply this cleanup plan", + }) + } + slices.Sort(result.KeepAgents) + slices.SortFunc(result.Diagnostics, func(left, right setupCleanupDiagnostic) int { + return strings.Compare(left.Code+"\x00"+left.Path, right.Code+"\x00"+right.Path) + }) + slices.SortFunc(result.NextActions, func(left, right setupCleanupNextAction) int { + return strings.Compare( + left.Type+"\x00"+left.Path+"\x00"+left.Command, + right.Type+"\x00"+right.Path+"\x00"+right.Command, + ) + }) +} + +func writeSetupCleanupOutput(out io.Writer, format string, result setupCleanupResult) error { + if format == operatorOutputFormatJSON { + if err := writeOperatorJSON(out, result); err != nil { + return fmt.Errorf("write setup cleanup JSON: %w", err) + } + return nil + } + if _, err := fmt.Fprintf( + out, + "Setup cleanup\nStatus: %s\nDry run: %t\nScope: %s\n", + result.Status, + result.DryRun, + result.Scope, + ); err != nil { + return fmt.Errorf("write setup cleanup output: %w", err) + } + if err := writeSetupCleanupPlan(out, result); err != nil { + return err + } + for _, diagnostic := range result.Diagnostics { + if _, err := fmt.Fprintf( + out, + "- [%s] %s %s: %s\n", + diagnostic.Severity, + diagnostic.Code, + diagnostic.Path, + diagnostic.Message, + ); err != nil { + return fmt.Errorf("write setup cleanup diagnostic: %w", err) + } + } + for _, action := range result.NextActions { + value := action.Command + if value == "" { + value = action.Path + } + if _, err := fmt.Fprintf(out, "Next action: %s (%s)\n", value, action.Description); err != nil { + return fmt.Errorf("write setup cleanup next action: %w", err) + } + } + return nil +} + +func writeSetupCleanupPlan(out io.Writer, result setupCleanupResult) error { + if err := writeSetupCleanupStringSection(out, "Keep agents", result.KeepAgents, false); err != nil { + return err + } + if err := writeSetupCleanupStringSection( + out, + "Retained directories", + result.RetainedDirectories, + false, + ); err != nil { + return err + } + if err := writeSetupCleanupCandidates(out, result.RemovalCandidates); err != nil { + return err + } + return writeSetupCleanupSkipped(out, result.Skipped) +} + +func writeSetupCleanupStringSection(out io.Writer, title string, values []string, emptyNone bool) error { + if _, err := fmt.Fprintln(out, title+":"); err != nil { + return err + } + if len(values) == 0 && emptyNone { + _, err := fmt.Fprintln(out, " none") + return err + } + for _, value := range values { + if _, err := fmt.Fprintln(out, " "+value); err != nil { + return err + } + } + return nil +} + +func writeSetupCleanupCandidates(out io.Writer, candidates []setup.CleanupCandidate) error { + if _, err := fmt.Fprintln(out, "Safe removal candidates:"); err != nil { + return err + } + if len(candidates) == 0 { + if _, err := fmt.Fprintln(out, " none"); err != nil { + return err + } + } + for _, candidate := range candidates { + if _, err := fmt.Fprintf( + out, + " %s %s %s\n", + candidate.Path, + candidate.Mode, + candidate.Ownership, + ); err != nil { + return err + } + } + return nil +} + +func writeSetupCleanupSkipped(out io.Writer, skippedItems []setup.CleanupSkipped) error { + if len(skippedItems) == 0 { + return nil + } + if _, err := fmt.Fprintln(out, "Preserved for review:"); err != nil { + return err + } + for _, skipped := range skippedItems { + if _, err := fmt.Fprintf(out, " %s %s\n", skipped.Path, skipped.Code); err != nil { + return err + } + } + return nil +} + +func displayCleanupCandidates( + items []setup.CleanupCandidate, + resolver setup.ResolverOptions, + global bool, +) []setup.CleanupCandidate { + result := make([]setup.CleanupCandidate, len(items)) + copy(result, items) + for i := range result { + result[i].Path = displayCleanupPath(result[i].Path, resolver, global) + } + return result +} + +func displayCleanupSkipped( + items []setup.CleanupSkipped, + resolver setup.ResolverOptions, + global bool, +) []setup.CleanupSkipped { + result := make([]setup.CleanupSkipped, len(items)) + copy(result, items) + for i := range result { + result[i].Path = displayCleanupPath(result[i].Path, resolver, global) + } + return result +} + +func displayCleanupPaths(paths []string, resolver setup.ResolverOptions, global bool) []string { + result := make([]string, 0, len(paths)) + for _, path := range paths { + result = append(result, displayCleanupPath(path, resolver, global)) + } + return result +} + +func displayCleanupPath(path string, resolver setup.ResolverOptions, global bool) string { + if global { + return filepath.Clean(path) + } + workspaceRoot, _, err := resolveSetupAssetRoots(resolver) + if err != nil { + return filepath.Clean(path) + } + relative, err := filepath.Rel(workspaceRoot, path) + if err != nil { + return filepath.Clean(path) + } + return filepath.ToSlash(relative) +} + +func setupCleanupApplyCommand(keepAgents []string, global bool) string { + agents := append([]string(nil), keepAgents...) + slices.Sort(agents) + args := []string{"productize", "setup", "cleanup"} + for _, agent := range agents { + args = append(args, "--keep-agent", agent) + } + if global { + args = append(args, "--global") + } + args = append(args, "--yes") + return strings.Join(args, " ") +} diff --git a/internal/cli/setup_doctor.go b/internal/cli/setup_doctor.go index df613fa5..89201532 100644 --- a/internal/cli/setup_doctor.go +++ b/internal/cli/setup_doctor.go @@ -30,16 +30,17 @@ type setupDoctorAgent struct { } type setupDoctorSkillTarget struct { - Name string `json:"name"` - Origin string `json:"origin"` - Agent string `json:"agent"` - AgentName string `json:"agent_name"` - Scope string `json:"scope"` - Mode string `json:"mode"` - State string `json:"state"` - TargetPath string `json:"target_path"` - CanonicalPath string `json:"canonical_path,omitempty"` - WillOverwrite bool `json:"will_overwrite"` + Name string `json:"name"` + Origin string `json:"origin"` + Agent string `json:"agent"` + AgentName string `json:"agent_name"` + AgentNames []string `json:"agent_names"` + Scope string `json:"scope"` + Mode string `json:"mode"` + State string `json:"state"` + TargetPath string `json:"target_path"` + CanonicalPath string `json:"canonical_path,omitempty"` + WillOverwrite bool `json:"will_overwrite"` } type setupDoctorReusableAgent struct { @@ -278,6 +279,7 @@ func setupDoctorSkillStatuses( ResolverOptions: resolver, AgentName: agentName, SkillNames: bundledNames, + ScopeHint: scope, }) if err != nil { return nil, err @@ -350,6 +352,7 @@ func mergeSetupDoctorSkillTargets( Origin: string(preview.Skill.Origin), Agent: preview.Agent.DisplayName, AgentName: preview.Agent.Name, + AgentNames: append([]string(nil), preview.AgentNames...), Scope: status.scope, Mode: status.mode, State: status.state, diff --git a/internal/cli/setup_summary.go b/internal/cli/setup_summary.go new file mode 100644 index 00000000..19ee6887 --- /dev/null +++ b/internal/cli/setup_summary.go @@ -0,0 +1,289 @@ +package cli + +import ( + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/itseffi/productize/internal/setup" +) + +type setupDirectoryEntry struct { + Path string `json:"path"` + Mode string `json:"mode"` +} + +type setupDirectorySummary struct { + SelectedAgents []string `json:"selected_agents"` + AgentSkillDirectories []setupDirectoryEntry `json:"agent_skill_directories"` + SharedProductizeMetadata []string `json:"shared_productize_metadata"` + OtherAgentDirectories []string `json:"other_agent_directories"` + SkillCount int `json:"skill_count"` + OverwriteCount int `json:"overwrite_count"` + InstallMode string `json:"install_mode"` + AffectedReusableAgentCount int `json:"affected_reusable_agent_count"` +} + +type setupSummaryInventory struct { + directories map[string]setup.InstallMode + overwrites map[string]struct{} + skills map[string]struct{} +} + +func buildSetupDirectorySummary( + resolver setup.ResolverOptions, + cfg setup.InstallConfig, + supportedAgents []setup.Agent, + previews []setup.PreviewItem, + reusableAgentPreviews []setup.ReusableAgentPreviewItem, +) (setupDirectorySummary, error) { + selected, err := setup.SelectAgents(supportedAgents, cfg.AgentNames) + if err != nil { + return setupDirectorySummary{}, err + } + workspaceRoot, homeDir, err := resolveSetupAssetRoots(resolver) + if err != nil { + return setupDirectorySummary{}, err + } + + summary := setupDirectorySummary{ + SelectedAgents: []string{}, + AgentSkillDirectories: []setupDirectoryEntry{}, + SharedProductizeMetadata: []string{}, + OtherAgentDirectories: []string{}, + InstallMode: string(cfg.Mode), + AffectedReusableAgentCount: len(reusableAgentPreviews), + } + for i := range selected { + summary.SelectedAgents = append(summary.SelectedAgents, selected[i].DisplayName) + } + inventory := collectSetupSummaryInventory(cfg, previews, reusableAgentPreviews, workspaceRoot, homeDir) + summary.AgentSkillDirectories, err = setupSummaryDirectories( + inventory.directories, + workspaceRoot, + cfg.Global, + ) + if err != nil { + return setupDirectorySummary{}, err + } + summary.SharedProductizeMetadata, err = setupSummaryMetadataPaths( + workspaceRoot, + homeDir, + cfg.Global, + len(reusableAgentPreviews) > 0, + ) + if err != nil { + return setupDirectorySummary{}, err + } + summary.SkillCount = len(inventory.skills) + summary.OverwriteCount = len(inventory.overwrites) + return summary, nil +} + +func collectSetupSummaryInventory( + cfg setup.InstallConfig, + previews []setup.PreviewItem, + reusableAgentPreviews []setup.ReusableAgentPreviewItem, + workspaceRoot string, + homeDir string, +) setupSummaryInventory { + inventory := setupSummaryInventory{ + directories: make(map[string]setup.InstallMode), + overwrites: make(map[string]struct{}), + skills: make(map[string]struct{}), + } + for i := range previews { + addSkillPreviewToSummaryInventory(&inventory, cfg, previews[i], workspaceRoot, homeDir) + } + for i := range reusableAgentPreviews { + target := setupAbsoluteSummaryPath( + reusableAgentPreviews[i].TargetPath, + workspaceRoot, + homeDir, + cfg.Global, + ) + addSetupOverwrite(&inventory, target) + } + return inventory +} + +func addSkillPreviewToSummaryInventory( + inventory *setupSummaryInventory, + cfg setup.InstallConfig, + preview setup.PreviewItem, + workspaceRoot string, + homeDir string, +) { + inventory.skills[preview.Skill.Name] = struct{}{} + target := setupAbsoluteSummaryPath(preview.TargetPath, workspaceRoot, homeDir, cfg.Global) + canonical := target + if strings.TrimSpace(preview.CanonicalPath) != "" { + canonical = setupAbsoluteSummaryPath(preview.CanonicalPath, workspaceRoot, homeDir, cfg.Global) + } + targetRoot := filepath.Dir(target) + canonicalRoot := filepath.Dir(canonical) + targetMode := cfg.Mode + if sameInstallPath(targetRoot, canonicalRoot) { + targetMode = setup.InstallModeCopy + } + inventory.directories[targetRoot] = targetMode + if cfg.Mode == setup.InstallModeSymlink { + inventory.directories[canonicalRoot] = setup.InstallModeCopy + addSetupOverwrite(inventory, canonical) + } + addSetupOverwrite(inventory, target) +} + +func addSetupOverwrite(inventory *setupSummaryInventory, path string) { + if setupPathExists(path) { + inventory.overwrites[filepath.Clean(path)] = struct{}{} + } +} + +func setupSummaryDirectories( + directories map[string]setup.InstallMode, + workspaceRoot string, + global bool, +) ([]setupDirectoryEntry, error) { + result := make([]setupDirectoryEntry, 0, len(directories)) + for path, mode := range directories { + display, err := setupSummaryPath(path, workspaceRoot, global) + if err != nil { + return nil, err + } + result = append(result, setupDirectoryEntry{Path: display, Mode: string(mode)}) + } + slices.SortFunc(result, func(left, right setupDirectoryEntry) int { + return strings.Compare(left.Path+"\x00"+left.Mode, right.Path+"\x00"+right.Mode) + }) + return result, nil +} + +func setupSummaryMetadataPaths( + workspaceRoot string, + homeDir string, + global bool, + includeAgents bool, +) ([]string, error) { + base := workspaceRoot + if global { + base = homeDir + } + paths := []string{filepath.Join(base, ".productize", "catalog")} + if includeAgents { + paths = append(paths, filepath.Join(base, ".productize", "agents")) + } + result := make([]string, 0, len(paths)) + for _, path := range paths { + display, err := setupSummaryPath(path, workspaceRoot, global) + if err != nil { + return nil, err + } + result = append(result, display) + } + slices.Sort(result) + return result, nil +} + +func setupAbsoluteSummaryPath(path, workspaceRoot, homeDir string, global bool) string { + if filepath.IsAbs(path) { + return filepath.Clean(path) + } + base := workspaceRoot + if global { + base = homeDir + } + return filepath.Join(base, path) +} + +func setupSummaryPath(path, workspaceRoot string, global bool) (string, error) { + path = filepath.Clean(path) + if global { + return path, nil + } + relative, err := filepath.Rel(workspaceRoot, path) + if err != nil { + return "", fmt.Errorf("make setup summary path relative: %w", err) + } + if relative == "." { + return ".", nil + } + return filepath.ToSlash(relative), nil +} + +func setupPathExists(path string) bool { + _, err := os.Lstat(path) + return err == nil +} + +func printSetupDirectorySummary(out io.Writer, summary setupDirectorySummary, indent string) error { + if len(summary.SelectedAgents) == 0 && len(summary.AgentSkillDirectories) == 0 && + len(summary.SharedProductizeMetadata) == 0 { + return nil + } + if err := writeSetupSummaryLines(out, indent, "Selected agents", summary.SelectedAgents, false); err != nil { + return err + } + if err := writeSetupSummaryDirectories(out, indent, summary.AgentSkillDirectories); err != nil { + return err + } + if err := writeSetupSummaryLines( + out, + indent, + "Shared Productize metadata", + summary.SharedProductizeMetadata, + false, + ); err != nil { + return err + } + if err := writeSetupSummaryLines( + out, + indent, + "Other agent directories modified", + summary.OtherAgentDirectories, + true, + ); err != nil { + return err + } + _, err := fmt.Fprintf( + out, + "%sSkills: %d Overwrites: %d Method: %s Reusable agents: %d\n", + indent, + summary.SkillCount, + summary.OverwriteCount, + summary.InstallMode, + summary.AffectedReusableAgentCount, + ) + return err +} + +func writeSetupSummaryLines(out io.Writer, indent, title string, values []string, emptyNone bool) error { + if _, err := fmt.Fprintln(out, indent+title+":"); err != nil { + return err + } + if len(values) == 0 && emptyNone { + _, err := fmt.Fprintln(out, indent+" none") + return err + } + for _, value := range values { + if _, err := fmt.Fprintln(out, indent+" "+value); err != nil { + return err + } + } + return nil +} + +func writeSetupSummaryDirectories(out io.Writer, indent string, directories []setupDirectoryEntry) error { + if _, err := fmt.Fprintln(out, indent+"Agent skill directories:"); err != nil { + return err + } + for _, directory := range directories { + if _, err := fmt.Fprintf(out, "%s %s %s\n", indent, directory.Path, directory.Mode); err != nil { + return err + } + } + return nil +} diff --git a/internal/cli/setup_test.go b/internal/cli/setup_test.go index db5c7115..61f3cccf 100644 --- a/internal/cli/setup_test.go +++ b/internal/cli/setup_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "path/filepath" "strings" "testing" @@ -11,6 +12,119 @@ import ( "github.com/spf13/cobra" ) +func TestInteractiveAgentSelectionUsesExactlyOneDefault(t *testing.T) { + t.Parallel() + + supported := []setup.Agent{ + {Name: "codex", DisplayName: "Codex", ProjectRootDir: ".agents/skills", Universal: true}, + {Name: "claude-code", DisplayName: "Claude Code", ProjectRootDir: ".claude/skills"}, + } + tests := []struct { + name string + detected []setup.Agent + input string + want string + wantErr string + }{ + { + name: "one detected agent is the only default", + detected: []setup.Agent{supported[0]}, + input: "\n", + want: "codex", + }, + { + name: "several detected agents require a choice", + detected: supported, + input: "\n", + wantErr: "selection is required", + }, + { + name: "no detected agents require a choice", + input: "2\n", + want: "claude-code", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + cmd := &cobra.Command{Use: "setup"} + cmd.SetIn(strings.NewReader(tt.input)) + cmd.SetOut(&bytes.Buffer{}) + selected, err := selectInteractiveAgent(cmd, supported, tt.detected, "Target Agent", "Choose one") + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("selection error = %v, want %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("select interactive agent: %v", err) + } + if selected != tt.want { + t.Fatalf("selected agent = %q, want %q", selected, tt.want) + } + }) + } +} + +func TestSetupDirectorySummaryShowsOnlyUniqueSelectedDirectories(t *testing.T) { + t.Parallel() + + workspaceRoot := t.TempDir() + homeDir := t.TempDir() + resolver := setup.ResolverOptions{CWD: workspaceRoot, HomeDir: homeDir} + supported, err := setup.SupportedAgents(resolver) + if err != nil { + t.Fatalf("list supported agents: %v", err) + } + previews, err := setup.PreviewSelectedSkills( + resolver, + []setup.Skill{{Name: "productize", Directory: "productize", Origin: setup.AssetOriginBundled}}, + []string{"codex", "opencode"}, + false, + setup.InstallModeCopy, + ) + if err != nil { + t.Fatalf("preview shared setup targets: %v", err) + } + summary, err := buildSetupDirectorySummary( + resolver, + setup.InstallConfig{ + ResolverOptions: resolver, + AgentNames: []string{"codex", "opencode"}, + Mode: setup.InstallModeCopy, + }, + supported, + previews, + []setup.ReusableAgentPreviewItem{{ + ReusableAgent: setup.ReusableAgent{Name: "architect-advisor"}, + TargetPath: filepath.Join(workspaceRoot, ".productize", "agents", "architect-advisor"), + }}, + ) + if err != nil { + t.Fatalf("build setup directory summary: %v", err) + } + var output bytes.Buffer + if err := printSetupDirectorySummary(&output, summary, ""); err != nil { + t.Fatalf("print setup directory summary: %v", err) + } + content := output.String() + for _, snippet := range []string{ + "Selected agents:\n Codex\n OpenCode", + "Agent skill directories:\n .agents/skills copy", + "Shared Productize metadata:\n .productize/agents\n .productize/catalog", + "Other agent directories modified:\n none", + "Skills: 1 Overwrites: 0 Method: copy Reusable agents: 1", + } { + if !strings.Contains(content, snippet) { + t.Fatalf("summary missing %q\noutput:\n%s", snippet, content) + } + } + if strings.Contains(content, ".claude") || strings.Count(content, ".agents/skills") != 1 { + t.Fatalf("summary contains unselected or duplicate directories:\n%s", content) + } +} + func TestSetupHelpShowsSetupFlagsOnly(t *testing.T) { t.Parallel() @@ -657,3 +771,57 @@ func TestSetupDoctorTextReportsTargets(t *testing.T) { } } } + +func TestSetupDoctorGlobalScopeDoesNotAcceptCurrentProjectInstall(t *testing.T) { + t.Parallel() + + projectDir := t.TempDir() + homeDir := t.TempDir() + resolver := setup.ResolverOptions{CWD: projectDir, HomeDir: homeDir} + allSkills, err := setup.ListBundledSkills() + if err != nil { + t.Fatalf("list bundled skills: %v", err) + } + selectedSkills, err := setup.SelectSkills(allSkills, []string{"productize"}) + if err != nil { + t.Fatalf("select productize skill: %v", err) + } + _, failures, err := setup.InstallSelectedSkills( + resolver, + selectedSkills, + []string{"codex"}, + false, + setup.InstallModeCopy, + ) + if err != nil || len(failures) != 0 { + t.Fatalf("install project skill: err=%v failures=%#v", err, failures) + } + supported, err := setup.SupportedAgents(resolver) + if err != nil { + t.Fatalf("list supported agents: %v", err) + } + selectedAgents, err := setup.SelectAgents(supported, []string{"codex"}) + if err != nil { + t.Fatalf("select codex: %v", err) + } + state := newSetupCommandState() + report, err := state.buildDoctorReport( + resolver, + setup.EffectiveCatalog{Skills: selectedSkills}, + supported, + selectedAgents, + selectedAgents, + true, + setup.InstallModeCopy, + ) + if err != nil { + t.Fatalf("build global doctor report: %v", err) + } + if len(report.SkillTargets) != 1 { + t.Fatalf("unexpected global skill targets: %#v", report.SkillTargets) + } + target := report.SkillTargets[0] + if target.Scope != string(setup.InstallScopeGlobal) || target.State != string(setup.VerifyStateMissing) { + t.Fatalf("project install satisfied global doctor request: %#v", target) + } +} diff --git a/internal/cli/testdata/onboard_existing_help.golden b/internal/cli/testdata/onboard_existing_help.golden new file mode 100644 index 00000000..8c9cfcb5 --- /dev/null +++ b/internal/cli/testdata/onboard_existing_help.golden @@ -0,0 +1,30 @@ +Deterministically inspect an existing repository, generate Productize project +knowledge, install Productize setup assets for one interactively selected agent (or explicit +repeated --agent targets), and register the workspace. + +Usage: + productize onboard existing [path] [flags] + +Examples: + productize onboard existing + productize onboard existing ../my-app --agent codex --yes + productize onboard existing --all-agents --yes + productize onboard existing --skip-setup --dry-run + productize onboard existing --agent codex --format json --yes + +Flags: + -a, --agent stringArray Target one agent/editor name per occurrence + --all-agents Explicitly install to every supported agent/editor destination + --copy Copy setup assets instead of symlinking them + --core-only Install only core Productize skills + --dry-run Preview onboarding without any mutations + --exclude strings Exclude a repository path pattern (repeatable) + --force Overwrite unmarked generated project knowledge targets + --format string Output format: text or json (default "text") + -g, --global Install setup assets in the user scope + -h, --help help for existing + --name string Operator-facing workspace name + --no-tactical Alias for --core-only + --skip-register Skip daemon startup and workspace registration + --skip-setup Skip Productize setup asset installation + -y, --yes Skip the combined confirmation prompt diff --git a/internal/cli/testdata/setup_cleanup_help.golden b/internal/cli/testdata/setup_cleanup_help.golden new file mode 100644 index 00000000..30825257 --- /dev/null +++ b/internal/cli/testdata/setup_cleanup_help.golden @@ -0,0 +1,18 @@ +Inspect every non-retained agent skill directory and remove only Productize-managed +symlinks or byte-identical copies. Drifted, unknown, and user-owned content is preserved for review. + +Usage: + productize setup cleanup [flags] + +Examples: + productize setup cleanup --keep-agent opencode + productize setup cleanup --keep-agent codex --keep-agent claude-code --yes + productize setup cleanup --keep-agent codex --dry-run --format json + +Flags: + --dry-run Preview cleanup without removing files + --format string Output format: text or json (default "text") + -g, --global Clean user-scoped agent skill directories + -h, --help help for cleanup + --keep-agent stringArray Agent target to retain (repeatable) + -y, --yes Skip the cleanup confirmation prompt diff --git a/internal/cli/testdata/setup_help.golden b/internal/cli/testdata/setup_help.golden new file mode 100644 index 00000000..fda62f27 --- /dev/null +++ b/internal/cli/testdata/setup_help.golden @@ -0,0 +1,38 @@ +Install Productize's full bundled skill catalog, any additional skills shipped by +enabled extensions, and reusable agents in either the project or user scope selected during setup. + +Interactive setup selects one agent target. Use repeated --agent flags for multiple targets, +or --all-agents to install into every supported target. + +Usage: + productize setup [flags] + productize setup [command] + +Examples: + productize setup + productize setup --list + productize setup --doctor --agent codex --format json + productize setup --agent codex --agent claude --skill create-prd --skill create-techspec --yes + productize setup --all-agents --yes + productize setup --core-only + productize setup --agent cursor --global --copy --yes + +Available Commands: + cleanup Remove old Productize-managed agent skill installations safely + +Flags: + -a, --agent stringArray Target one agent/editor name per occurrence + --all Deprecated: setup installs all skills by default; also skips prompts + --all-agents Explicitly install to every supported agent/editor destination + --copy Copy files instead of symlinking to agent directories + --core-only Install only core workflow, lifecycle, and gate skills + --doctor Inspect setup targets and drift without installing + --format string Output format for --doctor: text or json (default "text") + -g, --global Install to the user directory instead of the project + -h, --help help for setup + -l, --list List setup assets without installing + --no-tactical Alias for --core-only + -s, --skill strings Setup skill name to install (repeatable) + -y, --yes Skip confirmation prompts + +Use "productize setup [command] --help" for more information about a command. diff --git a/internal/setup/catalog_effective_test.go b/internal/setup/catalog_effective_test.go index 50fe07be..3d9adad5 100644 --- a/internal/setup/catalog_effective_test.go +++ b/internal/setup/catalog_effective_test.go @@ -2,6 +2,7 @@ package setup import ( "path/filepath" + "strings" "testing" ) @@ -130,3 +131,32 @@ func TestInstallSelectedSkillsCopiesBundledAndExtensionSkills(t *testing.T) { assertFileExists(t, filepath.Join(projectDir, ".agents", "skills", "idea-pack", "SKILL.md")) assertFileExists(t, filepath.Join(projectDir, ".agents", "skills", "idea-pack", "references", "notes.md")) } + +func TestPreviewSelectedSkillsDeduplicatesSharedPhysicalTargets(t *testing.T) { + t.Parallel() + + bundle := newTestBundle(t, map[string]string{ + "productize/SKILL.md": "---\nname: productize\ndescription: Core workflow\n---\n", + }) + skills, err := ListSkills(bundle) + if err != nil { + t.Fatalf("list bundled skills: %v", err) + } + + items, err := PreviewSelectedSkills( + ResolverOptions{CWD: t.TempDir(), HomeDir: t.TempDir()}, + skills, + []string{"codex", "opencode"}, + false, + InstallModeCopy, + ) + if err != nil { + t.Fatalf("preview selected skills: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected one physical target, got %#v", items) + } + if got := strings.Join(items[0].AgentNames, ","); got != "codex,opencode" { + t.Fatalf("shared target agents = %q, want codex,opencode", got) + } +} diff --git a/internal/setup/cleanup.go b/internal/setup/cleanup.go new file mode 100644 index 00000000..6c254ca2 --- /dev/null +++ b/internal/setup/cleanup.go @@ -0,0 +1,613 @@ +package setup + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" +) + +// CleanupConfig describes one conservative setup cleanup inspection. +type CleanupConfig struct { + ResolverOptions + Skills []Skill + KeepAgentNames []string + Global bool + DryRun bool +} + +// AgentSkillDirectory identifies one unique physical skill directory and the +// supported agents that share it. +type AgentSkillDirectory struct { + Path string + AgentNames []string +} + +// CleanupCandidate is one verified Productize-managed path safe to remove. +type CleanupCandidate struct { + Path string `json:"path"` + Skill string `json:"skill"` + AgentNames []string `json:"agent_names"` + Mode InstallMode `json:"mode"` + Ownership string `json:"ownership"` +} + +// CleanupSkipped is one path preserved because ownership or content could not +// be proven safely. +type CleanupSkipped struct { + Code string `json:"code"` + Path string `json:"path"` + Skill string `json:"skill,omitempty"` + AgentNames []string `json:"agent_names"` + Message string `json:"message"` + Remediation string `json:"remediation"` +} + +// CleanupFailure records an operational removal failure. +type CleanupFailure struct { + Path string `json:"path"` + Message string `json:"message"` +} + +// CleanupPlan is the deterministic, read-only result of cleanup inspection. +type CleanupPlan struct { + Scope InstallScope + KeepAgents []Agent + RetainedDirectories []string + RemovalCandidates []CleanupCandidate + Skipped []CleanupSkipped + NeedsReview bool + HasManagedTargets bool + DryRun bool + manifest OwnershipManifest + manifestPath string + nonRetainedRootPaths []string +} + +// CleanupApplyResult reports paths removed during cleanup application. +type CleanupApplyResult struct { + Removed []CleanupCandidate + Failed []CleanupFailure +} + +type cleanupPlanningInputs struct { + keepAgents []Agent + directories []AgentSkillDirectory + retained map[string]struct{} + canonicalRoot string + manifest OwnershipManifest + manifestPath string + base string + skillsByDir map[string]Skill +} + +// ResolveAgentSkillDirectories resolves and deduplicates physical skill roots. +func ResolveAgentSkillDirectories( + options ResolverOptions, + agentNames []string, + global bool, +) ([]AgentSkillDirectory, error) { + all, err := SupportedAgents(options) + if err != nil { + return nil, err + } + selected, err := SelectAgents(all, agentNames) + if err != nil { + return nil, err + } + env, err := resolveEnvironment(options) + if err != nil { + return nil, err + } + directories := make([]AgentSkillDirectory, 0, len(selected)) + indexes := make(map[string]int, len(selected)) + for i := range selected { + root, err := resolveAgentSkillRoot(selected[i], env, global) + if err != nil { + return nil, err + } + key := filepath.Clean(root) + if index, ok := indexes[key]; ok { + directories[index].AgentNames = append(directories[index].AgentNames, selected[i].Name) + continue + } + indexes[key] = len(directories) + directories = append(directories, AgentSkillDirectory{ + Path: key, + AgentNames: []string{selected[i].Name}, + }) + } + slices.SortFunc(directories, func(left, right AgentSkillDirectory) int { + return strings.Compare(left.Path, right.Path) + }) + return directories, nil +} + +// PlanCleanup inventories non-retained setup targets without mutating them. +func PlanCleanup(cfg CleanupConfig) (CleanupPlan, error) { + if len(cfg.KeepAgentNames) == 0 { + return CleanupPlan{}, errors.New("plan setup cleanup: keep at least one agent") + } + inputs, err := resolveCleanupPlanningInputs(cfg) + if err != nil { + return CleanupPlan{}, err + } + plan := CleanupPlan{ + Scope: installScopeForGlobal(cfg.Global), + KeepAgents: append([]Agent(nil), inputs.keepAgents...), + DryRun: cfg.DryRun, + manifest: inputs.manifest, + manifestPath: inputs.manifestPath, + RemovalCandidates: []CleanupCandidate{}, + Skipped: []CleanupSkipped{}, + } + for path := range inputs.retained { + plan.RetainedDirectories = append(plan.RetainedDirectories, path) + } + slices.Sort(plan.RetainedDirectories) + + for i := range inputs.directories { + directory := inputs.directories[i] + if _, ok := inputs.retained[filepath.Clean(directory.Path)]; ok { + continue + } + plan.nonRetainedRootPaths = append(plan.nonRetainedRootPaths, directory.Path) + if err := scanCleanupDirectory(&plan, inputs, directory); err != nil { + return CleanupPlan{}, err + } + } + sortCleanupPlan(&plan) + return plan, nil +} + +func resolveCleanupPlanningInputs(cfg CleanupConfig) (cleanupPlanningInputs, error) { + allAgents, err := SupportedAgents(cfg.ResolverOptions) + if err != nil { + return cleanupPlanningInputs{}, err + } + keepAgents, err := SelectAgents(allAgents, cfg.KeepAgentNames) + if err != nil { + return cleanupPlanningInputs{}, err + } + allNames := make([]string, 0, len(allAgents)) + for i := range allAgents { + allNames = append(allNames, allAgents[i].Name) + } + directories, err := ResolveAgentSkillDirectories(cfg.ResolverOptions, allNames, cfg.Global) + if err != nil { + return cleanupPlanningInputs{}, err + } + keptNames := make([]string, 0, len(keepAgents)) + for i := range keepAgents { + keptNames = append(keptNames, keepAgents[i].Name) + } + keptDirectories, err := ResolveAgentSkillDirectories(cfg.ResolverOptions, keptNames, cfg.Global) + if err != nil { + return cleanupPlanningInputs{}, err + } + retained := make(map[string]struct{}, len(keptDirectories)+1) + for i := range keptDirectories { + retained[filepath.Clean(keptDirectories[i].Path)] = struct{}{} + } + env, err := resolveEnvironment(cfg.ResolverOptions) + if err != nil { + return cleanupPlanningInputs{}, err + } + canonicalRoot := canonicalSkillsRoot(env, cfg.Global) + if retainedTargetsUseCanonicalRoot(keptDirectories, canonicalRoot) { + retained[filepath.Clean(canonicalRoot)] = struct{}{} + } + + manifest, err := ReadOwnershipManifest(cfg.ResolverOptions, cfg.Global) + if err != nil { + return cleanupPlanningInputs{}, err + } + manifestPath, err := OwnershipManifestPath(cfg.ResolverOptions, cfg.Global) + if err != nil { + return cleanupPlanningInputs{}, err + } + base := env.cwd + if cfg.Global { + base = env.homeDir + } + skillsByDir := make(map[string]Skill, len(cfg.Skills)) + for i := range cfg.Skills { + skillsByDir[sanitizeName(cfg.Skills[i].Name)] = cfg.Skills[i] + } + return cleanupPlanningInputs{ + keepAgents: keepAgents, + directories: directories, + retained: retained, + canonicalRoot: canonicalRoot, + manifest: manifest, + manifestPath: manifestPath, + base: base, + skillsByDir: skillsByDir, + }, nil +} + +func scanCleanupDirectory( + plan *CleanupPlan, + inputs cleanupPlanningInputs, + directory AgentSkillDirectory, +) error { + entries, err := os.ReadDir(directory.Path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("read setup cleanup target %q: %w", directory.Path, err) + } + for _, entry := range entries { + path := filepath.Join(directory.Path, entry.Name()) + owned := ownershipEntriesForPath(inputs.manifest.Entries, plan.Scope, inputs.base, path) + candidate, skipped, classifyErr := classifyCleanupPath( + path, + entry.Name(), + directory.AgentNames, + owned, + inputs.skillsByDir, + inputs.canonicalRoot, + ) + if classifyErr != nil { + return classifyErr + } + appendCleanupClassification(plan, candidate, skipped) + } + return nil +} + +func appendCleanupClassification( + plan *CleanupPlan, + candidate *CleanupCandidate, + skipped *CleanupSkipped, +) { + if candidate != nil { + plan.RemovalCandidates = append(plan.RemovalCandidates, *candidate) + plan.HasManagedTargets = true + } + if skipped == nil { + return + } + plan.Skipped = append(plan.Skipped, *skipped) + plan.NeedsReview = true + if skipped.Code != "unknown_content" { + plan.HasManagedTargets = true + } +} + +// ApplyCleanup removes only candidates proven safe by PlanCleanup. +func ApplyCleanup(ctx context.Context, plan CleanupPlan) (CleanupApplyResult, error) { + result := CleanupApplyResult{Removed: []CleanupCandidate{}, Failed: []CleanupFailure{}} + if plan.DryRun { + return result, nil + } + removedPaths := make(map[string]struct{}, len(plan.RemovalCandidates)) + for i := range plan.RemovalCandidates { + if err := ctx.Err(); err != nil { + return result, fmt.Errorf("apply setup cleanup: %w", err) + } + candidate := plan.RemovalCandidates[i] + if err := removeCleanupCandidate(candidate.Path); err != nil { + result.Failed = append(result.Failed, CleanupFailure{Path: candidate.Path, Message: err.Error()}) + continue + } + removedPaths[filepath.Clean(candidate.Path)] = struct{}{} + result.Removed = append(result.Removed, candidate) + } + for _, root := range plan.nonRetainedRootPaths { + if err := removeEmptyDirectory(root); err != nil { + result.Failed = append(result.Failed, CleanupFailure{Path: root, Message: err.Error()}) + } + } + if len(removedPaths) > 0 { + manifest := plan.manifest + manifest.Entries = filterRemovedOwnershipEntries(manifest.Entries, plan.Scope, plan.manifestPath, removedPaths) + if err := writeOwnershipManifest(plan.manifestPath, manifest); err != nil { + result.Failed = append(result.Failed, CleanupFailure{Path: plan.manifestPath, Message: err.Error()}) + } + } + if len(result.Failed) > 0 { + errs := make([]error, 0, len(result.Failed)) + for i := range result.Failed { + errs = append(errs, fmt.Errorf("%s: %s", result.Failed[i].Path, result.Failed[i].Message)) + } + return result, errors.Join(errs...) + } + return result, nil +} + +func resolveAgentSkillRoot(agent Agent, env resolvedEnvironment, global bool) (string, error) { + if agent.Universal { + return canonicalSkillsRoot(env, global), nil + } + root := agent.ProjectRootDir + base := env.cwd + if global { + root = agent.GlobalRootDir + base = env.homeDir + } + if strings.TrimSpace(root) == "" { + return "", fmt.Errorf("agent %q does not support %s setup", agent.Name, installScopeForGlobal(global)) + } + if !filepath.IsAbs(root) { + root = filepath.Join(base, root) + } + return filepath.Clean(root), nil +} + +func retainedTargetsUseCanonicalRoot(directories []AgentSkillDirectory, canonicalRoot string) bool { + for i := range directories { + entries, err := os.ReadDir(directories[i].Path) + if err != nil { + continue + } + for _, entry := range entries { + path := filepath.Join(directories[i].Path, entry.Name()) + if symlinkPointsTo(path, filepath.Join(canonicalRoot, entry.Name())) { + return true + } + } + } + return false +} + +func ownershipEntriesForPath( + entries []OwnershipEntry, + scope InstallScope, + base string, + path string, +) []OwnershipEntry { + matched := make([]OwnershipEntry, 0) + for i := range entries { + if entries[i].Scope != scope { + continue + } + resolved := filepath.Clean(filepath.Join(base, filepath.FromSlash(entries[i].Target))) + if samePath(resolved, path) { + matched = append(matched, entries[i]) + } + } + return matched +} + +func classifyCleanupPath( + path string, + directoryName string, + agentNames []string, + owned []OwnershipEntry, + skillsByDir map[string]Skill, + canonicalRoot string, +) (*CleanupCandidate, *CleanupSkipped, error) { + if len(owned) > 0 { + return classifyOwnedCleanupPath(path, agentNames, owned, canonicalRoot) + } + return classifyLegacyCleanupPath(path, directoryName, agentNames, skillsByDir, canonicalRoot) +} + +func classifyOwnedCleanupPath( + path string, + agentNames []string, + owned []OwnershipEntry, + canonicalRoot string, +) (*CleanupCandidate, *CleanupSkipped, error) { + skillName := owned[0].Skill + for i := 1; i < len(owned); i++ { + if owned[i].Skill != skillName { + return nil, preservedCleanupPath( + "ambiguous_ownership", + path, + "", + agentNames, + "Ownership metadata maps this path to multiple skills", + ), nil + } + } + expectedCanonical := filepath.Join(canonicalRoot, sanitizeName(skillName)) + if symlinkPointsTo(path, expectedCanonical) { + return cleanupCandidate(path, skillName, agentNames, InstallModeSymlink, "manifest"), nil, nil + } + matches, err := matchesOwnedCleanupChecksum(path, owned) + if err != nil { + return nil, nil, err + } + if matches { + return cleanupCandidate(path, skillName, agentNames, InstallModeCopy, "manifest"), nil, nil + } + return nil, preservedCleanupPath( + "managed_content_drifted", + path, + skillName, + agentNames, + "Productize ownership exists, but the installed content no longer matches its recorded checksum", + ), nil +} + +func matchesOwnedCleanupChecksum(path string, owned []OwnershipEntry) (bool, error) { + info, err := os.Lstat(path) + if err != nil { + return false, fmt.Errorf("inspect managed cleanup path %q: %w", path, err) + } + if !info.IsDir() { + return false, nil + } + checksum, err := checksumInstalledDirectory(path) + if err != nil { + return false, fmt.Errorf("checksum managed cleanup path %q: %w", path, err) + } + for i := range owned { + if owned[i].Checksum != "" && owned[i].Checksum == checksum { + return true, nil + } + } + return false, nil +} + +func classifyLegacyCleanupPath( + path string, + directoryName string, + agentNames []string, + skillsByDir map[string]Skill, + canonicalRoot string, +) (*CleanupCandidate, *CleanupSkipped, error) { + skill, known := skillsByDir[directoryName] + if !known { + return nil, preservedCleanupPath( + "unknown_content", + path, + "", + agentNames, + "This entry is not in the current Productize skill catalog", + ), nil + } + expectedCanonical := filepath.Join(canonicalRoot, sanitizeName(skill.Name)) + if symlinkPointsTo(path, expectedCanonical) { + return cleanupCandidate(path, skill.Name, agentNames, InstallModeSymlink, "legacy"), nil, nil + } + info, err := os.Lstat(path) + if err != nil { + return nil, nil, fmt.Errorf("inspect legacy cleanup path %q: %w", path, err) + } + if info.IsDir() { + sourceFS, sourceErr := resolveSkillSource(skill) + if sourceErr != nil { + return nil, nil, sourceErr + } + sourceDir := skill.SourceDir + if strings.TrimSpace(sourceDir) == "" { + sourceDir = skill.Directory + } + _, drifted, compareErr := compareInstalledDirectory(sourceFS, sourceDir, path, "skill") + if compareErr != nil { + return nil, nil, fmt.Errorf("compare legacy cleanup path %q: %w", path, compareErr) + } + if !drifted { + return cleanupCandidate(path, skill.Name, agentNames, InstallModeCopy, "legacy"), nil, nil + } + } + return nil, preservedCleanupPath( + "legacy_content_drifted", + path, + skill.Name, + agentNames, + "The path resembles a Productize skill but is not byte-identical to the current source", + ), nil +} + +func cleanupCandidate( + path, skill string, + agentNames []string, + mode InstallMode, + ownership string, +) *CleanupCandidate { + return &CleanupCandidate{ + Path: path, + Skill: skill, + AgentNames: append([]string(nil), agentNames...), + Mode: mode, + Ownership: ownership, + } +} + +func preservedCleanupPath( + code, path, skill string, + agentNames []string, + message string, +) *CleanupSkipped { + return &CleanupSkipped{ + Code: code, + Path: path, + Skill: skill, + AgentNames: append([]string(nil), agentNames...), + Message: message, + Remediation: "Review this path manually; Productize did not delete it", + } +} + +func symlinkPointsTo(path, expectedTarget string) bool { + info, err := os.Lstat(path) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + return false + } + target, err := os.Readlink(path) + if err != nil { + return false + } + if !filepath.IsAbs(target) { + target = filepath.Join(filepath.Dir(path), target) + } + return samePath(target, expectedTarget) +} + +func sortCleanupPlan(plan *CleanupPlan) { + slices.SortFunc(plan.RemovalCandidates, func(left, right CleanupCandidate) int { + return strings.Compare(left.Path, right.Path) + }) + slices.SortFunc(plan.Skipped, func(left, right CleanupSkipped) int { + return strings.Compare(left.Path+"\x00"+left.Code, right.Path+"\x00"+right.Code) + }) + slices.Sort(plan.nonRetainedRootPaths) +} + +func removeCleanupCandidate(path string) error { + info, err := os.Lstat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("inspect cleanup candidate: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 { + if err := os.Remove(path); err != nil { + return fmt.Errorf("remove managed symlink: %w", err) + } + return nil + } + if !info.IsDir() { + return errors.New("refusing to remove a non-directory managed path") + } + if err := os.RemoveAll(path); err != nil { + return fmt.Errorf("remove managed skill directory: %w", err) + } + return nil +} + +func removeEmptyDirectory(path string) error { + entries, err := os.ReadDir(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("inspect configured agent directory: %w", err) + } + if len(entries) != 0 { + return nil + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove empty configured agent directory: %w", err) + } + return nil +} + +func filterRemovedOwnershipEntries( + entries []OwnershipEntry, + scope InstallScope, + manifestPath string, + removed map[string]struct{}, +) []OwnershipEntry { + base := filepath.Dir(filepath.Dir(filepath.Dir(manifestPath))) + kept := make([]OwnershipEntry, 0, len(entries)) + for i := range entries { + if entries[i].Scope == scope { + path := filepath.Clean(filepath.Join(base, filepath.FromSlash(entries[i].Target))) + if _, ok := removed[path]; ok { + continue + } + } + kept = append(kept, entries[i]) + } + return kept +} diff --git a/internal/setup/install.go b/internal/setup/install.go index 667ca237..720fd784 100644 --- a/internal/setup/install.go +++ b/internal/setup/install.go @@ -61,6 +61,7 @@ func Preview(cfg InstallConfig) ([]PreviewItem, error) { } items := make([]PreviewItem, 0, len(selectedSkills)*len(selectedAgents)) + itemIndexes := make(map[string]int, len(selectedSkills)*len(selectedAgents)) for i := range selectedSkills { for j := range selectedAgents { canonicalPath, targetPath, err := resolveInstallPaths( @@ -78,9 +79,10 @@ func Preview(cfg InstallConfig) ([]PreviewItem, error) { willOverwrite = true } - items = append(items, PreviewItem{ + items = appendDeduplicatedPreview(items, itemIndexes, PreviewItem{ Skill: selectedSkills[i], Agent: selectedAgents[j], + AgentNames: []string{selectedAgents[j].Name}, CanonicalPath: canonicalPath, TargetPath: targetPath, WillOverwrite: willOverwrite, @@ -91,6 +93,20 @@ func Preview(cfg InstallConfig) ([]PreviewItem, error) { return items, nil } +func appendDeduplicatedPreview( + items []PreviewItem, + indexes map[string]int, + item PreviewItem, +) []PreviewItem { + key := item.Skill.Name + "\x00" + filepath.Clean(item.TargetPath) + if index, ok := indexes[key]; ok { + items[index].AgentNames = append(items[index].AgentNames, item.Agent.Name) + return items + } + indexes[key] = len(items) + return append(items, item) +} + // Install materializes bundled skills for the selected agents. func Install(cfg InstallConfig) (*Result, error) { if cfg.Bundle == nil { @@ -132,10 +148,11 @@ func installPreviewItem(bundle fs.FS, item *PreviewItem, mode InstallMode) (*Suc return nil, newFailure(item, mode, item.TargetPath, err) } return &SuccessItem{ - Skill: item.Skill, - Agent: item.Agent, - Path: item.TargetPath, - Mode: mode, + Skill: item.Skill, + Agent: item.Agent, + Path: item.TargetPath, + TargetPath: item.TargetPath, + Mode: mode, }, nil case InstallModeSymlink: if err := cleanAndCreateDirectory(item.CanonicalPath); err != nil { @@ -160,6 +177,7 @@ func installPreviewItem(bundle fs.FS, item *PreviewItem, mode InstallMode) (*Suc Skill: item.Skill, Agent: item.Agent, Path: item.TargetPath, + TargetPath: item.TargetPath, CanonicalPath: item.CanonicalPath, Mode: mode, SymlinkFailed: true, @@ -174,6 +192,7 @@ func installPreviewItem(bundle fs.FS, item *PreviewItem, mode InstallMode) (*Suc Skill: item.Skill, Agent: item.Agent, Path: path, + TargetPath: item.TargetPath, CanonicalPath: item.CanonicalPath, Mode: mode, }, nil diff --git a/internal/setup/ownership.go b/internal/setup/ownership.go new file mode 100644 index 00000000..30c9ac3e --- /dev/null +++ b/internal/setup/ownership.go @@ -0,0 +1,255 @@ +package setup + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" +) + +const ownershipManifestSchemaVersion = 1 + +// OwnershipManifest records Productize-managed skill installations without +// acting as a source of skill content. +type OwnershipManifest struct { + SchemaVersion int `json:"schema_version"` + Entries []OwnershipEntry `json:"entries"` +} + +// OwnershipEntry identifies one setup-managed agent/skill mapping. +type OwnershipEntry struct { + Scope InstallScope `json:"scope"` + Agent string `json:"agent"` + Skill string `json:"skill"` + Target string `json:"target"` + Mode InstallMode `json:"mode"` + Checksum string `json:"installed_content_checksum"` +} + +// OwnershipManifestPath returns the setup ownership manifest for one scope. +func OwnershipManifestPath(options ResolverOptions, global bool) (string, error) { + env, err := resolveEnvironment(options) + if err != nil { + return "", err + } + base := env.cwd + if global { + base = env.homeDir + } + return filepath.Join(base, ".productize", "catalog", "setup-ownership.json"), nil +} + +// ReadOwnershipManifest loads setup ownership metadata. A missing manifest is +// represented by an empty schema-versioned manifest. +func ReadOwnershipManifest(options ResolverOptions, global bool) (OwnershipManifest, error) { + path, err := OwnershipManifestPath(options, global) + if err != nil { + return OwnershipManifest{}, err + } + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return OwnershipManifest{SchemaVersion: ownershipManifestSchemaVersion, Entries: []OwnershipEntry{}}, nil + } + return OwnershipManifest{}, fmt.Errorf("read setup ownership manifest: %w", err) + } + var manifest OwnershipManifest + if err := json.Unmarshal(data, &manifest); err != nil { + return OwnershipManifest{}, fmt.Errorf("decode setup ownership manifest: %w", err) + } + if manifest.SchemaVersion != ownershipManifestSchemaVersion { + return OwnershipManifest{}, fmt.Errorf( + "unsupported setup ownership manifest schema %d", + manifest.SchemaVersion, + ) + } + if manifest.Entries == nil { + manifest.Entries = []OwnershipEntry{} + } + sortOwnershipEntries(manifest.Entries) + return manifest, nil +} + +// RecordSkillOwnership records successful skill materializations for every +// agent represented by each deduplicated physical preview. +func RecordSkillOwnership( + options ResolverOptions, + global bool, + requestedMode InstallMode, + previews []PreviewItem, + successes []SuccessItem, +) error { + manifest, err := ReadOwnershipManifest(options, global) + if err != nil { + return err + } + env, err := resolveEnvironment(options) + if err != nil { + return err + } + base := env.cwd + if global { + base = env.homeDir + } + successByTarget := make(map[string]SuccessItem, len(successes)) + for i := range successes { + target := successes[i].TargetPath + if strings.TrimSpace(target) == "" { + target = successes[i].Path + } + successByTarget[ownershipTargetKey(successes[i].Skill.Name, target)] = successes[i] + } + + entries := append([]OwnershipEntry(nil), manifest.Entries...) + for i := range previews { + preview := previews[i] + success, ok := successByTarget[ownershipTargetKey(preview.Skill.Name, preview.TargetPath)] + if !ok { + continue + } + checksum, err := checksumInstalledDirectory(preview.TargetPath) + if err != nil { + return fmt.Errorf("checksum installed skill %q: %w", preview.Skill.Name, err) + } + target, err := filepath.Rel(base, preview.TargetPath) + if err != nil { + return fmt.Errorf("make ownership target relative: %w", err) + } + mode := requestedMode + if success.SymlinkFailed || samePath(preview.CanonicalPath, preview.TargetPath) { + mode = InstallModeCopy + } + agents := append([]string(nil), preview.AgentNames...) + if len(agents) == 0 { + agents = []string{preview.Agent.Name} + } + for _, agentName := range agents { + entries = upsertOwnershipEntry(entries, OwnershipEntry{ + Scope: installScopeForGlobal(global), + Agent: agentName, + Skill: preview.Skill.Name, + Target: filepath.ToSlash(target), + Mode: mode, + Checksum: checksum, + }) + } + } + manifest.Entries = entries + path, err := OwnershipManifestPath(options, global) + if err != nil { + return err + } + return writeOwnershipManifest(path, manifest) +} + +func upsertOwnershipEntry(entries []OwnershipEntry, desired OwnershipEntry) []OwnershipEntry { + key := ownershipEntryKey(desired) + for i := range entries { + if ownershipEntryKey(entries[i]) == key { + entries[i] = desired + sortOwnershipEntries(entries) + return entries + } + } + entries = append(entries, desired) + sortOwnershipEntries(entries) + return entries +} + +func ownershipEntryKey(entry OwnershipEntry) string { + return strings.Join( + []string{string(entry.Scope), entry.Agent, entry.Skill, entry.Target}, + "\x00", + ) +} + +func ownershipTargetKey(skill, target string) string { + return skill + "\x00" + filepath.Clean(target) +} + +func sortOwnershipEntries(entries []OwnershipEntry) { + slices.SortFunc(entries, func(left, right OwnershipEntry) int { + return strings.Compare(ownershipEntryKey(left), ownershipEntryKey(right)) + }) +} + +func writeOwnershipManifest(path string, manifest OwnershipManifest) error { + manifest.SchemaVersion = ownershipManifestSchemaVersion + sortOwnershipEntries(manifest.Entries) + data, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return fmt.Errorf("encode setup ownership manifest: %w", err) + } + data = append(data, '\n') + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create setup ownership directory: %w", err) + } + temp, err := os.CreateTemp(dir, ".setup-ownership-*.tmp") + if err != nil { + return fmt.Errorf("create setup ownership temporary file: %w", err) + } + tempPath := temp.Name() + defer func() { + _ = os.Remove(tempPath) + }() + if err := temp.Chmod(0o600); err != nil { + _ = temp.Close() + return fmt.Errorf("set setup ownership permissions: %w", err) + } + if _, err := temp.Write(data); err != nil { + _ = temp.Close() + return fmt.Errorf("write setup ownership temporary file: %w", err) + } + if err := temp.Sync(); err != nil { + _ = temp.Close() + return fmt.Errorf("sync setup ownership temporary file: %w", err) + } + if err := temp.Close(); err != nil { + return fmt.Errorf("close setup ownership temporary file: %w", err) + } + if err := os.Rename(tempPath, path); err != nil { + return fmt.Errorf("replace setup ownership manifest: %w", err) + } + return nil +} + +func checksumInstalledDirectory(path string) (string, error) { + files, err := snapshotInstalledFiles(resolveInstalledPath(path), "skill") + if err != nil { + return "", err + } + return checksumFileSnapshot(files), nil +} + +func checksumFileSnapshot(files map[string][]byte) string { + paths := make([]string, 0, len(files)) + for path := range files { + paths = append(paths, path) + } + slices.Sort(paths) + hash := sha256.New() + var size [8]byte + for _, path := range paths { + binary.BigEndian.PutUint64(size[:], uint64(len(path))) + _, _ = hash.Write(size[:]) + _, _ = hash.Write([]byte(path)) + binary.BigEndian.PutUint64(size[:], uint64(len(files[path]))) + _, _ = hash.Write(size[:]) + _, _ = hash.Write(files[path]) + } + return hex.EncodeToString(hash.Sum(nil)) +} + +func installScopeForGlobal(global bool) InstallScope { + if global { + return InstallScopeGlobal + } + return InstallScopeProject +} diff --git a/internal/setup/ownership_cleanup_test.go b/internal/setup/ownership_cleanup_test.go new file mode 100644 index 00000000..b9156c1e --- /dev/null +++ b/internal/setup/ownership_cleanup_test.go @@ -0,0 +1,484 @@ +package setup + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestInstallSelectedSkillsRecordsDeduplicatedOwnership(t *testing.T) { + t.Parallel() + + bundle := newTestBundle(t, map[string]string{ + "productize/SKILL.md": "---\nname: productize\ndescription: Core workflow\n---\n", + }) + skills, err := ListSkills(bundle) + if err != nil { + t.Fatalf("list skills: %v", err) + } + projectDir := t.TempDir() + options := ResolverOptions{CWD: projectDir, HomeDir: t.TempDir()} + + successes, failures, err := InstallSelectedSkills( + options, + skills, + []string{"codex", "opencode"}, + false, + InstallModeCopy, + ) + if err != nil { + t.Fatalf("install selected skills: %v", err) + } + if len(failures) != 0 || len(successes) != 1 { + t.Fatalf("unexpected install results: successes=%#v failures=%#v", successes, failures) + } + + manifestPath, err := OwnershipManifestPath(options, false) + if err != nil { + t.Fatalf("resolve ownership manifest path: %v", err) + } + data, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatalf("read ownership manifest: %v", err) + } + var manifest OwnershipManifest + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatalf("decode ownership manifest: %v", err) + } + if manifest.SchemaVersion != 1 || len(manifest.Entries) != 2 { + t.Fatalf("unexpected ownership manifest: %#v", manifest) + } + if manifest.Entries[0].Agent != "codex" || manifest.Entries[1].Agent != "opencode" { + t.Fatalf("unexpected ownership agents: %#v", manifest.Entries) + } + for _, entry := range manifest.Entries { + if entry.Target != ".agents/skills/productize" || entry.Checksum == "" { + t.Fatalf("unexpected ownership entry: %#v", entry) + } + } +} + +func TestCleanupRemovesManagedTargetsAndPreservesRetainedAgent(t *testing.T) { + t.Parallel() + + bundle := newTestBundle(t, map[string]string{ + "productize/SKILL.md": "---\nname: productize\ndescription: Core workflow\n---\n", + }) + skills, err := ListSkills(bundle) + if err != nil { + t.Fatalf("list skills: %v", err) + } + projectDir := t.TempDir() + options := ResolverOptions{CWD: projectDir, HomeDir: t.TempDir()} + for _, agentName := range []string{"codex", "claude-code"} { + _, failures, installErr := InstallSelectedSkills( + options, + skills, + []string{agentName}, + false, + InstallModeCopy, + ) + if installErr != nil || len(failures) != 0 { + t.Fatalf("install %s: err=%v failures=%#v", agentName, installErr, failures) + } + } + + plan, err := PlanCleanup(CleanupConfig{ + ResolverOptions: options, + Skills: skills, + KeepAgentNames: []string{"claude-code"}, + }) + if err != nil { + t.Fatalf("plan cleanup: %v", err) + } + if len(plan.RemovalCandidates) != 1 || !strings.Contains(plan.RemovalCandidates[0].Path, ".agents/skills") { + t.Fatalf("unexpected cleanup candidates: %#v", plan.RemovalCandidates) + } + + result, err := ApplyCleanup(context.Background(), plan) + if err != nil { + t.Fatalf("apply cleanup: %v", err) + } + if len(result.Removed) != 1 { + t.Fatalf("unexpected removed targets: %#v", result.Removed) + } + assertPathMissing(t, filepath.Join(projectDir, ".agents", "skills", "productize")) + assertPathMissing(t, filepath.Join(projectDir, ".agents", "skills")) + assertFileExists(t, filepath.Join(projectDir, ".claude", "skills", "productize", "SKILL.md")) + + rerun, err := PlanCleanup(CleanupConfig{ + ResolverOptions: options, + Skills: skills, + KeepAgentNames: []string{"claude-code"}, + }) + if err != nil { + t.Fatalf("plan idempotent cleanup rerun: %v", err) + } + if len(rerun.RemovalCandidates) != 0 || rerun.HasManagedTargets { + t.Fatalf("cleanup rerun was not idempotent: %#v", rerun) + } +} + +func TestCleanupRetainsSharedUniversalRoot(t *testing.T) { + t.Parallel() + + bundle := newTestBundle(t, map[string]string{ + "productize/SKILL.md": "---\nname: productize\ndescription: Core workflow\n---\n", + }) + skills, err := ListSkills(bundle) + if err != nil { + t.Fatalf("list skills: %v", err) + } + projectDir := t.TempDir() + options := ResolverOptions{CWD: projectDir, HomeDir: t.TempDir()} + _, failures, err := InstallSelectedSkills(options, skills, []string{"codex"}, false, InstallModeCopy) + if err != nil || len(failures) != 0 { + t.Fatalf("install codex: err=%v failures=%#v", err, failures) + } + + plan, err := PlanCleanup(CleanupConfig{ + ResolverOptions: options, + Skills: skills, + KeepAgentNames: []string{"opencode"}, + }) + if err != nil { + t.Fatalf("plan shared-root cleanup: %v", err) + } + if len(plan.RemovalCandidates) != 0 || len(plan.RetainedDirectories) != 1 || + plan.RetainedDirectories[0] != filepath.Join(projectDir, ".agents", "skills") { + t.Fatalf("shared universal root was not retained: %#v", plan) + } +} + +func TestCleanupRemovesManagedSymlinkWithoutRemovingRetainedCanonicalRoot(t *testing.T) { + t.Parallel() + + bundle := newTestBundle(t, map[string]string{ + "productize/SKILL.md": "---\nname: productize\ndescription: Core workflow\n---\n", + }) + skills, err := ListSkills(bundle) + if err != nil { + t.Fatalf("list skills: %v", err) + } + projectDir := t.TempDir() + options := ResolverOptions{CWD: projectDir, HomeDir: t.TempDir()} + _, failures, err := InstallSelectedSkills( + options, + skills, + []string{"codex", "claude-code"}, + false, + InstallModeSymlink, + ) + if err != nil || len(failures) != 0 { + t.Fatalf("install symlink fixture: err=%v failures=%#v", err, failures) + } + claudePath := filepath.Join(projectDir, ".claude", "skills", "productize") + info, err := os.Lstat(claudePath) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("expected managed Claude symlink: info=%v err=%v", info, err) + } + + plan, err := PlanCleanup(CleanupConfig{ + ResolverOptions: options, + Skills: skills, + KeepAgentNames: []string{"codex"}, + }) + if err != nil { + t.Fatalf("plan managed symlink cleanup: %v", err) + } + if len(plan.RemovalCandidates) != 1 || plan.RemovalCandidates[0].Path != claudePath || + plan.RemovalCandidates[0].Mode != InstallModeSymlink { + t.Fatalf("unexpected managed symlink candidates: %#v", plan.RemovalCandidates) + } + if _, err := ApplyCleanup(context.Background(), plan); err != nil { + t.Fatalf("apply managed symlink cleanup: %v", err) + } + assertPathMissing(t, claudePath) + assertFileExists(t, filepath.Join(projectDir, ".agents", "skills", "productize", "SKILL.md")) +} + +func TestCleanupRemovesLegacyExactCopyAndBrokenExpectedSymlink(t *testing.T) { + t.Parallel() + + bundle := newTestBundle(t, map[string]string{ + "productize/SKILL.md": "---\nname: productize\ndescription: Core workflow\n---\n", + }) + skills, err := ListSkills(bundle) + if err != nil { + t.Fatalf("list skills: %v", err) + } + projectDir := t.TempDir() + options := ResolverOptions{CWD: projectDir, HomeDir: t.TempDir()} + _, failures, err := InstallSelectedSkills(options, skills, []string{"codex"}, false, InstallModeCopy) + if err != nil || len(failures) != 0 { + t.Fatalf("install legacy fixture: err=%v failures=%#v", err, failures) + } + manifestPath, err := OwnershipManifestPath(options, false) + if err != nil { + t.Fatalf("resolve ownership manifest: %v", err) + } + if err := os.Remove(manifestPath); err != nil { + t.Fatalf("remove ownership manifest for legacy fixture: %v", err) + } + brokenLink := filepath.Join(projectDir, ".claude", "skills", "productize") + if err := os.MkdirAll(filepath.Dir(brokenLink), 0o755); err != nil { + t.Fatalf("create legacy symlink parent: %v", err) + } + expectedCanonical := filepath.Join(projectDir, ".agents", "skills", "productize") + if err := os.Symlink(expectedCanonical, brokenLink); err != nil { + t.Fatalf("create legacy managed symlink: %v", err) + } + if err := os.RemoveAll(expectedCanonical); err != nil { + t.Fatalf("break legacy managed symlink: %v", err) + } + + plan, err := PlanCleanup(CleanupConfig{ + ResolverOptions: options, + Skills: skills, + KeepAgentNames: []string{"droid"}, + }) + if err != nil { + t.Fatalf("plan legacy cleanup: %v", err) + } + if len(plan.RemovalCandidates) != 1 || plan.RemovalCandidates[0].Ownership != "legacy" || + plan.RemovalCandidates[0].Mode != InstallModeSymlink { + t.Fatalf("broken expected symlink was not a safe legacy candidate: %#v", plan.RemovalCandidates) + } + if _, err := ApplyCleanup(context.Background(), plan); err != nil { + t.Fatalf("apply legacy cleanup: %v", err) + } + assertPathMissing(t, brokenLink) +} + +func TestCleanupRemovesLegacyByteIdenticalCopy(t *testing.T) { + t.Parallel() + + bundle := newTestBundle(t, map[string]string{ + "productize/SKILL.md": "---\nname: productize\ndescription: Core workflow\n---\n", + }) + skills, err := ListSkills(bundle) + if err != nil { + t.Fatalf("list skills: %v", err) + } + projectDir := t.TempDir() + options := ResolverOptions{CWD: projectDir, HomeDir: t.TempDir()} + _, failures, err := InstallSelectedSkills(options, skills, []string{"droid"}, false, InstallModeCopy) + if err != nil || len(failures) != 0 { + t.Fatalf("install legacy copy fixture: err=%v failures=%#v", err, failures) + } + manifestPath, err := OwnershipManifestPath(options, false) + if err != nil { + t.Fatalf("resolve ownership manifest: %v", err) + } + if err := os.Remove(manifestPath); err != nil { + t.Fatalf("remove ownership manifest for legacy fixture: %v", err) + } + + plan, err := PlanCleanup(CleanupConfig{ + ResolverOptions: options, + Skills: skills, + KeepAgentNames: []string{"claude-code"}, + }) + if err != nil { + t.Fatalf("plan legacy copy cleanup: %v", err) + } + if len(plan.RemovalCandidates) != 1 || plan.RemovalCandidates[0].Ownership != "legacy" || + plan.RemovalCandidates[0].Mode != InstallModeCopy { + t.Fatalf("byte-identical copy was not a safe legacy candidate: %#v", plan.RemovalCandidates) + } + if _, err := ApplyCleanup(context.Background(), plan); err != nil { + t.Fatalf("apply legacy copy cleanup: %v", err) + } + assertPathMissing(t, filepath.Join(projectDir, ".factory", "skills", "productize")) +} + +func TestCleanupGlobalScopeLeavesProjectInstallUntouched(t *testing.T) { + t.Parallel() + + bundle := newTestBundle(t, map[string]string{ + "productize/SKILL.md": "---\nname: productize\ndescription: Core workflow\n---\n", + }) + skills, err := ListSkills(bundle) + if err != nil { + t.Fatalf("list skills: %v", err) + } + projectDir := t.TempDir() + homeDir := t.TempDir() + options := ResolverOptions{CWD: projectDir, HomeDir: homeDir} + for _, global := range []bool{false, true} { + _, failures, installErr := InstallSelectedSkills( + options, + skills, + []string{"codex"}, + global, + InstallModeCopy, + ) + if installErr != nil || len(failures) != 0 { + t.Fatalf("install global=%t: err=%v failures=%#v", global, installErr, failures) + } + } + + plan, err := PlanCleanup(CleanupConfig{ + ResolverOptions: options, + Skills: skills, + KeepAgentNames: []string{"claude-code"}, + Global: true, + }) + if err != nil { + t.Fatalf("plan global cleanup: %v", err) + } + if _, err := ApplyCleanup(context.Background(), plan); err != nil { + t.Fatalf("apply global cleanup: %v", err) + } + assertPathMissing(t, filepath.Join(homeDir, ".agents", "skills", "productize")) + assertFileExists(t, filepath.Join(projectDir, ".agents", "skills", "productize", "SKILL.md")) +} + +func TestCleanupReportsPartialOperationalFailure(t *testing.T) { + t.Parallel() + + bundle := newTestBundle(t, map[string]string{ + "productize/SKILL.md": "---\nname: productize\ndescription: Core workflow\n---\n", + }) + skills, err := ListSkills(bundle) + if err != nil { + t.Fatalf("list skills: %v", err) + } + projectDir := t.TempDir() + options := ResolverOptions{CWD: projectDir, HomeDir: t.TempDir()} + for _, agentName := range []string{"codex", "droid"} { + _, failures, installErr := InstallSelectedSkills( + options, + skills, + []string{agentName}, + false, + InstallModeCopy, + ) + if installErr != nil || len(failures) != 0 { + t.Fatalf("install %s: err=%v failures=%#v", agentName, installErr, failures) + } + } + plan, err := PlanCleanup(CleanupConfig{ + ResolverOptions: options, + Skills: skills, + KeepAgentNames: []string{"claude-code"}, + }) + if err != nil { + t.Fatalf("plan partial cleanup: %v", err) + } + if len(plan.RemovalCandidates) != 2 { + t.Fatalf("expected two cleanup candidates, got %#v", plan.RemovalCandidates) + } + failingPath := plan.RemovalCandidates[0].Path + if err := os.RemoveAll(failingPath); err != nil { + t.Fatalf("replace cleanup candidate: %v", err) + } + if err := os.WriteFile(failingPath, []byte("user file\n"), 0o600); err != nil { + t.Fatalf("write replacement user file: %v", err) + } + + result, err := ApplyCleanup(context.Background(), plan) + if err == nil { + t.Fatal("expected partial cleanup failure") + } + if len(result.Removed) != 1 || len(result.Failed) != 1 { + t.Fatalf("unexpected partial cleanup result: %#v", result) + } + assertFileExists(t, failingPath) +} + +func TestCleanupPreservesDriftedAndUnknownContent(t *testing.T) { + t.Parallel() + + bundle := newTestBundle(t, map[string]string{ + "productize/SKILL.md": "---\nname: productize\ndescription: Core workflow\n---\n", + }) + skills, err := ListSkills(bundle) + if err != nil { + t.Fatalf("list skills: %v", err) + } + projectDir := t.TempDir() + options := ResolverOptions{CWD: projectDir, HomeDir: t.TempDir()} + _, failures, err := InstallSelectedSkills(options, skills, []string{"codex"}, false, InstallModeCopy) + if err != nil || len(failures) != 0 { + t.Fatalf("install codex: err=%v failures=%#v", err, failures) + } + driftedPath := filepath.Join(projectDir, ".agents", "skills", "productize", "SKILL.md") + if err := os.WriteFile(driftedPath, []byte("user changes\n"), 0o644); err != nil { + t.Fatalf("write drifted skill: %v", err) + } + unknownPath := filepath.Join(projectDir, ".agents", "skills", "personal-skill") + if err := os.MkdirAll(unknownPath, 0o755); err != nil { + t.Fatalf("create unknown skill: %v", err) + } + + plan, err := PlanCleanup(CleanupConfig{ + ResolverOptions: options, + Skills: skills, + KeepAgentNames: []string{"claude-code"}, + }) + if err != nil { + t.Fatalf("plan cleanup: %v", err) + } + if len(plan.RemovalCandidates) != 0 || len(plan.Skipped) != 2 || !plan.NeedsReview { + t.Fatalf("unexpected conservative cleanup plan: %#v", plan) + } + + result, err := ApplyCleanup(context.Background(), plan) + if err != nil { + t.Fatalf("apply cleanup: %v", err) + } + if len(result.Removed) != 0 { + t.Fatalf("cleanup removed preserved content: %#v", result.Removed) + } + assertFileExists(t, driftedPath) + if _, err := os.Stat(unknownPath); err != nil { + t.Fatalf("unknown skill was not preserved: %v", err) + } +} + +func TestCleanupDryRunIsPure(t *testing.T) { + t.Parallel() + + bundle := newTestBundle(t, map[string]string{ + "productize/SKILL.md": "---\nname: productize\ndescription: Core workflow\n---\n", + }) + skills, err := ListSkills(bundle) + if err != nil { + t.Fatalf("list skills: %v", err) + } + projectDir := t.TempDir() + options := ResolverOptions{CWD: projectDir, HomeDir: t.TempDir()} + _, failures, err := InstallSelectedSkills(options, skills, []string{"codex"}, false, InstallModeCopy) + if err != nil || len(failures) != 0 { + t.Fatalf("install codex: err=%v failures=%#v", err, failures) + } + + plan, err := PlanCleanup(CleanupConfig{ + ResolverOptions: options, + Skills: skills, + KeepAgentNames: []string{"claude-code"}, + DryRun: true, + }) + if err != nil { + t.Fatalf("plan cleanup: %v", err) + } + result, err := ApplyCleanup(context.Background(), plan) + if err != nil { + t.Fatalf("apply dry-run cleanup: %v", err) + } + if len(result.Removed) != 0 { + t.Fatalf("dry-run removed targets: %#v", result.Removed) + } + assertFileExists(t, filepath.Join(projectDir, ".agents", "skills", "productize", "SKILL.md")) +} + +func assertPathMissing(t *testing.T, path string) { + t.Helper() + if _, err := os.Lstat(path); !os.IsNotExist(err) { + t.Fatalf("expected %s to be missing, got %v", path, err) + } +} diff --git a/internal/setup/skills_selected.go b/internal/setup/skills_selected.go index ad426be1..ef86efac 100644 --- a/internal/setup/skills_selected.go +++ b/internal/setup/skills_selected.go @@ -24,6 +24,7 @@ func PreviewSelectedSkills( } items := make([]PreviewItem, 0, len(skills)*len(selectedAgents)) + itemIndexes := make(map[string]int, len(skills)*len(selectedAgents)) for i := range skills { for j := range selectedAgents { canonicalPath, targetPath, err := resolveInstallPaths(skills[i], selectedAgents[j], env, global) @@ -36,9 +37,10 @@ func PreviewSelectedSkills( willOverwrite = true } - items = append(items, PreviewItem{ + items = appendDeduplicatedPreview(items, itemIndexes, PreviewItem{ Skill: skills[i], Agent: selectedAgents[j], + AgentNames: []string{selectedAgents[j].Name}, CanonicalPath: canonicalPath, TargetPath: targetPath, WillOverwrite: willOverwrite, @@ -88,6 +90,11 @@ func InstallSelectedSkills( } successes = append(successes, *success) } + if len(successes) > 0 { + if err := RecordSkillOwnership(options, global, mode, previews, successes); err != nil { + return successes, failures, err + } + } return successes, failures, nil } diff --git a/internal/setup/types.go b/internal/setup/types.go index 15eaad6f..f18c44f8 100644 --- a/internal/setup/types.go +++ b/internal/setup/types.go @@ -119,6 +119,7 @@ type VerifyConfig struct { AgentName string SkillNames []string + ScopeHint InstallScope } // SkillDrift describes how an installed skill differs from the bundled version. @@ -197,6 +198,7 @@ func (r VerifyResult) HasDrift() bool { type PreviewItem struct { Skill Skill Agent Agent + AgentNames []string CanonicalPath string TargetPath string WillOverwrite bool @@ -222,6 +224,7 @@ type SuccessItem struct { Skill Skill Agent Agent Path string + TargetPath string CanonicalPath string Mode InstallMode SymlinkFailed bool diff --git a/internal/setup/verify.go b/internal/setup/verify.go index e83c6c3c..cb82c719 100644 --- a/internal/setup/verify.go +++ b/internal/setup/verify.go @@ -17,7 +17,7 @@ func Verify(cfg VerifyConfig) (VerifyResult, error) { return VerifyResult{}, err } - scope, entries := selectVerificationEntries(inputs.projectEntries, inputs.globalEntries) + scope, entries := selectVerificationEntries(inputs.projectEntries, inputs.globalEntries, cfg.ScopeHint) skills, err := verifyEntries(cfg.Bundle, scope, entries) if err != nil { return VerifyResult{}, err @@ -96,7 +96,14 @@ func loadVerificationInputs(cfg VerifyConfig) (verificationInputs, error) { func selectVerificationEntries( projectEntries []verificationEntry, globalEntries []verificationEntry, + scopeHint InstallScope, ) (InstallScope, []verificationEntry) { + switch scopeHint { + case InstallScopeProject: + return InstallScopeProject, projectEntries + case InstallScopeGlobal: + return InstallScopeGlobal, globalEntries + } switch { case hasAnyInstalledSkill(projectEntries): return InstallScopeProject, projectEntries diff --git a/internal/setup/verify_test.go b/internal/setup/verify_test.go index 6a6e51c4..c696a508 100644 --- a/internal/setup/verify_test.go +++ b/internal/setup/verify_test.go @@ -140,6 +140,50 @@ func TestVerifyFallsBackToGlobalScopeWhenProjectSkillsAreAbsent(t *testing.T) { } } +func TestVerifyScopeHintDoesNotLetProjectInstallSatisfyGlobalRequest(t *testing.T) { + t.Parallel() + + bundle := newTestBundle(t, map[string]string{ + "create-prd/SKILL.md": "---\nname: create-prd\ndescription: Create a PRD\n---\n", + }) + projectDir := t.TempDir() + homeDir := t.TempDir() + + _, err := Install(InstallConfig{ + Bundle: bundle, + ResolverOptions: ResolverOptions{ + CWD: projectDir, + HomeDir: homeDir, + }, + SkillNames: []string{"create-prd"}, + AgentNames: []string{"codex"}, + Mode: InstallModeCopy, + }) + if err != nil { + t.Fatalf("install project skills: %v", err) + } + + result, err := Verify(VerifyConfig{ + Bundle: bundle, + ResolverOptions: ResolverOptions{ + CWD: projectDir, + HomeDir: homeDir, + }, + AgentName: "codex", + SkillNames: []string{"create-prd"}, + ScopeHint: InstallScopeGlobal, + }) + if err != nil { + t.Fatalf("verify global scope: %v", err) + } + if result.Scope != InstallScopeGlobal { + t.Fatalf("expected global scope from hint, got %q", result.Scope) + } + if !result.HasMissing() { + t.Fatal("expected missing global skill despite current project install") + } +} + func TestVerifyPrefersProjectScopeOverGlobalWhenProjectInstallIsPartial(t *testing.T) { t.Parallel() diff --git a/skills/productize-runtime/SKILL.md b/skills/productize-runtime/SKILL.md index ced37069..bb029fcd 100644 --- a/skills/productize-runtime/SKILL.md +++ b/skills/productize-runtime/SKILL.md @@ -24,7 +24,7 @@ Key characteristics: The standard development pipeline follows these phases in order. Each phase produces artifacts consumed by the next. 1. **Existing Project Onboarding** -- `productize onboard existing --agent ` resolves the repository root, inventories existing code and documentation, generates project knowledge, installs Productize, and registers the workspace. -2. **Setup** (new repos or manual control) -- `productize setup` installs the skill catalog into explicitly selected target agents plus any setup assets shipped by enabled extensions. +2. **Setup** (new repos or manual control) -- interactive `productize setup` selects one agent target. Repeated `--agent` flags explicitly install into several targets; `--all-agents` is the only all-target shortcut. 3. **Ideation** (optional) -- install and enable the first-party `idea-forge` extension, run `productize setup`, then use `/idea-forge` to expand a raw idea into a structured, research-backed spec at `.productize/tasks//_idea.md`. 4. **Requirements** -- `/create-prd` creates a business-focused Product Requirements Document at `.productize/tasks//_prd.md` with ADRs. 5. **Technical Design** -- `/create-techspec` translates the PRD into a technical specification at `.productize/tasks//_techspec.md` with ADRs. @@ -71,7 +71,8 @@ For a detailed step-by-step walkthrough of each phase, read `references/workflow | **Setup & Config** | | | | `productize onboard existing` | Integrate an existing repository, install agent assets, and register it | `--agent`, `--dry-run`, `--skip-setup`, `--skip-register`, `--format` | | `productize init existing` | Create or refresh only `.productize/project/` knowledge docs | `--dry-run`, `--exclude`, `--force`, `--format` | -| `productize setup` | Install core skills and enabled extension assets | `--agent`, `--skill`, `--global`, `--copy`, `--list`, `--all`, `--yes` | +| `productize setup` | Install skills into one selected agent target by default | `--agent`, `--all-agents`, `--skill`, `--global`, `--copy`, `--list`, `--yes` | +| `productize setup cleanup` | Safely remove old managed agent targets | `--keep-agent`, `--global`, `--dry-run`, `--yes`, `--format` | | `productize upgrade` | Update CLI to latest release | | | **Workflow Execution** | | | | `productize daemon` | Manage the home-scoped daemon lifecycle | `start`, `status`, `stop` | @@ -281,7 +282,8 @@ Management: `productize ext list`, `productize ext inspect `, `productize ## Common Patterns - For an existing repository, run `productize onboard existing --agent ` before PRD creation. -- Use `productize setup` separately for new repositories or manual installation control. +- Use `productize setup` separately for new repositories or manual installation control. Select one agent unless multiple explicit targets are required. +- Use `productize setup cleanup --keep-agent --dry-run` when an older installation populated unwanted agent directories. Never delete drifted or unknown paths automatically. - Follow the pipeline in order: onboarding (existing repos) -> idea (optional) -> PRD -> TechSpec -> Tasks -> Execution -> Review -> Fix. - Configure workspace defaults in `.productize/config.toml` to reduce repetitive CLI flags. - Run `productize tasks validate --name ` before `productize tasks run` to catch metadata issues early. diff --git a/skills/productize-runtime/references/cli-reference.md b/skills/productize-runtime/references/cli-reference.md index 59b68466..949ddfe9 100644 --- a/skills/productize-runtime/references/cli-reference.md +++ b/skills/productize-runtime/references/cli-reference.md @@ -27,8 +27,8 @@ code, documentation, or conventions. | Flag | Type | Default | Description | | --- | --- | --- | --- | | `[path]` | string | discovered project root | Explicit repository or subproject root | -| `--agent`, `-a` | string[] | | Target agent/editor name; repeatable | -| `--all-agents` | bool | false | Install for every supported agent/editor | +| `--agent`, `-a` | string[] | | One target agent/editor per occurrence; repeat for several | +| `--all-agents` | bool | false | Explicitly install for every supported agent/editor | | `--global`, `-g` | bool | false | Install agent assets in user scope | | `--copy` | bool | false | Copy rather than symlink installed assets | | `--core-only` | bool | false | Install only core workflow skills | @@ -88,7 +88,7 @@ Install the Productize skill catalog into explicitly selected target agents plus | Flag | Type | Default | Description | | --- | --- | --- | --- | -| `--agent`, `-a` | string[] | | Target agent/editor name (repeatable) | +| `--agent`, `-a` | string[] | | One target agent/editor per occurrence (repeatable) | | `--skill`, `-s` | string[] | | Setup skill name to install (repeatable) | | `--global`, `-g` | bool | false | Install to user directory instead of project | | `--copy` | bool | false | Copy files instead of symlinking | @@ -96,7 +96,7 @@ Install the Productize skill catalog into explicitly selected target agents plus | `--doctor` | bool | false | Inspect setup targets and drift without installing | | `--format` | string | text | Output format for `--doctor`: text or json | | `--yes`, `-y` | bool | false | Skip confirmation prompts | -| `--all-agents` | bool | false | Install to every supported agent/editor destination | +| `--all-agents` | bool | false | Explicitly install to every supported agent/editor destination | | `--all` | bool | false | Deprecated: setup installs all skills by default; also skips prompts only when an agent target is explicit | ``` @@ -112,6 +112,29 @@ Non-interactive setup requires an explicit target via `--agent ` or `--all-agents` so Productize does not install into multiple agent directories without clear user intent. +Interactive setup selects one agent. A default exists only when exactly one +agent is detected. Repeated `--agent` flags are required for multiple targets; +comma-separated lists are rejected, and `--agent` cannot be combined with +`--all-agents`. OpenCode and Codex share `.agents/skills`; Claude Code uses +`.claude/skills`. Confirmation lists exact unique directories before writes. + +### `productize setup cleanup` + +Conservatively remove old Productize-managed agent skill targets while retaining +one or more desired agents. + +| Flag | Type | Default | Description | +| --- | --- | --- | --- | +| `--keep-agent` | string[] | | Agent target to retain; repeatable | +| `--global`, `-g` | bool | false | Clean global instead of project targets | +| `--dry-run` | bool | false | Preview without mutations | +| `--yes`, `-y` | bool | false | Skip the cleanup confirmation | +| `--format` | string | text | Output format: text or json | + +Cleanup removes only manifest-owned content matching its installed checksum, or +legacy Productize symlinks/exact copies. It preserves drifted, unknown, and +user-owned content for review. It never removes shared `.productize` metadata. + ### `productize upgrade` Update the Productize CLI to the latest release. No flags. diff --git a/skills/productize-runtime/references/workflow-guide.md b/skills/productize-runtime/references/workflow-guide.md index b01ad769..2fb76b99 100644 --- a/skills/productize-runtime/references/workflow-guide.md +++ b/skills/productize-runtime/references/workflow-guide.md @@ -5,7 +5,7 @@ End-to-end walkthrough of the Productize development pipeline from setup through ## Prerequisites 1. **Install Productize.** Ensure the `productize` binary is available in the system PATH. -2. **Onboard an existing project.** Run `productize onboard existing --agent codex`; this includes project-scoped setup. For a new repository or manual installation control, run `productize setup --agent codex` instead. +2. **Onboard an existing project.** Run `productize onboard existing --agent codex`; this includes project-scoped setup. For a new repository or manual installation control, run `productize setup --agent codex` instead. Interactive setup/onboarding selects one agent; repeat `--agent` only when several targets are intentional. 3. **Install optional ideation extension when needed.** To use `/idea-forge`, run `productize ext install --yes itseffi/productize --remote github --ref --subdir extensions/idea-forge`, then `productize ext enable idea-forge`, then `productize setup` again. 4. **Configure workspace (optional).** Create `.productize/config.toml` to set default IDE, model, and other preferences. Read `config-reference.md` for all fields. diff --git a/skills/productize/SKILL.md b/skills/productize/SKILL.md index 73236db5..a556ed95 100644 --- a/skills/productize/SKILL.md +++ b/skills/productize/SKILL.md @@ -89,6 +89,8 @@ Use the smallest entry point that owns the cadence: a concrete route plan with approval/edit options. Tactical skills are internal implementation details behind this route. - `/productize adopt`: existing repository onboarding. Run `productize onboard existing`, review generated `.productize/project/` coverage, then recommend the next workflow route. Use `productize init existing` only for an intentional knowledge-only refresh without setup or workspace registration. + - Select one agent target by default. Use repeated `--agent ` flags only when the user explicitly requests multiple targets, and reserve `--all-agents` for an explicit all-target install. + - If targeted setup reports an older Productize-managed target, preview `productize setup cleanup --keep-agent --dry-run` and request approval before applying it. Cleanup must preserve drifted, unknown, and user-owned files. - `/productize-0-1`: new bet or new capability; closes at ship gate, pivot, pause, or kill. - `/productize-operate`: production deploy; continuous operating loop that does not close. - `/productize-grow`: stable product with activation evidence; closes when the growth target is hit or the strategy pivots.