diff --git a/README.md b/README.md index f6fea03..3deb769 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ dhq completion bash | zsh | fish | powershell dhq doctor (health check) dhq update (self-update to latest version) dhq skills list | install (auto-detect AI agents and install the DeployHQ skill) -dhq setup claude | codex | cursor | windsurf (install agent plugins, --project for project-level) +dhq setup claude | codex | cursor | windsurf (deprecated — use 'dhq skills') dhq mcp (start MCP server in stdio mode) ``` @@ -318,9 +318,9 @@ bare `dhq skills install`; **project-scope** agents write into the current repository and require an explicit `--agent` flag so login never mutates a repo as a side effect. -`dhq setup ` is a narrower alternative that installs the agent plugin for -a single tool (Claude Code, Codex, Cursor, or Windsurf), with `--project` for -project-level installs. +> **Deprecated:** `dhq setup ` (Claude Code, Codex, Cursor, Windsurf) is +> the older, narrower predecessor of `dhq skills install`. It still works but +> warns on use and will be removed in a future release — prefer `dhq skills`. ### Other agent helpers diff --git a/internal/commands/setup.go b/internal/commands/setup.go index de0ca99..a0cc588 100644 --- a/internal/commands/setup.go +++ b/internal/commands/setup.go @@ -45,6 +45,7 @@ type agentSetup struct { Use string Short string Name string + SkillsName string // equivalent target name for `dhq skills install --agent` PathFor func(scope) (string, error) Content func() []byte StrategyFor func(scope) writeStrategy @@ -59,6 +60,7 @@ var agents = []agentSetup{ Use: "claude", Short: "Install Claude Code skill", Name: "Claude Code", + SkillsName: "claude-code", PathFor: pathClaude, Content: func() []byte { return []byte(skillFrontmatter + skillBody) }, StrategyFor: always(strategyOverwrite), @@ -67,6 +69,7 @@ var agents = []agentSetup{ Use: "codex", Short: "Install OpenAI Codex AGENTS.md section", Name: "Codex", + SkillsName: "codex", PathFor: pathCodex, Content: func() []byte { return []byte(skillBody) }, StrategyFor: always(strategyMarkedBlock), @@ -75,15 +78,17 @@ var agents = []agentSetup{ Use: "cursor", Short: "Install Cursor project rule", Name: "Cursor", + SkillsName: "cursor", PathFor: pathCursor, Content: func() []byte { return []byte(cursorFrontmatter + skillBody) }, StrategyFor: always(strategyOverwrite), }, { - Use: "windsurf", - Short: "Install Windsurf integration", - Name: "Windsurf", - PathFor: pathWindsurf, + Use: "windsurf", + Short: "Install Windsurf integration", + Name: "Windsurf", + SkillsName: "windsurf", + PathFor: pathWindsurf, Content: func() []byte { return []byte(skillBody) }, // User-level writes into the shared ~/.codeium/.../global_rules.md, so we // must merge with a marker block. Project-level writes a dedicated file we own. @@ -99,8 +104,12 @@ var agents = []agentSetup{ func newSetupCmd() *cobra.Command { cmd := &cobra.Command{ Use: "setup", - Short: "Install agent plugins", - Long: "Install DeployHQ agent integration files for AI coding assistants.", + Short: "Install agent plugins (deprecated — use 'dhq skills install')", + Long: "Install DeployHQ agent integration files for AI coding assistants.\n\n" + + "DEPRECATED: 'dhq setup' is superseded by 'dhq skills install', which\n" + + "auto-detects installed agents and supports 12 of them (vs the 4 here).\n" + + "This command still works but will be removed in a future release.\n" + + "Migrate with 'dhq skills install' (or 'dhq skills install --agent ').", } for _, a := range agents { cmd.AddCommand(newAgentSetupCmd(a)) @@ -118,6 +127,14 @@ func newAgentSetupCmd(a agentSetup) *cobra.Command { Long: longHelp(a), Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { + env := cliCtx.Envelope + // 'dhq setup' is deprecated in favour of 'dhq skills'. Warn on every + // use (stderr, so JSON/data on stdout is unaffected). The note is + // mode-aware: 'dhq skills' has no uninstall and installs at each + // agent's own default scope, so we don't claim a like-for-like + // replacement for the --uninstall or --project paths. + env.Warn("%s", setupDeprecationNote(a, uninstall, project)) + sc := scopeUser if project { sc = scopeProject @@ -133,7 +150,6 @@ func newAgentSetupCmd(a agentSetup) *cobra.Command { return &output.InternalError{Message: "resolve install path", Cause: err} } - env := cliCtx.Envelope strategy := a.StrategyFor(sc) if uninstall { return runUninstall(env, a, path, strategy) @@ -148,12 +164,39 @@ func newAgentSetupCmd(a agentSetup) *cobra.Command { return cmd } +// setupDeprecationNote builds the per-run deprecation warning for `dhq setup`. +// It is mode-aware because the successor command isn't a like-for-like drop-in: +// - `dhq skills` has no uninstall, so the --uninstall path keeps using setup; +// - `dhq skills install --agent ` installs at that agent's own default +// scope (e.g. Cursor is user-scope there, project-scope here), so we don't +// promise scope equivalence on the --project path. +func setupDeprecationNote(a agentSetup, uninstall, project bool) string { + switch { + case uninstall: + return fmt.Sprintf( + "'dhq setup' is deprecated and will be removed in a future release. "+ + "'dhq skills' has no uninstall yet, so 'dhq setup %s --uninstall' "+ + "remains the way to remove this integration for now.", a.Use) + case project: + return fmt.Sprintf( + "'dhq setup' is deprecated; prefer 'dhq skills install --agent %s'. "+ + "Note 'dhq skills' installs at that agent's default scope rather than "+ + "project-local, and 'dhq setup' will be removed in a future release.", a.SkillsName) + default: + return fmt.Sprintf( + "'dhq setup' is deprecated; use 'dhq skills install --agent %s' instead "+ + "(auto-detects agents and supports 12 of them). 'dhq setup' will be "+ + "removed in a future release.", a.SkillsName) + } +} + func longHelp(a agentSetup) string { userPath, userErr := a.PathFor(scopeUser) projPath, _ := a.PathFor(scopeProject) var b strings.Builder fmt.Fprintf(&b, "Install %s integration files.\n\n", a.Name) + fmt.Fprintf(&b, "DEPRECATED: use 'dhq skills install --agent %s' instead.\n\n", a.SkillsName) if userErr == nil { fmt.Fprintf(&b, "Default (user-global): %s\n", userPath) } else { diff --git a/internal/commands/setup_test.go b/internal/commands/setup_test.go new file mode 100644 index 0000000..5f9ff45 --- /dev/null +++ b/internal/commands/setup_test.go @@ -0,0 +1,97 @@ +package commands + +import ( + "bytes" + "io" + "strings" + "testing" + + "github.com/deployhq/deployhq-cli/internal/cli" + "github.com/deployhq/deployhq-cli/internal/output" + "github.com/deployhq/deployhq-cli/internal/skillinstaller" +) + +// TestSetupSkillsNamesResolveToTargets locks the setup→skills agent-name +// mapping: every SkillsName the deprecation note points at must resolve to a +// real registered skills target, or the migration hint sends users to a +// nonexistent --agent value. +func TestSetupSkillsNamesResolveToTargets(t *testing.T) { + for _, a := range agents { + if a.SkillsName == "" { + t.Errorf("agent %q has no SkillsName for the deprecation hint", a.Use) + continue + } + if skillinstaller.Find(a.SkillsName) == nil { + t.Errorf("agent %q maps to SkillsName %q, which is not a registered 'dhq skills' target", + a.Use, a.SkillsName) + } + } +} + +// TestSetupDeprecationNote verifies the note is mode-aware: it must not tell +// uninstall users to run an install command, and must not promise scope +// equivalence on the --project path. +func TestSetupDeprecationNote(t *testing.T) { + a := agentSetup{Use: "claude", SkillsName: "claude-code"} + + t.Run("install", func(t *testing.T) { + note := setupDeprecationNote(a, false, false) + mustContain(t, note, "deprecated") + mustContain(t, note, "dhq skills install --agent claude-code") + }) + + t.Run("project", func(t *testing.T) { + note := setupDeprecationNote(a, false, true) + mustContain(t, note, "claude-code") + // Must flag that skills uses its own default scope, not project-local. + mustContain(t, note, "default scope") + }) + + t.Run("uninstall", func(t *testing.T) { + note := setupDeprecationNote(a, true, false) + mustContain(t, note, "no uninstall") + mustContain(t, note, "dhq setup claude --uninstall") + // Must NOT point uninstall users at an install command. + if strings.Contains(note, "install --agent") { + t.Errorf("uninstall note should not suggest an install command: %q", note) + } + }) +} + +// TestSetupCommand_WarnsOnStderrNotStdout drives a real setup subcommand and +// asserts the deprecation warning lands on stderr while stdout stays clean — +// the load-bearing output-contract property of the deprecation. +func TestSetupCommand_WarnsOnStderrNotStdout(t *testing.T) { + t.Setenv("HOME", t.TempDir()) // isolate from the real ~/.claude + + var stdout, stderr bytes.Buffer + origCtx := cliCtx + t.Cleanup(func() { cliCtx = origCtx }) + cliCtx = &cli.Context{ + Envelope: &output.Envelope{Stdout: &stdout, Stderr: &stderr, Logger: output.NewLogger()}, + } + + root := newSetupCmd() + // --uninstall on a fresh HOME removes nothing (no files written), so this + // exercises the warning path without filesystem side effects. + root.SetArgs([]string{"claude", "--uninstall"}) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + if err := root.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + + if !strings.Contains(stderr.String(), "deprecated") { + t.Errorf("deprecation warning missing from stderr: %q", stderr.String()) + } + if strings.TrimSpace(stdout.String()) != "" { + t.Errorf("stdout must stay clean (data channel), got: %q", stdout.String()) + } +} + +func mustContain(t *testing.T, s, sub string) { + t.Helper() + if !strings.Contains(s, sub) { + t.Errorf("expected %q to contain %q", s, sub) + } +} diff --git a/skills/deployhq/references/auth-setup.md b/skills/deployhq/references/auth-setup.md index 677f2f4..168f6df 100644 --- a/skills/deployhq/references/auth-setup.md +++ b/skills/deployhq/references/auth-setup.md @@ -110,39 +110,39 @@ account = "mycompany" ## Agent Setup -### `dhq setup claude` -Install Claude Code integration files. +### `dhq skills` (preferred) -| Flag | Description | -|------|-------------| -| `--project` | Install to project directory (`.claude/`) instead of user (`~/.claude/`) | -| `--uninstall` | Remove integration files | - -```bash -dhq setup claude -dhq setup claude --project -dhq setup claude --uninstall -``` - -### `dhq setup codex` -Install OpenAI Codex integration. +Install the DeployHQ skill into the AI coding agents on this machine. `dhq skills` +auto-detects installed agents and supports 12 of them (Aider, Antigravity, Claude +Code, Cline, Codex CLI, Continue.dev, Cursor, Gemini CLI, GitHub Copilot, Kiro CLI, +OpenCode, Windsurf). ```bash -dhq setup codex +dhq skills list # detected agents + skill status +dhq skills install # install for detected user-scope agents +dhq skills install --agent claude-code # install for a specific agent +dhq skills install --agent copilot # project-scope agents are opt-in via --agent ``` -### `dhq setup cursor` -Install Cursor integration. +User-scope agents install into your home directory and are covered by the bare +`dhq skills install`; project-scope agents write into the current repository and +require an explicit `--agent` flag. -```bash -dhq setup cursor -``` +### `dhq setup` (deprecated) -### `dhq setup windsurf` -Install Windsurf integration. +> **Deprecated:** `dhq setup claude|codex|cursor|windsurf` is the older, narrower +> predecessor of `dhq skills install`. It still works but warns on use and will be +> removed in a future release. Prefer `dhq skills`. Two caveats when migrating: +> `dhq skills` has no uninstall yet (use `dhq setup --uninstall` to remove), +> and it installs at each agent's own default scope, which may differ from +> `dhq setup --project`. ```bash -dhq setup windsurf +dhq setup claude # ~ dhq skills install --agent claude-code +dhq setup codex # ~ dhq skills install --agent codex +dhq setup cursor # ~ dhq skills install --agent cursor +dhq setup windsurf # ~ dhq skills install --agent windsurf +dhq setup claude --uninstall # remove (no dhq skills equivalent yet) ``` ### `dhq mcp`