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
8 changes: 8 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,14 @@ Historical remote guides are excluded from the supported documentation.
trace; set `HACK_DEBUG=1` to add the stack. Unexpected exceptions always print their stack.
- `--no-interactive` (or `HACK_NO_INTERACTIVE=1`) is a global flag: commands never prompt — they
apply documented defaults or fail fast with `E_INTERACTIVE_REQUIRED`.
- A run whose stdin or stdout is not a TTY (piped input, an agent harness, CI) is treated the same
way, with or without the flag: the command resolves every prompt by its documented
non-interactive policy within milliseconds and never waits on a prompt. Confirmations that have
a safe default apply it (`hack env unset KEY` removes the key; `hack doctor --fix` applies safe
remediations and skips destructive ones with a note); prompts that need a real answer fail
closed with `E_INTERACTIVE_REQUIRED`, naming the flag or argument that makes the invocation
scriptable (for example `hack update --yes`, `hack mcp --all`, `hack session attach <name>`).
Set `HACK_NO_INTERACTIVE=1` explicitly in scripts anyway so the intent is visible.
- `NO_COLOR` (or `HACK_NO_COLOR`) disables colored/decorated output.

Generated agent docs, Cursor rules, Codex skills, and the shared `~/.ai/skills/hack-cli` skill carry
Expand Down
5 changes: 4 additions & 1 deletion src/commands/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
renderAgentIntegrationFreshnessNotice,
} from "../cli/integration-sync.ts";
import { optPath } from "../cli/options.ts";
import { canPrompt } from "../lib/interactivity.ts";
import { openUrl } from "../lib/os.ts";
import { findProjectContext, findRepoRootForInit } from "../lib/project.ts";
import { findExecutableInPath, run } from "../lib/shell.ts";
Expand Down Expand Up @@ -215,7 +216,9 @@ async function resolveInitClient(opts: {
);
}

