From 94d0100594bc4bfd6e30a0956d101070fdd4abe4 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Mon, 11 May 2026 15:21:56 +0200 Subject: [PATCH 1/2] fix(setup): correct agent install paths and rename cmd/deployhq -> cmd/dhq dhq setup was writing SKILL.md to paths none of the four agents auto-discover (~/.claude/SKILL.md, ~/.codex/SKILL.md, ~/.cursor/SKILL.md, ~/.windsurf/SKILL.md). Each agent now writes to its canonical location: - claude: ~/.claude/skills/deployhq/SKILL.md (user) or .claude/skills/deployhq/SKILL.md (project) - codex: ~/.codex/AGENTS.md (user) or ./AGENTS.md (project), via marker block - cursor: .cursor/rules/deployhq.mdc with Cursor .mdc frontmatter; user-level errors with hint - windsurf: ~/.codeium/windsurf/memories/global_rules.md (user, marker block) or .windsurf/rules/deployhq.md (project) Shared files (AGENTS.md, global_rules.md) use BEGIN dhq / END dhq markers so reinstall is idempotent and uninstall removes only our block. Also rename cmd/deployhq/ -> cmd/dhq/ so `go install .../cmd/dhq@latest` produces a binary named dhq (matching brew/scoop). Updates .goreleaser.yaml, README install instructions, and CLAUDE.md build command accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) --- .goreleaser.yaml | 2 +- CLAUDE.md | 2 +- README.md | 4 +- cmd/{deployhq => dhq}/main.go | 0 internal/commands/setup.go | 384 +++++++++++++++++++++++++--------- 5 files changed, 293 insertions(+), 99 deletions(-) rename cmd/{deployhq => dhq}/main.go (100%) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 743c24f..c0fec1b 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -7,7 +7,7 @@ before: builds: - id: dhq - main: ./cmd/deployhq + main: ./cmd/dhq binary: dhq env: - CGO_ENABLED=0 diff --git a/CLAUDE.md b/CLAUDE.md index b1600ec..d69e50e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ ## Development ```bash -go build ./cmd/deployhq/ # binary outputs as dhq # Build binary +go build ./cmd/dhq/ # binary outputs as dhq # Build binary go test ./... -v # Run all tests (96 tests) go vet ./... # Static analysis ``` diff --git a/README.md b/README.md index 4932cc2..0951a0d 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ scoop install dhq ### Go ```bash -go install github.com/deployhq/deployhq-cli/cmd/deployhq@latest +go install github.com/deployhq/deployhq-cli/cmd/dhq@latest ``` ### Binary @@ -356,7 +356,7 @@ dep, _ := client.CreateDeployment(ctx, "my-app", sdk.DeploymentCreateRequest{ ## Development ```bash -go build ./cmd/deployhq/ +go build ./cmd/dhq/ go test ./... -v go vet ./... ``` diff --git a/cmd/deployhq/main.go b/cmd/dhq/main.go similarity index 100% rename from cmd/deployhq/main.go rename to cmd/dhq/main.go diff --git a/internal/commands/setup.go b/internal/commands/setup.go index e1728eb..02e050a 100644 --- a/internal/commands/setup.go +++ b/internal/commands/setup.go @@ -1,48 +1,98 @@ package commands import ( + "errors" "fmt" "os" "path/filepath" + "strings" "github.com/deployhq/deployhq-cli/internal/output" "github.com/spf13/cobra" ) -// agentSetup defines the configuration for an agent integration command. +// blockBegin / blockEnd delimit the section managed by `dhq setup` +// inside files that may also contain unrelated user content (AGENTS.md, global_rules.md). +const ( + blockBegin = "" + blockEnd = "" +) + +// errUnsupportedScope is returned when an agent does not support a given scope. +var errUnsupportedScope = errors.New("scope not supported") + +type scope string + +const ( + scopeUser scope = "user" + scopeProject scope = "project" +) + +// writeStrategy controls how content is combined with the target file. +type writeStrategy int + +const ( + // strategyOverwrite writes the entire file. Used when we own a dedicated path + // (e.g. ~/.claude/skills/deployhq/SKILL.md, .cursor/rules/deployhq.mdc). + strategyOverwrite writeStrategy = iota + // strategyMarkedBlock inserts or replaces our delimited block within a shared + // file (AGENTS.md, global_rules.md). On uninstall only the block is removed. + strategyMarkedBlock +) + +// agentSetup describes a single agent integration target. type agentSetup struct { - Use string // cobra Use field (e.g. "claude") - Short string // one-line description - Name string // display name (e.g. "Claude Code") - DotDir string // directory name (e.g. ".claude") - ExtraFile string // optional extra file to write (besides SKILL.md) + Use string + Short string + Name string + PathFor func(scope) (string, error) + Content func() []byte + StrategyFor func(scope) writeStrategy +} + +func always(s writeStrategy) func(scope) writeStrategy { + return func(scope) writeStrategy { return s } } var agents = []agentSetup{ { - Use: "claude", - Short: "Install Claude Code integration", - Name: "Claude Code", - DotDir: ".claude", - ExtraFile: "deployhq-commands.md", + Use: "claude", + Short: "Install Claude Code skill", + Name: "Claude Code", + PathFor: pathClaude, + Content: func() []byte { return []byte(skillFrontmatter + skillBody) }, + StrategyFor: always(strategyOverwrite), }, { - Use: "codex", - Short: "Install OpenAI Codex integration", - Name: "Codex", - DotDir: ".codex", + Use: "codex", + Short: "Install OpenAI Codex AGENTS.md section", + Name: "Codex", + PathFor: pathCodex, + Content: func() []byte { return []byte(skillBody) }, + StrategyFor: always(strategyMarkedBlock), }, { - Use: "cursor", - Short: "Install Cursor integration", - Name: "Cursor", - DotDir: ".cursor", + Use: "cursor", + Short: "Install Cursor project rule", + Name: "Cursor", + PathFor: pathCursor, + Content: func() []byte { return []byte(cursorFrontmatter + skillBody) }, + StrategyFor: always(strategyOverwrite), }, { - Use: "windsurf", - Short: "Install Windsurf integration", - Name: "Windsurf", - DotDir: ".windsurf", + Use: "windsurf", + Short: "Install Windsurf integration", + Name: "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. + StrategyFor: func(sc scope) writeStrategy { + if sc == scopeUser { + return strategyMarkedBlock + } + return strategyOverwrite + }, }, } @@ -52,11 +102,9 @@ func newSetupCmd() *cobra.Command { Short: "Install agent plugins", Long: "Install DeployHQ agent integration files for AI coding assistants.", } - for _, a := range agents { cmd.AddCommand(newAgentSetupCmd(a)) } - return cmd } @@ -67,102 +115,237 @@ func newAgentSetupCmd(a agentSetup) *cobra.Command { cmd := &cobra.Command{ Use: a.Use, Short: a.Short, - Long: fmt.Sprintf(`Install %s agent integration files. - -By default, files are installed in ~/%s/ (user-level) so the skill -is available in all sessions. Use --project to install in the current -directory's %s/ instead.`, a.Name, a.DotDir, a.DotDir), + Long: longHelp(a), RunE: func(cmd *cobra.Command, args []string) error { - env := cliCtx.Envelope - - scope := "user" - dir := "" + sc := scopeUser if project { - dir = a.DotDir - scope = "project" - } else { - home, err := os.UserHomeDir() - if err != nil { - return &output.InternalError{Message: "find home directory", Cause: err} - } - dir = filepath.Join(home, a.DotDir) + sc = scopeProject } - // Collect the files this agent installs - files := installedFiles(a, dir) + path, err := a.PathFor(sc) + if err != nil { + if errors.Is(err, errUnsupportedScope) { + return &output.UserError{ + Message: fmt.Sprintf("%s does not support %s-level install; rerun with --project", a.Name, sc), + } + } + return &output.InternalError{Message: "resolve install path", Cause: err} + } + env := cliCtx.Envelope + strategy := a.StrategyFor(sc) if uninstall { - return removeSetupFiles(files, dir, a.Name) + return runUninstall(env, a, path, strategy) } + return runInstall(env, a, path, sc, project, strategy) + }, + } - if err := os.MkdirAll(dir, 0755); err != nil { - return &output.InternalError{Message: fmt.Sprintf("create %s directory", a.DotDir), Cause: err} - } + cmd.Flags().BoolVar(&uninstall, "uninstall", false, "Remove installed files") + cmd.Flags().BoolVar(&project, "project", false, + "Install at project level (current directory) instead of user-global") + return cmd +} - // Write SKILL.md - if err := os.WriteFile(files["SKILL.md"], []byte(skillMD), 0644); err != nil { - return &output.InternalError{Message: "write SKILL.md", Cause: err} - } +func longHelp(a agentSetup) string { + userPath, userErr := a.PathFor(scopeUser) + projPath, _ := a.PathFor(scopeProject) - // Write optional extra file - if a.ExtraFile != "" { - content := "# DeployHQ CLI Commands\n\nRun `dhq commands --json` to get the full command catalog.\n\nRun `dhq --help` for usage information.\n" - if err := os.WriteFile(files[a.ExtraFile], []byte(content), 0644); err != nil { - return &output.InternalError{Message: fmt.Sprintf("write %s", a.ExtraFile), Cause: err} - } - } + var b strings.Builder + fmt.Fprintf(&b, "Install %s integration files.\n\n", a.Name) + if userErr == nil { + fmt.Fprintf(&b, "Default (user-global): %s\n", userPath) + } else { + fmt.Fprintf(&b, "User-global: not supported by %s\n", a.Name) + } + fmt.Fprintf(&b, "With --project: %s\n", projPath) + return b.String() +} - env.Status("Installed %s integration (%s-level):", a.Name, scope) - for _, p := range files { - env.Status(" %s", p) - } - uninstallFlag := "" - if project { - uninstallFlag = " --project" +func runInstall(env *output.Envelope, a agentSetup, path string, sc scope, project bool, strategy writeStrategy) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return &output.InternalError{Message: "create install directory", Cause: err} + } + + switch strategy { + case strategyOverwrite: + if err := os.WriteFile(path, a.Content(), 0644); err != nil { + return &output.InternalError{Message: "write " + path, Cause: err} + } + env.Status("Installed %s integration (%s-level): %s", a.Name, sc, path) + case strategyMarkedBlock: + added, err := upsertBlock(path, string(a.Content())) + if err != nil { + return &output.InternalError{Message: "update " + path, Cause: err} + } + verb := "Updated" + if added { + verb = "Added" + } + env.Status("%s DeployHQ block in %s (%s-level): %s", verb, a.Name, sc, path) + } + + uninstallFlag := "" + if project { + uninstallFlag = " --project" + } + env.Status("\nTo uninstall: dhq setup %s --uninstall%s", a.Use, uninstallFlag) + return nil +} + +func runUninstall(env *output.Envelope, a agentSetup, path string, strategy writeStrategy) error { + switch strategy { + case strategyOverwrite: + if err := os.Remove(path); err != nil { + if errors.Is(err, os.ErrNotExist) { + env.Status("%s integration not found at %s", a.Name, path) + return nil } - env.Status("\nTo uninstall: dhq setup %s --uninstall%s", a.Use, uninstallFlag) + return &output.InternalError{Message: "remove " + path, Cause: err} + } + // Best-effort cleanup of the deployhq-owned parent dir (e.g. .../skills/deployhq/). + _ = os.Remove(filepath.Dir(path)) + env.Status("Removed %s integration: %s", a.Name, path) + case strategyMarkedBlock: + removed, err := removeBlock(path) + if err != nil { + return &output.InternalError{Message: "update " + path, Cause: err} + } + if !removed { + env.Status("No DeployHQ block found in %s", path) return nil - }, + } + env.Status("Removed DeployHQ block from %s", path) } + return nil +} - cmd.Flags().BoolVar(&uninstall, "uninstall", false, "Remove installed files") - cmd.Flags().BoolVar(&project, "project", false, - fmt.Sprintf("Install to %s/ (project-level) instead of ~/%s/ (user-level)", a.DotDir, a.DotDir)) - return cmd +// ----- Path resolvers ------------------------------------------------------ + +func pathClaude(sc scope) (string, error) { + switch sc { + case scopeUser: + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".claude", "skills", "deployhq", "SKILL.md"), nil + case scopeProject: + return filepath.Join(".claude", "skills", "deployhq", "SKILL.md"), nil + } + return "", errUnsupportedScope } -// installedFiles returns a map of logical name → file path for the files an agent installs. -func installedFiles(a agentSetup, dir string) map[string]string { - files := map[string]string{ - "SKILL.md": filepath.Join(dir, "SKILL.md"), +func pathCodex(sc scope) (string, error) { + switch sc { + case scopeUser: + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".codex", "AGENTS.md"), nil + case scopeProject: + return "AGENTS.md", nil } - if a.ExtraFile != "" { - files[a.ExtraFile] = filepath.Join(dir, a.ExtraFile) + return "", errUnsupportedScope +} + +func pathCursor(sc scope) (string, error) { + // Cursor has no user-level rules directory; rules live in .cursor/rules/ per project. + if sc == scopeUser { + return "", errUnsupportedScope } - return files + return filepath.Join(".cursor", "rules", "deployhq.mdc"), nil } -// removeSetupFiles removes only the files we installed, not the whole directory -// (which may contain the user's own files like CLAUDE.md, memory, etc.). -func removeSetupFiles(files map[string]string, dir, name string) error { - removed := 0 - for _, f := range files { - if err := os.Remove(f); err == nil { - removed++ +func pathWindsurf(sc scope) (string, error) { + switch sc { + case scopeUser: + home, err := os.UserHomeDir() + if err != nil { + return "", err } + return filepath.Join(home, ".codeium", "windsurf", "memories", "global_rules.md"), nil + case scopeProject: + return filepath.Join(".windsurf", "rules", "deployhq.md"), nil } + return "", errUnsupportedScope +} - if removed == 0 { - cliCtx.Envelope.Status("%s integration not found at %s", name, dir) - } else { - cliCtx.Envelope.Status("Removed %s integration from %s (%d files)", name, dir, removed) +// ----- Marked-block helpers ------------------------------------------------ + +// upsertBlock inserts or replaces the dhq-managed block in the file at path. +// Returns true if the block was newly added (file created or block appended), +// false if an existing block was replaced. +func upsertBlock(path, content string) (added bool, err error) { + existing, readErr := os.ReadFile(path) + if readErr != nil && !errors.Is(readErr, os.ErrNotExist) { + return false, readErr } - return nil + + block := blockBegin + "\n" + strings.TrimRight(content, "\n") + "\n" + blockEnd + + if errors.Is(readErr, os.ErrNotExist) { + return true, os.WriteFile(path, []byte(block+"\n"), 0644) + } + + text := string(existing) + if start := strings.Index(text, blockBegin); start >= 0 { + rel := strings.Index(text[start:], blockEnd) + if rel < 0 { + return false, fmt.Errorf("found %s without matching %s", blockBegin, blockEnd) + } + end := start + rel + len(blockEnd) + return false, os.WriteFile(path, []byte(text[:start]+block+text[end:]), 0644) + } + + if !strings.HasSuffix(text, "\n") { + text += "\n" + } + text += "\n" + block + "\n" + return true, os.WriteFile(path, []byte(text), 0644) +} + +// removeBlock removes the dhq-managed block from the file at path. +// If the file becomes empty afterwards, it is deleted. +func removeBlock(path string) (bool, error) { + existing, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, err + } + text := string(existing) + start := strings.Index(text, blockBegin) + if start < 0 { + return false, nil + } + rel := strings.Index(text[start:], blockEnd) + if rel < 0 { + return false, fmt.Errorf("found %s without matching %s", blockBegin, blockEnd) + } + end := start + rel + len(blockEnd) + if end < len(text) && text[end] == '\n' { + end++ + } + // Also drop a leading blank line that we inserted to separate from prior content. + if start > 0 && text[start-1] == '\n' && start >= 2 && text[start-2] == '\n' { + start-- + } + text = text[:start] + text[end:] + if strings.TrimSpace(text) == "" { + return true, os.Remove(path) + } + return true, os.WriteFile(path, []byte(text), 0644) } -// skillMD is the embedded SKILL.md content. -// It can also be fetched remotely from the DeployHQ docs. -const skillMD = `--- +// ----- Skill content ------------------------------------------------------- + +// skillFrontmatter is the Anthropic-format frontmatter used for the Claude Code +// skill. Other agents either get a different frontmatter (Cursor) or none (Codex, +// Windsurf — they embed the body into shared files like AGENTS.md). +const skillFrontmatter = `--- name: deployhq description: > Deploy code, manage servers, and automate infrastructure via the DeployHQ CLI (dhq). @@ -176,7 +359,18 @@ metadata: repository: https://github.com/deployhq/deployhq-cli --- -# DeployHQ CLI — Agent Skill Guide +` + +// cursorFrontmatter is the .mdc rule frontmatter Cursor uses to know when to apply +// a rule. alwaysApply=false means Cursor pulls it in only when relevant. +const cursorFrontmatter = `--- +description: DeployHQ CLI usage guide — invoke when the user mentions deploys, DeployHQ projects/servers, or the dhq command +alwaysApply: false +--- + +` + +const skillBody = `# DeployHQ CLI — Agent Skill Guide ## Identity DeployHQ is a deployment automation platform. The ` + "`dhq`" + ` CLI manages projects, servers, and deployments. From c5c05596e12e405947249ba14b0ce5e497a7ba2f Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Mon, 11 May 2026 15:53:45 +0200 Subject: [PATCH 2/2] fix(setup): reject extra args and atomic-write shared files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from CodeRabbit review on PR #8: 1. Add `Args: cobra.NoArgs` to setup subcommands so `dhq setup claude foo` errors instead of silently ignoring the stray argument. Mutating commands should fail fast on unexpected input. 2. Write shared files (AGENTS.md, ~/.codeium/.../global_rules.md) atomically via a temp file + rename. Previously a crash mid-write could lose user content outside the dhq-managed block. The new writeFileAtomic helper replaces direct os.WriteFile calls in upsertBlock / removeBlock. Dedicated files we own (Claude SKILL.md, Cursor .mdc, Windsurf project rule) still use os.WriteFile — they're recoverable by rerunning `dhq setup`. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/commands/setup.go | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/internal/commands/setup.go b/internal/commands/setup.go index 02e050a..de0ca99 100644 --- a/internal/commands/setup.go +++ b/internal/commands/setup.go @@ -116,6 +116,7 @@ func newAgentSetupCmd(a agentSetup) *cobra.Command { Use: a.Use, Short: a.Short, Long: longHelp(a), + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { sc := scopeUser if project { @@ -274,6 +275,32 @@ func pathWindsurf(sc scope) (string, error) { // ----- Marked-block helpers ------------------------------------------------ +// writeFileAtomic writes data to path via a temp file + rename, so a crash +// mid-write can't corrupt the target. Used for shared files (AGENTS.md, +// global_rules.md) that may contain unrelated user content outside our block. +func writeFileAtomic(path string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".dhq-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(perm); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} + // upsertBlock inserts or replaces the dhq-managed block in the file at path. // Returns true if the block was newly added (file created or block appended), // false if an existing block was replaced. @@ -286,7 +313,7 @@ func upsertBlock(path, content string) (added bool, err error) { block := blockBegin + "\n" + strings.TrimRight(content, "\n") + "\n" + blockEnd if errors.Is(readErr, os.ErrNotExist) { - return true, os.WriteFile(path, []byte(block+"\n"), 0644) + return true, writeFileAtomic(path, []byte(block+"\n"), 0644) } text := string(existing) @@ -296,14 +323,14 @@ func upsertBlock(path, content string) (added bool, err error) { return false, fmt.Errorf("found %s without matching %s", blockBegin, blockEnd) } end := start + rel + len(blockEnd) - return false, os.WriteFile(path, []byte(text[:start]+block+text[end:]), 0644) + return false, writeFileAtomic(path, []byte(text[:start]+block+text[end:]), 0644) } if !strings.HasSuffix(text, "\n") { text += "\n" } text += "\n" + block + "\n" - return true, os.WriteFile(path, []byte(text), 0644) + return true, writeFileAtomic(path, []byte(text), 0644) } // removeBlock removes the dhq-managed block from the file at path. @@ -337,7 +364,7 @@ func removeBlock(path string) (bool, error) { if strings.TrimSpace(text) == "" { return true, os.Remove(path) } - return true, os.WriteFile(path, []byte(text), 0644) + return true, writeFileAtomic(path, []byte(text), 0644) } // ----- Skill content -------------------------------------------------------