Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions internal/commands/hello.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,12 @@ func newHelloCmd() *cobra.Command {
env.Status("Logged in as %s on %s.%s", creds.Email, creds.Account, host)
}

// Step 2: Fetch projects
// Step 2: Offer to install the DeployHQ skill for any AI agents
// installed on this machine. Auto-installs for the runtime agent,
// prompts for others. Non-fatal — hello continues on errors.
offerSkillInstall(env)

// Step 3: Fetch projects
var sdkOpts []sdk.Option
if baseURL := cliCtx.Config.BaseURL(creds.Account); baseURL != "" {
sdkOpts = append(sdkOpts, sdk.WithBaseURL(baseURL))
Expand Down Expand Up @@ -101,7 +106,7 @@ func newHelloCmd() *cobra.Command {
return initCmd.RunE(initCmd, nil)
}

// Step 3: Default project
// Step 4: Default project
defaultProject := cliCtx.Config.Project
if defaultProject != "" {
env.Status("Default project: %s", defaultProject)
Expand Down Expand Up @@ -134,7 +139,7 @@ func newHelloCmd() *cobra.Command {
output.ColorGreen.Fprintf(env.Stderr, "Saved to %s\n", path) //nolint:errcheck
}

// Step 4: Orientation
// Step 5: Orientation
env.Status("")
env.Status("You're all set! Here are some useful commands:")
env.Status(" dhq deploy Deploy your project")
Expand Down
117 changes: 117 additions & 0 deletions internal/commands/hello_skills.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package commands

import (
"fmt"
"strings"

"github.com/deployhq/deployhq-cli/internal/harness"
"github.com/deployhq/deployhq-cli/internal/output"
"github.com/deployhq/deployhq-cli/internal/skillinstaller"
"github.com/manifoldco/promptui"
)

// Test seams for offerSkillInstall. These three vars are the only external
// state the function depends on; overriding them lets tests exercise every
// branch (no targets / runtime auto-install / prompt yes / prompt no /
// project-scope skip) without a live TTY or real agent installs.
//
// Production code MUST NOT reassign these — only the test files do.
var (
detectInstalledFn = skillinstaller.DetectInstalled
detectRuntimeAgentFn = harness.Detect
confirmInstallFn = defaultConfirmInstall
)

// defaultConfirmInstall runs the Y/n promptui prompt and returns true on
// "yes". Extracted from offerSkillInstall so tests can substitute a
// deterministic answer.
func defaultConfirmInstall(label string) bool {
prompt := promptui.Prompt{
Label: label,
IsConfirm: true,
Default: "Y",
}
_, err := prompt.Run()
return err == nil
}

// offerSkillInstall is the Wrangler-style post-login hook that detects locally
// installed AI agents and offers to install the DeployHQ skill for them.
//
// Behaviour:
// - Runtime agent (the one currently running dhq, per harness.Detect) is
// auto-installed without prompting when an install is Needed — if the
// user is using dhq from inside Claude Code right now, they want it.
// - Other agents detected on disk are batched into a single Y/n prompt.
// - Errors are non-fatal: hello succeeds even if installs fail; users can
// re-run `dhq skills install` later.
//
// The function is a no-op when nothing is detected, when nothing needs
// installing, or when env.NonInteractive is set.
func offerSkillInstall(env *output.Envelope) {
detected := detectInstalledFn()
if len(detected) == 0 {
return
}

runtimeName := detectRuntimeAgentFn().Name

var runtime *skillinstaller.DetectResult
var others []skillinstaller.DetectResult
for i, d := range detected {
if !skillinstaller.Needed(d.Status) {
continue
}
// Project-scope targets (e.g. Copilot's .github/copilot-instructions.md)
// modify the current repo. Never install those as a side effect of
// 'dhq hello' — they're opt-in via 'dhq skills install --agent <name>'.
if d.Target.Scope() != skillinstaller.ScopeUser {
continue
}
if d.Target.Name() == runtimeName {
runtime = &detected[i]
continue
}
others = append(others, d)
}

if runtime != nil {
installOne(env, runtime.Target, "Installing DeployHQ skill for %s (you're using it now)…")
}

if len(others) == 0 || env.NonInteractive {
return
}

names := make([]string, len(others))
for i, d := range others {
names[i] = d.Target.DisplayName()
}
label := fmt.Sprintf("Detected AI agents that could use the DeployHQ skill: %s.\n Install for them now?", strings.Join(names, ", "))

if !confirmInstallFn(label) {
env.Status("Skipping. Run `dhq skills install` later if you change your mind.")
return
}

for _, d := range others {
installOne(env, d.Target, "Installing DeployHQ skill for %s…")
}
}

// installOne runs Install on a single target and prints a result line.
// statusFmt receives the DisplayName via %s.
func installOne(env *output.Envelope, t skillinstaller.Target, statusFmt string) {
env.Status(statusFmt, t.DisplayName())
path, err := t.Install()
if err != nil {
env.Warn("Could not install %s skill: %v", t.DisplayName(), err)
return
}
output.ColorGreen.Fprintf(env.Stderr, " Installed %s skill → %s\n", t.DisplayName(), path) //nolint:errcheck
if n, ok := t.(skillinstaller.Noter); ok {
if note := n.PostInstallNote(); note != "" {
env.Status(" %s", note)
}
}
}
Loading
Loading