From 89f44d81762651a3e0fb6e93ad72c1c83bca3a33 Mon Sep 17 00:00:00 2001 From: Thiago Durante Date: Fri, 6 Nov 2026 08:37:35 +0100 Subject: [PATCH 01/11] fix: point agents at the skill in first errors, warn off the ! shortcut Agents act on the CLI's errors but rarely run 'dhq --help', so the skill pointer there was missed. The not-signed-in guidance, auth status next_step, doctor and the "No project specified" error now carry it, and their JSON recovery adds install_and_read_skill (dhq skills install). Sign-in and sign-up guidance now also says not to use the agent's '!' shortcut: a command run that way has no terminal on stdin or stdout, so dhq can't prompt and refuses. Co-Authored-By: Claude Opus 5.5 --- internal/auth/guidance.go | 22 ++++++++++++++++--- internal/cli/context.go | 3 ++- internal/commands/auth.go | 12 +++++----- internal/commands/auth_signup_honesty_test.go | 4 ++++ internal/commands/doctor.go | 2 +- internal/commands/hello.go | 2 +- internal/commands/signup.go | 7 +++--- internal/output/breadcrumbs.go | 11 +++++++++- internal/output/breadcrumbs_test.go | 20 ++++++++++++++++- skills/deployhq/SKILL.md | 2 +- 10 files changed, 68 insertions(+), 17 deletions(-) diff --git a/internal/auth/guidance.go b/internal/auth/guidance.go index 313a79c..756a8bd 100644 --- a/internal/auth/guidance.go +++ b/internal/auth/guidance.go @@ -15,16 +15,32 @@ func APIKeyLocation(account, host string) string { return fmt.Sprintf("Settings → Security → API Credentials in DeployHQ (https://%s.%s/security)", account, host) } +// SeparateTerminal says where the person runs a sign-in or sign-up command: +// in a terminal the agent does not drive. Agents read "not through the AI +// agent" as allowing their own '!' shell shortcut (Claude Code even suggests +// it for interactive logins), but a command run that way gets no terminal on +// stdin or stdout, so dhq can't prompt and refuses. Hence named explicitly, +// with the reason. +const SeparateTerminal = "in a separate terminal window (not through the AI agent, and not with its '!' shortcut, " + + "which gives dhq no terminal to prompt in)" + +// SkillPointer sends an agent to DeployHQ's skill. Agents act on the CLI's +// errors but rarely run 'dhq --help', so the messages they meet first (not +// signed in, no project) carry it. +const SkillPointer = "AI agents: run 'dhq skills install', then read and follow the DeployHQ skill it installs: " + + "it has the setup steps, including when to ask the person." + // SignInHint is the guidance shown whenever dhq needs credentials it cannot // prompt for (for example when run by an AI agent). The API key is a secret: // the person enters it themselves, in a terminal the agent does not drive, so // it never ends up in a chat transcript. A person without an account signs up // the same way, since sign-up asks for a password. func SignInHint(account, host string) string { - return "Ask the person to sign in themselves: run 'dhq auth login' in a separate terminal window " + - "(not through the AI agent). It prompts for the account, email and API key, and stores them securely.\n" + + return "Ask the person to sign in themselves: run 'dhq auth login' " + SeparateTerminal + + ". It prompts for the account, email and API key, and stores them securely.\n" + "If the person has no DeployHQ account yet, they run 'dhq signup' themselves instead, also in a separate terminal window.\n" + "The API key is under " + APIKeyLocation(account, host) + ".\n" + "Never paste an API key into an AI chat.\n" + - "For CI, set DEPLOYHQ_ACCOUNT, DEPLOYHQ_EMAIL and DEPLOYHQ_API_KEY as secret environment variables instead." + "For CI, set DEPLOYHQ_ACCOUNT, DEPLOYHQ_EMAIL and DEPLOYHQ_API_KEY as secret environment variables instead.\n" + + SkillPointer } diff --git a/internal/cli/context.go b/internal/cli/context.go index 9be64b1..dc7a6e0 100644 --- a/internal/cli/context.go +++ b/internal/cli/context.go @@ -226,7 +226,8 @@ func (c *Context) RequireProject() (string, error) { Hint: "Find your project identifier with 'dhq projects list', then set it:\n" + " dhq config set project (persist for this directory)\n" + " --project (pass per command)\n" + - " export DEPLOYHQ_PROJECT= (set for the shell session)", + " export DEPLOYHQ_PROJECT= (set for the shell session)\n" + + auth.SkillPointer, } } diff --git a/internal/commands/auth.go b/internal/commands/auth.go index fac0169..55725ad 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -58,8 +58,9 @@ The API key is under Settings → Security → API Credentials in DeployHQ (https://.deployhq.com/security). AI agents: do not ask the person for their API key. Ask them to run -'dhq auth login' themselves in a separate terminal window, never to paste the -key into the chat. For CI, set DEPLOYHQ_ACCOUNT, DEPLOYHQ_EMAIL and +'dhq auth login' themselves in a separate terminal window, not with the agent's '!' +shortcut (it gives dhq no terminal to prompt in), and never to paste the key +into the chat. For CI, set DEPLOYHQ_ACCOUNT, DEPLOYHQ_EMAIL and DEPLOYHQ_API_KEY as secret environment variables instead of logging in.`, Example: ` # Interactive login (prompts for any missing values) dhq auth login @@ -439,15 +440,16 @@ func invalidCredentialsNextStep(creds *cli.ResolvedCredentials, host string) str case "file": return "Check the account and api_key in the dhq config file ('dhq config show'). " + keyAt } - return "Ask the person to sign in again by running 'dhq auth login' in a separate terminal window. " + keyAt + return "Ask the person to sign in again by running 'dhq auth login' " + auth.SeparateTerminal + ". " + keyAt } // signInNextStep asks the person to sign in themselves. -const signInNextStep = "Ask the person to sign in by running 'dhq auth login' in a separate terminal window." +const signInNextStep = "Ask the person to sign in by running 'dhq auth login' " + auth.SeparateTerminal + "." // notLoggedInNextStep is the next step when dhq has no credentials to use. The // person may not have an account yet, so it names sign-up too. -const notLoggedInNextStep = signInNextStep + " If they have no DeployHQ account yet, they run 'dhq signup' there instead." +const notLoggedInNextStep = signInNextStep + " If they have no DeployHQ account yet, they run 'dhq signup' there instead. " + + auth.SkillPointer // notLoggedInStatus is the not_logged_in result. It names the account dhq is // configured to use, if any, and where that setting came from, since a wrong diff --git a/internal/commands/auth_signup_honesty_test.go b/internal/commands/auth_signup_honesty_test.go index 6e361ce..3f7591f 100644 --- a/internal/commands/auth_signup_honesty_test.go +++ b/internal/commands/auth_signup_honesty_test.go @@ -62,6 +62,8 @@ func TestAuthLogin_NonInteractive_PointsToOwnTerminalNotKeyFlag(t *testing.T) { var authErr *output.AuthError require.True(t, errors.As(err, &authErr), "got %T", err) assert.Contains(t, authErr.Hint, "separate terminal window") + assert.Contains(t, authErr.Hint, "not with its '!' shortcut", "agents read 'not through the agent' as allowing '!'") + assert.Contains(t, authErr.Hint, "dhq skills install", "agents rarely run --help, so the first errors point at the skill") assert.Contains(t, authErr.Hint, "Settings → Security → API Credentials") assert.Contains(t, authErr.Hint, "DEPLOYHQ_API_KEY") assert.NotContains(t, authErr.Hint, "--api-key", "must not suggest passing the key through the agent") @@ -380,6 +382,8 @@ func TestAuthStatus_JSON_NotLoggedIn(t *testing.T) { assert.NotEmpty(t, res["message"]) assert.Contains(t, res["next_step"], "dhq auth login") assert.Contains(t, res["next_step"], "separate terminal window") + assert.Contains(t, res["next_step"], "not with its '!' shortcut") + assert.Contains(t, res["next_step"], "dhq skills install") } func TestCheckAuthStatus_NetworkFailureIsUnconfirmed(t *testing.T) { diff --git a/internal/commands/doctor.go b/internal/commands/doctor.go index 0b2f846..dcc54b1 100644 --- a/internal/commands/doctor.go +++ b/internal/commands/doctor.go @@ -20,7 +20,7 @@ func newDoctorCmd() *cobra.Command { // Check 1: Auth creds, err := auth.Load() if err != nil { - checks = append(checks, doctorCheck{"Authentication", "fail", "Not logged in. Run 'dhq auth login', or 'dhq signup' if you have no DeployHQ account yet."}) + checks = append(checks, doctorCheck{"Authentication", "fail", "Not logged in. Run 'dhq auth login', or 'dhq signup' if you have no DeployHQ account yet, " + auth.SeparateTerminal + ". " + auth.SkillPointer}) } else { checks = append(checks, doctorCheck{"Authentication", "ok", "Logged in as " + creds.Email}) } diff --git a/internal/commands/hello.go b/internal/commands/hello.go index a142550..285b81e 100644 --- a/internal/commands/hello.go +++ b/internal/commands/hello.go @@ -28,7 +28,7 @@ func newHelloCmd() *cobra.Command { if env.NonInteractive { return &output.UserError{ Message: "'dhq hello' is interactive-only and cannot run in non-interactive mode", - Hint: "Ask the person to run 'dhq hello' (or 'dhq auth login') themselves in a separate terminal window, not through the AI agent; never paste an API key into an AI chat. Then set a default project with: dhq config set project ", + Hint: "Ask the person to run 'dhq hello' (or 'dhq auth login') themselves " + auth.SeparateTerminal + "; never paste an API key into an AI chat. Then set a default project with: dhq config set project ", } } if !env.IsTTY { diff --git a/internal/commands/signup.go b/internal/commands/signup.go index 60e43bb..2fb16b6 100644 --- a/internal/commands/signup.go +++ b/internal/commands/signup.go @@ -22,8 +22,8 @@ const termsURL = "https://www.deployhq.com/terms" // signupInOwnTerminalHint is shown when sign-up can't prompt (for example when // run by an AI agent). The password must come from the person, typed at a // hidden prompt, never relayed through (or invented by) an agent. -const signupInOwnTerminalHint = "Ask the person to run 'dhq signup' themselves in a separate terminal window " + - "(not through the AI agent): it asks for the password at a hidden prompt and asks them to accept the " + +const signupInOwnTerminalHint = "Ask the person to run 'dhq signup' themselves " + auth.SeparateTerminal + + ": it asks for the password at a hidden prompt and asks them to accept the " + "Terms of Service. Never ask for, or make up, a password in an AI chat." // signupNonInteractiveMessage is the refusal when sign-up can't prompt. It @@ -48,7 +48,8 @@ After signing up, open the verification link DeployHQ emails you: until the email address is verified, DeployHQ refuses API requests. AI agents: do not run this with a password in the command. Ask the person to -run 'dhq signup' themselves in a separate terminal window.`, +run 'dhq signup' themselves in a separate terminal window, not with the agent's +'!' shortcut, which gives dhq no terminal to prompt in.`, RunE: func(cmd *cobra.Command, args []string) error { env := cliCtx.Envelope reader := bufio.NewReader(os.Stdin) diff --git a/internal/output/breadcrumbs.go b/internal/output/breadcrumbs.go index f3fba70..3b470b3 100644 --- a/internal/output/breadcrumbs.go +++ b/internal/output/breadcrumbs.go @@ -226,6 +226,11 @@ func isRetryable(code, message string) bool { return false } +// installSkillCrumb points an agent at DeployHQ's skill. It is offered on the +// errors an agent meets first in a new setup (not signed in, no project), +// since agents act on these but rarely run 'dhq --help'. +var installSkillCrumb = Breadcrumb{Action: "install_and_read_skill", Cmd: "dhq skills install"} + // recoveryActions returns suggested next commands for common errors. func recoveryActions(code, message string) []Breadcrumb { msg := strings.ToLower(message) @@ -240,18 +245,22 @@ func recoveryActions(code, message string) []Breadcrumb { {Action: "ask_user_to_login_in_separate_terminal", Cmd: "dhq auth login"}, } if notSignedIn(msg) { - crumbs = append(crumbs, Breadcrumb{Action: "ask_user_to_signup_in_separate_terminal", Cmd: "dhq signup"}) + crumbs = append(crumbs, + Breadcrumb{Action: "ask_user_to_signup_in_separate_terminal", Cmd: "dhq signup"}, + installSkillCrumb) } return append(crumbs, Breadcrumb{Action: "check_status", Cmd: "dhq auth status"}) case notSignedIn(msg): return []Breadcrumb{ {Action: "ask_user_to_login_in_separate_terminal", Cmd: "dhq auth login"}, {Action: "ask_user_to_signup_in_separate_terminal", Cmd: "dhq signup"}, + installSkillCrumb, } case strings.Contains(msg, "no project"): return []Breadcrumb{ {Action: "list_projects", Cmd: "dhq projects list --json"}, {Action: "set_project", Cmd: "dhq config set project "}, + installSkillCrumb, } case strings.Contains(msg, "not found"): return []Breadcrumb{ diff --git a/internal/output/breadcrumbs_test.go b/internal/output/breadcrumbs_test.go index f068ebf..968e340 100644 --- a/internal/output/breadcrumbs_test.go +++ b/internal/output/breadcrumbs_test.go @@ -126,10 +126,28 @@ func TestErrorResponseFromErr_AuthError(t *testing.T) { func TestErrorResponseFromErr_NotLoggedIn_OffersSignup(t *testing.T) { data := ErrorResponseFromErr(errors.New("auth: not logged in")).Data.(ErrorData) - require.Len(t, data.Recovery, 2) + require.Len(t, data.Recovery, 3) assert.Equal(t, "dhq auth login", data.Recovery[0].Cmd) assert.Equal(t, "ask_user_to_signup_in_separate_terminal", data.Recovery[1].Action) assert.Equal(t, "dhq signup", data.Recovery[1].Cmd) + assert.Equal(t, Breadcrumb{Action: "install_and_read_skill", Cmd: "dhq skills install"}, data.Recovery[2]) +} + +// Agents act on these errors but rarely run 'dhq --help', so the first +// errors of a new setup point at DeployHQ's skill. +func TestErrorResponseFromErr_FirstSetupErrorsOfferSkill(t *testing.T) { + skill := Breadcrumb{Action: "install_and_read_skill", Cmd: "dhq skills install"} + for name, err := range map[string]error{ + "not logged in": errors.New("auth: not logged in"), + "auth not signed in": &AuthError{Message: "Not logged in"}, + "no project": &UserError{Message: "No project specified"}, + } { + t.Run(name, func(t *testing.T) { + assert.Contains(t, ErrorResponseFromErr(err).Data.(ErrorData).Recovery, skill) + }) + } + // A rejected login is not a new setup: the person is already signed up. + assert.NotContains(t, ErrorResponseFromErr(&AuthError{Message: "Invalid credentials"}).Data.(ErrorData).Recovery, skill) } func TestErrorResponseFromErr_SignedInAuthErrorsDoNotOfferSignup(t *testing.T) { diff --git a/skills/deployhq/SKILL.md b/skills/deployhq/SKILL.md index db37cf8..3de442d 100644 --- a/skills/deployhq/SKILL.md +++ b/skills/deployhq/SKILL.md @@ -41,7 +41,7 @@ Check first: `dhq auth status --json`. If it reports a signed-in account, setup If not, the first question is **"Do you already have a DeployHQ account?"** -- **Yes:** the person signs in by running `dhq auth login` in a separate terminal window of their own (not through the agent). It prompts for the account name, the login email and an API key, and hides the key as it is typed. When they say they are done, the agent runs `dhq auth status --json` to confirm. +- **Yes:** the person signs in by running `dhq auth login` in a separate terminal window of their own (not through the agent, and not with its `!` shortcut, which has no terminal to prompt in). It prompts for the account name, the login email and an API key, and hides the key as it is typed. When they say they are done, the agent runs `dhq auth status --json` to confirm. - **No:** with the person's OK, they create an account either on the DeployHQ website or by running `dhq signup` in their own terminal (it prompts for the email and password, hiding the password, asks them to accept the Terms of Service, and saves the new credentials). DeployHQ then sends a verification email; the person verifies before anything else will work. Signing up on the website does not sign in the CLI, so after verifying, that person also runs `dhq auth login` in their own terminal. The agent runs `dhq auth status --json` afterwards. `dhq auth login` and `dhq signup` cannot prompt inside an agent (non-interactive mode), so run there they would need the key or password on the command line. That is why the person runs them. From a9785203789b24036d2d25618cc5c833c83bca35 Mon Sep 17 00:00:00 2001 From: Thiago Durante Date: Fri, 6 Nov 2026 08:37:35 +0100 Subject: [PATCH 02/11] fix(repos): check the branch of HTTPS git repositories DeployHQ saves the branch of a git repository connected over HTTP(S) without checking it, so a branch that isn't in the repository was accepted and the first deployment failed. repos create/update now check it against the branch list and fail with the branches and the command to pick one; when the list can't be read they carry on as before. repos create no longer suggests the deploy key for HTTPS URLs, which never use it. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 22 ++++++++ internal/commands/repos.go | 66 ++++++++++++++++++++--- internal/commands/repos_branch_test.go | 72 +++++++++++++++++++++++++- 3 files changed, 153 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f45a5d5..1c1583a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,28 @@ While the CLI is pre-1.0, minor versions may carry breaking changes to the publi ## [Unreleased] +### Fixed + +- **Pointing AI agents at DeployHQ's skill**: agents act on the CLI's errors + but rarely run `dhq --help`, so the skill pointer there was missed. The first + messages of a new setup now carry it too: the "not signed in" guidance, + `next_step` in `dhq auth status --json`, `dhq doctor`, and the + "No project specified" error. In `--json` output their recovery actions add + `install_and_read_skill` (`dhq skills install`). +- **Signing in or signing up "in a separate terminal window"** now says + "not with the agent's `!` shortcut, which gives dhq no terminal to prompt in" + as well. Agents read "not through the AI agent" as allowing it, and suggested + `! dhq auth login` / `! dhq signup`, which always refuse there. +- **`dhq repos create` / `dhq repos update` with an HTTPS git URL**: DeployHQ + saves the branch of such a repository without checking it, so a branch that + isn't in the repository was accepted and the first deployment failed. dhq now + checks the branch against the repository's branch list and fails (exit code 1) + with the branches and the command to pick one. When the list can't be read, + it carries on as before. +- **`dhq repos create` with an HTTPS URL** no longer suggests adding the + project's deploy key: such a repository is read with a username and password + (or nothing, when public), never with the key. + ## [0.24.0] - 2026-11-04 ### Fixed diff --git a/internal/commands/repos.go b/internal/commands/repos.go index 2e5ab97..125490f 100644 --- a/internal/commands/repos.go +++ b/internal/commands/repos.go @@ -120,10 +120,14 @@ func newReposCreateCmd() *cobra.Command { project, projErr := client.GetProject(cliCtx.Background(), projectID) env := cliCtx.Envelope - if branchErr := branchNotKeptError(cliCtx.Background(), client, projectID, "connected", branch, repo); branchErr != nil { + branchErr := branchNotKeptError(cliCtx.Background(), client, projectID, "connected", branch, repo) + if branchErr == nil { + branchErr = httpBranchMissingError(cliCtx.Background(), client, projectID, "connected", branch, repo) + } + if branchErr != nil { // The repository is connected, so the deploy key is still // needed once the branch is fixed. - if projErr == nil && project.PublicKey != "" { + if projErr == nil && project.PublicKey != "" && !isHTTPRepoURL(url) { if env.WantsJSON() { branchErr.Recovery = append(branchErr.Recovery, deployKeyBreadcrumbs(url, project.PublicKey, projectID)...) } else { @@ -137,14 +141,14 @@ func newReposCreateCmd() *cobra.Command { } if env.WantsJSON() { breadcrumbs := []output.Breadcrumb{} - if projErr == nil && project.PublicKey != "" { + if projErr == nil && project.PublicKey != "" && !isHTTPRepoURL(url) { breadcrumbs = append(breadcrumbs, deployKeyBreadcrumbs(url, project.PublicKey, projectID)...) } return env.WriteJSON(output.NewResponse(repo, fmt.Sprintf("Repository created: %s", repo.URL), breadcrumbs...)) } env.Status("Repository created: %s (%s)", repo.URL, repo.ScmType) - if projErr == nil && project.PublicKey != "" { + if projErr == nil && project.PublicKey != "" && !isHTTPRepoURL(url) { env.Status("") env.Status("Deploy key (add to your repo for DeployHQ to pull):") env.Status(" %s", project.PublicKey) @@ -185,6 +189,9 @@ func newReposUpdateCmd() *cobra.Command { if branchErr := branchNotKeptError(cliCtx.Background(), client, projectID, "updated", branch, repo); branchErr != nil { return branchErr } + if branchErr := httpBranchMissingError(cliCtx.Background(), client, projectID, "updated", branch, repo); branchErr != nil { + return branchErr + } env := cliCtx.Envelope if env.WantsJSON() { @@ -438,11 +445,50 @@ func branchNotKeptError(ctx context.Context, client *sdk.Client, projectID, done } } + var branches map[string]string + if b, err := client.ListBranches(ctx, projectID, nil); err == nil { + branches = b + } + return pickBranchError(projectID, + fmt.Sprintf("The repository was %s, but on branch %q: %q isn't in it", done, repo.Branch, requested), + branches) +} + +// httpBranchMissingError reports a requested branch that is not in a git +// repository connected over HTTP(S). DeployHQ checks the branch of an SSH +// repository when it saves it (see branchNotKeptError), but for a git URL +// starting with http it skips that check and saves the branch as given, so +// the first deployment would fail. dhq checks it against the repository's +// branch list instead. When the list can't be read (a private repository +// whose credentials don't work yet, a network error) or is empty, nothing is +// reported: the branch can't be shown to be wrong. +func httpBranchMissingError(ctx context.Context, client *sdk.Client, projectID, done, requested string, repo *sdk.Repository) *output.UserError { + if requested == "" || repo == nil || repo.Branch != requested || + repo.ScmType != "git" || !strings.HasPrefix(repo.URL, "http") { + return nil + } + branches, err := client.ListBranches(ctx, projectID, nil) + if err != nil || len(branches) == 0 { + return nil + } + if _, ok := branches[requested]; ok { + return nil + } + return pickBranchError(projectID, + fmt.Sprintf("The repository was %s on branch %q, but %q isn't in it", done, requested, requested), + branches) +} + +// pickBranchError is the error for a saved branch that isn't in the +// repository: it lists the branches (at most maxBranchesShown) and gives the +// command to pick one. branches may be nil when the list couldn't be read. +func pickBranchError(projectID, message string, branches map[string]string) *output.UserError { + project := quoteRerunArg(projectID) setBranch := fmt.Sprintf("dhq repos update -p %s --branch ", project) listBranches := fmt.Sprintf("dhq repos branches -p %s", project) hint := "Pick one with: " + setBranch - if branches, err := client.ListBranches(ctx, projectID, nil); err == nil && len(branches) > 0 { + if len(branches) > 0 { names := make([]string, 0, len(branches)) for name := range branches { names = append(names, name) @@ -459,7 +505,7 @@ func branchNotKeptError(ctx context.Context, client *sdk.Client, projectID, done } return &output.UserError{ - Message: fmt.Sprintf("The repository was %s, but on branch %q: %q isn't in it", done, repo.Branch, requested), + Message: message, Hint: hint + "\nNothing has been deployed.", Recovery: []output.Breadcrumb{ {Action: "set_branch", Cmd: setBranch}, @@ -468,6 +514,14 @@ func branchNotKeptError(ctx context.Context, client *sdk.Client, projectID, done } } +// isHTTPRepoURL reports whether repoURL is an HTTP(S) URL. Such a repository +// is read with a username and password (or none, when public), never with +// the project's deploy key. +func isHTTPRepoURL(repoURL string) bool { + u := strings.ToLower(repoURL) + return strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") +} + // rerunArgSafe matches values that can be pasted into a shell unquoted. var rerunArgSafe = regexp.MustCompile(`^[A-Za-z0-9@%+=:,./_-]+$`) diff --git a/internal/commands/repos_branch_test.go b/internal/commands/repos_branch_test.go index e326a7b..8933779 100644 --- a/internal/commands/repos_branch_test.go +++ b/internal/commands/repos_branch_test.go @@ -28,6 +28,8 @@ type fakeRepoAPI struct { // sentBranch records the branch the CLI asked for when creating the // repository. sentBranch string + // savedURL is the URL of the saved repository; empty means an SSH URL. + savedURL string } func installFakeRepoAPI(t *testing.T, api *fakeRepoAPI) { @@ -42,7 +44,11 @@ func installFakeRepoAPI(t *testing.T, api *fakeRepoAPI) { Request: r, }, nil } - saved := `{"scm_type":"git","url":"git@github.com:acme/my-app.git","branch":"` + api.savedBranch + `"}` + url := api.savedURL + if url == "" { + url = "git@github.com:acme/my-app.git" + } + saved := `{"scm_type":"git","url":"` + url + `","branch":"` + api.savedBranch + `"}` switch { case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/projects/my-app/repository"): var sent struct { @@ -250,3 +256,67 @@ func TestReposUpdate_ServerErrorWithRepositoryKeepsOriginalError(t *testing.T) { assert.Equal(t, output.ExitInternalError, output.ClassifyError(err)) assert.Contains(t, err.Error(), "500") } + +// DeployHQ doesn't check the branch of a git repository connected over +// HTTP(S): it saves the branch as given. dhq checks it against the branch list. +func TestReposBranch_HTTPSBranchMissingFails(t *testing.T) { + const httpsURL = "https://github.com/acme/my-app.git" + for _, tc := range []struct { + args []string + done string + }{ + {[]string{"repos", "create", "--scm-type", "git", "--url", httpsURL, "--branch", "does-not-exist"}, "connected"}, + {[]string{"repos", "update", "--branch", "does-not-exist"}, "updated"}, + } { + t.Run(tc.done, func(t *testing.T) { + withResolvableContext(t) + installFakeRepoAPI(t, &fakeRepoAPI{savedBranch: "does-not-exist", savedURL: httpsURL}) + + err := runServersCmd(t, tc.args...) + var uerr *output.UserError + require.True(t, errors.As(err, &uerr), "got %T: %v", err, err) + assert.Equal(t, output.ExitUserError, output.ClassifyError(err)) + assert.Equal(t, `The repository was `+tc.done+` on branch "does-not-exist", but "does-not-exist" isn't in it`, uerr.Message) + assert.Contains(t, uerr.Hint, "Branches: develop, main.") + assert.Contains(t, uerr.Hint, "Pick one with: dhq repos update -p my-app --branch ") + + data := output.ErrorResponseFromErr(err).Data.(output.ErrorData) + require.Len(t, data.Recovery, 2, "no deploy key for an HTTPS repository: %+v", data.Recovery) + assert.Equal(t, "set_branch", data.Recovery[0].Action) + assert.Equal(t, "list_branches", data.Recovery[1].Action) + }) + } +} + +func TestReposBranch_HTTPSBranchPresentSucceeds(t *testing.T) { + withResolvableContext(t) + installFakeRepoAPI(t, &fakeRepoAPI{savedBranch: "develop", savedURL: "https://github.com/acme/my-app.git"}) + + require.NoError(t, runServersCmd(t, "repos", "create", "--scm-type", "git", + "--url", "https://github.com/acme/my-app.git", "--branch", "develop")) +} + +// When the branch list can't be read (a private repository whose credentials +// don't work yet, a network error), the branch can't be shown to be wrong. +func TestReposBranch_HTTPSBranchListUnavailableSucceeds(t *testing.T) { + withResolvableContext(t) + installFakeRepoAPI(t, &fakeRepoAPI{ + savedBranch: "does-not-exist", + savedURL: "https://github.com/acme/my-app.git", + branchesStatus: http.StatusInternalServerError, + }) + + require.NoError(t, runServersCmd(t, "repos", "update", "--branch", "does-not-exist")) +} + +func TestIsHTTPRepoURL(t *testing.T) { + for url, want := range map[string]bool{ + "https://github.com/acme/app.git": true, + "HTTP://git.example.com/app.git": true, + "git@github.com:acme/app.git": false, + "ssh://git@github.com/acme/app.git": false, + "svn+ssh://svn.example.com/app": false, + } { + assert.Equal(t, want, isHTTPRepoURL(url), url) + } +} From e57cd04a53c7bc35eec379a81b10d39d9db72790 Mon Sep 17 00:00:00 2001 From: Thiago Durante Date: Fri, 6 Nov 2026 08:49:05 +0100 Subject: [PATCH 03/11] fix: install the skill for the running agent only; review follow-ups - The skill pointer and install_and_read_skill crumb name the running agent ('dhq skills install --agent '): a bare install writes into every AI tool found on the machine. Unknown or project-scope agents get a placeholder plus 'dhq skills list'. - The signup line, doctor, and the --password-stdin hint carry the '!' warning; doctor addresses agents, not the person at their terminal. - dhq deploy's "No project specified" hints point at the skill too. - The HTTPS branch check uses the same case-insensitive URL test as the deploy-key decision; listed branch names are shell-quoted. - Tests: SSH kept branch not rechecked, empty branch list, uppercase scheme, quoted names, deploy-key hint HTTPS vs SSH, RequireProject. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 +++- internal/auth/guidance.go | 30 ++++++-- internal/cli/context.go | 2 +- internal/cli/context_require_project_test.go | 25 +++++++ internal/commands/auth.go | 8 +- internal/commands/auth_signup_honesty_test.go | 9 ++- internal/commands/deploy.go | 8 +- internal/commands/doctor.go | 3 +- internal/commands/repos.go | 7 +- internal/commands/repos_branch_test.go | 75 +++++++++++++++++++ internal/commands/servers_auth.go | 3 +- internal/output/breadcrumbs.go | 12 ++- internal/output/breadcrumbs_test.go | 22 +++++- .../skillinstaller/install_command_test.go | 21 ++++++ internal/skillinstaller/installer.go | 13 ++++ 15 files changed, 222 insertions(+), 29 deletions(-) create mode 100644 internal/cli/context_require_project_test.go create mode 100644 internal/skillinstaller/install_command_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c1583a..31a0010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,18 +15,23 @@ While the CLI is pre-1.0, minor versions may carry breaking changes to the publi but rarely run `dhq --help`, so the skill pointer there was missed. The first messages of a new setup now carry it too: the "not signed in" guidance, `next_step` in `dhq auth status --json`, `dhq doctor`, and the - "No project specified" error. In `--json` output their recovery actions add - `install_and_read_skill` (`dhq skills install`). + "No project specified" errors (including `dhq deploy`'s). In `--json` output + their recovery actions add `install_and_read_skill`. The command names the + agent running dhq (for example `dhq skills install --agent claude-code`), so + the skill is installed for that agent only, not for every AI tool on the + machine; when dhq can't tell which agent it is, the agent fills in its name. - **Signing in or signing up "in a separate terminal window"** now says "not with the agent's `!` shortcut, which gives dhq no terminal to prompt in" as well. Agents read "not through the AI agent" as allowing it, and suggested - `! dhq auth login` / `! dhq signup`, which always refuse there. + `! dhq auth login` / `! dhq signup`, which always refuse there. The hint for + adding a password-only server with `--password-stdin` says the same. - **`dhq repos create` / `dhq repos update` with an HTTPS git URL**: DeployHQ saves the branch of such a repository without checking it, so a branch that isn't in the repository was accepted and the first deployment failed. dhq now checks the branch against the repository's branch list and fails (exit code 1) with the branches and the command to pick one. When the list can't be read, - it carries on as before. + it carries on as before. Branch names in these messages are now quoted when + they contain shell syntax, as they may be pasted into a command. - **`dhq repos create` with an HTTPS URL** no longer suggests adding the project's deploy key: such a repository is read with a username and password (or nothing, when public), never with the key. diff --git a/internal/auth/guidance.go b/internal/auth/guidance.go index 756a8bd..9a4eef7 100644 --- a/internal/auth/guidance.go +++ b/internal/auth/guidance.go @@ -1,6 +1,12 @@ package auth -import "fmt" +import ( + "fmt" + "strings" + + "github.com/deployhq/deployhq-cli/internal/harness" + "github.com/deployhq/deployhq-cli/internal/skillinstaller" +) // APIKeyLocation tells the person where to find their API key in the DeployHQ // web app. account and host are optional; when account is empty a placeholder @@ -24,11 +30,25 @@ func APIKeyLocation(account, host string) string { const SeparateTerminal = "in a separate terminal window (not through the AI agent, and not with its '!' shortcut, " + "which gives dhq no terminal to prompt in)" +// SkillInstallCommand is the command that installs DeployHQ's skill for the +// agent running dhq only (see skillinstaller.InstallCommandFor), never for +// every AI tool on the machine. +func SkillInstallCommand() string { + return skillinstaller.InstallCommandFor(harness.Detect().Name) +} + // SkillPointer sends an agent to DeployHQ's skill. Agents act on the CLI's // errors but rarely run 'dhq --help', so the messages they meet first (not // signed in, no project) carry it. -const SkillPointer = "AI agents: run 'dhq skills install', then read and follow the DeployHQ skill it installs: " + - "it has the setup steps, including when to ask the person." +func SkillPointer() string { + cmd := SkillInstallCommand() + names := "" + if strings.Contains(cmd, "<") { + names = " ('dhq skills list' names the agents)" + } + return "AI agents: install DeployHQ's skill for yourself with '" + cmd + "'" + names + + ", then read and follow it: it has the setup steps, including when to ask the person." +} // SignInHint is the guidance shown whenever dhq needs credentials it cannot // prompt for (for example when run by an AI agent). The API key is a secret: @@ -38,9 +58,9 @@ const SkillPointer = "AI agents: run 'dhq skills install', then read and follow func SignInHint(account, host string) string { return "Ask the person to sign in themselves: run 'dhq auth login' " + SeparateTerminal + ". It prompts for the account, email and API key, and stores them securely.\n" + - "If the person has no DeployHQ account yet, they run 'dhq signup' themselves instead, also in a separate terminal window.\n" + + "If the person has no DeployHQ account yet, they run 'dhq signup' themselves instead, the same way: " + SeparateTerminal + ".\n" + "The API key is under " + APIKeyLocation(account, host) + ".\n" + "Never paste an API key into an AI chat.\n" + "For CI, set DEPLOYHQ_ACCOUNT, DEPLOYHQ_EMAIL and DEPLOYHQ_API_KEY as secret environment variables instead.\n" + - SkillPointer + SkillPointer() } diff --git a/internal/cli/context.go b/internal/cli/context.go index dc7a6e0..f378c46 100644 --- a/internal/cli/context.go +++ b/internal/cli/context.go @@ -227,7 +227,7 @@ func (c *Context) RequireProject() (string, error) { " dhq config set project (persist for this directory)\n" + " --project (pass per command)\n" + " export DEPLOYHQ_PROJECT= (set for the shell session)\n" + - auth.SkillPointer, + auth.SkillPointer(), } } diff --git a/internal/cli/context_require_project_test.go b/internal/cli/context_require_project_test.go new file mode 100644 index 0000000..5fea975 --- /dev/null +++ b/internal/cli/context_require_project_test.go @@ -0,0 +1,25 @@ +package cli + +import ( + "testing" + + "github.com/deployhq/deployhq-cli/internal/config" + "github.com/deployhq/deployhq-cli/internal/output" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The "No project specified" error is one of the first an agent meets in a new +// setup: its hint and JSON recovery point at DeployHQ's skill. +func TestRequireProject_PointsAgentsAtSkill(t *testing.T) { + t.Setenv("DEPLOYHQ_AGENT", "claude-code") + c := &Context{Config: &config.Config{}} + _, err := c.RequireProject() + require.Error(t, err) + + var uerr *output.UserError + require.ErrorAs(t, err, &uerr) + assert.Contains(t, uerr.Hint, "dhq skills install --agent claude-code") + assert.Contains(t, output.ErrorResponseFromErr(err).Data.(output.ErrorData).Recovery, + output.Breadcrumb{Action: "install_and_read_skill", Cmd: "dhq skills install --agent claude-code"}) +} diff --git a/internal/commands/auth.go b/internal/commands/auth.go index 55725ad..2ad7481 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -448,8 +448,10 @@ const signInNextStep = "Ask the person to sign in by running 'dhq auth login' " // notLoggedInNextStep is the next step when dhq has no credentials to use. The // person may not have an account yet, so it names sign-up too. -const notLoggedInNextStep = signInNextStep + " If they have no DeployHQ account yet, they run 'dhq signup' there instead. " + - auth.SkillPointer +func notLoggedInNextStep() string { + return signInNextStep + " If they have no DeployHQ account yet, they run 'dhq signup' there instead. " + + auth.SkillPointer() +} // notLoggedInStatus is the not_logged_in result. It names the account dhq is // configured to use, if any, and where that setting came from, since a wrong @@ -459,7 +461,7 @@ func notLoggedInStatus(cfg *config.Config) authStatusResult { Status: authStatusNotLoggedIn, Message: "No credentials found: dhq has no stored login to use, and flags, " + "DEPLOYHQ_* environment variables and config files do not set all of account, email and API key", - NextStep: notLoggedInNextStep, + NextStep: notLoggedInNextStep(), } if cfg.Account == "" { return res diff --git a/internal/commands/auth_signup_honesty_test.go b/internal/commands/auth_signup_honesty_test.go index 3f7591f..7c48c6d 100644 --- a/internal/commands/auth_signup_honesty_test.go +++ b/internal/commands/auth_signup_honesty_test.go @@ -63,7 +63,8 @@ func TestAuthLogin_NonInteractive_PointsToOwnTerminalNotKeyFlag(t *testing.T) { require.True(t, errors.As(err, &authErr), "got %T", err) assert.Contains(t, authErr.Hint, "separate terminal window") assert.Contains(t, authErr.Hint, "not with its '!' shortcut", "agents read 'not through the agent' as allowing '!'") - assert.Contains(t, authErr.Hint, "dhq skills install", "agents rarely run --help, so the first errors point at the skill") + assert.Contains(t, authErr.Hint, "dhq skills install --agent", "agents rarely run --help, so the first errors point at the skill") + assert.Regexp(t, `dhq signup'[^\n]*not with its '!' shortcut`, authErr.Hint, "the signup line carries the '!' warning too") assert.Contains(t, authErr.Hint, "Settings → Security → API Credentials") assert.Contains(t, authErr.Hint, "DEPLOYHQ_API_KEY") assert.NotContains(t, authErr.Hint, "--api-key", "must not suggest passing the key through the agent") @@ -86,8 +87,8 @@ func TestSignInHint_MentionsSignupForPeopleWithoutAnAccount(t *testing.T) { } func TestNotLoggedInNextStep_MentionsSignup(t *testing.T) { - assert.Contains(t, notLoggedInNextStep, "dhq auth login") - assert.Contains(t, notLoggedInNextStep, "dhq signup") + assert.Contains(t, notLoggedInNextStep(), "dhq auth login") + assert.Contains(t, notLoggedInNextStep(), "dhq signup") // Sign-up just succeeded when the credentials could not be saved: signing // up again is not the fix. assert.NotContains(t, credentialsNotSavedNextStep, "dhq signup") @@ -383,7 +384,7 @@ func TestAuthStatus_JSON_NotLoggedIn(t *testing.T) { assert.Contains(t, res["next_step"], "dhq auth login") assert.Contains(t, res["next_step"], "separate terminal window") assert.Contains(t, res["next_step"], "not with its '!' shortcut") - assert.Contains(t, res["next_step"], "dhq skills install") + assert.Contains(t, res["next_step"], "dhq skills install --agent") } func TestCheckAuthStatus_NetworkFailureIsUnconfirmed(t *testing.T) { diff --git a/internal/commands/deploy.go b/internal/commands/deploy.go index 4365e0c..46c838e 100644 --- a/internal/commands/deploy.go +++ b/internal/commands/deploy.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/deployhq/deployhq-cli/internal/auth" "github.com/deployhq/deployhq-cli/internal/output" "github.com/deployhq/deployhq-cli/pkg/sdk" "github.com/manifoldco/promptui" @@ -189,7 +190,8 @@ func resolveDeployProject(ctx context.Context, client *sdk.Client, configured st case 0: return "", &output.UserError{ Message: "No project specified", - Hint: "This account has no projects yet.\nTo provision and deploy in one step: dhq launch\nOr create a project in the DeployHQ dashboard, then re-run with --project .", + Hint: "This account has no projects yet.\nTo provision and deploy in one step: dhq launch\nOr create a project in the DeployHQ dashboard, then re-run with --project .\n" + + auth.SkillPointer(), } case 1: env.Status("Auto-selected project: %s", projects[0].Name) @@ -218,8 +220,8 @@ func resolveDeployProject(ctx context.Context, client *sdk.Client, configured st } return "", &output.UserError{ Message: "No project specified", - Hint: fmt.Sprintf("Account has %d projects — pass --project . Available: %s", - len(projects), strings.Join(items, ", ")), + Hint: fmt.Sprintf("Account has %d projects — pass --project . Available: %s\n%s", + len(projects), strings.Join(items, ", "), auth.SkillPointer()), } } } diff --git a/internal/commands/doctor.go b/internal/commands/doctor.go index dcc54b1..96a13f1 100644 --- a/internal/commands/doctor.go +++ b/internal/commands/doctor.go @@ -20,7 +20,8 @@ func newDoctorCmd() *cobra.Command { // Check 1: Auth creds, err := auth.Load() if err != nil { - checks = append(checks, doctorCheck{"Authentication", "fail", "Not logged in. Run 'dhq auth login', or 'dhq signup' if you have no DeployHQ account yet, " + auth.SeparateTerminal + ". " + auth.SkillPointer}) + checks = append(checks, doctorCheck{"Authentication", "fail", "Not logged in. Run 'dhq auth login', or 'dhq signup' if you have no DeployHQ account yet. " + + "When an AI agent runs dhq, the person runs either one themselves " + auth.SeparateTerminal + ". " + auth.SkillPointer()}) } else { checks = append(checks, doctorCheck{"Authentication", "ok", "Logged in as " + creds.Email}) } diff --git a/internal/commands/repos.go b/internal/commands/repos.go index 125490f..13ba877 100644 --- a/internal/commands/repos.go +++ b/internal/commands/repos.go @@ -464,7 +464,7 @@ func branchNotKeptError(ctx context.Context, client *sdk.Client, projectID, done // reported: the branch can't be shown to be wrong. func httpBranchMissingError(ctx context.Context, client *sdk.Client, projectID, done, requested string, repo *sdk.Repository) *output.UserError { if requested == "" || repo == nil || repo.Branch != requested || - repo.ScmType != "git" || !strings.HasPrefix(repo.URL, "http") { + repo.ScmType != "git" || !isHTTPRepoURL(repo.URL) { return nil } branches, err := client.ListBranches(ctx, projectID, nil) @@ -494,6 +494,11 @@ func pickBranchError(projectID, message string, branches map[string]string) *out names = append(names, name) } sort.Strings(names) + // Branch names come from the repository and may hold shell syntax + // (release&hotfix); quote them as they may be pasted into a command. + for i, name := range names { + names[i] = quoteRerunArg(name) + } list := strings.Join(names, ", ") if len(names) > maxBranchesShown { list = strings.Join(names[:maxBranchesShown], ", ") + diff --git a/internal/commands/repos_branch_test.go b/internal/commands/repos_branch_test.go index 8933779..078dfba 100644 --- a/internal/commands/repos_branch_test.go +++ b/internal/commands/repos_branch_test.go @@ -6,6 +6,8 @@ import ( "fmt" "io" "net/http" + "os" + "path/filepath" "strings" "testing" @@ -320,3 +322,76 @@ func TestIsHTTPRepoURL(t *testing.T) { assert.Equal(t, want, isHTTPRepoURL(url), url) } } + +// The HTTP(S) check runs only where DeployHQ didn't check: an SSH repository +// whose branch was kept passed DeployHQ's own check, even if the branch list +// read back differs. +func TestReposBranch_SSHKeptBranchNotRechecked(t *testing.T) { + withResolvableContext(t) + installFakeRepoAPI(t, &fakeRepoAPI{savedBranch: "feature"}) // list: main, develop + + require.NoError(t, runServersCmd(t, "repos", "update", "--branch", "feature")) +} + +func TestReposBranch_HTTPSUppercaseSchemeChecked(t *testing.T) { + withResolvableContext(t) + installFakeRepoAPI(t, &fakeRepoAPI{savedBranch: "nope", savedURL: "HTTPS://github.com/acme/my-app.git"}) + + err := runServersCmd(t, "repos", "update", "--branch", "nope") + var uerr *output.UserError + require.True(t, errors.As(err, &uerr), "got %T: %v", err, err) +} + +// An empty branch list can't show the branch is wrong. +func TestReposBranch_HTTPSEmptyBranchListSucceeds(t *testing.T) { + withResolvableContext(t) + installFakeRepoAPI(t, &fakeRepoAPI{savedBranch: "main", savedURL: "https://github.com/acme/my-app.git", branchesBody: "{}"}) + + require.NoError(t, runServersCmd(t, "repos", "update", "--branch", "main")) +} + +// Branch names come from the repository and are quoted in the hint, since they +// may be pasted into a command. +func TestReposBranch_ListedBranchNamesAreQuoted(t *testing.T) { + withResolvableContext(t) + installFakeRepoAPI(t, &fakeRepoAPI{ + savedBranch: "nope", savedURL: "https://github.com/acme/my-app.git", + branchesBody: `{"main":"abc","a$(id)":"def"}`, + }) + + err := runServersCmd(t, "repos", "update", "--branch", "nope") + var uerr *output.UserError + require.True(t, errors.As(err, &uerr), "got %T: %v", err, err) + assert.Contains(t, uerr.Hint, "Branches: 'a$(id)', main.") +} + +// An HTTPS repository is never read with the project's deploy key, so a +// successful create doesn't suggest adding it. +func TestReposCreate_HTTPSNoDeployKeySuggestion(t *testing.T) { + withResolvableContext(t) + out := filepath.Join(t.TempDir(), "out.jsonl") + t.Setenv("DEPLOYHQ_OUTPUT_FILE", out) + installFakeRepoAPI(t, &fakeRepoAPI{savedBranch: "main", savedURL: "https://github.com/acme/my-app.git"}) + + require.NoError(t, runServersCmd(t, "repos", "create", "--json", "--scm-type", "git", + "--url", "https://github.com/acme/my-app.git", "--branch", "main")) + b, err := os.ReadFile(out) + require.NoError(t, err) + require.Contains(t, string(b), "Repository created") + assert.NotContains(t, string(b), "deploy-key") + assert.NotContains(t, string(b), projectPublicKey) +} + +// The same create over SSH does suggest it: the control for the test above. +func TestReposCreate_SSHDeployKeySuggestion(t *testing.T) { + withResolvableContext(t) + out := filepath.Join(t.TempDir(), "out.jsonl") + t.Setenv("DEPLOYHQ_OUTPUT_FILE", out) + installFakeRepoAPI(t, &fakeRepoAPI{savedBranch: "main"}) + + require.NoError(t, runServersCmd(t, "repos", "create", "--json", "--scm-type", "git", + "--url", "git@github.com:acme/my-app.git", "--branch", "main")) + b, err := os.ReadFile(out) + require.NoError(t, err) + assert.Contains(t, string(b), "gh repo deploy-key add") +} diff --git a/internal/commands/servers_auth.go b/internal/commands/servers_auth.go index c8db389..5eb4962 100644 --- a/internal/commands/servers_auth.go +++ b/internal/commands/servers_auth.go @@ -8,6 +8,7 @@ import ( "regexp" "strings" + "github.com/deployhq/deployhq-cli/internal/auth" "github.com/deployhq/deployhq-cli/internal/output" "github.com/deployhq/deployhq-cli/pkg/sdk" "github.com/spf13/cobra" @@ -50,7 +51,7 @@ func serverUsesSSHKeys(protocolType string, useSSHKeys bool) bool { const passwordAgentHint = "Use key-based access instead (--use-ssh-keys) if the server supports it. " + "For password-only servers (FTP/FTPS, or SFTP with a password), the person who owns the password " + "should add the server themselves rather than give the password to an AI agent: either run this " + - "command in their own terminal with --password-stdin (it asks for the password with hidden input), " + + "command " + auth.SeparateTerminal + " with --password-stdin (it asks for the password with hidden input), " + "or add the server in the DeployHQ web app." // readServerPassword resolves the server password for `dhq servers create`. diff --git a/internal/output/breadcrumbs.go b/internal/output/breadcrumbs.go index 3b470b3..f097e7e 100644 --- a/internal/output/breadcrumbs.go +++ b/internal/output/breadcrumbs.go @@ -5,6 +5,7 @@ import ( "net/http" "strings" + "github.com/deployhq/deployhq-cli/internal/auth" "github.com/deployhq/deployhq-cli/pkg/sdk" ) @@ -229,7 +230,10 @@ func isRetryable(code, message string) bool { // installSkillCrumb points an agent at DeployHQ's skill. It is offered on the // errors an agent meets first in a new setup (not signed in, no project), // since agents act on these but rarely run 'dhq --help'. -var installSkillCrumb = Breadcrumb{Action: "install_and_read_skill", Cmd: "dhq skills install"} +// It installs the skill for the running agent only. +func installSkillCrumb() Breadcrumb { + return Breadcrumb{Action: "install_and_read_skill", Cmd: auth.SkillInstallCommand()} +} // recoveryActions returns suggested next commands for common errors. func recoveryActions(code, message string) []Breadcrumb { @@ -247,20 +251,20 @@ func recoveryActions(code, message string) []Breadcrumb { if notSignedIn(msg) { crumbs = append(crumbs, Breadcrumb{Action: "ask_user_to_signup_in_separate_terminal", Cmd: "dhq signup"}, - installSkillCrumb) + installSkillCrumb()) } return append(crumbs, Breadcrumb{Action: "check_status", Cmd: "dhq auth status"}) case notSignedIn(msg): return []Breadcrumb{ {Action: "ask_user_to_login_in_separate_terminal", Cmd: "dhq auth login"}, {Action: "ask_user_to_signup_in_separate_terminal", Cmd: "dhq signup"}, - installSkillCrumb, + installSkillCrumb(), } case strings.Contains(msg, "no project"): return []Breadcrumb{ {Action: "list_projects", Cmd: "dhq projects list --json"}, {Action: "set_project", Cmd: "dhq config set project "}, - installSkillCrumb, + installSkillCrumb(), } case strings.Contains(msg, "not found"): return []Breadcrumb{ diff --git a/internal/output/breadcrumbs_test.go b/internal/output/breadcrumbs_test.go index 968e340..10d2f11 100644 --- a/internal/output/breadcrumbs_test.go +++ b/internal/output/breadcrumbs_test.go @@ -125,18 +125,20 @@ func TestErrorResponseFromErr_AuthError(t *testing.T) { } func TestErrorResponseFromErr_NotLoggedIn_OffersSignup(t *testing.T) { + t.Setenv("DEPLOYHQ_AGENT", "claude-code") data := ErrorResponseFromErr(errors.New("auth: not logged in")).Data.(ErrorData) require.Len(t, data.Recovery, 3) assert.Equal(t, "dhq auth login", data.Recovery[0].Cmd) assert.Equal(t, "ask_user_to_signup_in_separate_terminal", data.Recovery[1].Action) assert.Equal(t, "dhq signup", data.Recovery[1].Cmd) - assert.Equal(t, Breadcrumb{Action: "install_and_read_skill", Cmd: "dhq skills install"}, data.Recovery[2]) + assert.Equal(t, Breadcrumb{Action: "install_and_read_skill", Cmd: "dhq skills install --agent claude-code"}, data.Recovery[2]) } // Agents act on these errors but rarely run 'dhq --help', so the first // errors of a new setup point at DeployHQ's skill. func TestErrorResponseFromErr_FirstSetupErrorsOfferSkill(t *testing.T) { - skill := Breadcrumb{Action: "install_and_read_skill", Cmd: "dhq skills install"} + t.Setenv("DEPLOYHQ_AGENT", "claude-code") + skill := Breadcrumb{Action: "install_and_read_skill", Cmd: "dhq skills install --agent claude-code"} for name, err := range map[string]error{ "not logged in": errors.New("auth: not logged in"), "auth not signed in": &AuthError{Message: "Not logged in"}, @@ -277,3 +279,19 @@ func TestBreadcrumb_ResourceFields(t *testing.T) { assert.Contains(t, string(data), `"resource":"deployment"`) assert.Contains(t, string(data), `"id":"abc"`) } + +// A bare 'dhq skills install' would install for every AI tool on the machine, +// so the crumb names the running agent, or leaves a placeholder. +func TestErrorResponseFromErr_SkillCrumbNeverInstallsForEveryAgent(t *testing.T) { + for agent, want := range map[string]string{ + "codex": "dhq skills install --agent codex", + "copilot": "dhq skills install --agent ", // writes into the repo + "not-a-agent": "dhq skills install --agent ", + } { + t.Run(agent, func(t *testing.T) { + t.Setenv("DEPLOYHQ_AGENT", agent) + rec := ErrorResponseFromErr(errors.New("auth: not logged in")).Data.(ErrorData).Recovery + assert.Contains(t, rec, Breadcrumb{Action: "install_and_read_skill", Cmd: want}) + }) + } +} diff --git a/internal/skillinstaller/install_command_test.go b/internal/skillinstaller/install_command_test.go new file mode 100644 index 0000000..649aa3d --- /dev/null +++ b/internal/skillinstaller/install_command_test.go @@ -0,0 +1,21 @@ +package skillinstaller + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// The command names one agent: a bare 'dhq skills install' would install for +// every AI tool found on the machine. +func TestInstallCommandFor(t *testing.T) { + for name, want := range map[string]string{ + "claude-code": "dhq skills install --agent claude-code", + "cursor": "dhq skills install --agent cursor", + "copilot": "dhq skills install --agent ", // project scope: writes into the repo + "": "dhq skills install --agent ", + "vim": "dhq skills install --agent ", + } { + assert.Equal(t, want, InstallCommandFor(name), name) + } +} diff --git a/internal/skillinstaller/installer.go b/internal/skillinstaller/installer.go index 073b838..c988657 100644 --- a/internal/skillinstaller/installer.go +++ b/internal/skillinstaller/installer.go @@ -160,6 +160,19 @@ func Find(name string) Target { return nil } +// InstallCommandFor returns the command that installs the DeployHQ skill for +// the named agent only. A bare 'dhq skills install' installs for every agent +// found on the machine, which an agent must not do on its own. When the name +// isn't a supported agent, or its skill lives in the current repository +// (project scope, which changes the repo), the agent is left as a placeholder +// for the agent to fill in, with 'dhq skills list' naming the choices. +func InstallCommandFor(agentName string) string { + if t := Find(agentName); t != nil && t.Scope() == ScopeUser { + return "dhq skills install --agent " + agentName + } + return "dhq skills install --agent " +} + // Needed reports whether the skill needs to be (re)installed for this target. // Returns true for StatusAvailable and StatusOutdated; false for // StatusInstalled and StatusNotInstalled. From a4538ae4ef3d9ba1737f7ee15ae9e66f0db3a80b Mon Sep 17 00:00:00 2001 From: Thiago Durante Date: Fri, 6 Nov 2026 09:08:51 +0100 Subject: [PATCH 04/11] fix: dhq --help names the running agent for the skill install A bare 'dhq skills install' writes into every AI tool on the machine, which is what the first-error pointer already avoids. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + internal/commands/auth_signup_honesty_test.go | 3 ++- internal/commands/root.go | 5 +++-- skills/deployhq/SKILL.md | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31a0010..65e6d1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ While the CLI is pre-1.0, minor versions may carry breaking changes to the publi agent running dhq (for example `dhq skills install --agent claude-code`), so the skill is installed for that agent only, not for every AI tool on the machine; when dhq can't tell which agent it is, the agent fills in its name. + `dhq --help` names the agent the same way. - **Signing in or signing up "in a separate terminal window"** now says "not with the agent's `!` shortcut, which gives dhq no terminal to prompt in" as well. Agents read "not through the AI agent" as allowing it, and suggested diff --git a/internal/commands/auth_signup_honesty_test.go b/internal/commands/auth_signup_honesty_test.go index 7c48c6d..1d1d8ff 100644 --- a/internal/commands/auth_signup_honesty_test.go +++ b/internal/commands/auth_signup_honesty_test.go @@ -306,13 +306,14 @@ func TestNewSignupOutput_JSON(t *testing.T) { // ── Root help ──────────────────────────────────────────────────────────────── func TestRootHelp_PointsAgentsToSetupInstructions(t *testing.T) { + t.Setenv("DEPLOYHQ_AGENT", "claude-code") cmd := NewRootCmd("test") var stdout bytes.Buffer cmd.SetOut(&stdout) cmd.SetArgs([]string{"--help"}) require.NoError(t, cmd.Execute()) assert.Contains(t, stdout.String(), "https://www.deployhq.com/agents.md") - assert.Contains(t, stdout.String(), "install DeployHQ's skill with 'dhq skills install' and follow it") + assert.Contains(t, stdout.String(), "install DeployHQ's skill for yourself with 'dhq skills install --agent claude-code'") } func TestDescribeCredentialSource(t *testing.T) { diff --git a/internal/commands/root.go b/internal/commands/root.go index b01abcd..ee99aa6 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/deployhq/deployhq-cli/internal/auth" "github.com/deployhq/deployhq-cli/internal/cli" "github.com/deployhq/deployhq-cli/internal/config" "github.com/deployhq/deployhq-cli/internal/harness" @@ -51,8 +52,8 @@ func NewRootCmd(version string) *cobra.Command { Short: "DeployHQ CLI — deploy from your terminal", Long: `The official DeployHQ command-line interface for managing projects, servers, and deployments. -AI agents: install DeployHQ's skill with 'dhq skills install' and follow it, or read -https://www.deployhq.com/agents.md. +AI agents: install DeployHQ's skill for yourself with '` + auth.SkillInstallCommand() + `' and +follow it, or read https://www.deployhq.com/agents.md. Feedback & feature requests: https://changelog.deployhq.com Support: support@deployhq.com`, diff --git a/skills/deployhq/SKILL.md b/skills/deployhq/SKILL.md index 3de442d..c104bc6 100644 --- a/skills/deployhq/SKILL.md +++ b/skills/deployhq/SKILL.md @@ -41,7 +41,7 @@ Check first: `dhq auth status --json`. If it reports a signed-in account, setup If not, the first question is **"Do you already have a DeployHQ account?"** -- **Yes:** the person signs in by running `dhq auth login` in a separate terminal window of their own (not through the agent, and not with its `!` shortcut, which has no terminal to prompt in). It prompts for the account name, the login email and an API key, and hides the key as it is typed. When they say they are done, the agent runs `dhq auth status --json` to confirm. +- **Yes:** the person signs in by running `dhq auth login` in a separate terminal window of their own (not through the agent, and not with its `!` shortcut, which gives dhq no terminal to prompt in). It prompts for the account name, the login email and an API key, and hides the key as it is typed. When they say they are done, the agent runs `dhq auth status --json` to confirm. - **No:** with the person's OK, they create an account either on the DeployHQ website or by running `dhq signup` in their own terminal (it prompts for the email and password, hiding the password, asks them to accept the Terms of Service, and saves the new credentials). DeployHQ then sends a verification email; the person verifies before anything else will work. Signing up on the website does not sign in the CLI, so after verifying, that person also runs `dhq auth login` in their own terminal. The agent runs `dhq auth status --json` afterwards. `dhq auth login` and `dhq signup` cannot prompt inside an agent (non-interactive mode), so run there they would need the key or password on the command line. That is why the person runs them. From 075e0033ac9b8a428ae6b2ed78330492ebaf8f6d Mon Sep 17 00:00:00 2001 From: Thiago Durante Date: Fri, 6 Nov 2026 09:56:48 +0100 Subject: [PATCH 05/11] feat: dhq skills show, so agents read the setup steps in the session A real agent run showed the skill pointer can't work as "install, then follow": a skill installed mid-session is not loaded until the agent's next session (Claude Code: "Unknown skill" right after install). - New 'dhq skills show [reference]' prints the embedded skill (plain Markdown, also piped; --json gives name/content/references). - The first-error pointer, recovery crumb (read_setup_skill) and 'dhq --help' ask the agent to read it now; installing stays as the option for future sessions, scoped to the running agent. - Skill: check the files and exclude deploy scripts / local config before the first deploy; with launch, create the project, add the exclusions, then 'dhq launch --project'. - deployments logs prints each entry's detail (as the web app does) and, without --step, honours --json. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 38 ++++++--- internal/auth/guidance.go | 15 +++- internal/cli/context_require_project_test.go | 5 +- internal/commands/agent_metadata.go | 6 ++ internal/commands/auth_signup_honesty_test.go | 6 +- internal/commands/deployments.go | 40 +++++++-- .../commands/deployments_logs_detail_test.go | 59 +++++++++++++ internal/commands/root.go | 5 +- internal/commands/skills.go | 85 +++++++++++++++++++ internal/commands/skills_show_test.go | 85 +++++++++++++++++++ internal/output/breadcrumbs.go | 16 ++-- internal/output/breadcrumbs_test.go | 21 +++-- skills/deployhq/SKILL.md | 4 +- skills/deployhq/references/launch.md | 5 ++ 14 files changed, 343 insertions(+), 47 deletions(-) create mode 100644 internal/commands/deployments_logs_detail_test.go create mode 100644 internal/commands/skills_show_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 65e6d1e..0dfc4e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,18 +9,36 @@ While the CLI is pre-1.0, minor versions may carry breaking changes to the publi ## [Unreleased] +### Added + +- **`dhq skills show [reference]`** prints DeployHQ's skill for AI agents (or + one of its references) without installing anything. An agent can read it in + the session it is working in: a skill installed with `dhq skills install` is + only loaded by the agent's next session. Plain Markdown, also when piped; + `--json` gives `{name, content, references}`. + ### Fixed -- **Pointing AI agents at DeployHQ's skill**: agents act on the CLI's errors - but rarely run `dhq --help`, so the skill pointer there was missed. The first - messages of a new setup now carry it too: the "not signed in" guidance, - `next_step` in `dhq auth status --json`, `dhq doctor`, and the - "No project specified" errors (including `dhq deploy`'s). In `--json` output - their recovery actions add `install_and_read_skill`. The command names the - agent running dhq (for example `dhq skills install --agent claude-code`), so - the skill is installed for that agent only, not for every AI tool on the - machine; when dhq can't tell which agent it is, the agent fills in its name. - `dhq --help` names the agent the same way. +- **Getting AI agents to the skill**: agents act on the CLI's errors but rarely + run `dhq --help`, and a skill they install mid-session isn't loaded until + their next one. The first messages of a new setup (the "not signed in" + guidance, `next_step` in `dhq auth status --json`, `dhq doctor`, and the + "No project specified" errors, including `dhq deploy`'s) now ask the agent + to read the setup steps with `dhq skills show` before going on. In `--json` + output their recovery actions add `read_setup_skill` (`dhq skills show`). + `dhq --help` says the same. Installing for future sessions is offered for the + running agent only (for example `dhq skills install --agent claude-code`), + not for every AI tool on the machine. +- **The skill says to exclude files before the first deploy.** Every file in + the repository is uploaded unless excluded; the setup steps now say to check + the files and exclude deploy scripts or local config first, and, since + `dhq launch` deploys straight away, to create the project, add the + exclusions, then run `dhq launch --project `. +- **`dhq deployments logs`** prints each log entry's detail under its message, + as the DeployHQ web app does (command output, the list of excluded files, an + error's cause). Without it, "Excluded files for server ..." showed no files. + Without `--step`, `--json` (or piped output) now returns every step's logs as + JSON instead of text. - **Signing in or signing up "in a separate terminal window"** now says "not with the agent's `!` shortcut, which gives dhq no terminal to prompt in" as well. Agents read "not through the AI agent" as allowing it, and suggested diff --git a/internal/auth/guidance.go b/internal/auth/guidance.go index 9a4eef7..9a79991 100644 --- a/internal/auth/guidance.go +++ b/internal/auth/guidance.go @@ -37,17 +37,24 @@ func SkillInstallCommand() string { return skillinstaller.InstallCommandFor(harness.Detect().Name) } +// SkillReadCommand prints DeployHQ's skill. An agent reads it in the session +// it is working in: a skill installed mid-session is not loaded until the +// agent's next session. +const SkillReadCommand = "dhq skills show" + // SkillPointer sends an agent to DeployHQ's skill. Agents act on the CLI's // errors but rarely run 'dhq --help', so the messages they meet first (not -// signed in, no project) carry it. +// signed in, no project) carry it. It asks the agent to read the skill now, +// not to install it: an installed skill only loads in a new session. func SkillPointer() string { cmd := SkillInstallCommand() names := "" if strings.Contains(cmd, "<") { - names = " ('dhq skills list' names the agents)" + names = ", 'dhq skills list' names the agents" } - return "AI agents: install DeployHQ's skill for yourself with '" + cmd + "'" + names + - ", then read and follow it: it has the setup steps, including when to ask the person." + return "AI agents: before going on, read DeployHQ's setup steps with '" + SkillReadCommand + "' and follow them: " + + "they say what to set up before the first deploy and when to ask the person. " + + "(To have them in future sessions too: '" + cmd + "'" + names + ".)" } // SignInHint is the guidance shown whenever dhq needs credentials it cannot diff --git a/internal/cli/context_require_project_test.go b/internal/cli/context_require_project_test.go index 5fea975..d077732 100644 --- a/internal/cli/context_require_project_test.go +++ b/internal/cli/context_require_project_test.go @@ -19,7 +19,8 @@ func TestRequireProject_PointsAgentsAtSkill(t *testing.T) { var uerr *output.UserError require.ErrorAs(t, err, &uerr) - assert.Contains(t, uerr.Hint, "dhq skills install --agent claude-code") + assert.Contains(t, uerr.Hint, "read DeployHQ's setup steps with 'dhq skills show'") + assert.Contains(t, uerr.Hint, "dhq skills install --agent claude-code", "install is scoped to the running agent") assert.Contains(t, output.ErrorResponseFromErr(err).Data.(output.ErrorData).Recovery, - output.Breadcrumb{Action: "install_and_read_skill", Cmd: "dhq skills install --agent claude-code"}) + output.Breadcrumb{Action: "read_setup_skill", Cmd: "dhq skills show"}) } diff --git a/internal/commands/agent_metadata.go b/internal/commands/agent_metadata.go index cd87ec9..e7ce1f7 100644 --- a/internal/commands/agent_metadata.go +++ b/internal/commands/agent_metadata.go @@ -54,6 +54,12 @@ var commandMetadataTable = map[string]AgentMetadata{ ResourceTypes: []string{"project", "server", "deployment"}, }, + // Skills + "dhq skills show": { + // Prints the embedded skill; changes nothing. + Idempotent: true, SupportsJSON: true, SafeForAutomation: true, + }, + // Projects "dhq projects list": { Idempotent: true, SupportsJSON: true, SafeForAutomation: true, diff --git a/internal/commands/auth_signup_honesty_test.go b/internal/commands/auth_signup_honesty_test.go index 1d1d8ff..0d9c096 100644 --- a/internal/commands/auth_signup_honesty_test.go +++ b/internal/commands/auth_signup_honesty_test.go @@ -63,7 +63,7 @@ func TestAuthLogin_NonInteractive_PointsToOwnTerminalNotKeyFlag(t *testing.T) { require.True(t, errors.As(err, &authErr), "got %T", err) assert.Contains(t, authErr.Hint, "separate terminal window") assert.Contains(t, authErr.Hint, "not with its '!' shortcut", "agents read 'not through the agent' as allowing '!'") - assert.Contains(t, authErr.Hint, "dhq skills install --agent", "agents rarely run --help, so the first errors point at the skill") + assert.Contains(t, authErr.Hint, "read DeployHQ's setup steps with 'dhq skills show'", "agents rarely run --help, so the first errors point at the skill") assert.Regexp(t, `dhq signup'[^\n]*not with its '!' shortcut`, authErr.Hint, "the signup line carries the '!' warning too") assert.Contains(t, authErr.Hint, "Settings → Security → API Credentials") assert.Contains(t, authErr.Hint, "DEPLOYHQ_API_KEY") @@ -313,7 +313,8 @@ func TestRootHelp_PointsAgentsToSetupInstructions(t *testing.T) { cmd.SetArgs([]string{"--help"}) require.NoError(t, cmd.Execute()) assert.Contains(t, stdout.String(), "https://www.deployhq.com/agents.md") - assert.Contains(t, stdout.String(), "install DeployHQ's skill for yourself with 'dhq skills install --agent claude-code'") + assert.Contains(t, stdout.String(), "read DeployHQ's setup steps with 'dhq skills show'") + assert.Contains(t, stdout.String(), "'dhq skills install --agent claude-code'") } func TestDescribeCredentialSource(t *testing.T) { @@ -385,6 +386,7 @@ func TestAuthStatus_JSON_NotLoggedIn(t *testing.T) { assert.Contains(t, res["next_step"], "dhq auth login") assert.Contains(t, res["next_step"], "separate terminal window") assert.Contains(t, res["next_step"], "not with its '!' shortcut") + assert.Contains(t, res["next_step"], "dhq skills show") assert.Contains(t, res["next_step"], "dhq skills install --agent") } diff --git a/internal/commands/deployments.go b/internal/commands/deployments.go index a9ee4fe..16c2790 100644 --- a/internal/commands/deployments.go +++ b/internal/commands/deployments.go @@ -2,6 +2,8 @@ package commands import ( "fmt" + "io" + "strings" "github.com/deployhq/deployhq-cli/internal/output" "github.com/deployhq/deployhq-cli/pkg/sdk" @@ -423,6 +425,7 @@ func newDeploymentsLogsCmd() *cobra.Command { return nil } // Show logs for all steps that have logs + var all []stepLogs for _, step := range dep.Steps { if !step.Logs { continue @@ -431,10 +434,15 @@ func newDeploymentsLogsCmd() *cobra.Command { if err != nil { continue } - cliCtx.Envelope.Status("\n--- %s (%s) ---", step.Description, step.Status) - for _, log := range logs { - fmt.Fprintln(cliCtx.Envelope.Stdout, log.Message) //nolint:errcheck + if cliCtx.Envelope.WantsJSON() { + all = append(all, stepLogs{Step: step.Identifier, Description: step.Description, Status: step.Status, Logs: logs}) + continue } + cliCtx.Envelope.Status("\n--- %s (%s) ---", step.Description, step.Status) + printStepLogs(cliCtx.Envelope.Stdout, logs) + } + if cliCtx.Envelope.WantsJSON() { + return cliCtx.Envelope.WriteJSON(output.NewResponse(all, fmt.Sprintf("Logs for %d steps", len(all)))) } return nil } @@ -449,9 +457,7 @@ func newDeploymentsLogsCmd() *cobra.Command { return env.WriteJSON(output.NewResponse(logs, fmt.Sprintf("%d log entries", len(logs)))) } - for _, log := range logs { - fmt.Fprintln(env.Stdout, log.Message) //nolint:errcheck - } + printStepLogs(env.Stdout, logs) return nil }, } @@ -482,3 +488,25 @@ func newDeploymentsWatchCmd() *cobra.Command { }, } } + +// printStepLogs prints each log entry's message and, under it, its detail +// (command output, the list of excluded files, an error's cause), as the +// DeployHQ web app and its plain-text log do. +func printStepLogs(w io.Writer, logs []sdk.DeploymentStepLog) { + for _, log := range logs { + fmt.Fprintln(w, log.Message) //nolint:errcheck + if log.Detail != nil { + if d := strings.TrimRight(*log.Detail, "\n"); strings.TrimSpace(d) != "" { + fmt.Fprintln(w, d) //nolint:errcheck + } + } + } +} + +// stepLogs is one step's logs in 'dhq deployments logs --json' without --step. +type stepLogs struct { + Step string `json:"step"` + Description string `json:"description"` + Status string `json:"status"` + Logs []sdk.DeploymentStepLog `json:"logs"` +} diff --git a/internal/commands/deployments_logs_detail_test.go b/internal/commands/deployments_logs_detail_test.go new file mode 100644 index 0000000..9a82f54 --- /dev/null +++ b/internal/commands/deployments_logs_detail_test.go @@ -0,0 +1,59 @@ +package commands + +import ( + "bytes" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/deployhq/deployhq-cli/pkg/sdk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Each log's detail is printed under its message, as the DeployHQ web app +// shows it: without it, "Excluded files for server ..." showed no files. +func TestPrintStepLogs_PrintsDetail(t *testing.T) { + detail := " - deploy.sh (1 file)\n" + blank := " \n" + var out bytes.Buffer + printStepLogs(&out, []sdk.DeploymentStepLog{ + {Message: "Excluded files for server 'site'", Detail: &detail}, + {Message: "Skipped due to project/account settings"}, + {Message: "Done", Detail: &blank}, + }) + assert.Equal(t, "Excluded files for server 'site'\n - deploy.sh (1 file)\nSkipped due to project/account settings\nDone\n", out.String()) +} + +// Without --step, --json returns every step's logs (with their detail) instead +// of printing text. +func TestDeploymentsLogs_AllStepsJSON(t *testing.T) { + withResolvableContext(t) + out := filepath.Join(t.TempDir(), "out.jsonl") + t.Setenv("DEPLOYHQ_OUTPUT_FILE", out) + orig := http.DefaultTransport + t.Cleanup(func() { http.DefaultTransport = orig }) + http.DefaultTransport = roundTripFunc(func(r *http.Request) (*http.Response, error) { + body := `{}` + switch { + case strings.HasSuffix(r.URL.Path, "/projects/my-app/deployments/dep1"): + body = `{"identifier":"dep1","status":"completed","steps":[ + {"identifier":"s1","description":"Generating manifest","status":"completed","logs":true}, + {"identifier":"s2","description":"Transferring files","status":"completed","logs":false}]}` + case strings.HasSuffix(r.URL.Path, "/deployments/dep1/steps/s1/logs"): + body = `[{"id":1,"step":"s1","message":"Excluded files for server 'site'","detail":" - deploy.sh (1 file)"}]` + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), + Header: http.Header{"Content-Type": []string{"application/json"}}, Request: r}, nil + }) + + require.NoError(t, runServersCmd(t, "deployments", "logs", "dep1", "--json")) + b, err := os.ReadFile(out) + require.NoError(t, err) + assert.Contains(t, string(b), `"description":"Generating manifest"`) + assert.Contains(t, string(b), `deploy.sh (1 file)`) + assert.NotContains(t, string(b), "Transferring files", "steps without logs are left out") +} diff --git a/internal/commands/root.go b/internal/commands/root.go index ee99aa6..ddfc275 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -52,8 +52,9 @@ func NewRootCmd(version string) *cobra.Command { Short: "DeployHQ CLI — deploy from your terminal", Long: `The official DeployHQ command-line interface for managing projects, servers, and deployments. -AI agents: install DeployHQ's skill for yourself with '` + auth.SkillInstallCommand() + `' and -follow it, or read https://www.deployhq.com/agents.md. +AI agents: read DeployHQ's setup steps with '` + auth.SkillReadCommand + `' and follow them +(install them for future sessions with '` + auth.SkillInstallCommand() + `'), +or read https://www.deployhq.com/agents.md. Feedback & feature requests: https://changelog.deployhq.com Support: support@deployhq.com`, diff --git a/internal/commands/skills.go b/internal/commands/skills.go index 9e51a84..6e65f34 100644 --- a/internal/commands/skills.go +++ b/internal/commands/skills.go @@ -2,9 +2,13 @@ package commands import ( "fmt" + "io/fs" + "sort" + "strings" "github.com/deployhq/deployhq-cli/internal/output" "github.com/deployhq/deployhq-cli/internal/skillinstaller" + "github.com/deployhq/deployhq-cli/skills" "github.com/spf13/cobra" ) @@ -16,6 +20,7 @@ func newSkillsCmd() *cobra.Command { (Claude Code, Cursor, etc.). Run during 'dhq hello' or standalone. Examples: + dhq skills show Print the skill, to read it right away dhq skills list Show every supported agent and status dhq skills install Install for detected user-scope agents (Claude Code, Cursor, etc.). Skips @@ -25,6 +30,7 @@ Examples: (writes into the current repo) dhq skills install --agent claude-code Install for any specific agent`, } + cmd.AddCommand(newSkillsShowCmd()) cmd.AddCommand(newSkillsListCmd()) cmd.AddCommand(newSkillsInstallCmd()) return cmd @@ -148,3 +154,82 @@ func newSkillsInstallCmd() *cobra.Command { cmd.Flags().StringVar(&agentFlag, "agent", "", "Install for a specific agent only (e.g. claude-code)") return cmd } + +// skillDir is the embedded skill's root in skills.FS. +const skillDir = "deployhq" + +// skillReferences returns the names of the skill's reference docs (without +// ".md"), sorted. +func skillReferences() []string { + entries, err := fs.ReadDir(skills.FS, skillDir+"/references") + if err != nil { + return nil + } + var names []string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".md") { + names = append(names, strings.TrimSuffix(e.Name(), ".md")) + } + } + sort.Strings(names) + return names +} + +// skillShowOutput is the --json shape of 'dhq skills show'. +type skillShowOutput struct { + Name string `json:"name"` + Content string `json:"content"` + References []string `json:"references"` +} + +// newSkillsShowCmd prints the embedded skill. A skill installed with +// 'dhq skills install' is only loaded by the agent's next session, so an +// agent that is setting DeployHQ up right now reads it this way instead. +func newSkillsShowCmd() *cobra.Command { + return &cobra.Command{ + Use: "show [reference]", + Short: "Print the DeployHQ skill, so an AI agent can read it right away", + Long: `Print DeployHQ's skill for AI agents: the setup steps, including when to ask +the person. Nothing is installed or changed. + +An agent can read it straight away, in the session it is working in. A skill +installed with 'dhq skills install' is only loaded by the agent's next session. + +With a reference name, print that reference document instead. The names are +listed at the end of the skill.`, + Example: ` # Read the skill + dhq skills show + + # Read one of its references + dhq skills show launch`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + env := cliCtx.Envelope + refs := skillReferences() + + name, path := "SKILL", skillDir+"/SKILL.md" + if len(args) == 1 { + name = strings.TrimSuffix(args[0], ".md") + path = skillDir + "/references/" + name + ".md" + } + data, err := skills.FS.ReadFile(path) + if err != nil { + return &output.UserError{ + Message: fmt.Sprintf("Unknown skill reference: %q", name), + Hint: "References: " + strings.Join(refs, ", "), + } + } + + if env.JSONMode { + return env.WriteJSON(output.NewResponse(skillShowOutput{Name: name, Content: string(data), References: refs}, + fmt.Sprintf("DeployHQ skill: %s", name))) + } + // Plain Markdown even when piped: this is a document to read. + fmt.Fprint(env.Stdout, string(data)) //nolint:errcheck + if len(args) == 0 { + fmt.Fprintf(env.Stdout, "\n---\nThe references linked above are printed with 'dhq skills show ': %s\n", strings.Join(refs, ", ")) //nolint:errcheck + } + return nil + }, + } +} diff --git a/internal/commands/skills_show_test.go b/internal/commands/skills_show_test.go new file mode 100644 index 0000000..6a24aa2 --- /dev/null +++ b/internal/commands/skills_show_test.go @@ -0,0 +1,85 @@ +package commands + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "strings" + "testing" + + "github.com/deployhq/deployhq-cli/internal/cli" + "github.com/deployhq/deployhq-cli/internal/config" + "github.com/deployhq/deployhq-cli/internal/output" + "github.com/deployhq/deployhq-cli/skills" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// runSkillsShow runs 'dhq skills show' with stdout not a terminal, as when an +// agent runs it, and returns stdout. +func runSkillsShow(t *testing.T, jsonMode bool, args ...string) (string, error) { + t.Helper() + var stdout bytes.Buffer + orig := cliCtx + t.Cleanup(func() { cliCtx = orig }) + cliCtx = &cli.Context{ + Config: &config.Config{}, + Envelope: &output.Envelope{ + Stdout: &stdout, Stderr: io.Discard, Logger: &output.Logger{}, + NonInteractive: true, JSONMode: jsonMode, IsTTY: false, + }, + } + cmd := newSkillsShowCmd() + err := cmd.RunE(cmd, args) + return stdout.String(), err +} + +// Piped (as an agent runs it), it prints the skill as Markdown, not JSON: +// it is a document to read. +func TestSkillsShow_PrintsSkillMarkdown(t *testing.T) { + want, err := skills.FS.ReadFile("deployhq/SKILL.md") + require.NoError(t, err) + + out, err := runSkillsShow(t, false) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(out, string(want)), "starts with SKILL.md verbatim") + assert.Contains(t, out, "dhq skills show ") + assert.Contains(t, out, "launch", "lists the references") +} + +func TestSkillsShow_Reference(t *testing.T) { + want, err := skills.FS.ReadFile("deployhq/references/launch.md") + require.NoError(t, err) + + for _, arg := range []string{"launch", "launch.md"} { + out, err := runSkillsShow(t, false, arg) + require.NoError(t, err) + assert.Equal(t, string(want), out, arg) + } +} + +func TestSkillsShow_UnknownReference(t *testing.T) { + _, err := runSkillsShow(t, false, "nope") + var uerr *output.UserError + require.True(t, errors.As(err, &uerr), "got %T: %v", err, err) + assert.Contains(t, uerr.Message, `"nope"`) + assert.Contains(t, uerr.Hint, "launch") + + _, err = runSkillsShow(t, false, "../SKILL") + require.Error(t, err, "only reference documents can be printed") +} + +func TestSkillsShow_JSON(t *testing.T) { + out, err := runSkillsShow(t, true) + require.NoError(t, err) + var res struct { + OK bool `json:"ok"` + Data skillShowOutput `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(out), &res)) + assert.True(t, res.OK) + assert.Equal(t, "SKILL", res.Data.Name) + assert.Contains(t, res.Data.Content, "DeployHQ") + assert.Contains(t, res.Data.References, "launch") +} diff --git a/internal/output/breadcrumbs.go b/internal/output/breadcrumbs.go index f097e7e..a39fcae 100644 --- a/internal/output/breadcrumbs.go +++ b/internal/output/breadcrumbs.go @@ -227,12 +227,12 @@ func isRetryable(code, message string) bool { return false } -// installSkillCrumb points an agent at DeployHQ's skill. It is offered on the +// readSkillCrumb points an agent at DeployHQ's skill. It is offered on the // errors an agent meets first in a new setup (not signed in, no project), -// since agents act on these but rarely run 'dhq --help'. -// It installs the skill for the running agent only. -func installSkillCrumb() Breadcrumb { - return Breadcrumb{Action: "install_and_read_skill", Cmd: auth.SkillInstallCommand()} +// since agents act on these but rarely run 'dhq --help'. It reads the skill +// rather than installing it: an installed skill only loads in a new session. +func readSkillCrumb() Breadcrumb { + return Breadcrumb{Action: "read_setup_skill", Cmd: auth.SkillReadCommand} } // recoveryActions returns suggested next commands for common errors. @@ -251,20 +251,20 @@ func recoveryActions(code, message string) []Breadcrumb { if notSignedIn(msg) { crumbs = append(crumbs, Breadcrumb{Action: "ask_user_to_signup_in_separate_terminal", Cmd: "dhq signup"}, - installSkillCrumb()) + readSkillCrumb()) } return append(crumbs, Breadcrumb{Action: "check_status", Cmd: "dhq auth status"}) case notSignedIn(msg): return []Breadcrumb{ {Action: "ask_user_to_login_in_separate_terminal", Cmd: "dhq auth login"}, {Action: "ask_user_to_signup_in_separate_terminal", Cmd: "dhq signup"}, - installSkillCrumb(), + readSkillCrumb(), } case strings.Contains(msg, "no project"): return []Breadcrumb{ {Action: "list_projects", Cmd: "dhq projects list --json"}, {Action: "set_project", Cmd: "dhq config set project "}, - installSkillCrumb(), + readSkillCrumb(), } case strings.Contains(msg, "not found"): return []Breadcrumb{ diff --git a/internal/output/breadcrumbs_test.go b/internal/output/breadcrumbs_test.go index 10d2f11..aa28e73 100644 --- a/internal/output/breadcrumbs_test.go +++ b/internal/output/breadcrumbs_test.go @@ -131,14 +131,14 @@ func TestErrorResponseFromErr_NotLoggedIn_OffersSignup(t *testing.T) { assert.Equal(t, "dhq auth login", data.Recovery[0].Cmd) assert.Equal(t, "ask_user_to_signup_in_separate_terminal", data.Recovery[1].Action) assert.Equal(t, "dhq signup", data.Recovery[1].Cmd) - assert.Equal(t, Breadcrumb{Action: "install_and_read_skill", Cmd: "dhq skills install --agent claude-code"}, data.Recovery[2]) + assert.Equal(t, Breadcrumb{Action: "read_setup_skill", Cmd: "dhq skills show"}, data.Recovery[2]) } // Agents act on these errors but rarely run 'dhq --help', so the first // errors of a new setup point at DeployHQ's skill. func TestErrorResponseFromErr_FirstSetupErrorsOfferSkill(t *testing.T) { t.Setenv("DEPLOYHQ_AGENT", "claude-code") - skill := Breadcrumb{Action: "install_and_read_skill", Cmd: "dhq skills install --agent claude-code"} + skill := Breadcrumb{Action: "read_setup_skill", Cmd: "dhq skills show"} for name, err := range map[string]error{ "not logged in": errors.New("auth: not logged in"), "auth not signed in": &AuthError{Message: "Not logged in"}, @@ -280,18 +280,17 @@ func TestBreadcrumb_ResourceFields(t *testing.T) { assert.Contains(t, string(data), `"id":"abc"`) } -// A bare 'dhq skills install' would install for every AI tool on the machine, -// so the crumb names the running agent, or leaves a placeholder. -func TestErrorResponseFromErr_SkillCrumbNeverInstallsForEveryAgent(t *testing.T) { - for agent, want := range map[string]string{ - "codex": "dhq skills install --agent codex", - "copilot": "dhq skills install --agent ", // writes into the repo - "not-a-agent": "dhq skills install --agent ", - } { +// The crumb reads the skill (it changes nothing) rather than installing it: +// an installed skill only loads in the agent's next session. +func TestErrorResponseFromErr_SkillCrumbReadsNotInstalls(t *testing.T) { + for _, agent := range []string{"claude-code", "codex", ""} { t.Run(agent, func(t *testing.T) { t.Setenv("DEPLOYHQ_AGENT", agent) rec := ErrorResponseFromErr(errors.New("auth: not logged in")).Data.(ErrorData).Recovery - assert.Contains(t, rec, Breadcrumb{Action: "install_and_read_skill", Cmd: want}) + assert.Contains(t, rec, Breadcrumb{Action: "read_setup_skill", Cmd: "dhq skills show"}) + for _, b := range rec { + assert.NotContains(t, b.Cmd, "skills install") + } }) } } diff --git a/skills/deployhq/SKILL.md b/skills/deployhq/SKILL.md index c104bc6..6388d43 100644 --- a/skills/deployhq/SKILL.md +++ b/skills/deployhq/SKILL.md @@ -104,11 +104,11 @@ Small steps, each one something the person can approve on its own: 6. **Add the target.** - Own server: DeployHQ connects with the project's access key (`dhq projects show --json=public_key`). Installing that key on the server changes who can log in to it, so the agent shows the key and asks before adding it, or the person adds it themselves. Then `dhq servers create ...` (see [servers.md](references/servers.md)). If the server only accepts a password, the person adds it in the DeployHQ web app so the password never passes through the chat. - DeployHQ hosting: `dhq beta enroll --protocol static_hosting` (needs an account admin) and `dhq servers create -p --protocol-type static_hosting --subdomain ...`, or a `managed_vps` server. Check with the person before each. -7. **Configure** build commands, excluded files, env vars as the project needs (see "Configure deployment"). +7. **Configure** build commands, excluded files, env vars as the project needs (see "Configure deployment"). Every file in the repository is uploaded unless it is excluded, so before the first deploy check the files (`git ls-files`) and exclude what must not be published, such as deploy scripts or local config: `dhq excluded-files create -p --path ` (all servers unless `--server`). 8. **First deploy.** Summarise the project, branch and target, ask, then `dhq deploy -p -s --wait --json`, which waits for the deployment to finish (a new one may start as `queued`). 9. **Confirm.** Report success only when the JSON shows `"status": "completed"`. A non-zero exit means the command failed: if a deployment was created, its `status` says how it ended; otherwise read the error. (`dhq deployments watch -p ` is a live view for a person following along in their own terminal, not a step for the agent.) -`dhq launch` does steps 4 to 9 for DeployHQ hosting in a single command. It is an option when the person has chosen DeployHQ hosting and wants it done in one go, not the default; see [launch.md](references/launch.md). +`dhq launch` does steps 4 to 9 for DeployHQ hosting in a single command. It is an option when the person has chosen DeployHQ hosting and wants it done in one go, not the default; see [launch.md](references/launch.md). It deploys straight away, so files that must not be published are excluded first: create the project (`dhq projects create`), add the exclusions, then run `dhq launch --project `. ### "Deploy code" 1. `dhq projects list --json` — find project permalink diff --git a/skills/deployhq/references/launch.md b/skills/deployhq/references/launch.md index ac65fa9..6517eb2 100644 --- a/skills/deployhq/references/launch.md +++ b/skills/deployhq/references/launch.md @@ -25,6 +25,11 @@ Each of these is hard to undo, so the person agrees to all of them before the agent runs `dhq launch`. A good sequence: - Run `dhq launch --static --dry-run --json` (or `--vps`) and show the person what it would do. +- **Exclude files before launching.** `dhq launch` deploys at once, and every file in + the repository is uploaded unless it is excluded. Check the files (`git ls-files`); if + any must not be published (a deploy script, local config), create the project first + with `dhq projects create`, add them with `dhq excluded-files create -p --path `, + then run `dhq launch --project `. - Offer the choice: run `dhq launch`, or go step by step (`dhq projects create`, `dhq repos create`, `dhq beta enroll`, `dhq servers create --protocol-type static_hosting`, `dhq deploy`), approving each action on its own. See "Set up DeployHQ for this From 83e056d20bdf7cfb2c1585d9ef4864c857f235e2 Mon Sep 17 00:00:00 2001 From: Thiago Durante Date: Fri, 6 Nov 2026 10:09:20 +0100 Subject: [PATCH 06/11] fix(deployments logs): JSON for zero steps and failed step reads; review follow-ups - Without --step, JSON output is always an array (empty before a deployment has steps) and a step whose logs can't be read carries an error instead of being left out (text mode warns). - Agent metadata: deployments logs supports JSON. - Tests: piped output without --json, zero steps, failed step read; the '../' reference case asserts the unknown-reference error. - CHANGELOG: the text-to-JSON switch for piped output is under Changed. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 ++- internal/commands/agent_metadata.go | 2 +- internal/commands/deployments.go | 15 +++- .../commands/deployments_logs_detail_test.go | 69 +++++++++++++++---- internal/commands/skills_show_test.go | 3 +- 5 files changed, 78 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dfc4e9..df7a6ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,15 @@ While the CLI is pre-1.0, minor versions may carry breaking changes to the publi only loaded by the agent's next session. Plain Markdown, also when piped; `--json` gives `{name, content, references}`. +### Changed + +- **`dhq deployments logs` without `--step`** now honours `--json`, and so, like + other commands, returns JSON when its output is piped: one entry per step + with its logs (an empty array before a deployment has steps, and an `error` + for a step whose logs couldn't be read). It used to print text whatever the + output was, so scripts that grep the piped text should pass `--table` or + read the JSON. + ### Fixed - **Getting AI agents to the skill**: agents act on the CLI's errors but rarely @@ -37,8 +46,6 @@ While the CLI is pre-1.0, minor versions may carry breaking changes to the publi - **`dhq deployments logs`** prints each log entry's detail under its message, as the DeployHQ web app does (command output, the list of excluded files, an error's cause). Without it, "Excluded files for server ..." showed no files. - Without `--step`, `--json` (or piped output) now returns every step's logs as - JSON instead of text. - **Signing in or signing up "in a separate terminal window"** now says "not with the agent's `!` shortcut, which gives dhq no terminal to prompt in" as well. Agents read "not through the AI agent" as allowing it, and suggested diff --git a/internal/commands/agent_metadata.go b/internal/commands/agent_metadata.go index e7ce1f7..a7f5e8e 100644 --- a/internal/commands/agent_metadata.go +++ b/internal/commands/agent_metadata.go @@ -149,7 +149,7 @@ var commandMetadataTable = map[string]AgentMetadata{ ResourceTypes: []string{"deployment"}, }, "dhq deployments logs": { - Idempotent: true, SupportsJSON: false, SafeForAutomation: true, + Idempotent: true, SupportsJSON: true, SafeForAutomation: true, ResourceTypes: []string{"deployment"}, }, "dhq deployments watch": { diff --git a/internal/commands/deployments.go b/internal/commands/deployments.go index 16c2790..afe6716 100644 --- a/internal/commands/deployments.go +++ b/internal/commands/deployments.go @@ -420,18 +420,26 @@ func newDeploymentsLogsCmd() *cobra.Command { if err != nil { return err } - if len(dep.Steps) == 0 { + // Show logs for all steps that have logs. In JSON, always an + // array (empty when there are no steps yet), and a step whose + // logs couldn't be read says so rather than being left out. + all := []stepLogs{} + if len(dep.Steps) == 0 && !cliCtx.Envelope.WantsJSON() { cliCtx.Envelope.Status("No steps found for deployment %s", args[0]) return nil } - // Show logs for all steps that have logs - var all []stepLogs for _, step := range dep.Steps { if !step.Logs { continue } logs, err := client.GetDeploymentStepLogs(cliCtx.Background(), projectID, args[0], step.Identifier) if err != nil { + if cliCtx.Envelope.WantsJSON() { + all = append(all, stepLogs{Step: step.Identifier, Description: step.Description, Status: step.Status, + Logs: []sdk.DeploymentStepLog{}, Error: err.Error()}) + } else { + cliCtx.Envelope.Warn("Could not read the logs of %q: %v", step.Description, err) + } continue } if cliCtx.Envelope.WantsJSON() { @@ -509,4 +517,5 @@ type stepLogs struct { Description string `json:"description"` Status string `json:"status"` Logs []sdk.DeploymentStepLog `json:"logs"` + Error string `json:"error,omitempty"` // set when the logs couldn't be read } diff --git a/internal/commands/deployments_logs_detail_test.go b/internal/commands/deployments_logs_detail_test.go index 9a82f54..ee58d5b 100644 --- a/internal/commands/deployments_logs_detail_test.go +++ b/internal/commands/deployments_logs_detail_test.go @@ -28,32 +28,71 @@ func TestPrintStepLogs_PrintsDetail(t *testing.T) { assert.Equal(t, "Excluded files for server 'site'\n - deploy.sh (1 file)\nSkipped due to project/account settings\nDone\n", out.String()) } -// Without --step, --json returns every step's logs (with their detail) instead -// of printing text. -func TestDeploymentsLogs_AllStepsJSON(t *testing.T) { - withResolvableContext(t) - out := filepath.Join(t.TempDir(), "out.jsonl") - t.Setenv("DEPLOYHQ_OUTPUT_FILE", out) +// fakeDeploymentLogsAPI answers deployment dep1 with the given steps body and +// serves step s1's logs (or a 500 when logsStatus is set). +func fakeDeploymentLogsAPI(t *testing.T, steps string, logsStatus int) { + t.Helper() orig := http.DefaultTransport t.Cleanup(func() { http.DefaultTransport = orig }) http.DefaultTransport = roundTripFunc(func(r *http.Request) (*http.Response, error) { - body := `{}` + status, body := http.StatusOK, `{}` switch { case strings.HasSuffix(r.URL.Path, "/projects/my-app/deployments/dep1"): - body = `{"identifier":"dep1","status":"completed","steps":[ - {"identifier":"s1","description":"Generating manifest","status":"completed","logs":true}, - {"identifier":"s2","description":"Transferring files","status":"completed","logs":false}]}` + body = `{"identifier":"dep1","status":"completed","steps":` + steps + `}` case strings.HasSuffix(r.URL.Path, "/deployments/dep1/steps/s1/logs"): body = `[{"id":1,"step":"s1","message":"Excluded files for server 'site'","detail":" - deploy.sh (1 file)"}]` + if logsStatus != 0 { + status, body = logsStatus, `{"error":"boom"}` + } } - return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: http.Header{"Content-Type": []string{"application/json"}}, Request: r}, nil }) +} - require.NoError(t, runServersCmd(t, "deployments", "logs", "dep1", "--json")) +const twoSteps = `[ + {"identifier":"s1","description":"Generating manifest","status":"completed","logs":true}, + {"identifier":"s2","description":"Transferring files","status":"completed","logs":false}]` + +// runLogsCapture runs 'dhq deployments logs dep1 ' and returns the JSON +// written (captured through DEPLOYHQ_OUTPUT_FILE). +func runLogsCapture(t *testing.T, args ...string) string { + t.Helper() + out := filepath.Join(t.TempDir(), "out.jsonl") + t.Setenv("DEPLOYHQ_OUTPUT_FILE", out) + require.NoError(t, runServersCmd(t, append([]string{"deployments", "logs", "dep1"}, args...)...)) b, err := os.ReadFile(out) require.NoError(t, err) - assert.Contains(t, string(b), `"description":"Generating manifest"`) - assert.Contains(t, string(b), `deploy.sh (1 file)`) - assert.NotContains(t, string(b), "Transferring files", "steps without logs are left out") + return string(b) +} + +// Without --step, --json (or piped output, as here: the test's stdout is not +// a terminal) returns every step's logs, with their detail, as JSON. +func TestDeploymentsLogs_AllStepsJSON(t *testing.T) { + for name, args := range map[string][]string{"--json": {"--json"}, "piped": nil} { + t.Run(name, func(t *testing.T) { + withResolvableContext(t) + fakeDeploymentLogsAPI(t, twoSteps, 0) + got := runLogsCapture(t, args...) + assert.Contains(t, got, `"description":"Generating manifest"`) + assert.Contains(t, got, `deploy.sh (1 file)`) + assert.NotContains(t, got, "Transferring files", "steps without logs are left out") + }) + } +} + +// A deployment with no steps yet (queued) still gives JSON: an empty array. +func TestDeploymentsLogs_NoStepsJSON(t *testing.T) { + withResolvableContext(t) + fakeDeploymentLogsAPI(t, `[]`, 0) + assert.Contains(t, runLogsCapture(t, "--json"), `"data":[]`) +} + +// A step whose logs can't be read is reported, not silently left out. +func TestDeploymentsLogs_StepLogsErrorReported(t *testing.T) { + withResolvableContext(t) + fakeDeploymentLogsAPI(t, twoSteps, http.StatusInternalServerError) + got := runLogsCapture(t, "--json") + assert.Contains(t, got, `"step":"s1"`) + assert.Contains(t, got, `"error":`) } diff --git a/internal/commands/skills_show_test.go b/internal/commands/skills_show_test.go index 6a24aa2..944b9bb 100644 --- a/internal/commands/skills_show_test.go +++ b/internal/commands/skills_show_test.go @@ -67,7 +67,8 @@ func TestSkillsShow_UnknownReference(t *testing.T) { assert.Contains(t, uerr.Hint, "launch") _, err = runSkillsShow(t, false, "../SKILL") - require.Error(t, err, "only reference documents can be printed") + require.True(t, errors.As(err, &uerr), "only reference documents can be printed: got %T: %v", err, err) + assert.Contains(t, uerr.Message, "Unknown skill reference") } func TestSkillsShow_JSON(t *testing.T) { From 439c0a453aee13d6ccc06d3758a1cd797d727925 Mon Sep 17 00:00:00 2001 From: Thiago Durante Date: Fri, 6 Nov 2026 10:34:28 +0100 Subject: [PATCH 07/11] fix(deployments logs): stay text when piped; JSON only with --json The all-steps path has always printed text, also when piped, and scripts grep it. JSON there now needs an explicit --json; the detail is part of the text either way. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 ++---- internal/commands/deployments.go | 17 ++++---- .../commands/deployments_logs_detail_test.go | 40 +++++++++++++------ 3 files changed, 41 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df7a6ce..36a5300 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,15 +17,6 @@ While the CLI is pre-1.0, minor versions may carry breaking changes to the publi only loaded by the agent's next session. Plain Markdown, also when piped; `--json` gives `{name, content, references}`. -### Changed - -- **`dhq deployments logs` without `--step`** now honours `--json`, and so, like - other commands, returns JSON when its output is piped: one entry per step - with its logs (an empty array before a deployment has steps, and an `error` - for a step whose logs couldn't be read). It used to print text whatever the - output was, so scripts that grep the piped text should pass `--table` or - read the JSON. - ### Fixed - **Getting AI agents to the skill**: agents act on the CLI's errors but rarely @@ -46,6 +37,9 @@ While the CLI is pre-1.0, minor versions may carry breaking changes to the publi - **`dhq deployments logs`** prints each log entry's detail under its message, as the DeployHQ web app does (command output, the list of excluded files, an error's cause). Without it, "Excluded files for server ..." showed no files. + Without `--step`, `--json` now returns every step's logs as JSON (an empty + array before a deployment has steps, and an `error` for a step whose logs + couldn't be read); without `--json` it prints text as before, also when piped. - **Signing in or signing up "in a separate terminal window"** now says "not with the agent's `!` shortcut, which gives dhq no terminal to prompt in" as well. Agents read "not through the AI agent" as allowing it, and suggested diff --git a/internal/commands/deployments.go b/internal/commands/deployments.go index afe6716..014aedd 100644 --- a/internal/commands/deployments.go +++ b/internal/commands/deployments.go @@ -420,11 +420,14 @@ func newDeploymentsLogsCmd() *cobra.Command { if err != nil { return err } - // Show logs for all steps that have logs. In JSON, always an - // array (empty when there are no steps yet), and a step whose - // logs couldn't be read says so rather than being left out. + // Show logs for all steps that have logs. JSON only with an + // explicit --json: this has always printed text, also when + // piped, and scripts grep it. In JSON, always an array (empty + // when there are no steps yet), and a step whose logs couldn't + // be read says so rather than being left out. + asJSON := cliCtx.Envelope.JSONMode all := []stepLogs{} - if len(dep.Steps) == 0 && !cliCtx.Envelope.WantsJSON() { + if len(dep.Steps) == 0 && !asJSON { cliCtx.Envelope.Status("No steps found for deployment %s", args[0]) return nil } @@ -434,7 +437,7 @@ func newDeploymentsLogsCmd() *cobra.Command { } logs, err := client.GetDeploymentStepLogs(cliCtx.Background(), projectID, args[0], step.Identifier) if err != nil { - if cliCtx.Envelope.WantsJSON() { + if asJSON { all = append(all, stepLogs{Step: step.Identifier, Description: step.Description, Status: step.Status, Logs: []sdk.DeploymentStepLog{}, Error: err.Error()}) } else { @@ -442,14 +445,14 @@ func newDeploymentsLogsCmd() *cobra.Command { } continue } - if cliCtx.Envelope.WantsJSON() { + if asJSON { all = append(all, stepLogs{Step: step.Identifier, Description: step.Description, Status: step.Status, Logs: logs}) continue } cliCtx.Envelope.Status("\n--- %s (%s) ---", step.Description, step.Status) printStepLogs(cliCtx.Envelope.Stdout, logs) } - if cliCtx.Envelope.WantsJSON() { + if asJSON { return cliCtx.Envelope.WriteJSON(output.NewResponse(all, fmt.Sprintf("Logs for %d steps", len(all)))) } return nil diff --git a/internal/commands/deployments_logs_detail_test.go b/internal/commands/deployments_logs_detail_test.go index ee58d5b..557900d 100644 --- a/internal/commands/deployments_logs_detail_test.go +++ b/internal/commands/deployments_logs_detail_test.go @@ -66,19 +66,35 @@ func runLogsCapture(t *testing.T, args ...string) string { return string(b) } -// Without --step, --json (or piped output, as here: the test's stdout is not -// a terminal) returns every step's logs, with their detail, as JSON. +// Without --step, --json returns every step's logs, with their detail, as JSON. func TestDeploymentsLogs_AllStepsJSON(t *testing.T) { - for name, args := range map[string][]string{"--json": {"--json"}, "piped": nil} { - t.Run(name, func(t *testing.T) { - withResolvableContext(t) - fakeDeploymentLogsAPI(t, twoSteps, 0) - got := runLogsCapture(t, args...) - assert.Contains(t, got, `"description":"Generating manifest"`) - assert.Contains(t, got, `deploy.sh (1 file)`) - assert.NotContains(t, got, "Transferring files", "steps without logs are left out") - }) - } + withResolvableContext(t) + fakeDeploymentLogsAPI(t, twoSteps, 0) + got := runLogsCapture(t, "--json") + assert.Contains(t, got, `"description":"Generating manifest"`) + assert.Contains(t, got, `deploy.sh (1 file)`) + assert.NotContains(t, got, "Transferring files", "steps without logs are left out") +} + +// Piped without --json (the test's stdout is not a terminal), it still prints +// text, as it always has, so scripts that grep it keep working; the detail is +// now part of that text. +func TestDeploymentsLogs_PipedStaysText(t *testing.T) { + withResolvableContext(t) + fakeDeploymentLogsAPI(t, twoSteps, 0) + + r, w, err := os.Pipe() + require.NoError(t, err) + origStdout := os.Stdout + os.Stdout = w + runErr := runServersCmd(t, "deployments", "logs", "dep1") + os.Stdout = origStdout + require.NoError(t, w.Close()) + b, err := io.ReadAll(r) + require.NoError(t, err) + require.NoError(t, runErr) + + assert.Equal(t, "Excluded files for server 'site'\n - deploy.sh (1 file)\n", string(b)) } // A deployment with no steps yet (queued) still gives JSON: an empty array. From 70357666d20452d63a860a9b36ee864aef4fb936 Mon Sep 17 00:00:00 2001 From: Thiago Durante Date: Fri, 6 Nov 2026 10:38:13 +0100 Subject: [PATCH 08/11] fix: review follow-ups on deploy hints and deployments logs - dhq deploy, account with no projects: the dhq launch suggestion says to exclude files first (dhq skills show launch); no setup pointer for an account that already has projects. - deployments logs: --table / --quiet still win over --json; help says piped output without --step stays text. - The skill-read command constant lives in output; auth refers to it, so output no longer imports auth. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++++-- internal/auth/guidance.go | 7 +++---- internal/commands/deploy.go | 8 +++++--- internal/commands/deploy_test.go | 6 ++++++ internal/commands/deployments.go | 7 ++++++- internal/output/breadcrumbs.go | 8 ++++++-- 6 files changed, 30 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36a5300..d44245c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,8 @@ While the CLI is pre-1.0, minor versions may carry breaking changes to the publi run `dhq --help`, and a skill they install mid-session isn't loaded until their next one. The first messages of a new setup (the "not signed in" guidance, `next_step` in `dhq auth status --json`, `dhq doctor`, and the - "No project specified" errors, including `dhq deploy`'s) now ask the agent + "No project specified" errors, including `dhq deploy`'s for an account with + no projects yet) now ask the agent to read the setup steps with `dhq skills show` before going on. In `--json` output their recovery actions add `read_setup_skill` (`dhq skills show`). `dhq --help` says the same. Installing for future sessions is offered for the @@ -33,7 +34,8 @@ While the CLI is pre-1.0, minor versions may carry breaking changes to the publi the repository is uploaded unless excluded; the setup steps now say to check the files and exclude deploy scripts or local config first, and, since `dhq launch` deploys straight away, to create the project, add the - exclusions, then run `dhq launch --project `. + exclusions, then run `dhq launch --project `. `dhq deploy`'s hint + for an account with no projects, which suggests `dhq launch`, says the same. - **`dhq deployments logs`** prints each log entry's detail under its message, as the DeployHQ web app does (command output, the list of excluded files, an error's cause). Without it, "Excluded files for server ..." showed no files. diff --git a/internal/auth/guidance.go b/internal/auth/guidance.go index 9a79991..e603ba8 100644 --- a/internal/auth/guidance.go +++ b/internal/auth/guidance.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/deployhq/deployhq-cli/internal/harness" + "github.com/deployhq/deployhq-cli/internal/output" "github.com/deployhq/deployhq-cli/internal/skillinstaller" ) @@ -37,10 +38,8 @@ func SkillInstallCommand() string { return skillinstaller.InstallCommandFor(harness.Detect().Name) } -// SkillReadCommand prints DeployHQ's skill. An agent reads it in the session -// it is working in: a skill installed mid-session is not loaded until the -// agent's next session. -const SkillReadCommand = "dhq skills show" +// SkillReadCommand prints DeployHQ's skill (see output.SkillReadCommand). +const SkillReadCommand = output.SkillReadCommand // SkillPointer sends an agent to DeployHQ's skill. Agents act on the CLI's // errors but rarely run 'dhq --help', so the messages they meet first (not diff --git a/internal/commands/deploy.go b/internal/commands/deploy.go index 46c838e..94c5171 100644 --- a/internal/commands/deploy.go +++ b/internal/commands/deploy.go @@ -190,7 +190,9 @@ func resolveDeployProject(ctx context.Context, client *sdk.Client, configured st case 0: return "", &output.UserError{ Message: "No project specified", - Hint: "This account has no projects yet.\nTo provision and deploy in one step: dhq launch\nOr create a project in the DeployHQ dashboard, then re-run with --project .\n" + + Hint: "This account has no projects yet.\n" + + "To provision and deploy in one step: dhq launch. It deploys straight away, so exclude files that must not be published first: dhq skills show launch\n" + + "Or create a project in the DeployHQ dashboard, then re-run with --project .\n" + auth.SkillPointer(), } case 1: @@ -220,8 +222,8 @@ func resolveDeployProject(ctx context.Context, client *sdk.Client, configured st } return "", &output.UserError{ Message: "No project specified", - Hint: fmt.Sprintf("Account has %d projects — pass --project . Available: %s\n%s", - len(projects), strings.Join(items, ", "), auth.SkillPointer()), + Hint: fmt.Sprintf("Account has %d projects — pass --project . Available: %s", + len(projects), strings.Join(items, ", ")), } } } diff --git a/internal/commands/deploy_test.go b/internal/commands/deploy_test.go index eb4058b..da99e52 100644 --- a/internal/commands/deploy_test.go +++ b/internal/commands/deploy_test.go @@ -619,6 +619,8 @@ func TestResolveDeployProject_MultipleProjectsListsThem(t *testing.T) { assert.Contains(t, msg, "a-id (Alpha)") assert.Contains(t, msg, "b-id (Beta)") assert.Contains(t, msg, "c-id (Gamma)") + // An account with projects is set up already: no setup-steps pointer. + assert.NotContains(t, msg, "dhq skills show") } // TestResolveDeployProject_JSONModeSkipsPicker guards the stdout=data contract: @@ -659,6 +661,10 @@ func TestResolveDeployProject_ZeroProjects(t *testing.T) { assert.Empty(t, id) assert.Contains(t, err.Error(), "No project specified") assert.Contains(t, err.Error(), "no projects yet") + // A new account: dhq launch deploys at once, so the hint says to exclude + // files first, and agents are pointed at the setup steps. + assert.Contains(t, err.Error(), "exclude files that must not be published first: dhq skills show launch") + assert.Contains(t, err.Error(), "read DeployHQ's setup steps with 'dhq skills show'") } func TestResolveDeployProject_APIErrorKeepsHeadline(t *testing.T) { diff --git a/internal/commands/deployments.go b/internal/commands/deployments.go index 014aedd..c819740 100644 --- a/internal/commands/deployments.go +++ b/internal/commands/deployments.go @@ -397,6 +397,11 @@ func newDeploymentsLogsCmd() *cobra.Command { cmd := &cobra.Command{ Use: "logs ", Short: "Show deployment step logs", + Long: `Show a deployment's step logs: each entry's message and, under it, its detail +(command output, the list of excluded files, an error's cause). + +Without --step, the logs of every step are printed as text, also when piped; +add --json for JSON. With --step, piped output is JSON, as for other commands.`, Example: ` # Show logs for every step of a deployment dhq deployments logs dep-abc123 -p my-app @@ -425,7 +430,7 @@ func newDeploymentsLogsCmd() *cobra.Command { // piped, and scripts grep it. In JSON, always an array (empty // when there are no steps yet), and a step whose logs couldn't // be read says so rather than being left out. - asJSON := cliCtx.Envelope.JSONMode + asJSON := cliCtx.Envelope.JSONMode && cliCtx.Envelope.WantsJSON() // --table / --quiet still win all := []stepLogs{} if len(dep.Steps) == 0 && !asJSON { cliCtx.Envelope.Status("No steps found for deployment %s", args[0]) diff --git a/internal/output/breadcrumbs.go b/internal/output/breadcrumbs.go index a39fcae..ece3013 100644 --- a/internal/output/breadcrumbs.go +++ b/internal/output/breadcrumbs.go @@ -5,7 +5,6 @@ import ( "net/http" "strings" - "github.com/deployhq/deployhq-cli/internal/auth" "github.com/deployhq/deployhq-cli/pkg/sdk" ) @@ -232,9 +231,14 @@ func isRetryable(code, message string) bool { // since agents act on these but rarely run 'dhq --help'. It reads the skill // rather than installing it: an installed skill only loads in a new session. func readSkillCrumb() Breadcrumb { - return Breadcrumb{Action: "read_setup_skill", Cmd: auth.SkillReadCommand} + return Breadcrumb{Action: "read_setup_skill", Cmd: SkillReadCommand} } +// SkillReadCommand prints DeployHQ's skill. An agent reads it in the session +// it is working in: a skill installed mid-session is not loaded until the +// agent's next session. +const SkillReadCommand = "dhq skills show" + // recoveryActions returns suggested next commands for common errors. func recoveryActions(code, message string) []Breadcrumb { msg := strings.ToLower(message) From e0a03fd17f6e30184828f6106ff1e21285350486 Mon Sep 17 00:00:00 2001 From: Thiago Durante Date: Fri, 6 Nov 2026 10:56:44 +0100 Subject: [PATCH 09/11] fix(skill): DeployHQ hosting is free during the beta, may be billed after Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ skills/deployhq/SKILL.md | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d44245c..39bc2fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,9 @@ While the CLI is pre-1.0, minor versions may carry breaking changes to the publi `dhq launch` deploys straight away, to create the project, add the exclusions, then run `dhq launch --project `. `dhq deploy`'s hint for an account with no projects, which suggests `dhq launch`, says the same. +- **The skill says DeployHQ hosting is free during the beta** and may be billed + once it ends. It said "billed once the beta ends", which agents passed on as + "may be a paid resource". - **`dhq deployments logs`** prints each log entry's detail under its message, as the DeployHQ web app does (command output, the list of excluded files, an error's cause). Without it, "Excluded files for server ..." showed no files. diff --git a/skills/deployhq/SKILL.md b/skills/deployhq/SKILL.md index 6388d43..8b2905a 100644 --- a/skills/deployhq/SKILL.md +++ b/skills/deployhq/SKILL.md @@ -98,7 +98,7 @@ Small steps, each one something the person can approve on its own: 2. **Existing project?** `dhq projects list --json`, and check for a `.deployhq.toml` in the folder. If a matching project exists, ask whether to reuse it rather than creating a second one. 3. **Where to deploy.** Ask, and wait for the answer. The usual options: - **Their own server** (SSH/SFTP, FTP, S3 and others): most DeployHQ customers deploy this way. - - **DeployHQ hosting**: Static Hosting for static sites, or a Managed VPS. These are beta features that the account has to be enrolled in, and are billed once the beta ends. A Managed VPS needs a payment method on the account; Static Hosting does not (an account that isn't paying yet can have one site, paying accounts get more). + - **DeployHQ hosting**: Static Hosting for static sites, or a Managed VPS. These are beta features that the account has to be enrolled in. They are free during the beta and may be billed once it ends. A Managed VPS needs a payment method on the account; Static Hosting does not (an account that isn't paying yet can have one site, paying accounts get more). 4. **Create the project** (after the person agrees): `dhq projects create --name "" --json`, then `dhq config set project ` so later sessions find it. 5. **Connect the repository** (after the person agrees; for a private repository this gives DeployHQ read access): `dhq repos create -p --scm-type git --url --json`. See [repos.md](references/repos.md) for the access key a private repository needs. 6. **Add the target.** From 017ad71def2be1f7b9421a993f569a463509eee3 Mon Sep 17 00:00:00 2001 From: Thiago Durante Date: Fri, 6 Nov 2026 11:01:45 +0100 Subject: [PATCH 10/11] fix(skill): check the files on the branch DeployHQ deploys, not the local index git ls-files lists the local checkout's index, which can differ from the remote branch DeployHQ deploys (a staged deletion, unpushed work). Use git fetch + git ls-tree -r --name-only origin/. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++- internal/commands/skills_show_test.go | 13 +++++++++++++ skills/deployhq/SKILL.md | 2 +- skills/deployhq/references/launch.md | 4 +++- 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39bc2fe..89e93ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,8 @@ While the CLI is pre-1.0, minor versions may carry breaking changes to the publi not for every AI tool on the machine. - **The skill says to exclude files before the first deploy.** Every file in the repository is uploaded unless excluded; the setup steps now say to check - the files and exclude deploy scripts or local config first, and, since + the files on the branch DeployHQ deploys (the remote's, not the local + checkout) and exclude deploy scripts or local config first, and, since `dhq launch` deploys straight away, to create the project, add the exclusions, then run `dhq launch --project `. `dhq deploy`'s hint for an account with no projects, which suggests `dhq launch`, says the same. diff --git a/internal/commands/skills_show_test.go b/internal/commands/skills_show_test.go index 944b9bb..fa240b2 100644 --- a/internal/commands/skills_show_test.go +++ b/internal/commands/skills_show_test.go @@ -84,3 +84,16 @@ func TestSkillsShow_JSON(t *testing.T) { assert.Contains(t, res.Data.Content, "DeployHQ") assert.Contains(t, res.Data.References, "launch") } + +// The pre-deploy file check lists the branch DeployHQ deploys (the remote's), +// not the local checkout: `git ls-files` shows the local index, which can +// differ (a staged deletion, unpushed work). +func TestSkill_PreDeployFileCheckUsesRemoteBranch(t *testing.T) { + for _, path := range []string{"deployhq/SKILL.md", "deployhq/references/launch.md"} { + b, err := skills.FS.ReadFile(path) + require.NoError(t, err) + s := strings.Join(strings.Fields(string(b)), " ") + assert.Contains(t, s, "git ls-tree -r --name-only origin/", path) + assert.NotContains(t, s, "git ls-files", path) + } +} diff --git a/skills/deployhq/SKILL.md b/skills/deployhq/SKILL.md index 8b2905a..be1713c 100644 --- a/skills/deployhq/SKILL.md +++ b/skills/deployhq/SKILL.md @@ -104,7 +104,7 @@ Small steps, each one something the person can approve on its own: 6. **Add the target.** - Own server: DeployHQ connects with the project's access key (`dhq projects show --json=public_key`). Installing that key on the server changes who can log in to it, so the agent shows the key and asks before adding it, or the person adds it themselves. Then `dhq servers create ...` (see [servers.md](references/servers.md)). If the server only accepts a password, the person adds it in the DeployHQ web app so the password never passes through the chat. - DeployHQ hosting: `dhq beta enroll --protocol static_hosting` (needs an account admin) and `dhq servers create -p --protocol-type static_hosting --subdomain ...`, or a `managed_vps` server. Check with the person before each. -7. **Configure** build commands, excluded files, env vars as the project needs (see "Configure deployment"). Every file in the repository is uploaded unless it is excluded, so before the first deploy check the files (`git ls-files`) and exclude what must not be published, such as deploy scripts or local config: `dhq excluded-files create -p --path ` (all servers unless `--server`). +7. **Configure** build commands, excluded files, env vars as the project needs (see "Configure deployment"). Every file in the repository is uploaded unless it is excluded, so before the first deploy check the files on the branch DeployHQ deploys, which is what the remote repository has, not the local checkout (`git fetch`, then `git ls-tree -r --name-only origin/`), and exclude what must not be published, such as deploy scripts or local config: `dhq excluded-files create -p --path ` (all servers unless `--server`). 8. **First deploy.** Summarise the project, branch and target, ask, then `dhq deploy -p -s --wait --json`, which waits for the deployment to finish (a new one may start as `queued`). 9. **Confirm.** Report success only when the JSON shows `"status": "completed"`. A non-zero exit means the command failed: if a deployment was created, its `status` says how it ended; otherwise read the error. (`dhq deployments watch -p ` is a live view for a person following along in their own terminal, not a step for the agent.) diff --git a/skills/deployhq/references/launch.md b/skills/deployhq/references/launch.md index 6517eb2..7384adb 100644 --- a/skills/deployhq/references/launch.md +++ b/skills/deployhq/references/launch.md @@ -26,7 +26,9 @@ agent runs `dhq launch`. A good sequence: - Run `dhq launch --static --dry-run --json` (or `--vps`) and show the person what it would do. - **Exclude files before launching.** `dhq launch` deploys at once, and every file in - the repository is uploaded unless it is excluded. Check the files (`git ls-files`); if + the repository is uploaded unless it is excluded. Check the files on the branch + DeployHQ deploys, as the remote repository has it (`git fetch`, then + `git ls-tree -r --name-only origin/`); if any must not be published (a deploy script, local config), create the project first with `dhq projects create`, add them with `dhq excluded-files create -p --path `, then run `dhq launch --project `. From 5ef2525772732bea09fcf071b30ab8a601a52824 Mon Sep 17 00:00:00 2001 From: Thiago Durante Date: Fri, 6 Nov 2026 11:05:29 +0100 Subject: [PATCH 11/11] fix(skills): bump the skill version so older installs show as outdated The skill changed meaningfully in this release (read it with dhq skills show, exclude files before the first deploy, beta pricing) and Version had stayed 1 since skills were added, so installs were never flagged. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++++ skills/embed.go | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89e93ca..cc40f80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,11 @@ While the CLI is pre-1.0, minor versions may carry breaking changes to the publi - **The skill says DeployHQ hosting is free during the beta** and may be billed once it ends. It said "billed once the beta ends", which agents passed on as "may be a paid resource". +- **Installed copies of the skill are now reported as outdated**, so + `dhq skills list` shows them and `dhq hello` refreshes the running agent's + copy (the skill's version is now 2; it hadn't changed since the skill was + added, so older installs were never flagged). `dhq skills install` always + rewrites it. - **`dhq deployments logs`** prints each log entry's detail under its message, as the DeployHQ web app does (command output, the list of excluded files, an error's cause). Without it, "Excluded files for server ..." showed no files. diff --git a/skills/embed.go b/skills/embed.go index 2d0db4a..28016ad 100644 --- a/skills/embed.go +++ b/skills/embed.go @@ -19,4 +19,4 @@ var FS embed.FS // alongside the installed files so a future `dhq` can detect stale installs // and update without re-prompting. Bump when the skill content changes // meaningfully (new commands, restructured references, etc.). -const Version = "1" +const Version = "2"