if (!(process.stdin.isTTY && process.stdout.isTTY)) {
// No TTY, HACK_NO_INTERACTIVE, or --no-interactive: print the prompt
// instead of opening a picker that cannot settle on a piped stdin.
if (!canPrompt()) {
return null;
}

Expand Down
7 changes: 2 additions & 5 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,7 @@ import { resolveInstalledGrafanaHost } from "../lib/global-service-host.ts";
import { getString, isRecord } from "../lib/guards.ts";
import { resolveHackInvocation } from "../lib/hack-cli.ts";
import { inspectHackEnvOverlayWarnings } from "../lib/hack-env.ts";
import {
confirmSafe,
isExplicitlyNonInteractive,
} from "../lib/interactivity.ts";
import { canPrompt, confirmSafe } from "../lib/interactivity.ts";
import { findHackLocalCaCertPath } from "../lib/local-ca.ts";
import { noteBrowserTargetsForLocalNetworkHint } from "../lib/local-network-hint.ts";
import {
Expand Down Expand Up @@ -4344,7 +4341,7 @@ async function doctorConfirm(opts: {
initialValue: opts.initialValue,
nonInteractive: opts.destructive ? "decline" : "accept-default",
});
if (!ok && opts.destructive && isExplicitlyNonInteractive()) {
if (!ok && opts.destructive && !canPrompt()) {
noteSkippedDoctorFixStep({ what: firstLine(opts.message) });
}
return ok;
Expand Down
8 changes: 8 additions & 0 deletions src/commands/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
withHandler,
} from "../cli/command.ts";
import { optPath } from "../cli/options.ts";
import { canPrompt, requireInteractive } from "../lib/interactivity.ts";
import type { AgentDocTarget } from "../mcp/agent-docs.ts";
import { upsertAgentDocs } from "../mcp/agent-docs.ts";
import {
Expand Down Expand Up @@ -401,6 +402,13 @@ async function resolveTargets(opts: {
return dedupeTargets({ targets });
}

if (!canPrompt()) {
requireInteractive({
what: "hack mcp needs at least one target",
hint: "Pass --all, or any of --cursor, --claude, --codex.",
});
}

const selected = await autocompleteMultiselect<McpTarget>({
message: "Select MCP clients to configure:",
required: true,
Expand Down
28 changes: 23 additions & 5 deletions src/commands/secrets.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { confirm, isCancel, password, text } from "@clack/prompts";
import { isCancel, password, text } from "@clack/prompts";

import { secrets } from "bun";
import type { CliContext, CommandArgs } from "../cli/command.ts";
import { defineCommand, defineOption, withHandler } from "../cli/command.ts";
import {
canPrompt,
confirmSafe,
requireInteractive,
} from "../lib/interactivity.ts";
import { logger } from "../ui/logger.ts";

const DEFAULT_SECRETS_SERVICE = "hack-cli" as const;
Expand Down Expand Up @@ -75,6 +80,13 @@ async function resolveName(positionals: readonly string[]): Promise<string> {
return fromPos;
}

if (!canPrompt()) {
requireInteractive({
what: "a secret name is required",
hint: "Pass the name as an argument (e.g. `hack secrets get <name>`).",
});
}

const name = await text({
message: "Secret name:",
validate: (value) => {
Expand Down Expand Up @@ -102,6 +114,13 @@ async function handleSecretsSet({
[args.positionals.name].filter((v) => typeof v === "string")
);

if (!canPrompt()) {
requireInteractive({
what: `a value for secret "${name}" is required`,
hint: "Run `hack secrets set <name>` in a terminal; the value is read from a hidden prompt and never from an argument.",
});
}

const value = await password({
message: `Value for "${name}" (${service}):`,
validate: (v) => (!v || v.length === 0 ? "Required" : undefined),
Expand Down Expand Up @@ -150,13 +169,12 @@ async function handleSecretsDelete({
[args.positionals.name].filter((v) => typeof v === "string")
);

const ok = await confirm({
const ok = await confirmSafe({
message: `Delete secret "${name}" (${service})?`,
initialValue: false,
nonInteractive: "fail",
hint: "Run `hack secrets delete <name>` in a terminal to confirm the deletion.",
});
if (isCancel(ok)) {
return 1;
}
if (!ok) {
return 0;
}
Expand Down
14 changes: 14 additions & 0 deletions src/commands/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
resolveHackEnv,
selectHackEnvValues,
} from "../lib/hack-env.ts";
import { canPrompt, requireInteractive } from "../lib/interactivity.ts";
import { appendHackHostTrustEnvironment } from "../lib/local-ca.ts";
import {
defaultProjectSlugFromPath,
Expand Down Expand Up @@ -347,6 +348,12 @@ async function promptAttachedWorkspaceAction(opts: {
readonly attachedWorkspaceName: string;
readonly nextWorkspaceName: string;
}): Promise<"attach" | "new" | null> {
if (!canPrompt()) {
requireInteractive({
what: `workspace '${opts.attachedWorkspaceName}' is attached elsewhere`,
hint: "Pass --new (optionally with --name <suffix>) to create an isolated workspace instead of attaching, or run this command in a terminal.",
});
}
const action = await p.select({
message: `Workspace '${opts.attachedWorkspaceName}' is attached elsewhere`,
options: [
Expand Down Expand Up @@ -816,6 +823,13 @@ async function handleSessionPicker(): Promise<number> {
return 1;
}

if (!canPrompt()) {
requireInteractive({
what: "the workspace picker needs a selection",
hint: "Run `hack session list` to see workspaces and `hack session attach <name>` to attach to one.",
});
}

const selection = await p.select({
message: "Select workspace or project",
options,
Expand Down
5 changes: 4 additions & 1 deletion src/commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { defineCommand, defineOption, withHandler } from "../cli/command.ts";
import { optJson } from "../cli/options.ts";
import { resolveGlobalHackDir } from "../lib/config-paths.ts";
import { ensureDir } from "../lib/fs.ts";
import { canPrompt } from "../lib/interactivity.ts";
import {
ensureBundledMutagenInstalled,
getMutagenPath,
Expand Down Expand Up @@ -185,7 +186,9 @@ const handleUpdate: CommandHandlerFor<Spec> = async ({

const wantsInstall = await confirmInstall({
yes: args.options.yes === true,
isTty: process.stdout.isTTY,
// canPrompt() covers stdin and stdout TTYs plus the explicit opt-outs; a
// piped stdin alone would otherwise spin in the clack prompt (HACK-1385).
isTty: canPrompt(),
prompt: `${announce}. Install now?`,
});
if (!wantsInstall.ok) {
Expand Down
17 changes: 11 additions & 6 deletions src/lib/interactivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,24 +91,29 @@ const NON_INTERACTIVE_ANSWERS: Readonly<
/**
* Confirm prompt that honors the no-interactive convention.
*
* When the run is explicitly non-interactive (`--no-interactive` or
* `HACK_NO_INTERACTIVE`), the declared policy resolves the answer without
* prompting:
* Whenever prompting is disallowed ({@link canPrompt} is false: stdin or
* stdout is not a TTY, `HACK_NO_INTERACTIVE`, or `--no-interactive`), the
* declared policy resolves the answer without prompting:
* - `accept-default`: answer with `initialValue` (the documented default),
* - `decline`: answer `false` (safe skip for destructive/sudo actions),
* - `fail`: throw `E_INTERACTIVE_REQUIRED` with the provided hint.
*
* Otherwise a normal clack confirm runs (cancel throws, matching existing
* prompt-site behavior). TTY-less runs without the explicit opt-out keep the
* legacy clack behavior so wrapper environments stay unchanged.
* prompt-site behavior).
*
* Invariant: the clack prompt is never entered without a TTY on both
* streams. Its readline loop cannot settle on a piped stdin and spins at
* full CPU forever (HACK-1385), so a TTY-less run must resolve through the
* policy within milliseconds. Never call `@clack/prompts` directly from a
* command without a `canPrompt()` guard in front of it.
*/
export async function confirmSafe(opts: {
readonly message: string;
readonly initialValue: boolean;
readonly nonInteractive: NonInteractivePolicy;
readonly hint?: string;
}): Promise<boolean> {
if (!isExplicitlyNonInteractive()) {
if (canPrompt()) {
const ok = await confirm({
message: opts.message,
initialValue: opts.initialValue,
Expand Down
Loading
Loading