From 8e260fcdd94948a79507e0680427a438888c6805 Mon Sep 17 00:00:00 2001 From: itseffi <15998472+itseffi@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:12:25 +0200 Subject: [PATCH] v3 --- README.md | 12 +- agents/README.md | 5 +- agents/embed.go | 2 +- agents/productize-operator/AGENT.md | 58 +++ docs/cli-reference.md | 15 + docs/reusable-agents.md | 6 + docs/skills.md | 9 +- internal/cli/agents_commands.go | 97 +++- internal/cli/agents_commands_test.go | 41 +- internal/cli/setup.go | 29 +- internal/cli/setup_doctor.go | 490 ++++++++++++++++++ internal/cli/setup_test.go | 343 +++++++++++- internal/setup/bundle_test.go | 15 +- skills/productize-runtime/SKILL.md | 2 +- .../references/cli-reference.md | 16 +- .../references/workflow-guide.md | 2 +- skills/productize/SKILL.md | 47 ++ test/skills_bundle_test.go | 26 + 18 files changed, 1184 insertions(+), 31 deletions(-) create mode 100644 agents/productize-operator/AGENT.md create mode 100644 internal/cli/setup_doctor.go diff --git a/README.md b/README.md index f201c721..b3a2f993 100644 --- a/README.md +++ b/README.md @@ -47,16 +47,18 @@ go install github.com/itseffi/productize/cmd/productize@latest ### 2. Install the skills into your agent ```bash -productize setup +productize setup --agent codex ``` -`setup` detects your installed AI tools and installs the full skill catalog into each — it supports **40+ agents and editors** (Claude Code, Codex, Cursor, Droid, OpenCode, Pi, Gemini, Copilot, Windsurf, Amp, Continue, Goose, Roo Code, Cline, and more). Installing skills is separate from *executing* through a runtime: to run `tasks run`, `reviews fix`, or `exec` you need an ACP-capable runtime (see the table below). Common options: +`setup` installs the skill catalog into the agent/editor you choose — it supports **40+ agents and editors** (Claude Code, Codex, Cursor, Droid, OpenCode, Pi, Gemini, Copilot, Windsurf, Amp, Continue, Goose, Roo Code, Cline, and more). Non-interactive installs require an explicit target so Productize does not spray `.claude`, `.agents`, `.codex`, or other tool directories without permission. Installing skills is separate from *executing* through a runtime: to run `tasks run`, `reviews fix`, or `exec` you need an ACP-capable runtime (see the table below). Common options: ```bash productize setup --list # show everything that would be installed +productize setup --doctor --agent codex --format json productize setup --core-only # install only the core lifecycle skills productize setup --global # install for all projects (~/.productize) instead of just this one productize setup --agent claude --agent codex --yes +productize setup --all-agents --yes ``` ### 3. Adopt existing project context @@ -134,8 +136,13 @@ Beyond skills, Productize ships **reusable agents** — named, purpose-built sub ```bash productize agents list productize agents inspect +productize exec --agent productize-operator "build X" ``` +`productize-operator` is the first-stop agent for routing Productize work. It reads +project knowledge, checks workflow artifacts, identifies missing PRD/TechSpec/task +steps, and recommends the exact next command before mutating files or running agents. + See [docs/reusable-agents.md](docs/reusable-agents.md). ### ACP runtimes (execution backends) @@ -173,6 +180,7 @@ productize runs watch # stream a running job |---------|--------------| | `productize init existing` | Adopt an existing repo into `.productize/project/` knowledge docs. | | `productize setup` | Install the skill catalog and reusable agents into your AI agent(s). | +| `productize setup --doctor` | Inspect setup targets, paths, and drift without installing. | | `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. | diff --git a/agents/README.md b/agents/README.md index fe01ec3d..168ac12b 100644 --- a/agents/README.md +++ b/agents/README.md @@ -1,4 +1,5 @@ Bundled reusable agents live in subdirectories of this package. -The package currently ships no bundled reusable agents. This file keeps the -embedded filesystem root readable without embedding Go source files. +The package ships `productize-operator`, the first-stop agent for routing +Productize work through project adoption, PRD, TechSpec, task execution, and +review/fix workflows. diff --git a/agents/embed.go b/agents/embed.go index 4a7fe656..63b63f0f 100644 --- a/agents/embed.go +++ b/agents/embed.go @@ -4,5 +4,5 @@ import "embed" // FS holds the bundled reusable-agent fixtures installed by `productize setup`. // -//go:embed README.md +//go:embed README.md productize-operator/AGENT.md var FS embed.FS diff --git a/agents/productize-operator/AGENT.md b/agents/productize-operator/AGENT.md new file mode 100644 index 00000000..6800ea23 --- /dev/null +++ b/agents/productize-operator/AGENT.md @@ -0,0 +1,58 @@ +--- +title: Productize Operator +description: Routes Productize work, selects the next workflow step, and keeps tactical skills behind one operator-facing interface. +--- + +# Productize Operator + +You are the Productize operator agent. Your job is to turn a user request into the +next safe Productize workflow action. + +## Operating Contract + +Always inspect existing context before recommending a route: + +1. Read `.productize/project/context.md` when it exists. +2. Read `.productize/project/conventions.md` when it exists. +3. Read `.productize/project/decisions.md` when it exists. +4. Inspect `.productize/tasks/` for active workflow artifacts. +5. Treat tactical skills as internal implementation details unless the user asks + for a specific skill by name. + +## Route Selection + +For `/productize build ` or `build `: + +- If `.productize/project/context.md` is missing, route first to + `productize init existing`. +- 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 `. +- If review artifacts exist, route to review or fix commands. + +## Output Format + +Return exactly these sections: + +```text +Route selected: +1. ... + +Why: +... + +Project context read: +... + +Missing artifacts: +... + +Next command: +... + +Approval needed: +... +``` + +Ask for approval before any route step that writes files, runs an implementation +agent, changes git state, or starts a long-running daemon task. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 06d2dd3a..9dcaab32 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -43,11 +43,26 @@ productize setup [flags] | `--global`, `-g` | `false` | Install to user directory instead of project | | `--copy` | `false` | Copy files instead of symlinking | | `--list`, `-l` | `false` | List setup assets without installing | +| `--doctor` | `false` | Inspect setup targets and drift without installing | +| `--format` | `text` | Output format for `--doctor`: `text` or `json` | | `--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` | `false` | Deprecated: setup installs all skills by default; also skips prompts | +Non-interactive installs require an explicit target: + +```bash +productize setup --agent opencode --yes +productize setup --all-agents --yes +productize setup --doctor --agent opencode --format json +``` + +Use `--doctor` to see the detected agents, selected targets, install paths, +catalog path, stale or missing assets, and extension conflicts before changing +anything. + ## `productize upgrade` Upgrade Productize to the latest release. diff --git a/docs/reusable-agents.md b/docs/reusable-agents.md index 223c2777..ca11f806 100644 --- a/docs/reusable-agents.md +++ b/docs/reusable-agents.md @@ -131,8 +131,14 @@ That automatic host injection is what lets optional extension skills such as `id productize agents list # list resolved agents productize agents inspect reviewer # inspect one definition + validation status productize exec --agent reviewer "Review the staged changes" +productize exec --agent productize-operator "build X" ``` +Productize ships `productize-operator` as the default reusable agent for driving +the workflow. It reads project knowledge, inspects workflow artifacts, selects the +next Productize route, and asks for approval before file writes, agent runs, or git +changes. + Example `inspect` output (paths omitted): ```text diff --git a/docs/skills.md b/docs/skills.md index ff0d67c9..08a21072 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -42,9 +42,11 @@ Skills are split into two tiers so you can choose how much to install: By default, `productize setup` installs **everything** (the full-power default). Narrow it with: ```bash +productize setup --agent codex # explicit target for non-interactive installs 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 ``` ## The effective catalog @@ -58,7 +60,12 @@ This catalog is what your agents read to discover available skills. It's plain J ## How agents use skills -Productize doesn't run a skill router. Once installed, skills live in your agent's own skills directory (symlinked by default, or copied with `--copy`). Your agent (Claude Code, Codex, Cursor, …) discovers the `SKILL.md` files and exposes them — typically as named commands you invoke during a session. Productize's job is to install, resolve, and keep the catalog coherent; your agent does the invoking. +Productize provides one front-door routing skill, `/productize`, and keeps tactical +skills behind that operator surface for normal users. Once installed, skills live +in your agent's own skills directory (symlinked by default, or copied with +`--copy`). Your agent (Claude Code, Codex, Cursor, …) discovers the `SKILL.md` +files and exposes them. Start with `/productize build ` unless you know +the exact lifecycle skill you need. ## Writing your own skill diff --git a/internal/cli/agents_commands.go b/internal/cli/agents_commands.go index 0723f786..98cd26f4 100644 --- a/internal/cli/agents_commands.go +++ b/internal/cli/agents_commands.go @@ -12,6 +12,7 @@ import ( reusableagents "github.com/itseffi/productize/internal/core/agents" "github.com/itseffi/productize/internal/core/agents/mcpserver" + "github.com/itseffi/productize/internal/setup" "github.com/spf13/cobra" ) @@ -19,7 +20,9 @@ type reusableAgentRegistryFactory func() *reusableagents.Registry type agentsListCommandState struct { simpleCommandBase - newRegistry reusableAgentRegistryFactory + newRegistry reusableAgentRegistryFactory + listBundledReusableAgents func() ([]setup.ReusableAgent, error) + verifyReusableAgents func(setup.ReusableAgentVerifyConfig) (setup.ReusableAgentVerifyResult, error) } type agentsInspectCommandState struct { @@ -44,6 +47,13 @@ type inspectAgentReport struct { ValidationError error } +type bundledReusableAgentListItem struct { + Agent setup.ReusableAgent + Scope setup.InstallScope + State setup.VerifyState + TargetPath string +} + func newAgentsCommand() *cobra.Command { cmd := &cobra.Command{ Use: "agents", @@ -68,7 +78,11 @@ Workspace agents live under .productize/agents// and override same-name ag } func newAgentsListCommand() *cobra.Command { - state := &agentsListCommandState{newRegistry: func() *reusableagents.Registry { return reusableagents.New() }} + state := &agentsListCommandState{ + newRegistry: func() *reusableagents.Registry { return reusableagents.New() }, + listBundledReusableAgents: setup.ListBundledReusableAgents, + verifyReusableAgents: setup.VerifyReusableAgents, + } return &cobra.Command{ Use: "list", Short: "List resolved reusable agents", @@ -131,7 +145,11 @@ func (s *agentsListCommandState) run(cmd *cobra.Command, _ []string) error { if err != nil { return withExitCode(2, fmt.Errorf("discover reusable agents: %w", err)) } - if err := writeAgentsListText(cmd.OutOrStdout(), catalog); err != nil { + bundledAgents, err := s.bundledReusableAgentStatus() + if err != nil { + return withExitCode(2, fmt.Errorf("inspect bundled reusable agents: %w", err)) + } + if err := writeAgentsListText(cmd.OutOrStdout(), catalog, bundledAgents); err != nil { return withExitCode(2, fmt.Errorf("write agents list: %w", err)) } return nil @@ -144,6 +162,53 @@ func (s *agentsListCommandState) registry() *reusableagents.Registry { return reusableagents.New() } +func (s *agentsListCommandState) bundledReusableAgentStatus() ([]bundledReusableAgentListItem, error) { + listBundledReusableAgents := s.listBundledReusableAgents + if listBundledReusableAgents == nil { + listBundledReusableAgents = setup.ListBundledReusableAgents + } + bundledAgents, err := listBundledReusableAgents() + if err != nil { + return nil, err + } + if len(bundledAgents) == 0 { + return nil, nil + } + + verifyReusableAgents := s.verifyReusableAgents + if verifyReusableAgents == nil { + verifyReusableAgents = setup.VerifyReusableAgents + } + verified, err := verifyReusableAgents(setup.ReusableAgentVerifyConfig{ + ResolverOptions: currentResolverOptions(), + ReusableAgents: bundledAgents, + ScopeHint: setup.InstallScopeProject, + }) + if err != nil { + return nil, err + } + + verifiedByName := make(map[string]setup.VerifiedReusableAgent, len(verified.Agents)) + for i := range verified.Agents { + verifiedByName[verified.Agents[i].ReusableAgent.Name] = verified.Agents[i] + } + + items := make([]bundledReusableAgentListItem, 0, len(bundledAgents)) + for i := range bundledAgents { + item := bundledReusableAgentListItem{ + Agent: bundledAgents[i], + Scope: verified.Scope, + State: setup.VerifyStateMissing, + } + if resolved, ok := verifiedByName[bundledAgents[i].Name]; ok { + item.State = resolved.State + item.TargetPath = resolved.TargetPath + } + items = append(items, item) + } + return items, nil +} + func (s *agentsInspectCommandState) run(cmd *cobra.Command, args []string) error { ctx, stop := signalCommandContext(cmd) defer stop() @@ -260,7 +325,11 @@ func buildInspectAgentReport(catalog reusableagents.Catalog, name string) (inspe ) } -func writeAgentsListText(out io.Writer, catalog reusableagents.Catalog) error { +func writeAgentsListText( + out io.Writer, + catalog reusableagents.Catalog, + bundledAgents []bundledReusableAgentListItem, +) error { if len(catalog.Agents) == 0 { if _, err := fmt.Fprintln( out, @@ -288,6 +357,26 @@ func writeAgentsListText(out io.Writer, catalog reusableagents.Catalog) error { } } + if len(bundledAgents) > 0 { + if _, err := fmt.Fprintf(out, "\nsetup-managed reusable agents: %d\n", len(bundledAgents)); err != nil { + return err + } + for i := range bundledAgents { + agent := &bundledAgents[i] + if _, err := fmt.Fprintf( + out, + "\n%s\n source: bundled\n status: %s\n scope: %s\n target: %s\n description: %s\n", + agent.Agent.Name, + agent.State, + agent.Scope, + blankFallback(agent.TargetPath), + blankFallback(agent.Agent.Description), + ); err != nil { + return err + } + } + } + if len(catalog.Problems) == 0 { return nil } diff --git a/internal/cli/agents_commands_test.go b/internal/cli/agents_commands_test.go index a33c8bff..f6a02f46 100644 --- a/internal/cli/agents_commands_test.go +++ b/internal/cli/agents_commands_test.go @@ -18,6 +18,7 @@ import ( "github.com/itseffi/productize/internal/core/agents/mcpserver" "github.com/itseffi/productize/internal/core/model" coreRun "github.com/itseffi/productize/internal/core/run" + "github.com/itseffi/productize/internal/setup" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/spf13/cobra" ) @@ -256,6 +257,29 @@ func TestAgentsListShowsWorkspaceAndGlobalSources(t *testing.T) { } } +func TestAgentsListShowsBundledOperatorAgentBeforeInstall(t *testing.T) { + homeDir := t.TempDir() + workspaceRoot := t.TempDir() + t.Setenv("HOME", homeDir) + writeCLIWorkspaceConfig(t, workspaceRoot, "") + withWorkingDir(t, workspaceRoot) + + output, err := executeRootCommand("agents", "list") + if err != nil { + t.Fatalf("execute agents list: %v\noutput:\n%s", err, output) + } + for _, want := range []string{ + "setup-managed reusable agents: 1", + "productize-operator", + "source: bundled", + "status: missing", + } { + if !strings.Contains(output, want) { + t.Fatalf("expected output to contain %q\noutput:\n%s", want, output) + } + } +} + func TestAgentsInspectInvalidAgentPrintsValidationBeforeNonZero(t *testing.T) { workspaceRoot := t.TempDir() t.Setenv("GITHUB_TOKEN", "test-token") @@ -504,13 +528,26 @@ func TestBuildInspectAgentReportAndListHelpers(t *testing.T) { } var listOutput bytes.Buffer - if err := writeAgentsListText(&listOutput, catalog); err != nil { + if err := writeAgentsListText(&listOutput, catalog, []bundledReusableAgentListItem{ + { + Agent: setup.ReusableAgent{ + Name: "productize-operator", + Description: "Routes Productize work", + }, + Scope: setup.InstallScopeProject, + State: setup.VerifyStateMissing, + TargetPath: "/workspace/.productize/agents/productize-operator", + }, + }); err != nil { t.Fatalf("write list output: %v", err) } for _, want := range []string{ "resolved reusable agents: 1", "runtime: ide=codex model=gpt-5.5 reasoning=high access=default", "mcp: 1 server(s): github", + "setup-managed reusable agents: 1", + "productize-operator", + "status: missing", "invalid reusable agent definitions: 1", "planner (global)", } { @@ -528,7 +565,7 @@ func TestBuildInspectAgentReportAndListHelpers(t *testing.T) { } var emptyListOutput bytes.Buffer - if err := writeAgentsListText(&emptyListOutput, reusableagents.Catalog{}); err != nil { + if err := writeAgentsListText(&emptyListOutput, reusableagents.Catalog{}, nil); err != nil { t.Fatalf("write empty list output: %v", err) } if !strings.Contains(emptyListOutput.String(), "no reusable agents found") { diff --git a/internal/cli/setup.go b/internal/cli/setup.go index aa792b41..3213cfc2 100644 --- a/internal/cli/setup.go +++ b/internal/cli/setup.go @@ -20,10 +20,13 @@ type setupCommandState struct { global bool copy bool list bool + doctor bool yes bool all bool + allAgents bool coreOnly bool noTactical bool + format string loadCatalog func(context.Context, setup.ResolverOptions) (setup.EffectiveCatalog, error) listAgents func(setup.ResolverOptions) ([]setup.Agent, error) @@ -55,6 +58,8 @@ type setupReusableAgentInstallFunc func( setup.ReusableAgentInstallConfig, ) ([]setup.ReusableAgentSuccessItem, []setup.ReusableAgentFailureItem, error) +const universalAgentSkillTargetDir = ".agents/skills" + type setupInstallPlan struct { Config setup.InstallConfig Skills []setup.Skill @@ -76,7 +81,9 @@ enabled extensions, and reusable agents in either the project or user scope sele The command can run interactively or entirely from flags.`, Example: ` 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`, RunE: state.run, @@ -87,9 +94,12 @@ The command can run interactively or entirely from flags.`, 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") cmd.Flags().BoolVarP(&state.list, "list", "l", false, "List setup assets without installing") + cmd.Flags().BoolVar(&state.doctor, "doctor", false, "Inspect setup targets and drift without installing") 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().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") return cmd @@ -121,6 +131,9 @@ func (s *setupCommandState) run(cmd *cobra.Command, _ []string) error { printSetupAssets(cmd, catalog.Skills, catalog.ReusableAgents, catalog.Conflicts) return nil } + if s.doctor { + return s.runDoctor(cmd, resolver, catalog) + } if !s.yes && s.isInteractive() { printWelcomeHeader(cmd) @@ -170,6 +183,16 @@ func (s *setupCommandState) prepareRunMode() error { if s.all { s.yes = true } + if s.all && len(s.agentNames) == 0 && !s.allAgents { + return errors.New( + "productize setup --all no longer selects every agent; pass --agent or --all-agents explicitly", + ) + } + 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", + ) + } if s.coreOnly && s.noTactical { return errors.New("use only one of --core-only or --no-tactical") } @@ -339,7 +362,7 @@ func (s *setupCommandState) resolveAgentSelection( if len(s.agentNames) > 0 { return append([]string(nil), s.agentNames...), nil } - if s.all { + if s.allAgents { return agentNames(supported), nil } if s.yes { @@ -354,7 +377,7 @@ func (s *setupCommandState) resolveAgentSelection( for _, agent := range supported { scopeHint := agent.ProjectRootDir if agent.Universal { - scopeHint = ".agents/skills" + scopeHint = universalAgentSkillTargetDir } label := fmt.Sprintf("%s [%s]", agent.DisplayName, scopeHint) options = append(options, promptOption{Label: label, Value: agent.Name}) @@ -419,7 +442,7 @@ func (s *setupCommandState) resolveInstallMode( root = agent.GlobalRootDir } if agent.Universal { - root = ".agents/skills" + root = universalAgentSkillTargetDir } roots[root] = struct{}{} } diff --git a/internal/cli/setup_doctor.go b/internal/cli/setup_doctor.go new file mode 100644 index 00000000..df613fa5 --- /dev/null +++ b/internal/cli/setup_doctor.go @@ -0,0 +1,490 @@ +package cli + +import ( + "fmt" + "io" + "path/filepath" + + "github.com/itseffi/productize/internal/setup" + "github.com/spf13/cobra" +) + +type setupDoctorReport struct { + Scope string `json:"scope"` + Mode string `json:"mode"` + CatalogPath string `json:"catalog_path"` + SupportedAgents []setupDoctorAgent `json:"supported_agents"` + DetectedAgents []setupDoctorAgent `json:"detected_agents"` + SelectedAgents []setupDoctorAgent `json:"selected_agents"` + SkillTargets []setupDoctorSkillTarget `json:"skill_targets"` + ReusableAgentTargets []setupDoctorReusableAgent `json:"reusable_agent_targets"` + Warnings []string `json:"warnings"` +} + +type setupDoctorAgent struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + Detected bool `json:"detected"` + ProjectRoot string `json:"project_root"` + GlobalRoot string `json:"global_root"` +} + +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"` +} + +type setupDoctorReusableAgent struct { + Name string `json:"name"` + Origin string `json:"origin"` + Scope string `json:"scope"` + State string `json:"state"` + TargetPath string `json:"target_path"` + ResolvedPath string `json:"resolved_path,omitempty"` + WillOverwrite bool `json:"will_overwrite"` +} + +func (s *setupCommandState) runDoctor( + cmd *cobra.Command, + resolver setup.ResolverOptions, + catalog setup.EffectiveCatalog, +) error { + format, err := normalizeOperatorOutputFormat(s.format) + if err != nil { + return err + } + + supportedAgents, detectedAgents, err := s.loadAgents(resolver) + if err != nil { + return err + } + selectedAgents, err := s.resolveDoctorAgents(supportedAgents, detectedAgents) + if err != nil { + return err + } + globalScope := s.global + mode, err := s.resolveDoctorInstallMode(supportedAgents, selectedAgents, globalScope) + if err != nil { + return err + } + report, err := s.buildDoctorReport( + resolver, + catalog, + supportedAgents, + detectedAgents, + selectedAgents, + globalScope, + mode, + ) + if err != nil { + return err + } + + if format == operatorOutputFormatJSON { + return writeOperatorJSON(cmd.OutOrStdout(), report) + } + + printSetupDoctorReport(cmd, report) + return nil +} + +func (s *setupCommandState) resolveDoctorAgents( + supported []setup.Agent, + detected []setup.Agent, +) ([]setup.Agent, error) { + switch { + case len(s.agentNames) > 0: + return setup.SelectAgents(supported, s.agentNames) + case s.allAgents: + return append([]setup.Agent(nil), supported...), nil + default: + return append([]setup.Agent(nil), detected...), nil + } +} + +func (s *setupCommandState) resolveDoctorInstallMode( + supportedAgents []setup.Agent, + selectedAgents []setup.Agent, + global bool, +) (setup.InstallMode, error) { + if s.copy { + return setup.InstallModeCopy, nil + } + if len(selectedAgents) == 0 { + return setup.InstallModeCopy, nil + } + + names := make([]string, 0, len(selectedAgents)) + for i := range selectedAgents { + names = append(names, selectedAgents[i].Name) + } + selected, err := setup.SelectAgents(supportedAgents, names) + if err != nil { + return "", err + } + roots := make(map[string]struct{}, len(selected)) + for i := range selected { + root := selected[i].ProjectRootDir + if global { + root = selected[i].GlobalRootDir + } + if selected[i].Universal { + root = universalAgentSkillTargetDir + } + roots[root] = struct{}{} + } + if len(roots) <= 1 { + return setup.InstallModeCopy, nil + } + return setup.InstallModeSymlink, nil +} + +func (s *setupCommandState) buildDoctorReport( + resolver setup.ResolverOptions, + catalog setup.EffectiveCatalog, + supportedAgents []setup.Agent, + detectedAgents []setup.Agent, + selectedAgents []setup.Agent, + global bool, + mode setup.InstallMode, +) (setupDoctorReport, error) { + catalogPath, err := setupCatalogPath(resolver, global) + if err != nil { + return setupDoctorReport{}, err + } + scope := scopeLabel(global) + report := setupDoctorReport{ + Scope: scope, + Mode: string(mode), + CatalogPath: catalogPath, + SupportedAgents: doctorAgents(supportedAgents), + DetectedAgents: doctorAgents(detectedAgents), + SelectedAgents: doctorAgents(selectedAgents), + Warnings: setupDoctorWarnings(catalog.Conflicts), + } + if len(selectedAgents) == 0 { + report.Warnings = append( + report.Warnings, + "no setup target selected; pass --agent or --all-agents to inspect install targets", + ) + return report, nil + } + + selectedSkillNames := s.resolveSkillSelection(catalog.Skills) + selectedSkills, err := setup.SelectSkills(catalog.Skills, selectedSkillNames) + if err != nil { + return setupDoctorReport{}, err + } + selectedAgentNames := agentNames(selectedAgents) + previews, err := s.previewSkills(resolver, selectedSkills, selectedAgentNames, global, mode) + if err != nil { + return setupDoctorReport{}, err + } + statuses, err := setupDoctorSkillStatuses(resolver, selectedSkills, selectedAgentNames, global) + if err != nil { + return setupDoctorReport{}, err + } + report.SkillTargets = mergeSetupDoctorSkillTargets(previews, statuses, scope, mode) + + reusableAgentPreviews, err := s.previewReusableAgents(setup.ReusableAgentInstallConfig{ + ResolverOptions: resolver, + ReusableAgents: catalog.ReusableAgents, + Global: global, + }) + if err != nil { + return setupDoctorReport{}, err + } + reusableAgentStatus, err := setup.VerifyReusableAgents(setup.ReusableAgentVerifyConfig{ + ResolverOptions: resolver, + ReusableAgents: catalog.ReusableAgents, + ScopeHint: installScope(global), + }) + if err != nil { + return setupDoctorReport{}, err + } + report.ReusableAgentTargets = mergeSetupDoctorReusableAgents(reusableAgentPreviews, reusableAgentStatus, scope) + return report, nil +} + +func setupCatalogPath(resolver setup.ResolverOptions, global bool) (string, error) { + workspaceRoot, homeDir, err := resolveSetupAssetRoots(resolver) + if err != nil { + return "", err + } + root := filepath.Join(workspaceRoot, ".productize", "catalog") + if global { + root = filepath.Join(homeDir, ".productize", "catalog") + } + return filepath.Join(root, "skills.json"), nil +} + +func installScope(global bool) setup.InstallScope { + if global { + return setup.InstallScopeGlobal + } + return setup.InstallScopeProject +} + +func doctorAgents(agents []setup.Agent) []setupDoctorAgent { + result := make([]setupDoctorAgent, 0, len(agents)) + for i := range agents { + result = append(result, setupDoctorAgent{ + Name: agents[i].Name, + DisplayName: agents[i].DisplayName, + Detected: agents[i].Detected, + ProjectRoot: agents[i].ProjectRootDir, + GlobalRoot: agents[i].GlobalRootDir, + }) + } + return result +} + +func setupDoctorWarnings(conflicts []setup.CatalogConflict) []string { + warnings := make([]string, 0, len(conflicts)) + for i := range conflicts { + warnings = append(warnings, formatSetupCatalogConflict(conflicts[i])) + } + return warnings +} + +type setupDoctorSkillStatus struct { + state string + scope string + mode string +} + +func setupDoctorSkillStatuses( + resolver setup.ResolverOptions, + selectedSkills []setup.Skill, + selectedAgentNames []string, + global bool, +) (map[string]setupDoctorSkillStatus, error) { + statuses := make(map[string]setupDoctorSkillStatus) + bundledNames := selectedSkillNamesByOrigin(selectedSkills, setup.AssetOriginBundled) + extensionPacks := setup.ExtensionSkillPackSources(selectedSkills) + scope := installScope(global) + + for _, agentName := range selectedAgentNames { + if len(bundledNames) > 0 { + result, err := setup.VerifyBundledSkills(setup.VerifyConfig{ + ResolverOptions: resolver, + AgentName: agentName, + SkillNames: bundledNames, + }) + if err != nil { + return nil, err + } + for i := range result.Skills { + statuses[setupDoctorSkillKey(agentName, result.Skills[i].Skill.Name)] = setupDoctorSkillStatus{ + state: string(result.Skills[i].State), + scope: string(result.Scope), + mode: string(result.Mode), + } + } + } + if len(extensionPacks) == 0 { + continue + } + result, err := setup.VerifyExtensionSkillPacks(setup.ExtensionVerifyConfig{ + ResolverOptions: resolver, + Packs: extensionPacks, + AgentName: agentName, + ScopeHint: scope, + }) + if err != nil { + return nil, err + } + for i := range result.Skills { + statuses[setupDoctorSkillKey(agentName, result.Skills[i].Skill.Name)] = setupDoctorSkillStatus{ + state: string(result.Skills[i].State), + scope: string(result.Scope), + mode: string(result.Mode), + } + } + } + return statuses, nil +} + +func selectedSkillNamesByOrigin(skills []setup.Skill, origin setup.AssetOrigin) []string { + names := make([]string, 0, len(skills)) + for i := range skills { + if skills[i].Origin != origin { + continue + } + names = append(names, skills[i].Name) + } + return names +} + +func setupDoctorSkillKey(agentName, skillName string) string { + return agentName + "\x00" + skillName +} + +func mergeSetupDoctorSkillTargets( + previews []setup.PreviewItem, + statuses map[string]setupDoctorSkillStatus, + scope string, + mode setup.InstallMode, +) []setupDoctorSkillTarget { + targets := make([]setupDoctorSkillTarget, 0, len(previews)) + for i := range previews { + preview := &previews[i] + status := statuses[setupDoctorSkillKey(preview.Agent.Name, preview.Skill.Name)] + if status.state == "" { + status = setupDoctorSkillStatus{ + state: string(setup.VerifyStateMissing), + scope: scope, + mode: string(mode), + } + } + targets = append(targets, setupDoctorSkillTarget{ + Name: preview.Skill.Name, + Origin: string(preview.Skill.Origin), + Agent: preview.Agent.DisplayName, + AgentName: preview.Agent.Name, + Scope: status.scope, + Mode: status.mode, + State: status.state, + TargetPath: preview.TargetPath, + CanonicalPath: preview.CanonicalPath, + WillOverwrite: preview.WillOverwrite, + }) + } + return targets +} + +func mergeSetupDoctorReusableAgents( + previews []setup.ReusableAgentPreviewItem, + status setup.ReusableAgentVerifyResult, + scope string, +) []setupDoctorReusableAgent { + statusByName := make(map[string]setup.VerifiedReusableAgent, len(status.Agents)) + for i := range status.Agents { + statusByName[status.Agents[i].ReusableAgent.Name] = status.Agents[i] + } + targets := make([]setupDoctorReusableAgent, 0, len(previews)) + for i := range previews { + preview := &previews[i] + verified := statusByName[preview.ReusableAgent.Name] + state := string(verified.State) + if state == "" { + state = string(setup.VerifyStateMissing) + } + targetScope := string(status.Scope) + if targetScope == "" { + targetScope = scope + } + targets = append(targets, setupDoctorReusableAgent{ + Name: preview.ReusableAgent.Name, + Origin: string(preview.ReusableAgent.Origin), + Scope: targetScope, + State: state, + TargetPath: preview.TargetPath, + ResolvedPath: verified.ResolvedPath, + WillOverwrite: preview.WillOverwrite, + }) + } + return targets +} + +func printSetupDoctorReport(cmd *cobra.Command, report setupDoctorReport) { + styles := newCLIChromeStyles() + w := cmd.OutOrStdout() + cwd, homeDir := displayRoots() + + fmt.Fprintln(w, styles.sectionTitle.Render("Setup Doctor")) + fmt.Fprintf(w, " %s %s\n", styles.label.Render("Scope "), styles.value.Render(report.Scope)) + fmt.Fprintf(w, " %s %s\n", styles.label.Render("Method"), styles.value.Render(report.Mode)) + fmt.Fprintf( + w, + " %s %s\n", + styles.label.Render("Catalog"), + styles.path.Render(shortenPath(report.CatalogPath, cwd, homeDir)), + ) + printSetupDoctorAgents(w, styles, "Detected Agents", report.DetectedAgents) + printSetupDoctorAgents(w, styles, "Selected Agents", report.SelectedAgents) + printSetupDoctorSkillTargets(w, styles, report.SkillTargets, cwd, homeDir) + printSetupDoctorReusableAgentTargets(w, styles, report.ReusableAgentTargets, cwd, homeDir) + if len(report.Warnings) == 0 { + return + } + fmt.Fprintln(w) + fmt.Fprintln(w, styles.sectionTitle.Render("Warnings")) + for i := range report.Warnings { + fmt.Fprintf(w, " %s %s\n", styles.warn.Render("!"), report.Warnings[i]) + } +} + +func printSetupDoctorAgents( + w io.Writer, + styles cliChromeStyles, + title string, + agents []setupDoctorAgent, +) { + fmt.Fprintln(w) + fmt.Fprintln(w, styles.sectionTitle.Render(title)) + if len(agents) == 0 { + fmt.Fprintln(w, " none") + return + } + for i := range agents { + fmt.Fprintf(w, " %s %s\n", styles.agent.Render(agents[i].Name), agents[i].DisplayName) + } +} + +func printSetupDoctorSkillTargets( + w io.Writer, + styles cliChromeStyles, + targets []setupDoctorSkillTarget, + cwd, homeDir string, +) { + fmt.Fprintln(w) + fmt.Fprintln(w, styles.sectionTitle.Render("Skill Install Targets")) + if len(targets) == 0 { + fmt.Fprintln(w, " none") + return + } + for i := range targets { + target := &targets[i] + fmt.Fprintf( + w, + " %s -> %s %s [%s]\n", + styles.skill.Render(target.Name), + styles.agent.Render(target.AgentName), + styles.path.Render(shortenPath(target.TargetPath, cwd, homeDir)), + target.State, + ) + } +} + +func printSetupDoctorReusableAgentTargets( + w io.Writer, + styles cliChromeStyles, + targets []setupDoctorReusableAgent, + cwd, homeDir string, +) { + fmt.Fprintln(w) + fmt.Fprintln(w, styles.sectionTitle.Render("Reusable Agent Targets")) + if len(targets) == 0 { + fmt.Fprintln(w, " none") + return + } + for i := range targets { + target := &targets[i] + fmt.Fprintf( + w, + " %s %s [%s]\n", + styles.agent.Render(target.Name), + styles.path.Render(shortenPath(target.TargetPath, cwd, homeDir)), + target.State, + ) + } +} diff --git a/internal/cli/setup_test.go b/internal/cli/setup_test.go index 158b3e11..db5c7115 100644 --- a/internal/cli/setup_test.go +++ b/internal/cli/setup_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "context" + "encoding/json" "strings" "testing" @@ -24,8 +25,11 @@ func TestSetupHelpShowsSetupFlagsOnly(t *testing.T) { "--global", "--copy", "--list", + "--doctor", + "--format", "--yes", "--all", + "--all-agents", "--core-only", "--no-tactical", } @@ -81,7 +85,7 @@ func TestSetupSkillSelectionDefaultsToFullCatalogAndSupportsCoreOnly(t *testing. } } -func TestSetupRunYesFailsWithoutDetectedAgents(t *testing.T) { +func TestSetupRunYesRequiresExplicitTarget(t *testing.T) { t.Parallel() state := newSetupCommandState() @@ -114,10 +118,34 @@ func TestSetupRunYesFailsWithoutDetectedAgents(t *testing.T) { err := state.run(cmd, nil) if err == nil { - t.Fatal("expected setup run to fail when no agents are detected") + t.Fatal("expected setup run to fail without an explicit target") } - if !strings.Contains(err.Error(), "no agents detected") { - t.Fatalf("expected missing detected agents error, got %v", err) + if !strings.Contains(err.Error(), "requires an explicit target") { + t.Fatalf("expected explicit target error, got %v", err) + } +} + +func TestSetupRunAllAgentsRequiresExplicitOptIn(t *testing.T) { + t.Parallel() + + state := newSetupCommandState() + state.all = true + state.loadCatalog = func(_ context.Context, _ setup.ResolverOptions) (setup.EffectiveCatalog, error) { + return setup.EffectiveCatalog{ + Skills: []setup.Skill{{Name: "create-prd", Description: "Create a PRD"}}, + }, nil + } + + cmd := &cobra.Command{Use: "setup"} + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := state.run(cmd, nil) + if err == nil { + t.Fatal("expected deprecated --all to fail without explicit agent scope") + } + if !strings.Contains(err.Error(), "--all no longer selects every agent") { + t.Fatalf("unexpected --all error: %v", err) } } @@ -202,6 +230,7 @@ func TestSetupRunYesUsesProjectScopeForReusableAgentsWhenGlobalFlagIsFalse(t *te state := newSetupCommandState() state.yes = true + state.agentNames = []string{"codex"} state.loadCatalog = func(_ context.Context, _ setup.ResolverOptions) (setup.EffectiveCatalog, error) { return setup.EffectiveCatalog{ Skills: []setup.Skill{{Name: "productize", Description: "Core workflow"}}, @@ -322,3 +351,309 @@ func TestSetupRunYesUsesProjectScopeForReusableAgentsWhenGlobalFlagIsFalse(t *te t.Fatalf("unexpected reusable-agent install config: %#v", installCfg) } } + +func TestSetupRunAllAgentsYesInstallsEverySupportedAgent(t *testing.T) { + t.Parallel() + + state := newSetupCommandState() + state.yes = true + state.allAgents = true + state.loadCatalog = func(_ context.Context, _ setup.ResolverOptions) (setup.EffectiveCatalog, error) { + return setup.EffectiveCatalog{ + Skills: []setup.Skill{{Name: "productize", Description: "Core workflow"}}, + }, nil + } + state.listAgents = func(setup.ResolverOptions) ([]setup.Agent, error) { + return []setup.Agent{ + { + Name: "codex", + DisplayName: "Codex", + ProjectRootDir: ".agents/skills", + GlobalRootDir: ".codex/skills", + Universal: true, + }, + { + Name: "opencode", + DisplayName: "OpenCode", + ProjectRootDir: ".opencode/skills", + GlobalRootDir: ".opencode/skills", + }, + }, nil + } + state.detectAgents = func(setup.ResolverOptions) ([]setup.Agent, error) { + return nil, nil + } + state.previewSkills = func( + _ setup.ResolverOptions, + skills []setup.Skill, + agents []string, + _ bool, + _ setup.InstallMode, + ) ([]setup.PreviewItem, error) { + if got := strings.Join(agents, ","); got != "codex,opencode" { + t.Fatalf("selected agents = %q, want codex,opencode", got) + } + return []setup.PreviewItem{ + {Skill: skills[0], Agent: setup.Agent{Name: agents[0], DisplayName: "Codex"}}, + {Skill: skills[0], Agent: setup.Agent{Name: agents[1], DisplayName: "OpenCode"}}, + }, nil + } + state.previewReusableAgents = func(setup.ReusableAgentInstallConfig) ([]setup.ReusableAgentPreviewItem, error) { + return nil, nil + } + state.installSkills = func( + _ setup.ResolverOptions, + _ []setup.Skill, + agents []string, + _ bool, + _ setup.InstallMode, + ) ([]setup.SuccessItem, []setup.FailureItem, error) { + if got := strings.Join(agents, ","); got != "codex,opencode" { + t.Fatalf("install agents = %q, want codex,opencode", got) + } + return nil, nil, nil + } + state.installReusableAgents = func( + setup.ReusableAgentInstallConfig, + ) ([]setup.ReusableAgentSuccessItem, []setup.ReusableAgentFailureItem, error) { + return nil, nil, nil + } + + cmd := &cobra.Command{Use: "setup"} + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.Flags().Bool("global", false, "global") + cmd.Flags().Bool("copy", false, "copy") + + if err := state.run(cmd, nil); err != nil { + t.Fatalf("run setup --all-agents: %v", err) + } +} + +func TestSetupRunYesWithOpenCodeTargetsOnlyOpenCode(t *testing.T) { + t.Parallel() + + state := newSetupCommandState() + state.yes = true + state.agentNames = []string{"opencode"} + state.loadCatalog = func(_ context.Context, _ setup.ResolverOptions) (setup.EffectiveCatalog, error) { + return setup.EffectiveCatalog{ + Skills: []setup.Skill{{Name: "productize", Description: "Core workflow"}}, + }, nil + } + state.listAgents = func(setup.ResolverOptions) ([]setup.Agent, error) { + return []setup.Agent{ + { + Name: "codex", + DisplayName: "Codex", + ProjectRootDir: ".agents/skills", + GlobalRootDir: ".codex/skills", + Universal: true, + }, + { + Name: "opencode", + DisplayName: "OpenCode", + ProjectRootDir: ".opencode/skills", + GlobalRootDir: ".opencode/skills", + }, + }, nil + } + state.detectAgents = func(setup.ResolverOptions) ([]setup.Agent, error) { + return []setup.Agent{ + { + Name: "codex", + DisplayName: "Codex", + ProjectRootDir: ".agents/skills", + GlobalRootDir: ".codex/skills", + Universal: true, + }, + { + Name: "opencode", + DisplayName: "OpenCode", + ProjectRootDir: ".opencode/skills", + GlobalRootDir: ".opencode/skills", + }, + }, nil + } + state.previewSkills = func( + _ setup.ResolverOptions, + skills []setup.Skill, + agents []string, + _ bool, + _ setup.InstallMode, + ) ([]setup.PreviewItem, error) { + if got := strings.Join(agents, ","); got != "opencode" { + t.Fatalf("selected agents = %q, want opencode", got) + } + return []setup.PreviewItem{ + {Skill: skills[0], Agent: setup.Agent{Name: "opencode", DisplayName: "OpenCode"}}, + }, nil + } + state.previewReusableAgents = func(setup.ReusableAgentInstallConfig) ([]setup.ReusableAgentPreviewItem, error) { + return nil, nil + } + state.installSkills = func( + _ setup.ResolverOptions, + _ []setup.Skill, + agents []string, + _ bool, + _ setup.InstallMode, + ) ([]setup.SuccessItem, []setup.FailureItem, error) { + if got := strings.Join(agents, ","); got != "opencode" { + t.Fatalf("install agents = %q, want opencode", got) + } + return nil, nil, nil + } + state.installReusableAgents = func( + setup.ReusableAgentInstallConfig, + ) ([]setup.ReusableAgentSuccessItem, []setup.ReusableAgentFailureItem, error) { + return nil, nil, nil + } + + cmd := &cobra.Command{Use: "setup"} + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.Flags().Bool("global", false, "global") + cmd.Flags().Bool("copy", false, "copy") + + if err := state.run(cmd, nil); err != nil { + t.Fatalf("run setup --agent opencode: %v", err) + } +} + +func TestSetupDoctorJSONIsReadOnlyAndReportsTargets(t *testing.T) { + t.Parallel() + + state := newSetupCommandState() + state.doctor = true + state.format = "json" + state.agentNames = []string{"codex"} + state.loadCatalog = func(_ context.Context, _ setup.ResolverOptions) (setup.EffectiveCatalog, error) { + return setup.EffectiveCatalog{ + Skills: []setup.Skill{ + {Name: "productize", Description: "Core workflow", Origin: setup.AssetOriginBundled}, + }, + }, nil + } + state.listAgents = func(setup.ResolverOptions) ([]setup.Agent, error) { + return []setup.Agent{ + { + Name: "codex", + DisplayName: "Codex", + ProjectRootDir: ".agents/skills", + GlobalRootDir: ".codex/skills", + Universal: true, + Detected: true, + }, + }, nil + } + state.detectAgents = func(setup.ResolverOptions) ([]setup.Agent, error) { + return []setup.Agent{ + { + Name: "codex", + DisplayName: "Codex", + ProjectRootDir: ".agents/skills", + GlobalRootDir: ".codex/skills", + Universal: true, + Detected: true, + }, + }, nil + } + state.installSkills = func( + setup.ResolverOptions, + []setup.Skill, + []string, + bool, + setup.InstallMode, + ) ([]setup.SuccessItem, []setup.FailureItem, error) { + t.Fatal("doctor must not install skills") + return nil, nil, nil + } + state.installReusableAgents = func( + setup.ReusableAgentInstallConfig, + ) ([]setup.ReusableAgentSuccessItem, []setup.ReusableAgentFailureItem, error) { + t.Fatal("doctor must not install reusable agents") + return nil, nil, nil + } + + cmd := &cobra.Command{Use: "setup"} + var output bytes.Buffer + cmd.SetOut(&output) + cmd.SetErr(&output) + + if err := state.run(cmd, nil); err != nil { + t.Fatalf("run setup doctor: %v\noutput:\n%s", err, output.String()) + } + + var report setupDoctorReport + if err := json.Unmarshal(output.Bytes(), &report); err != nil { + t.Fatalf("decode setup doctor JSON: %v\noutput:\n%s", err, output.String()) + } + if len(report.SelectedAgents) != 1 || report.SelectedAgents[0].Name != "codex" { + t.Fatalf("unexpected selected agents: %#v", report.SelectedAgents) + } + if len(report.SkillTargets) != 1 || report.SkillTargets[0].Name != "productize" { + t.Fatalf("unexpected skill targets: %#v", report.SkillTargets) + } + if !strings.HasSuffix(report.CatalogPath, ".productize/catalog/skills.json") { + t.Fatalf("unexpected catalog path: %q", report.CatalogPath) + } +} + +func TestSetupDoctorTextReportsTargets(t *testing.T) { + t.Parallel() + + state := newSetupCommandState() + state.doctor = true + state.agentNames = []string{"codex"} + state.loadCatalog = func(_ context.Context, _ setup.ResolverOptions) (setup.EffectiveCatalog, error) { + return setup.EffectiveCatalog{ + Skills: []setup.Skill{ + {Name: "productize", Description: "Core workflow", Origin: setup.AssetOriginBundled}, + }, + }, nil + } + state.listAgents = func(setup.ResolverOptions) ([]setup.Agent, error) { + return []setup.Agent{ + { + Name: "codex", + DisplayName: "Codex", + ProjectRootDir: ".agents/skills", + GlobalRootDir: ".codex/skills", + Universal: true, + }, + }, nil + } + state.detectAgents = func(setup.ResolverOptions) ([]setup.Agent, error) { + return []setup.Agent{ + { + Name: "codex", + DisplayName: "Codex", + ProjectRootDir: ".agents/skills", + GlobalRootDir: ".codex/skills", + Universal: true, + Detected: true, + }, + }, nil + } + + cmd := &cobra.Command{Use: "setup"} + var output bytes.Buffer + cmd.SetOut(&output) + cmd.SetErr(&output) + + if err := state.run(cmd, nil); err != nil { + t.Fatalf("run setup doctor text: %v\noutput:\n%s", err, output.String()) + } + for _, snippet := range []string{ + "Setup Doctor", + "Detected Agents", + "Selected Agents", + "Skill Install Targets", + "Reusable Agent Targets", + } { + if !strings.Contains(output.String(), snippet) { + t.Fatalf("expected setup doctor text to include %q\noutput:\n%s", snippet, output.String()) + } + } +} diff --git a/internal/setup/bundle_test.go b/internal/setup/bundle_test.go index d6ee086e..37ab45b8 100644 --- a/internal/setup/bundle_test.go +++ b/internal/setup/bundle_test.go @@ -71,22 +71,25 @@ func TestBundledWorkflowMemorySkillIncludesReferenceFile(t *testing.T) { } } -func TestListBundledReusableAgentsAllowsEmptyRoster(t *testing.T) { - t.Run("Should return empty bundled reusable-agent roster when none exist", func(t *testing.T) { +func TestListBundledReusableAgentsExposesOperatorAgent(t *testing.T) { + t.Run("Should include the bundled Productize operator agent", func(t *testing.T) { t.Parallel() reusableAgents, err := ListBundledReusableAgents() if err != nil { t.Fatalf("list bundled reusable agents: %v", err) } - if len(reusableAgents) != 0 { - t.Fatalf("expected bundled reusable-agent roster to be empty, got %#v", reusableAgents) + if len(reusableAgents) != 1 { + t.Fatalf("expected one bundled reusable agent, got %#v", reusableAgents) + } + if reusableAgents[0].Name != "productize-operator" { + t.Fatalf("expected productize-operator, got %#v", reusableAgents[0]) } }) } -func TestBundledReusableAgentsRootRemainsReadableWhenEmpty(t *testing.T) { - t.Run("Should keep bundled reusable agents root readable when the roster is empty", func(t *testing.T) { +func TestBundledReusableAgentsRootRemainsReadable(t *testing.T) { + t.Run("Should keep bundled reusable agents root readable", func(t *testing.T) { t.Parallel() bundle, err := bundledReusableAgentsRoot() diff --git a/skills/productize-runtime/SKILL.md b/skills/productize-runtime/SKILL.md index 7a750b7b..a878e419 100644 --- a/skills/productize-runtime/SKILL.md +++ b/skills/productize-runtime/SKILL.md @@ -23,7 +23,7 @@ Key characteristics: The standard development pipeline follows these phases in order. Each phase produces artifacts consumed by the next. -1. **Setup** -- `productize setup` installs core skills into target agents plus any setup assets shipped by enabled extensions. +1. **Setup** -- `productize setup` installs the skill catalog into explicitly selected target agents plus any setup assets shipped by enabled extensions. 2. **Existing Project Adoption** (recommended for mature repos) -- `productize init existing` writes durable repo context under `.productize/project/`. 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. diff --git a/skills/productize-runtime/references/cli-reference.md b/skills/productize-runtime/references/cli-reference.md index c6525b76..68e5839c 100644 --- a/skills/productize-runtime/references/cli-reference.md +++ b/skills/productize-runtime/references/cli-reference.md @@ -42,7 +42,7 @@ not invoke an AI model. ### `productize setup` -Install core workflow skills into target agents plus any setup assets shipped by enabled extensions. +Install the Productize skill catalog into explicitly selected target agents plus any setup assets shipped by enabled extensions. | Flag | Type | Default | Description | | --- | --- | --- | --- | @@ -50,18 +50,26 @@ Install core workflow skills into target agents plus any setup assets shipped by | `--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 | -| `--list`, `-l` | bool | false | List core skills and enabled extension assets without installing | +| `--list`, `-l` | bool | false | List setup assets without installing | +| `--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` | bool | false | Install all skills to all agents without prompts | +| `--all-agents` | bool | false | 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 | ``` productize setup productize setup --list +productize setup --doctor --agent codex --format json productize setup --agent codex --agent claude --skill create-prd --yes -productize setup --all +productize setup --all-agents --yes productize setup --agent cursor --global --copy --yes ``` +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. + ### `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 030d8619..6e560f19 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. **Run setup.** Execute `productize setup` to install core skills into target agents plus setup assets from enabled extensions. For a quick start: `productize setup --all`. +2. **Run setup.** Execute `productize setup` to install the skill catalog into target agents plus setup assets from enabled extensions. For non-interactive setup, pick an explicit target such as `productize setup --agent codex --yes`; use `productize setup --all-agents --yes` only when you intentionally want every supported agent/editor destination. 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 ac2dc237..513056ab 100644 --- a/skills/productize/SKILL.md +++ b/skills/productize/SKILL.md @@ -84,6 +84,10 @@ Use the smallest entry point that owns the cadence: **Playbooks** +- `/productize build `: main operator entry point. Inspect project + knowledge and workflow artifacts, choose the next lifecycle route, and return + a concrete route plan with approval/edit options. Tactical skills are internal + implementation details behind this route. - `/productize adopt`: existing repository adoption. Run `productize init existing`, read generated `.productize/project/` context, then recommend the next workflow route. - `/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. @@ -114,6 +118,7 @@ Use the smallest entry point that owns the cadence: | User intent | Route | Signals | |---|---|---| | I have an idea | Think | thesis framing, opportunity, wedge | +| I want to build a feature or change | Operator Build Route | `/productize build`, build, implement, add, improve, ship | | I need to use Productize on an existing repo | Adopt Existing Project | adopt, existing project, mature repo, repo context, project memory | | I need positioning/growth | Strategize / Growth | positioning, brand, PMF, AARRR, CAC/LTV | | I need a PRD/spec | Plan / Build With AI | PRDs, requirements, technical handoff, implementation plan | @@ -141,6 +146,12 @@ Use the smallest entry point that owns the cadence: 1. Classify the user's persona, product stage, artifact mode, evidence state, and decision mode. + - If the user asks `/productize build `, use the operator build + route. Read `.productize/project/context.md`, `.productize/project/conventions.md`, + and `.productize/project/decisions.md` when present. Inspect + `.productize/tasks/` for existing PRD, TechSpec, task, and review artifacts. + Return the required route-plan contract below and ask whether to approve or + edit the route before mutating files or running agents. - If the user asks to adopt, onboard, or initialize Productize for an existing repository, route to `/productize adopt`: run `productize init existing`, read `.productize/project/context.md`, `.productize/project/conventions.md`, and `.productize/project/decisions.md`, then recommend the next workflow route. 2. Route to the narrowest Productize skill that can produce the artifact or build step. If the request spans stages, sequence the skills and explain the order. @@ -176,6 +187,42 @@ Use the smallest entry point that owns the cadence: ## Output Contract +For `/productize build `, return this operator route plan: + +```text +Route selected: +1. ... + +Why: +... + +Project context read: +... + +Missing artifacts: +... + +Next command: +... + +Approval needed: +... +``` + +Route rules for `/productize build `: + +- If `.productize/project/context.md` is missing, make `productize init existing` + the first route step. +- If project context exists but PRD, TechSpec, or tasks are missing, route through + `/create-prd`, `/create-techspec`, and `/create-tasks` in that order. +- If task files exist, route to `productize tasks run `. +- If review artifacts exist, route to review/fix before completion. +- Always name the exact next command or skill. +- Ask for approval before any step that writes files, runs an implementation + agent, changes git state, or starts a long-running daemon task. + +For all other Productize routes: + Return: 1. **Route**: selected skill or skill sequence, with a one-sentence reason. diff --git a/test/skills_bundle_test.go b/test/skills_bundle_test.go index 64409dba..ea3e96a2 100644 --- a/test/skills_bundle_test.go +++ b/test/skills_bundle_test.go @@ -173,6 +173,32 @@ func TestCreateTasksSkillDocumentsTaskTypeRegistryAndValidation(t *testing.T) { } } +func TestProductizeSkillDocumentsBuildOperatorRoute(t *testing.T) { + t.Parallel() + + root := repoRoot(t) + skillPath := filepath.Join(root, "skills", "productize", "SKILL.md") + content, err := os.ReadFile(skillPath) + if err != nil { + t.Fatalf("read %s: %v", skillPath, err) + } + + required := []string{ + "/productize build ", + "Route selected:", + "Project context read:", + "Missing artifacts:", + "Next command:", + "Approval needed:", + "productize init existing", + } + for _, snippet := range required { + if !strings.Contains(string(content), snippet) { + t.Fatalf("expected %s to include %q", skillPath, snippet) + } + } +} + func TestTaskDocsOmitLegacyTaskFrontmatterKeys(t *testing.T) { t.Parallel()