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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,64 @@ 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

- **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 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
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 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 <permalink>`. `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".
- **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.
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
`! 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. 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.

## [0.24.0] - 2026-11-04

### Fixed
Expand Down
52 changes: 47 additions & 5 deletions internal/auth/guidance.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
package auth

import "fmt"
import (
"fmt"
"strings"

"github.com/deployhq/deployhq-cli/internal/harness"
"github.com/deployhq/deployhq-cli/internal/output"
"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
Expand All @@ -15,16 +22,51 @@ 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)"

// 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)
}

// 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
// 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"
}
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
// 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" +
"If the person has no DeployHQ account yet, they run 'dhq signup' themselves instead, also in a separate terminal window.\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, 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."
"For CI, set DEPLOYHQ_ACCOUNT, DEPLOYHQ_EMAIL and DEPLOYHQ_API_KEY as secret environment variables instead.\n" +
SkillPointer()
}
3 changes: 2 additions & 1 deletion internal/cli/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <identifier> (persist for this directory)\n" +
" --project <identifier> (pass per command)\n" +
" export DEPLOYHQ_PROJECT=<identifier> (set for the shell session)",
" export DEPLOYHQ_PROJECT=<identifier> (set for the shell session)\n" +
auth.SkillPointer(),
}
}

Expand Down
26 changes: 26 additions & 0 deletions internal/cli/context_require_project_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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, "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: "read_setup_skill", Cmd: "dhq skills show"})
}
8 changes: 7 additions & 1 deletion internal/commands/agent_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -143,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": {
Expand Down
16 changes: 10 additions & 6 deletions internal/commands/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,9 @@ The API key is under Settings → Security → API Credentials in DeployHQ
(https://<your-account>.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
Expand Down Expand Up @@ -439,15 +440,18 @@ 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."
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
Expand All @@ -457,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
Expand Down
14 changes: 11 additions & 3 deletions internal/commands/auth_signup_honesty_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ 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, "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")
assert.NotContains(t, authErr.Hint, "--api-key", "must not suggest passing the key through the agent")
Expand All @@ -84,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")
Expand Down Expand Up @@ -303,13 +306,15 @@ 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(), "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) {
Expand Down Expand Up @@ -380,6 +385,9 @@ 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 show")
assert.Contains(t, res["next_step"], "dhq skills install --agent")
}

func TestCheckAuthStatus_NetworkFailureIsUnconfirmed(t *testing.T) {
Expand Down
6 changes: 5 additions & 1 deletion internal/commands/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -189,7 +190,10 @@ 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 <identifier>.",
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 <identifier>.\n" +
auth.SkillPointer(),
}
case 1:
env.Status("Auto-selected project: %s", projects[0].Name)
Expand Down
6 changes: 6 additions & 0 deletions internal/commands/deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading