From 14cf4f8a1026c1e04fe6fe34d9ca6252fad86cea Mon Sep 17 00:00:00 2001 From: cvince Date: Sun, 8 Nov 2026 17:24:50 -0700 Subject: [PATCH 01/27] feat(agents): teach agents to check org/project/branch, stop on a browser sign-in, and find secrets with capy secrets --- src/core/agentsBlockPlan.ts | 3 +++ tests/core/agentsBlockPlan.test.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/core/agentsBlockPlan.ts b/src/core/agentsBlockPlan.ts index 5939470f..ba16b253 100644 --- a/src/core/agentsBlockPlan.ts +++ b/src/core/agentsBlockPlan.ts @@ -25,6 +25,9 @@ export const AGENTS_BLOCK = [ '- Run `capy help --json` for every command, its options, and its error codes.', '- Always pass `--json` and branch on the `code` field, never on message text.', '- Run `capy pair --json` in an interactive terminal/PTY. After browser approval, it asks "Enable this location as [email]?" before installing the session or keys. Show the returned email to the human and obtain explicit Yes/No approval before answering. Never automatically confirm or pipe `yes`; Enter defaults to No. No upfront email is required.', + '- Before a command that writes, run `capy info --json` and check its `org`, `project` and `branch`. If they are not the ones the human means, stop and ask.', // COPY-FLAG + '- If a command starts a browser sign-in, this folder belongs to an organization this machine is not signed in to. Stop and tell the human. Never complete the sign-in yourself.', // COPY-FLAG + '- To find where a secret lives, run `capy secrets --json --name NAME`. It lists locations in the signed-in organization only, and never shows a value.', // COPY-FLAG '- Commands that write run bare `capy` first. If your folder and Capy differ they write nothing and exit 3 with `SECRETS_CONFLICT`, a `conflict_id`, and the differing names (fingerprints, never values).', // COPY-FLAG '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', // COPY-FLAG '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', // COPY-FLAG diff --git a/tests/core/agentsBlockPlan.test.ts b/tests/core/agentsBlockPlan.test.ts index 3f29d27c..85ce2ed4 100644 --- a/tests/core/agentsBlockPlan.test.ts +++ b/tests/core/agentsBlockPlan.test.ts @@ -33,6 +33,9 @@ describe('AGENTS_BLOCK', () => { '- Run `capy help --json` for every command, its options, and its error codes.', '- Always pass `--json` and branch on the `code` field, never on message text.', '- Run `capy pair --json` in an interactive terminal/PTY. After browser approval, it asks "Enable this location as [email]?" before installing the session or keys. Show the returned email to the human and obtain explicit Yes/No approval before answering. Never automatically confirm or pipe `yes`; Enter defaults to No. No upfront email is required.', + '- Before a command that writes, run `capy info --json` and check its `org`, `project` and `branch`. If they are not the ones the human means, stop and ask.', + '- If a command starts a browser sign-in, this folder belongs to an organization this machine is not signed in to. Stop and tell the human. Never complete the sign-in yourself.', + '- To find where a secret lives, run `capy secrets --json --name NAME`. It lists locations in the signed-in organization only, and never shows a value.', '- Commands that write run bare `capy` first. If your folder and Capy differ they write nothing and exit 3 with `SECRETS_CONFLICT`, a `conflict_id`, and the differing names (fingerprints, never values).', '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', From 73bdf6b30cc01383011719a0f4018fb0285fde3f Mon Sep 17 00:00:00 2001 From: cvince Date: Sun, 8 Nov 2026 20:00:09 -0700 Subject: [PATCH 02/27] feat(org): elicitations for delete-project and create-project --- docs/cli-reference.md | 22 +++ src/commands/projectCreateCommand.ts | 167 +++++++++++++++++++++ src/commands/projectLifecycleCommand.ts | 184 ++++++++++++++++++++---- src/core/agentsBlockPlan.ts | 1 + src/core/cliHelpDoc.ts | 5 + src/core/cliReferenceMarkdown.ts | 1 + src/core/keepVariableNames.ts | 50 +++++++ src/core/orgContext.ts | 115 ++++++++++----- src/core/projectNameRule.ts | 43 ++++++ src/index-dev.ts | 11 ++ src/index.ts | 12 ++ src/types/index.ts | 8 ++ src/ui/elicitation.ts | 41 ++++++ src/ui/promptEngine.ts | 15 +- tests/core/agentsBlockPlan.test.ts | 1 + 15 files changed, 602 insertions(+), 74 deletions(-) create mode 100644 src/commands/projectCreateCommand.ts create mode 100644 src/core/keepVariableNames.ts create mode 100644 src/core/projectNameRule.ts create mode 100644 src/ui/elicitation.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 8a91b5d6..9db85bb6 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -482,6 +482,23 @@ capy org [options] JSON support: no +### `capy org create-project` + +Create a project in the active organization (owners and admins only; does not link this folder) + +``` +capy org create-project [name] [options] +``` + +| Option | Description | Default | +|---|---|---| +| `--non-tty` | treat stdin as not a terminal: never ask for the name; with no name, refuse with a form to ask the human | | +| `--json` | emit machine-readable JSON instead of the human UI | | + +JSON support: yes (`--json`) + +Dry run: yes (`--dry-run`) + ### `capy org delete-project` Delete a project (owners only; hidden, not destroyed; an owner can restore it) @@ -1114,6 +1131,10 @@ Every refusal carries a stable `code` — branch on it, never on message text. - `SYSTEM_STORE_IMMUTABLE` - `PROJECT_AMBIGUOUS` - `CONFIRMATION_REQUIRED` +- `PROJECT_NAME_REQUIRED` +- `PROJECT_NAME_INVALID` +- `PROJECT_NAME_EXISTS` +- `ORG_SELECTION_REQUIRED` - `REPO_LINKS_UNSUPPORTED` - `DRY_RUN_UNSUPPORTED` - `GITHUB_TIMEOUT` @@ -1140,3 +1161,4 @@ Every refusal carries a stable `code` — branch on it, never on message text. - **JSON**: Pass --json on any command whose supportsJson is true and parse stdout as JSON; prose (progress, prompts, errors) goes to stderr so stdout stays pure JSON. - **Codes**: Branch only on the machine-readable `code` field (from --json output or a CapyError), never on human-readable message text — messages may be reworded without notice. - **Conflicts**: Every command that writes from a folder (edit, add, remove, connect, rotate, push, checkout, org, deploy, deploy revoke, deploy targets-remove) runs bare `capy` first; `checkout -b` is the one exception (a new branch carries the uncommitted values of the folder). Off a terminal, if the folder and Capy differ on any variable, nothing is written, the exit code is 3, and stdout is {ok:false, code:"SECRETS_CONFLICT", conflict_id, conflicts:[{name, project, branch, type, pinned, local, remote}]} (fingerprints only, never values; a Dokploy value has {name, target, pinned, local, remote}). Ask the person, then rerun the same command with --resolve NAME=pinned|local|remote|delete for every row (NAME@TARGET=local|remote for a Dokploy value) and --conflict . A state that moved since is SECRETS_CONFLICT_STALE with the fresh rows. There is no resolve-all. +- **Elicitation**: A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. kind "confirm" is a yes/no ({kind, question, details, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, fields:[{name, type, pattern, default?}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` with no name in agent mode refuses with PROJECT_NAME_REQUIRED and a form (details: {org:{id,name}}). Never answer it yourself and never add --yes on your own. diff --git a/src/commands/projectCreateCommand.ts b/src/commands/projectCreateCommand.ts new file mode 100644 index 00000000..569dd32c --- /dev/null +++ b/src/commands/projectCreateCommand.ts @@ -0,0 +1,167 @@ +/** + * `capy org create-project [name]` (CAP-748). + * + * Creates a project in the ACTIVE organization with the same service call the init flow uses + * (`ServiceClient.initializeProject`), and nothing else: it does not link the current folder, write a keep.lock, + * make a branch or touch a key. Bare `capy` still does those. + * + * Agent mode (`--json`, `--non-tty`, or stdin that is not a terminal) never opens a prompt. With no name it refuses + * with PROJECT_NAME_REQUIRED (exit 3) and an `elicitation` of kind "form" (`ui/elicitation.ts`) that the agent puts + * to the human. In a terminal it asks for the name with the init prompt. + * + * Every refusal is coded, and every decision keys on a code, an HTTP status or a typed field, never on message + * text. The service answers a duplicate name with a bare 409 and no `code`, so the STATUS decides that one. + */ +import { resolveOrgContext } from '../core/orgContext'; +import { ProjectManager } from '../core/projectManager'; +import { PROJECT_NAME_PATTERN_SOURCE, projectNameProblem } from '../core/projectNameRule'; +import { PromptEngine } from '../ui/promptEngine'; +import { isInteractive, EXIT_NEEDS_INPUT } from '../ui/interactive'; +import type { ElicitationForm } from '../ui/elicitation'; +import { CapyError, ERROR_CODES } from '../types/index'; +import { refuse, refusalFor, settle, type Refusal } from './projectLifecycleCommand'; + +export interface CreateProjectOpts { + json?: boolean; + /** `--non-tty`: treat stdin as not a terminal, so the name is never asked. */ + nonTty?: boolean; + /** The global `--dry-run`: check the name and the organization, create nothing. */ + dryRun?: boolean; + apiUrl?: string; + devMode?: boolean; +} + +/** What commander hands an action's `command`: only the merged view is needed. */ +interface CommandWithGlobals { + optsWithGlobals(): Record; +} + +/** The options as typed, wherever commander put them (`--non-tty` is taken by `org`, `--dry-run` by the program). */ +export function createProjectOptsFrom(command: CommandWithGlobals): CreateProjectOpts { + const merged = command.optsWithGlobals(); + return { + json: merged.json === true, + nonTty: merged.nonTty === true, + dryRun: merged.dryRun === true, + }; +} + +interface OrgRef { + readonly id: string; + readonly name: string; +} + +type CreateOutcome = + | { readonly kind: 'needs-name'; readonly elicitation: ElicitationForm; readonly org: OrgRef } + | { readonly kind: 'dry-run'; readonly name: string; readonly org: OrgRef } + | { readonly kind: 'created'; readonly project: { readonly id: string; readonly name: string }; readonly org: OrgRef }; + +/** The folder's name as a project name, when it is allowed as one. The init prompt offers the same. */ +function suggestedName(): string | undefined { + const folderName = new ProjectManager().getDefaultProjectName(); + return projectNameProblem(folderName) === null ? folderName : undefined; +} + +/** The form that asks for the name. `answer.rerun` keeps `` as the place the answer goes. */ +function nameForm(org: OrgRef): ElicitationForm { + const suggestion = suggestedName(); + return { + kind: 'form', + question: 'What should the project be called?', // COPY-FLAG + details: { org: { id: org.id, name: org.name } }, + fields: [{ + name: 'name', + type: 'string', + pattern: PROJECT_NAME_PATTERN_SOURCE, + ...(suggestion === undefined ? {} : { default: suggestion }), + }], + answer: { rerun: 'capy org create-project --json' }, + }; +} + +/** The name: as typed, else (in a terminal) as asked, else none. */ +async function chooseName(typed: string | undefined, agentMode: boolean): Promise { + if (typed !== undefined) return typed; + if (agentMode) return undefined; + return new PromptEngine().promptForProjectName(new ProjectManager().getDefaultProjectName()); +} + +async function createFlow(typed: string | undefined, opts: CreateProjectOpts, agentMode: boolean): Promise { + // Agent mode never opens a prompt, not even to pick an organization. + const { orgId, orgName, serviceClient } = await resolveOrgContext(opts.apiUrl, opts.devMode === true, { promptForOrg: !agentMode }); + const org: OrgRef = { id: orgId, name: orgName }; + + const name = await chooseName(typed, agentMode); + if (name === undefined) return { kind: 'needs-name', elicitation: nameForm(org), org }; + if (opts.dryRun === true) return { kind: 'dry-run', name, org }; + + const created = await serviceClient.initializeProject(name, orgId); + return { kind: 'created', project: { id: created.project_id, name: created.project_name }, org }; +} + +/** The refusal for a failed create: a duplicate name by its HTTP status, everything else as the lifecycle commands refuse it. */ +function createRefusalFor(err: unknown): Refusal { + if (err instanceof CapyError && err.code === ERROR_CODES.SERVICE_ERROR && err.details?.status === 409) { + return { + code: ERROR_CODES.PROJECT_NAME_EXISTS, + message: 'This organization already has a project with that name.', // COPY-FLAG + exitCode: 1, + }; + } + return refusalFor(err); +} + +function emitCreateOutcome(json: boolean, outcome: CreateOutcome): void { + if (outcome.kind === 'needs-name') { + return refuse(json, { + code: ERROR_CODES.PROJECT_NAME_REQUIRED, + message: '`capy org create-project` needs a project name. Pass it as an argument, or run it in a terminal.', // COPY-FLAG + exitCode: EXIT_NEEDS_INPUT, + extra: { elicitation: outcome.elicitation }, + lines: [` Organization: ${outcome.org.name}`], // COPY-FLAG + }); + } + if (outcome.kind === 'dry-run') { + if (json) { + console.log(JSON.stringify({ ok: true, dry_run: true, project: { name: outcome.name }, org: outcome.org }, null, 2)); + return; + } + console.log(`Dry run: would create project ${outcome.name} in ${outcome.org.name}. Nothing was created.`); // COPY-FLAG + return; + } + if (json) { + console.log(JSON.stringify({ ok: true, project: outcome.project, org: outcome.org }, null, 2)); + return; + } + console.log(`Created project ${outcome.project.name} in ${outcome.org.name}.`); // COPY-FLAG +} + +/** `capy org create-project [name] [--dry-run] [--json]` */ +export async function createProjectCommand(name: string | undefined, opts: CreateProjectOpts): Promise { + const json = opts.json === true; + const agentMode = json || !isInteractive(opts.nonTty); + + // Before any network call: a name that breaks the rule never leaves this process. + const problem = name === undefined ? null : projectNameProblem(name); + if (problem !== null) { + return refuse(json, { + code: problem.code, + message: problem.message, + exitCode: 1, + extra: problem.code === ERROR_CODES.PROJECT_NAME_INVALID ? { pattern: PROJECT_NAME_PATTERN_SOURCE } : undefined, + }); + } + + // A dry run asks nothing, and in a terminal the only thing left to do without a name is ask. + if (opts.dryRun === true && name === undefined && !agentMode) { + return refuse(json, { + code: ERROR_CODES.DRY_RUN_UNSUPPORTED, + message: '`capy org create-project` would ask for the name, so it cannot preview. Pass the name.', // COPY-FLAG + exitCode: 1, + }); + } + + const settled = await settle(() => createFlow(name, opts, agentMode)); + if (!settled.ok) return refuse(json, createRefusalFor(settled.error)); + emitCreateOutcome(json, settled.value); +} diff --git a/src/commands/projectLifecycleCommand.ts b/src/commands/projectLifecycleCommand.ts index 9acb1427..10ae641f 100644 --- a/src/commands/projectLifecycleCommand.ts +++ b/src/commands/projectLifecycleCommand.ts @@ -12,6 +12,11 @@ * * `--dry-run` reads the project and its deploy tokens and stops. The service has * no dry run, so the DELETE is simply never sent. + * + * Without `--yes` and with nobody to ask (agent mode), `delete-project` refuses with CONFIRMATION_REQUIRED (exit 3) + * and an `elicitation` (kind "confirm", `ui/elicitation.ts`): the project, the org, its branches, its variable + * NAMES (never a value or a hash) and its live deploy tokens, so the agent can put the question to the human. + * In a terminal the same summary is printed before the Yes/No question. */ import inquirer from 'inquirer'; import { resolveOrgContext } from '../core/orgContext'; @@ -19,6 +24,8 @@ import { excludeSystemProject } from '../system/reservedProjectName'; import { isInteractive, EXIT_NEEDS_INPUT } from '../ui/interactive'; import { CapyError, ERROR_CODES } from '../types/index'; import type { DeleteProjectResponse, LiveDeployTokenInfo } from '../service/serviceClient'; +import type { ElicitationConfirm } from '../ui/elicitation'; +import { variableBranchesFromKeep, type VariableBranches } from '../core/keepVariableNames'; export interface ProjectLifecycleOpts { json?: boolean; @@ -71,7 +78,7 @@ const CODED_FORBIDDEN: ReadonlySet = new Set([ ERROR_CODES.SYSTEM_STORE_IMMUTABLE, ]); -interface Refusal { +export interface Refusal { readonly code: string; readonly message: string; readonly exitCode: number; @@ -98,7 +105,7 @@ function describeToken(t: LiveDeployTokenInfo): string { return `${t.deploy_id}${t.label ? ` ${t.label}` : ''} (created ${t.created_at})`; // COPY-FLAG } -function refusalFor(err: unknown): Refusal { +export function refusalFor(err: unknown): Refusal { if (!(err instanceof CapyError)) { return { code: ERROR_CODES.SERVICE_ERROR, message: err instanceof Error ? err.message : String(err), exitCode: 1 }; } @@ -116,6 +123,9 @@ function refusalFor(err: unknown): Refusal { ], }; } + if (code === ERROR_CODES.ORG_SELECTION_REQUIRED) { + return { code, message: err.message, exitCode: EXIT_NEEDS_INPUT, extra: { orgs: Array.isArray(err.details?.orgs) ? err.details.orgs : [] } }; + } if (code === ERROR_CODES.PROJECT_AMBIGUOUS) { const matches: readonly ProjectRef[] = Array.isArray(err.details?.matches) ? err.details.matches : []; return { @@ -130,7 +140,7 @@ function refusalFor(err: unknown): Refusal { } /** The refusal on the right stream: JSON on stdout under `--json`, prose on stderr otherwise. Never returns. */ -function refuse(json: boolean, r: Refusal): never { +export function refuse(json: boolean, r: Refusal): never { if (json) { console.log(JSON.stringify({ ok: false, code: r.code, error: r.message, ...(r.extra ?? {}) }, null, 2)); } else { @@ -140,10 +150,10 @@ function refuse(json: boolean, r: Refusal): never { return process.exit(r.exitCode); } -type Settled = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: unknown }; +export type Settled = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: unknown }; /** Runs `fn` and returns what it threw as a value, so a refusal's `process.exit` is never caught by the code that raised it. */ -async function settle(fn: () => Promise): Promise> { +export async function settle(fn: () => Promise): Promise> { return fn().then( (value) => ({ ok: true as const, value }), (error: unknown) => ({ ok: false as const, error }), @@ -205,38 +215,154 @@ function liveOnly(tokens: readonly T[]) return tokens.filter((t) => t.revoked_at === null); } +/** What the person is shown, and the agent is told, about a project before it is deleted. Names only. */ +interface DeleteDetails { + readonly branches: readonly string[]; + readonly variables: readonly VariableBranches[]; + /** The parts that could not be read. Never "none": an unread part is not an empty one. */ + readonly unavailable: readonly ('branches' | 'variables')[]; +} + +type KeepRead = + | { readonly kind: 'keep'; readonly variables: readonly VariableBranches[] } + | { readonly kind: 'none' } + | { readonly kind: 'unavailable' }; + +/** The slice of the service client `readDeleteDetails` uses. */ +interface DeleteDetailsClient { + listBranches(projectId: string): Promise>; + getLatestSecrets(projectId: string, branch: string): Promise<{ readonly keep_hash: string; readonly keep_file?: string } | null>; +} + +/** + * The variable names of the project's latest keep.lock, as the service returns it (it is one file for every branch). + * Tries each branch in turn until one returns it. Only names and branches leave this function: the blob that comes + * with the keep.lock is never read. + */ +async function readKeepVariables( + client: DeleteDetailsClient, + projectId: string, + branches: readonly string[], + failedBefore: boolean, +): Promise { + if (branches.length === 0) return { kind: failedBefore ? 'unavailable' : 'none' }; + const [branch, ...rest] = branches; + const read = await settle(() => client.getLatestSecrets(projectId, branch)); + if (!read.ok) return readKeepVariables(client, projectId, rest, true); + const variables = read.value === null ? undefined : variableBranchesFromKeep(read.value.keep_file); + return variables === undefined ? readKeepVariables(client, projectId, rest, failedBefore) : { kind: 'keep', variables }; +} + +/** Best effort: a part that cannot be read is reported as `unavailable`, and the question is still asked. */ +async function readDeleteDetails(client: DeleteDetailsClient, projectId: string): Promise { + const listed = await settle(() => client.listBranches(projectId)); + if (!listed.ok || !Array.isArray(listed.value)) return { branches: [], variables: [], unavailable: ['branches', 'variables'] }; + const branches = listed.value.map((b) => b.name); + const keep = await readKeepVariables(client, projectId, branches, false); + return { + branches, + variables: keep.kind === 'keep' ? keep.variables : [], + unavailable: keep.kind === 'unavailable' ? ['variables'] : [], + }; +} + +interface OrgRef { + readonly id: string; + readonly name: string; +} + +/** The exact command that answers Yes: the project's id (never its name), and `--force` only if it was passed. */ +function deleteYesCommand(projectId: string, force: boolean): string { + return `capy org delete-project ${projectId} ${force ? '--force ' : ''}--yes --json`; +} + +/** The confirm elicitation for a delete. Names and ids only: a variable is its name and its branches, never more. */ +function deleteElicitation( + org: OrgRef, + project: ProjectRef, + tokens: readonly LiveDeployTokenInfo[], + details: DeleteDetails, + force: boolean, +): ElicitationConfirm { + return { + kind: 'confirm', + question: `Delete project ${project.name}?`, // COPY-FLAG + details: { + org: { id: org.id, name: org.name }, + project: { id: project.id, name: project.name }, + branches: details.branches, + variables: details.variables.map((v) => ({ name: v.name, branches: v.branches })), + live_deploy_tokens: tokens.map((t) => ({ deploy_id: t.deploy_id, label: t.label })), + ...(details.unavailable.length > 0 ? { unavailable: details.unavailable } : {}), + }, + answer: { yes: deleteYesCommand(project.id, force) }, + }; +} + type DeleteOutcome = | { readonly kind: 'dry-run'; readonly project: ProjectRef; readonly tokens: readonly LiveDeployTokenInfo[] } + | { readonly kind: 'needs-confirmation'; readonly elicitation: ElicitationConfirm; readonly lines: readonly string[] } | { readonly kind: 'cancelled' } | { readonly kind: 'deleted'; readonly result: DeleteProjectResponse }; -function showPreview(json: boolean, project: ProjectRef, tokens: readonly LiveDeployTokenInfo[], force: boolean): void { - say(json, ''); - say(json, ` Project: ${project.name} (${project.id})`); // COPY-FLAG - say(json, tokens.length === 0 ? ' No live deploy tokens.' : ` Live deploy tokens: ${tokens.length}`); // COPY-FLAG - tokens.forEach((t) => say(json, ` ${describeToken(t)}`)); - if (tokens.length > 0) { - say(json, force +function summaryLines(org: OrgRef, details: DeleteDetails): readonly string[] { + return [ + ` Organization: ${org.name}`, // COPY-FLAG + details.unavailable.includes('branches') + ? ' Branches: could not be read.' // COPY-FLAG + : details.branches.length === 0 ? ' No branches.' : ` Branches: ${details.branches.join(', ')}`, // COPY-FLAG + details.unavailable.includes('variables') + ? ' Variables: could not be read.' // COPY-FLAG + : ` Variables: ${details.variables.length}`, // COPY-FLAG + ...details.variables.map((v) => ` ${v.name}${v.branches.length === 0 ? '' : ` (${v.branches.join(', ')})`}`), + ]; +} + +/** What the person is shown about the project. Without `summary` (a dry run) it is the short preview. */ +function previewLines( + project: ProjectRef, + tokens: readonly LiveDeployTokenInfo[], + force: boolean, + summary?: { readonly org: OrgRef; readonly details: DeleteDetails }, +): readonly string[] { + return [ + '', + ` Project: ${project.name} (${project.id})`, // COPY-FLAG + ...(summary === undefined ? [] : summaryLines(summary.org, summary.details)), + tokens.length === 0 ? ' No live deploy tokens.' : ` Live deploy tokens: ${tokens.length}`, // COPY-FLAG + ...tokens.map((t) => ` ${describeToken(t)}`), + ...(tokens.length === 0 ? [] : [force ? ' --force: those deploys stop on their next restart.' // COPY-FLAG - : ' Without --force the delete is refused while these exist.'); // COPY-FLAG - } - say(json, ''); + : ' Without --force the delete is refused while these exist.']), // COPY-FLAG + '', + ]; +} + +function showPreview(json: boolean, lines: readonly string[]): void { + lines.forEach((l) => say(json, l)); } async function deleteFlow(ref: string, opts: DeleteProjectOpts): Promise { const json = opts.json === true; const force = opts.force === true; - const { orgId, serviceClient } = await resolveOrgContext(opts.apiUrl, opts.devMode === true); + const agentMode = !isInteractive(opts.nonTty); + // Agent mode never opens a prompt, not even to pick an organization. + const { orgId, orgName, serviceClient } = await resolveOrgContext(opts.apiUrl, opts.devMode === true, { promptForOrg: !agentMode }); + const org: OrgRef = { id: orgId, name: orgName }; const project = pickOne(await serviceClient.listProjects(), ref, false); // The token list is only read when it is shown: a dry run, or a person about to be asked. - const showsPreview = opts.dryRun === true || opts.yes !== true; - const tokens = showsPreview ? liveOnly((await serviceClient.listDeployTokens(orgId, project.id)).tokens) : []; + const asks = opts.dryRun !== true && opts.yes !== true; + const tokens = opts.dryRun === true || asks ? liveOnly((await serviceClient.listDeployTokens(orgId, project.id)).tokens) : []; if (opts.dryRun === true) return { kind: 'dry-run', project, tokens }; - if (opts.yes !== true) { - showPreview(json, project, tokens, force); + if (asks) { + const details = await readDeleteDetails(serviceClient, project.id); + const lines = previewLines(project, tokens, force, { org, details }); + // Nobody to ask: hand the question to the agent that ran this, and write nothing. + if (agentMode) return { kind: 'needs-confirmation', elicitation: deleteElicitation(org, project, tokens, details, force), lines }; + showPreview(json, lines); if (!(await confirm(json, `Delete project ${project.name}?`))) return { kind: 'cancelled' }; // COPY-FLAG } return { kind: 'deleted', result: await serviceClient.deleteProject(orgId, project.id, force) }; @@ -264,10 +390,19 @@ function emitDeleteOutcome(json: boolean, force: boolean, outcome: DeleteOutcome }, null, 2)); return; } - showPreview(false, project, tokens, force); + showPreview(false, previewLines(project, tokens, force)); console.log('Dry run: nothing was deleted.'); // COPY-FLAG return; } + if (outcome.kind === 'needs-confirmation') { + return refuse(json, { + code: ERROR_CODES.CONFIRMATION_REQUIRED, + message: '`capy org delete-project` needs confirmation. Pass --yes, or run it in a terminal.', // COPY-FLAG + exitCode: EXIT_NEEDS_INPUT, + extra: { elicitation: outcome.elicitation }, + lines: outcome.lines, + }); + } const { project, live_deploy_tokens } = outcome.result; if (json) { console.log(JSON.stringify({ ok: true, project, live_deploy_tokens }, null, 2)); @@ -281,11 +416,8 @@ function emitDeleteOutcome(json: boolean, force: boolean, outcome: DeleteOutcome export async function deleteProjectCommand(ref: string, opts: DeleteProjectOpts): Promise { const json = opts.json === true; - // Before any network call: no terminal and no --yes means nobody can answer. A dry run asks nothing. - if (opts.dryRun !== true && opts.yes !== true && !isInteractive(opts.nonTty)) { - refuseWithoutTerminal(json, 'capy org delete-project'); - } - + // No terminal and no --yes means nobody can answer: the flow stops with the question as an elicitation (exit 3). + // It reads the project first, because the question has to say what would be deleted. A dry run asks nothing. const settled = await settle(() => deleteFlow(ref, opts)); if (!settled.ok) return refuse(json, refusalFor(settled.error)); emitDeleteOutcome(json, opts.force === true, settled.value); diff --git a/src/core/agentsBlockPlan.ts b/src/core/agentsBlockPlan.ts index ba16b253..7c5a4070 100644 --- a/src/core/agentsBlockPlan.ts +++ b/src/core/agentsBlockPlan.ts @@ -31,6 +31,7 @@ export const AGENTS_BLOCK = [ '- Commands that write run bare `capy` first. If your folder and Capy differ they write nothing and exit 3 with `SECRETS_CONFLICT`, a `conflict_id`, and the differing names (fingerprints, never values).', // COPY-FLAG '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', // COPY-FLAG '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', // COPY-FLAG + '- If a JSON result has an `elicitation`, ask the human with it (a yes/no for `confirm`, a form for `form`), then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', // COPY-FLAG '- Never print, log, or commit secret values.', '- To set a value, pipe it from the command that produces it: ` | capy edit NAME --json`. Never put a value in a command argument, an `echo`, or a heredoc — it would land in your context and the shell history.', AGENTS_BLOCK_END, diff --git a/src/core/cliHelpDoc.ts b/src/core/cliHelpDoc.ts index 088268b3..b7c8b279 100644 --- a/src/core/cliHelpDoc.ts +++ b/src/core/cliHelpDoc.ts @@ -11,6 +11,7 @@ */ import type { Argument, Command, Option } from 'commander'; import { ERROR_CODES } from '../types/index'; +import { ELICITATION_HELP } from '../ui/elicitation'; import { DEPLOY_DOKPLOY_PLAN_SCHEMA, DEPLOY_VERCEL_PLAN_SCHEMA } from '../commands/deployDiscover/schema'; export interface CliArgumentDoc { @@ -74,6 +75,8 @@ export interface CliHelpDoc { codes: string; /** What a command that writes from a folder does first, and how a difference is answered off a terminal (CAP-717). */ conflicts?: string; + /** What an `elicitation` in a refusal is and what to do with it (CAP-748). */ + elicitation?: string; }; } @@ -115,6 +118,7 @@ export const DRY_RUN_COMMANDS: ReadonlySet = new Set([ 'connect', 'deploy', 'edit', + 'org create-project', 'remove', 'secrets', 'secrets set', @@ -183,6 +187,7 @@ export function buildCliHelpDoc(program: Command): CliHelpDoc { // COPY-FLAG: minimal and neutral; the contract is docs/conflict-resolution.md. conflicts: 'Every command that writes from a folder (edit, add, remove, connect, rotate, push, checkout, org, deploy, deploy revoke, deploy targets-remove) runs bare `capy` first; `checkout -b` is the one exception (a new branch carries the uncommitted values of the folder). Off a terminal, if the folder and Capy differ on any variable, nothing is written, the exit code is 3, and stdout is {ok:false, code:"SECRETS_CONFLICT", conflict_id, conflicts:[{name, project, branch, type, pinned, local, remote}]} (fingerprints only, never values; a Dokploy value has {name, target, pinned, local, remote}). Ask the person, then rerun the same command with --resolve NAME=pinned|local|remote|delete for every row (NAME@TARGET=local|remote for a Dokploy value) and --conflict . A state that moved since is SECRETS_CONFLICT_STALE with the fresh rows. There is no resolve-all.', + elicitation: ELICITATION_HELP, }, }; } diff --git a/src/core/cliReferenceMarkdown.ts b/src/core/cliReferenceMarkdown.ts index 93d1c374..16129f95 100644 --- a/src/core/cliReferenceMarkdown.ts +++ b/src/core/cliReferenceMarkdown.ts @@ -102,6 +102,7 @@ export function renderCliReferenceMarkdown(doc: CliHelpDoc): string { `- **JSON**: ${doc.conventions.json}`, `- **Codes**: ${doc.conventions.codes}`, ...(doc.conventions.conflicts === undefined ? [] : [`- **Conflicts**: ${doc.conventions.conflicts}`]), + ...(doc.conventions.elicitation === undefined ? [] : [`- **Elicitation**: ${doc.conventions.elicitation}`]), '', ]; return lines.join('\n'); diff --git a/src/core/keepVariableNames.ts b/src/core/keepVariableNames.ts new file mode 100644 index 00000000..719b66c2 --- /dev/null +++ b/src/core/keepVariableNames.ts @@ -0,0 +1,50 @@ +/** + * The variable NAMES in a keep.lock, and the branches each one is on. Nothing else survives. + * + * A keep.lock entry carries a resource id, a value hash, connector metadata and delivery records. A human asked + * "delete this project?" needs only the names and the branches, so this is the one place the rest is dropped: a + * caller that shows or emits the result cannot leak a hash, because the hash is never copied out. + */ + +export interface VariableBranches { + readonly name: string; + /** Sorted and unique. */ + readonly branches: readonly string[]; +} + +/** The branches of one keep.lock entry list; an entry with no branch name is skipped. */ +function branchesOf(entries: unknown): readonly string[] { + const names = (Array.isArray(entries) ? entries : []).flatMap((e: unknown) => { + const branch = typeof e === 'object' && e !== null ? (e as { branch?: unknown }).branch : undefined; + return typeof branch === 'string' && branch !== '' ? [branch] : []; + }); + return sortedCopy([...new Set(names)], compareStrings); +} + +const compareStrings = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); + +/** A sorted copy (`toSorted` is ES2023 and this package supports Node 18): the sorted array is built here and the input is never changed. */ +function sortedCopy(items: readonly T[], cmp: (a: T, b: T) => number): readonly T[] { + return Array.from(items).sort(cmp); +} + +/** + * Every variable name in `rawKeep` (the keep.lock exactly as the service returned it) with its branches, sorted by name. + * `undefined` when `rawKeep` is absent or is not a keep.lock: the caller must say "unknown", never "none". + */ +export function variableBranchesFromKeep(rawKeep: string | undefined): readonly VariableBranches[] | undefined { + if (rawKeep === undefined || rawKeep === '') return undefined; + const parsed = parseJson(rawKeep); + const variables = typeof parsed === 'object' && parsed !== null ? (parsed as { variables?: unknown }).variables : undefined; + if (typeof variables !== 'object' || variables === null) return undefined; + const rows = Object.entries(variables).map(([name, entries]) => ({ name, branches: branchesOf(entries) })); + return sortedCopy(rows, (a, b) => compareStrings(a.name, b.name)); +} + +function parseJson(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return undefined; + } +} diff --git a/src/core/orgContext.ts b/src/core/orgContext.ts index 8d3eab26..310ab022 100644 --- a/src/core/orgContext.ts +++ b/src/core/orgContext.ts @@ -2,15 +2,80 @@ import inquirer from 'inquirer'; import { AuthService } from '../auth/authService'; import { ServiceClient } from '../service/serviceClient'; import { ProjectManager } from './projectManager'; +import { CapyError, ERROR_CODES } from '../types/index'; +import type { AuthResult, Organization } from '../types/index'; export interface OrgContext { orgId: string; + /** The organization's name as the sign-in session lists it; the id when the session does not list it. */ + orgName: string; userId: string; userEmail?: string; authService: AuthService; serviceClient: ServiceClient; } +export interface OrgContextOptions { + /** + * Whether to ask which organization when this folder has none and the person belongs to several. Default true. + * `false` (agent mode): refuse with `ORG_SELECTION_REQUIRED` instead of opening a prompt. + */ + readonly promptForOrg?: boolean; +} + +/** Silent session scoped to `orgId`, else a silent session for any org, else a full sign-in scoped to `orgId`. */ +async function authenticateScoped(authService: AuthService, orgId: string | undefined): Promise { + const scopedSilent = await authService.authenticateSilent(orgId); + if (scopedSilent.success) return scopedSilent; + const plainSilent = await authService.authenticateSilent(); + if (plainSilent.success) return plainSilent; + return authService.authenticate(orgId); +} + +/** Silent session scoped to `orgId`, else a full sign-in scoped to `orgId`. */ +async function authenticateForOrg(authService: AuthService, orgId: string): Promise { + const scopedSilent = await authService.authenticateSilent(orgId); + if (scopedSilent.success) return scopedSilent; + return authService.authenticate(orgId); +} + +/** Exits with a message when the sign-in failed; otherwise returns the result. */ +function succeededOrExit(result: AuthResult, message: string): AuthResult { + if (result.success) return result; + console.error(message); + return process.exit(1); +} + +/** The one organization to use: the only one, or the one the person picks. Exits when there is none. */ +async function chooseOrg(orgs: readonly Organization[], promptForOrg: boolean): Promise { + if (orgs.length === 0) { + console.error('No organizations available. Run `capy` to create one.'); + return process.exit(1); + } + if (orgs.length === 1) return orgs[0].id; + if (!promptForOrg) { + throw new CapyError( + 'More than one organization is available and this folder has none. Run `capy` here, or run the command in a terminal.', // COPY-FLAG + ERROR_CODES.ORG_SELECTION_REQUIRED, + { orgs: orgs.map((o) => ({ id: o.id, name: o.name })) }, + ); + } + const { chosen } = await inquirer.prompt([{ + type: 'list', + name: 'chosen', + message: 'Select organization:', + choices: orgs.map(o => ({ name: o.name, value: o.id })), + }]); + return chosen; +} + +/** The org's name from whichever session result lists it; the id when none does. */ +function orgNameOf(orgId: string, results: readonly AuthResult[]): string { + const listed = results.flatMap((r) => r.organizations ?? []).find((o) => o.id === orgId)?.name; + const own = results.find((r) => r.organization_id === orgId)?.organization_name; + return listed ?? own ?? orgId; +} + /** * Resolve org context for org-level commands (invite, kick, users). * Prefers keep.lock when present; otherwise authenticates via cached session @@ -19,6 +84,7 @@ export interface OrgContext { export async function resolveOrgContext( apiUrl: string | undefined, devMode: boolean, + options: OrgContextOptions = {}, ): Promise { const pm = new ProjectManager(); const projectState = await pm.detectProjectState(); @@ -27,44 +93,23 @@ export async function resolveOrgContext( const serviceClient = new ServiceClient(apiUrl, devMode); serviceClient.setTokenProvider(() => authService.getValidToken()); - let orgId = projectState.organizationId; - let authResult = await authService.authenticateSilent(orgId); - if (!authResult.success) authResult = await authService.authenticateSilent(); - if (!authResult.success) authResult = await authService.authenticate(orgId); - if (!authResult.success) { - console.error('Authentication failed. Run `capy` to sign in.'); - process.exit(1); - } - - if (!orgId) { - const orgs = authResult.organizations || []; - if (orgs.length === 0) { - console.error('No organizations available. Run `capy` to create one.'); - process.exit(1); - } else if (orgs.length === 1) { - orgId = orgs[0].id; - } else { - const { chosen } = await inquirer.prompt([{ - type: 'list', - name: 'chosen', - message: 'Select organization:', - choices: orgs.map(o => ({ name: o.name, value: o.id })), - }]); - orgId = chosen; - } + const folderOrgId = projectState.organizationId; + const first = succeededOrExit( + await authenticateScoped(authService, folderOrgId), + 'Authentication failed. Run `capy` to sign in.', + ); - authResult = await authService.authenticateSilent(orgId); - if (!authResult.success) authResult = await authService.authenticate(orgId); - if (!authResult.success) { - console.error('Authentication failed for selected organization.'); - process.exit(1); - } - } + // The folder's own org wins; with none, the only org, or the one the person picks. + const orgId = folderOrgId ? folderOrgId : await chooseOrg(first.organizations || [], options.promptForOrg !== false); + const scoped = folderOrgId + ? first + : succeededOrExit(await authenticateForOrg(authService, orgId), 'Authentication failed for selected organization.'); return { - orgId: orgId!, - userId: authResult.user_id!, - userEmail: authResult.user_email, + orgId, + orgName: orgNameOf(orgId, [scoped, first]), + userId: scoped.user_id!, + userEmail: scoped.user_email, authService, serviceClient, }; diff --git a/src/core/projectNameRule.ts b/src/core/projectNameRule.ts new file mode 100644 index 00000000..10e2ad2e --- /dev/null +++ b/src/core/projectNameRule.ts @@ -0,0 +1,43 @@ +/** + * The rule for a project name, in one place: the init prompt (`PromptEngine.promptForProjectName`) and + * `capy org create-project` both use it, so they only ever change together. + * + * Non-empty, letters/numbers/hyphens/underscores only, and not the reserved system store name. + */ +import { ERROR_CODES } from '../types/index'; +import { isReservedProjectName, PROJECT_NAME_RESERVED_MESSAGE } from '../system/reservedProjectName'; + +/** The allowed characters, as the regular expression the init prompt has always used. */ +export const PROJECT_NAME_PATTERN = /^[a-zA-Z0-9-_]+$/; + +/** The same rule as a string, for JSON (`elicitation.fields[].pattern`). */ +export const PROJECT_NAME_PATTERN_SOURCE: string = PROJECT_NAME_PATTERN.source; + +export interface ProjectNameProblem { + /** The code to refuse with. */ + readonly code: typeof ERROR_CODES.PROJECT_NAME_INVALID | typeof ERROR_CODES.PROJECT_NAME_RESERVED; + /** For a human to read; never branched on. */ + readonly message: string; +} + +/** Why `input` is not an allowed project name, or null when it is. Checks in the order the init prompt always has. */ +export function projectNameProblem(input: string): ProjectNameProblem | null { + if (!input || input.trim().length === 0) { + return { code: ERROR_CODES.PROJECT_NAME_INVALID, message: 'Project name cannot be empty' }; + } + if (!PROJECT_NAME_PATTERN.test(input)) { + return { + code: ERROR_CODES.PROJECT_NAME_INVALID, + message: 'Project name can only contain letters, numbers, hyphens, and underscores', + }; + } + if (isReservedProjectName(input)) { + return { code: ERROR_CODES.PROJECT_NAME_RESERVED, message: PROJECT_NAME_RESERVED_MESSAGE }; + } + return null; +} + +/** The shape an inquirer `validate` wants: `true`, or the sentence to show. */ +export function validateProjectNameInput(input: string): string | true { + return projectNameProblem(input)?.message ?? true; +} diff --git a/src/index-dev.ts b/src/index-dev.ts index b39fce64..5f2fe78d 100644 --- a/src/index-dev.ts +++ b/src/index-dev.ts @@ -745,6 +745,17 @@ const orgCmd = program await cmd.execute({ nonTty: options.nonTty === true, conflictFlags: rawConflictFlagsOf(command) }); }); +orgCmd + .command('create-project [name]') + .description('Create a project in the active organization (owners and admins only; does not link this folder)') // COPY-FLAG + .option('--non-tty', 'treat stdin as not a terminal: never ask for the name; with no name, refuse with a form to ask the human') // COPY-FLAG + .option('--json', 'emit machine-readable JSON instead of the human UI') + .action(async (name: string | undefined, _options: any, command: any) => { + const { createProjectCommand, createProjectOptsFrom } = await import('./commands/projectCreateCommand'); + // Read from the merged view: `org`'s `--non-tty` and the program's `--dry-run` take those flags before this command sees them. + await createProjectCommand(name, { ...createProjectOptsFrom(command), apiUrl: process.env.CAPY_API_URL, devMode: true }); + }); + orgCmd .command('delete-project ') .description('Delete a project (owners only; hidden, not destroyed; an owner can restore it)') // COPY-FLAG diff --git a/src/index.ts b/src/index.ts index 5f3327fc..30b1b7a9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -781,6 +781,18 @@ const orgCmd = program await cmd.execute({ nonTty: options.nonTty === true, conflictFlags: rawConflictFlagsOf(command) }); }); +orgCmd + .command('create-project [name]') + .description('Create a project in the active organization (owners and admins only; does not link this folder)') // COPY-FLAG + .option('--non-tty', 'treat stdin as not a terminal: never ask for the name; with no name, refuse with a form to ask the human') // COPY-FLAG + .option('--json', 'emit machine-readable JSON instead of the human UI') + .action(async (name: string | undefined, _options: any, command: any) => { + assertNotLocalOnly('org create-project'); + const { createProjectCommand, createProjectOptsFrom } = await import('./commands/projectCreateCommand'); + // Read from the merged view: `org`'s `--non-tty` and the program's `--dry-run` take those flags before this command sees them. + await createProjectCommand(name, createProjectOptsFrom(command)); + }); + orgCmd .command('delete-project ') .description('Delete a project (owners only; hidden, not destroyed; an owner can restore it)') // COPY-FLAG diff --git a/src/types/index.ts b/src/types/index.ts index f11dbe3c..e6307b20 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -901,6 +901,14 @@ export const ERROR_CODES = { PROJECT_AMBIGUOUS: 'PROJECT_AMBIGUOUS', /** A destructive command needs a yes and there is no terminal to ask, and `--yes` was not passed. Exit 3. */ CONFIRMATION_REQUIRED: 'CONFIRMATION_REQUIRED', + /** `capy org create-project` with no name and nobody to ask (agent mode). The refusal carries an `elicitation` form. Exit 3. */ + PROJECT_NAME_REQUIRED: 'PROJECT_NAME_REQUIRED', + /** `capy org create-project`: the name is empty or has characters other than letters, numbers, hyphens and underscores. Exit 1. */ + PROJECT_NAME_INVALID: 'PROJECT_NAME_INVALID', + /** `capy org create-project`: the organization already has a project with that name (the service answered 409). Exit 1. */ + PROJECT_NAME_EXISTS: 'PROJECT_NAME_EXISTS', + /** A command that acts on "the active organization" has none (no keep.lock here) and the person belongs to several, with nobody to ask. Exit 3. */ + ORG_SELECTION_REQUIRED: 'ORG_SELECTION_REQUIRED', /** `GET /orgs/:orgId/repos` answered 404: the service predates project -> repo links. Never a failure of the command that asked. */ REPO_LINKS_UNSUPPORTED: 'REPO_LINKS_UNSUPPORTED', /** `--dry-run` on a path that would prompt (the `capy edit` TUI, an interactive `capy add`, `capy remove`'s confirmation): it cannot preview, and a dry run never runs for real. Exit 1. */ diff --git a/src/ui/elicitation.ts b/src/ui/elicitation.ts new file mode 100644 index 00000000..78b5c49a --- /dev/null +++ b/src/ui/elicitation.ts @@ -0,0 +1,41 @@ +/** + * An elicitation: a question a command cannot answer, handed to the agent that ran it so the agent can put it + * to the human. It rides inside a `--json` refusal (exit 3) next to the refusal's `code`, and never replaces it. + * + * Two kinds, one shape each: + * - `confirm`: a yes/no. `answer.yes` is the exact command to run when the human says yes. + * - `form`: one or more values. `fields` describes each; `answer.rerun` is the command to run with the values filled in. + * + * `details` is what the human needs to decide. It is built from names and ids only: never a value, a hash or ciphertext. + */ + +export interface ElicitationField { + readonly name: string; + readonly type: 'string'; + /** A regular expression (as a string) the value must match. */ + readonly pattern: string; + /** A value the human can accept as is. Absent when there is no sensible one. */ + readonly default?: string; +} + +export interface ElicitationConfirm { + readonly kind: 'confirm'; + readonly question: string; + readonly details: Readonly>; + readonly answer: { readonly yes: string }; +} + +export interface ElicitationForm { + readonly kind: 'form'; + readonly question: string; + readonly details: Readonly>; + readonly fields: readonly ElicitationField[]; + readonly answer: { readonly rerun: string }; +} + +export type Elicitation = ElicitationConfirm | ElicitationForm; + +/** What `capy help --json` says about elicitations (`conventions.elicitation`). */ +export const ELICITATION_HELP = + // COPY-FLAG: minimal and neutral. + 'A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. kind "confirm" is a yes/no ({kind, question, details, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, fields:[{name, type, pattern, default?}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` with no name in agent mode refuses with PROJECT_NAME_REQUIRED and a form (details: {org:{id,name}}). Never answer it yourself and never add --yes on your own.'; diff --git a/src/ui/promptEngine.ts b/src/ui/promptEngine.ts index 93b50798..e39e5c45 100644 --- a/src/ui/promptEngine.ts +++ b/src/ui/promptEngine.ts @@ -8,7 +8,7 @@ import { EnvVariable, UserDecisions } from '../types/index'; -import { isReservedProjectName, PROJECT_NAME_RESERVED_MESSAGE } from '../system/reservedProjectName'; +import { validateProjectNameInput } from '../core/projectNameRule'; export class PromptEngine { async promptForProjectName(defaultName: string): Promise { @@ -18,18 +18,7 @@ export class PromptEngine { name: 'projectName', message: `📁 Project name (default: "${defaultName}"):`, default: defaultName, - validate: (input: string) => { - if (!input || input.trim().length === 0) { - return 'Project name cannot be empty'; - } - if (!/^[a-zA-Z0-9-_]+$/.test(input)) { - return 'Project name can only contain letters, numbers, hyphens, and underscores'; - } - if (isReservedProjectName(input)) { - return PROJECT_NAME_RESERVED_MESSAGE; - } - return true; - } + validate: validateProjectNameInput, } ]); diff --git a/tests/core/agentsBlockPlan.test.ts b/tests/core/agentsBlockPlan.test.ts index 85ce2ed4..32eccbb2 100644 --- a/tests/core/agentsBlockPlan.test.ts +++ b/tests/core/agentsBlockPlan.test.ts @@ -39,6 +39,7 @@ describe('AGENTS_BLOCK', () => { '- Commands that write run bare `capy` first. If your folder and Capy differ they write nothing and exit 3 with `SECRETS_CONFLICT`, a `conflict_id`, and the differing names (fingerprints, never values).', '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', + '- If a JSON result has an `elicitation`, ask the human with it (a yes/no for `confirm`, a form for `form`), then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', '- Never print, log, or commit secret values.', '- To set a value, pipe it from the command that produces it: ` | capy edit NAME --json`. Never put a value in a command argument, an `echo`, or a heredoc — it would land in your context and the shell history.', '', From 9c84714b4383cf882e815ec5c8f5210505adb764 Mon Sep 17 00:00:00 2001 From: cvince Date: Sun, 8 Nov 2026 21:11:17 -0700 Subject: [PATCH 03/27] test(org): delete confirmation may read the project, never writes --- tests/commands/projectLifecycleCommand.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/commands/projectLifecycleCommand.test.ts b/tests/commands/projectLifecycleCommand.test.ts index 5e60e677..987a3e6e 100644 --- a/tests/commands/projectLifecycleCommand.test.ts +++ b/tests/commands/projectLifecycleCommand.test.ts @@ -138,14 +138,14 @@ describe('capy org delete-project', () => { afterEach(() => setTTY(false)); describe('confirmation', () => { - it('off a terminal without --yes: refuses with CONFIRMATION_REQUIRED (exit 3) before any request', async () => { + it('off a terminal without --yes: refuses with CONFIRMATION_REQUIRED (exit 3) and a confirm elicitation, writing nothing', async () => { service({ onWrite: () => deleted('proj_web', 'web') }); const out = await capture(() => deleteProjectCommand('web', { json: true })); expect(out.exitCode).toBe(3); - expect(JSON.parse(out.stdout)).toMatchObject({ ok: false, code: 'CONFIRMATION_REQUIRED' }); - expect(requests()).toEqual([]); + expect(JSON.parse(out.stdout)).toMatchObject({ ok: false, code: 'CONFIRMATION_REQUIRED', elicitation: { kind: 'confirm' } }); + expect(writes()).toEqual([]); expect(fakePrompt).not.toHaveBeenCalled(); }); @@ -157,7 +157,8 @@ describe('capy org delete-project', () => { expect(out.exitCode).toBe(3); expect(JSON.parse(out.stdout).code).toBe('CONFIRMATION_REQUIRED'); - expect(requests()).toEqual([]); + expect(writes()).toEqual([]); + expect(fakePrompt).not.toHaveBeenCalled(); }); it('the prompt defaults to no, and shows the project and its live tokens first', async () => { From 1a2ee224db31d8ed9265f5a2d32aa40f19eaa07d Mon Sep 17 00:00:00 2001 From: cvince Date: Sun, 8 Nov 2026 21:13:48 -0700 Subject: [PATCH 04/27] feat(agents): show elicitation details as a table, then ask the short question --- src/core/agentsBlockPlan.ts | 2 +- tests/core/agentsBlockPlan.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/agentsBlockPlan.ts b/src/core/agentsBlockPlan.ts index 7c5a4070..6749a690 100644 --- a/src/core/agentsBlockPlan.ts +++ b/src/core/agentsBlockPlan.ts @@ -31,7 +31,7 @@ export const AGENTS_BLOCK = [ '- Commands that write run bare `capy` first. If your folder and Capy differ they write nothing and exit 3 with `SECRETS_CONFLICT`, a `conflict_id`, and the differing names (fingerprints, never values).', // COPY-FLAG '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', // COPY-FLAG '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', // COPY-FLAG - '- If a JSON result has an `elicitation`, ask the human with it (a yes/no for `confirm`, a form for `form`), then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', // COPY-FLAG + '- If a JSON result has an `elicitation`, first show its `details` to the human as a short table, then ask only its `question` (a yes/no for `confirm`, a form for `form`), then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', // COPY-FLAG '- Never print, log, or commit secret values.', '- To set a value, pipe it from the command that produces it: ` | capy edit NAME --json`. Never put a value in a command argument, an `echo`, or a heredoc — it would land in your context and the shell history.', AGENTS_BLOCK_END, diff --git a/tests/core/agentsBlockPlan.test.ts b/tests/core/agentsBlockPlan.test.ts index 32eccbb2..56657d28 100644 --- a/tests/core/agentsBlockPlan.test.ts +++ b/tests/core/agentsBlockPlan.test.ts @@ -39,7 +39,7 @@ describe('AGENTS_BLOCK', () => { '- Commands that write run bare `capy` first. If your folder and Capy differ they write nothing and exit 3 with `SECRETS_CONFLICT`, a `conflict_id`, and the differing names (fingerprints, never values).', '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', - '- If a JSON result has an `elicitation`, ask the human with it (a yes/no for `confirm`, a form for `form`), then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', + '- If a JSON result has an `elicitation`, first show its `details` to the human as a short table, then ask only its `question` (a yes/no for `confirm`, a form for `form`), then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', '- Never print, log, or commit secret values.', '- To set a value, pipe it from the command that produces it: ` | capy edit NAME --json`. Never put a value in a command argument, an `echo`, or a heredoc — it would land in your context and the shell history.', '', From 8bc9f48ed7aa8506c12202dffd86f342a9ddcf82 Mon Sep 17 00:00:00 2001 From: cvince Date: Sun, 8 Nov 2026 21:30:56 -0700 Subject: [PATCH 05/27] feat(org): elicitations carry a display table agents print as given --- src/commands/projectCreateCommand.ts | 2 + src/commands/projectLifecycleCommand.ts | 9 +++ src/core/agentsBlockPlan.ts | 2 +- src/ui/elicitation.ts | 8 ++- src/ui/elicitationDisplay.ts | 67 +++++++++++++++++++ .../commands/projectLifecycleCommand.test.ts | 1 + tests/core/agentsBlockPlan.test.ts | 2 +- tests/ui/elicitationDisplay.test.ts | 49 ++++++++++++++ 8 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 src/ui/elicitationDisplay.ts create mode 100644 tests/ui/elicitationDisplay.test.ts diff --git a/src/commands/projectCreateCommand.ts b/src/commands/projectCreateCommand.ts index 569dd32c..824808ab 100644 --- a/src/commands/projectCreateCommand.ts +++ b/src/commands/projectCreateCommand.ts @@ -18,6 +18,7 @@ import { PROJECT_NAME_PATTERN_SOURCE, projectNameProblem } from '../core/project import { PromptEngine } from '../ui/promptEngine'; import { isInteractive, EXIT_NEEDS_INPUT } from '../ui/interactive'; import type { ElicitationForm } from '../ui/elicitation'; +import { createProjectDisplay } from '../ui/elicitationDisplay'; import { CapyError, ERROR_CODES } from '../types/index'; import { refuse, refusalFor, settle, type Refusal } from './projectLifecycleCommand'; @@ -69,6 +70,7 @@ function nameForm(org: OrgRef): ElicitationForm { kind: 'form', question: 'What should the project be called?', // COPY-FLAG details: { org: { id: org.id, name: org.name } }, + display: createProjectDisplay(org), fields: [{ name: 'name', type: 'string', diff --git a/src/commands/projectLifecycleCommand.ts b/src/commands/projectLifecycleCommand.ts index 10ae641f..7071764f 100644 --- a/src/commands/projectLifecycleCommand.ts +++ b/src/commands/projectLifecycleCommand.ts @@ -25,6 +25,7 @@ import { isInteractive, EXIT_NEEDS_INPUT } from '../ui/interactive'; import { CapyError, ERROR_CODES } from '../types/index'; import type { DeleteProjectResponse, LiveDeployTokenInfo } from '../service/serviceClient'; import type { ElicitationConfirm } from '../ui/elicitation'; +import { deleteProjectDisplay } from '../ui/elicitationDisplay'; import { variableBranchesFromKeep, type VariableBranches } from '../core/keepVariableNames'; export interface ProjectLifecycleOpts { @@ -295,6 +296,14 @@ function deleteElicitation( live_deploy_tokens: tokens.map((t) => ({ deploy_id: t.deploy_id, label: t.label })), ...(details.unavailable.length > 0 ? { unavailable: details.unavailable } : {}), }, + display: deleteProjectDisplay({ + org, + project, + branches: details.branches, + variables: details.variables, + liveDeployTokens: tokens, + unavailable: details.unavailable, + }), answer: { yes: deleteYesCommand(project.id, force) }, }; } diff --git a/src/core/agentsBlockPlan.ts b/src/core/agentsBlockPlan.ts index 6749a690..419cc241 100644 --- a/src/core/agentsBlockPlan.ts +++ b/src/core/agentsBlockPlan.ts @@ -31,7 +31,7 @@ export const AGENTS_BLOCK = [ '- Commands that write run bare `capy` first. If your folder and Capy differ they write nothing and exit 3 with `SECRETS_CONFLICT`, a `conflict_id`, and the differing names (fingerprints, never values).', // COPY-FLAG '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', // COPY-FLAG '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', // COPY-FLAG - '- If a JSON result has an `elicitation`, first show its `details` to the human as a short table, then ask only its `question` (a yes/no for `confirm`, a form for `form`), then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', // COPY-FLAG + '- If a JSON result has an `elicitation`, first print its `display` to the human exactly as given (never shorten or reorder it), then ask only its `question` (a yes/no for `confirm`, a form for `form`), then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', // COPY-FLAG '- Never print, log, or commit secret values.', '- To set a value, pipe it from the command that produces it: ` | capy edit NAME --json`. Never put a value in a command argument, an `echo`, or a heredoc — it would land in your context and the shell history.', AGENTS_BLOCK_END, diff --git a/src/ui/elicitation.ts b/src/ui/elicitation.ts index 78b5c49a..082bf5df 100644 --- a/src/ui/elicitation.ts +++ b/src/ui/elicitation.ts @@ -7,6 +7,8 @@ * - `form`: one or more values. `fields` describes each; `answer.rerun` is the command to run with the values filled in. * * `details` is what the human needs to decide. It is built from names and ids only: never a value, a hash or ciphertext. + * `display` is the same information as a markdown table (`ui/elicitationDisplay.ts`). The agent prints it exactly as + * given before asking `question`, so every human sees the same list. */ export interface ElicitationField { @@ -22,6 +24,8 @@ export interface ElicitationConfirm { readonly kind: 'confirm'; readonly question: string; readonly details: Readonly>; + /** Markdown. Print it exactly as given, then ask `question`. */ + readonly display: string; readonly answer: { readonly yes: string }; } @@ -29,6 +33,8 @@ export interface ElicitationForm { readonly kind: 'form'; readonly question: string; readonly details: Readonly>; + /** Markdown. Print it exactly as given, then ask `question`. */ + readonly display: string; readonly fields: readonly ElicitationField[]; readonly answer: { readonly rerun: string }; } @@ -38,4 +44,4 @@ export type Elicitation = ElicitationConfirm | ElicitationForm; /** What `capy help --json` says about elicitations (`conventions.elicitation`). */ export const ELICITATION_HELP = // COPY-FLAG: minimal and neutral. - 'A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. kind "confirm" is a yes/no ({kind, question, details, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, fields:[{name, type, pattern, default?}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` with no name in agent mode refuses with PROJECT_NAME_REQUIRED and a form (details: {org:{id,name}}). Never answer it yourself and never add --yes on your own.'; + 'A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. First print `display` (a markdown table) to the human exactly as given, without shortening or reordering it, then ask only `question`. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, default?}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` with no name in agent mode refuses with PROJECT_NAME_REQUIRED and a form (details: {org:{id,name}}). Never answer it yourself and never add --yes on your own.'; diff --git a/src/ui/elicitationDisplay.ts b/src/ui/elicitationDisplay.ts new file mode 100644 index 00000000..938b2d14 --- /dev/null +++ b/src/ui/elicitationDisplay.ts @@ -0,0 +1,67 @@ +/** + * The `display` of an elicitation: a markdown table the agent prints exactly as given, before it asks the question. + * One renderer per elicitation, so every agent shows the human the same thing and none can shorten or reorder it. + * Built from names and ids only, like `details`. + */ + +/** A table cell: pipes escaped and line breaks flattened, so a name can never break the table. */ +function cell(text: string): string { + return text.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, ' '); +} + +function table(headers: readonly [string, string], rows: readonly (readonly [string, string])[]): string { + return [ + `| ${cell(headers[0])} | ${cell(headers[1])} |`, + '| --- | --- |', + ...rows.map(([a, b]) => `| ${cell(a)} | ${cell(b)} |`), + ].join('\n'); +} + +export interface DeleteDisplayInput { + readonly org: { readonly id: string; readonly name: string }; + readonly project: { readonly id: string; readonly name: string }; + readonly branches: readonly string[]; + readonly variables: ReadonlyArray<{ readonly name: string; readonly branches: readonly string[] }>; + readonly liveDeployTokens: ReadonlyArray<{ readonly deploy_id: string; readonly label?: string | null }>; + readonly unavailable: readonly ('branches' | 'variables')[]; +} + +// COPY-FLAG: every heading and placeholder below is minimal and neutral, awaiting approval. +const NONE = 'none'; +const UNREADABLE = 'could not be read'; + +function branchesCell(input: DeleteDisplayInput): string { + if (input.unavailable.includes('branches')) return UNREADABLE; + return input.branches.length === 0 ? NONE : input.branches.join(', '); +} + +function tokensCell(input: DeleteDisplayInput): string { + if (input.liveDeployTokens.length === 0) return NONE; + return input.liveDeployTokens.map((t) => (t.label ? `${t.deploy_id} (${t.label})` : t.deploy_id)).join(', '); +} + +function variablesSection(input: DeleteDisplayInput): string { + if (input.unavailable.includes('variables')) return `Variables: ${UNREADABLE}`; + if (input.variables.length === 0) return `Variables: ${NONE}`; + return table( + ['Variable', 'Branches'], + input.variables.map((v) => [v.name, v.branches.length === 0 ? NONE : v.branches.join(', ')] as const), + ); +} + +/** The table for `capy org delete-project`: the project, then every variable, one row each, in keep.lock order. */ +export function deleteProjectDisplay(input: DeleteDisplayInput): string { + const summary = table(['', ''], [ + ['Org', input.org.name], + ['Project', `${input.project.name} (${input.project.id})`], + ['Branches', branchesCell(input)], + ['Variables', input.unavailable.includes('variables') ? UNREADABLE : String(input.variables.length)], + ['Live deploy tokens', tokensCell(input)], + ]); + return `${summary}\n\n${variablesSection(input)}`; +} + +/** The table for `capy org create-project` asking for a name: where the project will be created. */ +export function createProjectDisplay(org: { readonly id: string; readonly name: string }): string { + return table(['', ''], [['Org', org.name]]); +} diff --git a/tests/commands/projectLifecycleCommand.test.ts b/tests/commands/projectLifecycleCommand.test.ts index 987a3e6e..431dbec7 100644 --- a/tests/commands/projectLifecycleCommand.test.ts +++ b/tests/commands/projectLifecycleCommand.test.ts @@ -36,6 +36,7 @@ client.setTokenProvider((async () => ({ access_token: 'test-token' })) as never) mock.module('../../src/core/orgContext', () => ({ resolveOrgContext: mock(async () => ({ orgId: 'org_1', + orgName: 'Acme', userId: 'user_1', userEmail: 'owner@example.com', authService: {}, diff --git a/tests/core/agentsBlockPlan.test.ts b/tests/core/agentsBlockPlan.test.ts index 56657d28..a7f97c57 100644 --- a/tests/core/agentsBlockPlan.test.ts +++ b/tests/core/agentsBlockPlan.test.ts @@ -39,7 +39,7 @@ describe('AGENTS_BLOCK', () => { '- Commands that write run bare `capy` first. If your folder and Capy differ they write nothing and exit 3 with `SECRETS_CONFLICT`, a `conflict_id`, and the differing names (fingerprints, never values).', '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', - '- If a JSON result has an `elicitation`, first show its `details` to the human as a short table, then ask only its `question` (a yes/no for `confirm`, a form for `form`), then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', + '- If a JSON result has an `elicitation`, first print its `display` to the human exactly as given (never shorten or reorder it), then ask only its `question` (a yes/no for `confirm`, a form for `form`), then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', '- Never print, log, or commit secret values.', '- To set a value, pipe it from the command that produces it: ` | capy edit NAME --json`. Never put a value in a command argument, an `echo`, or a heredoc — it would land in your context and the shell history.', '', diff --git a/tests/ui/elicitationDisplay.test.ts b/tests/ui/elicitationDisplay.test.ts new file mode 100644 index 00000000..0f35d370 --- /dev/null +++ b/tests/ui/elicitationDisplay.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'bun:test'; +import { createProjectDisplay, deleteProjectDisplay } from '../../src/ui/elicitationDisplay'; + +const base = { + org: { id: 'org_1', name: 'Demos' }, + project: { id: 'proj_1', name: 'web' }, + branches: ['main', 'development'], + variables: [ + { name: 'B_VAR', branches: ['main'] }, + { name: 'A_VAR', branches: ['main', 'development'] }, + ], + liveDeployTokens: [], + unavailable: [], +} as const; + +describe('deleteProjectDisplay', () => { + it('lists every variable, one row each, in the order given', () => { + const out = deleteProjectDisplay(base); + const rows = out.split('\n').filter((l) => l.startsWith('| ') && l.includes('_VAR')); + expect(rows).toEqual(['| B_VAR | main |', '| A_VAR | main, development |']); + expect(out).toContain('| Variables | 2 |'); + expect(out).toContain('| Project | web (proj_1) |'); + expect(out).toContain('| Live deploy tokens | none |'); + }); + + it('never shortens a long list', () => { + const variables = Array.from({ length: 45 }, (_, i) => ({ name: `V_${i}`, branches: ['development'] })); + const out = deleteProjectDisplay({ ...base, variables }); + expect(variables.every((v) => out.includes(`| ${v.name} | development |`))).toBe(true); + }); + + it('says a part could not be read instead of calling it empty', () => { + const out = deleteProjectDisplay({ ...base, unavailable: ['branches', 'variables'] }); + expect(out).toContain('| Branches | could not be read |'); + expect(out).toContain('Variables: could not be read'); + expect(out).not.toContain('B_VAR'); + }); + + it('a pipe or line break in a label cannot break the table', () => { + const out = deleteProjectDisplay({ ...base, liveDeployTokens: [{ deploy_id: 'dep_1', label: 'a|b\nc' }] }); + expect(out).toContain('| Live deploy tokens | dep_1 (a\\|b c) |'); + }); +}); + +describe('createProjectDisplay', () => { + it('shows the org the project will be created in', () => { + expect(createProjectDisplay({ id: 'org_1', name: 'Demos' })).toContain('| Org | Demos |'); + }); +}); From 7eb397d86460ee6b47f1ca72eccfdb5b0f7ce2f1 Mon Sep 17 00:00:00 2001 From: cvince Date: Sun, 8 Nov 2026 21:39:07 -0700 Subject: [PATCH 06/27] docs(agents): elicitation table goes in the reply text, only the question in the box --- docs/cli-reference.md | 2 +- src/core/agentsBlockPlan.ts | 2 +- src/ui/elicitation.ts | 2 +- tests/core/agentsBlockPlan.test.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 9db85bb6..a02b3e36 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1161,4 +1161,4 @@ Every refusal carries a stable `code` — branch on it, never on message text. - **JSON**: Pass --json on any command whose supportsJson is true and parse stdout as JSON; prose (progress, prompts, errors) goes to stderr so stdout stays pure JSON. - **Codes**: Branch only on the machine-readable `code` field (from --json output or a CapyError), never on human-readable message text — messages may be reworded without notice. - **Conflicts**: Every command that writes from a folder (edit, add, remove, connect, rotate, push, checkout, org, deploy, deploy revoke, deploy targets-remove) runs bare `capy` first; `checkout -b` is the one exception (a new branch carries the uncommitted values of the folder). Off a terminal, if the folder and Capy differ on any variable, nothing is written, the exit code is 3, and stdout is {ok:false, code:"SECRETS_CONFLICT", conflict_id, conflicts:[{name, project, branch, type, pinned, local, remote}]} (fingerprints only, never values; a Dokploy value has {name, target, pinned, local, remote}). Ask the person, then rerun the same command with --resolve NAME=pinned|local|remote|delete for every row (NAME@TARGET=local|remote for a Dokploy value) and --conflict . A state that moved since is SECRETS_CONFLICT_STALE with the fresh rows. There is no resolve-all. -- **Elicitation**: A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. kind "confirm" is a yes/no ({kind, question, details, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, fields:[{name, type, pattern, default?}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` with no name in agent mode refuses with PROJECT_NAME_REQUIRED and a form (details: {org:{id,name}}). Never answer it yourself and never add --yes on your own. +- **Elicitation**: A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. First print `display` (a markdown table) to the human exactly as given, without shortening or reordering it, then ask only `question`. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, default?}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` with no name in agent mode refuses with PROJECT_NAME_REQUIRED and a form (details: {org:{id,name}}). Never answer it yourself and never add --yes on your own. diff --git a/src/core/agentsBlockPlan.ts b/src/core/agentsBlockPlan.ts index 419cc241..01c93534 100644 --- a/src/core/agentsBlockPlan.ts +++ b/src/core/agentsBlockPlan.ts @@ -31,7 +31,7 @@ export const AGENTS_BLOCK = [ '- Commands that write run bare `capy` first. If your folder and Capy differ they write nothing and exit 3 with `SECRETS_CONFLICT`, a `conflict_id`, and the differing names (fingerprints, never values).', // COPY-FLAG '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', // COPY-FLAG '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', // COPY-FLAG - '- If a JSON result has an `elicitation`, first print its `display` to the human exactly as given (never shorten or reorder it), then ask only its `question` (a yes/no for `confirm`, a form for `form`), then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', // COPY-FLAG + '- If a JSON result has an `elicitation`, first write its `display` table into your reply text exactly as given (never shorten or reorder it, and never put it inside the question box or a preview), then ask only its short `question` (a yes/no for `confirm`, a form for `form`), then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', // COPY-FLAG '- Never print, log, or commit secret values.', '- To set a value, pipe it from the command that produces it: ` | capy edit NAME --json`. Never put a value in a command argument, an `echo`, or a heredoc — it would land in your context and the shell history.', AGENTS_BLOCK_END, diff --git a/src/ui/elicitation.ts b/src/ui/elicitation.ts index 082bf5df..a67a478c 100644 --- a/src/ui/elicitation.ts +++ b/src/ui/elicitation.ts @@ -44,4 +44,4 @@ export type Elicitation = ElicitationConfirm | ElicitationForm; /** What `capy help --json` says about elicitations (`conventions.elicitation`). */ export const ELICITATION_HELP = // COPY-FLAG: minimal and neutral. - 'A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. First print `display` (a markdown table) to the human exactly as given, without shortening or reordering it, then ask only `question`. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, default?}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` with no name in agent mode refuses with PROJECT_NAME_REQUIRED and a form (details: {org:{id,name}}). Never answer it yourself and never add --yes on your own.'; + 'A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. First write `display` (a markdown table) into your reply text exactly as given, without shortening or reordering it and never inside the question box or a preview, then ask only the short `question`. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, default?}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` with no name in agent mode refuses with PROJECT_NAME_REQUIRED and a form (details: {org:{id,name}}). Never answer it yourself and never add --yes on your own.'; diff --git a/tests/core/agentsBlockPlan.test.ts b/tests/core/agentsBlockPlan.test.ts index a7f97c57..325998c9 100644 --- a/tests/core/agentsBlockPlan.test.ts +++ b/tests/core/agentsBlockPlan.test.ts @@ -39,7 +39,7 @@ describe('AGENTS_BLOCK', () => { '- Commands that write run bare `capy` first. If your folder and Capy differ they write nothing and exit 3 with `SECRETS_CONFLICT`, a `conflict_id`, and the differing names (fingerprints, never values).', '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', - '- If a JSON result has an `elicitation`, first print its `display` to the human exactly as given (never shorten or reorder it), then ask only its `question` (a yes/no for `confirm`, a form for `form`), then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', + '- If a JSON result has an `elicitation`, first write its `display` table into your reply text exactly as given (never shorten or reorder it, and never put it inside the question box or a preview), then ask only its short `question` (a yes/no for `confirm`, a form for `form`), then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', '- Never print, log, or commit secret values.', '- To set a value, pipe it from the command that produces it: ` | capy edit NAME --json`. Never put a value in a command argument, an `echo`, or a heredoc — it would land in your context and the shell history.', '', From e552d22b23dd4526fe6be68443add2be1f5ce89c Mon Sep 17 00:00:00 2001 From: cvince Date: Sun, 8 Nov 2026 21:48:34 -0700 Subject: [PATCH 07/27] feat(agents): deleted-project lines in the AGENTS block --- docs/cli-reference.md | 2 +- src/core/agentsBlockPlan.ts | 2 ++ tests/core/agentsBlockPlan.test.ts | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index a02b3e36..6a1a0565 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1161,4 +1161,4 @@ Every refusal carries a stable `code` — branch on it, never on message text. - **JSON**: Pass --json on any command whose supportsJson is true and parse stdout as JSON; prose (progress, prompts, errors) goes to stderr so stdout stays pure JSON. - **Codes**: Branch only on the machine-readable `code` field (from --json output or a CapyError), never on human-readable message text — messages may be reworded without notice. - **Conflicts**: Every command that writes from a folder (edit, add, remove, connect, rotate, push, checkout, org, deploy, deploy revoke, deploy targets-remove) runs bare `capy` first; `checkout -b` is the one exception (a new branch carries the uncommitted values of the folder). Off a terminal, if the folder and Capy differ on any variable, nothing is written, the exit code is 3, and stdout is {ok:false, code:"SECRETS_CONFLICT", conflict_id, conflicts:[{name, project, branch, type, pinned, local, remote}]} (fingerprints only, never values; a Dokploy value has {name, target, pinned, local, remote}). Ask the person, then rerun the same command with --resolve NAME=pinned|local|remote|delete for every row (NAME@TARGET=local|remote for a Dokploy value) and --conflict . A state that moved since is SECRETS_CONFLICT_STALE with the fresh rows. There is no resolve-all. -- **Elicitation**: A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. First print `display` (a markdown table) to the human exactly as given, without shortening or reordering it, then ask only `question`. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, default?}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` with no name in agent mode refuses with PROJECT_NAME_REQUIRED and a form (details: {org:{id,name}}). Never answer it yourself and never add --yes on your own. +- **Elicitation**: A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. First write `display` (a markdown table) into your reply text exactly as given, without shortening or reordering it and never inside the question box or a preview, then ask only the short `question`. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, default?}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` with no name in agent mode refuses with PROJECT_NAME_REQUIRED and a form (details: {org:{id,name}}). Never answer it yourself and never add --yes on your own. diff --git a/src/core/agentsBlockPlan.ts b/src/core/agentsBlockPlan.ts index 01c93534..41d63af8 100644 --- a/src/core/agentsBlockPlan.ts +++ b/src/core/agentsBlockPlan.ts @@ -32,6 +32,8 @@ export const AGENTS_BLOCK = [ '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', // COPY-FLAG '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', // COPY-FLAG '- If a JSON result has an `elicitation`, first write its `display` table into your reply text exactly as given (never shorten or reorder it, and never put it inside the question box or a preview), then ask only its short `question` (a yes/no for `confirm`, a form for `form`), then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', // COPY-FLAG + '- Never run `capy org delete-project` or `capy org restore-project` unless the human asked for that project by name. Only an org owner can run them.', // COPY-FLAG + '- `PROJECT_DELETED` means an org owner deleted this folder\'s project. Stop and tell the human. An org owner can restore it with `capy org restore-project ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`.', // COPY-FLAG '- Never print, log, or commit secret values.', '- To set a value, pipe it from the command that produces it: ` | capy edit NAME --json`. Never put a value in a command argument, an `echo`, or a heredoc — it would land in your context and the shell history.', AGENTS_BLOCK_END, diff --git a/tests/core/agentsBlockPlan.test.ts b/tests/core/agentsBlockPlan.test.ts index 325998c9..c282a46d 100644 --- a/tests/core/agentsBlockPlan.test.ts +++ b/tests/core/agentsBlockPlan.test.ts @@ -40,6 +40,8 @@ describe('AGENTS_BLOCK', () => { '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', '- If a JSON result has an `elicitation`, first write its `display` table into your reply text exactly as given (never shorten or reorder it, and never put it inside the question box or a preview), then ask only its short `question` (a yes/no for `confirm`, a form for `form`), then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', + '- Never run `capy org delete-project` or `capy org restore-project` unless the human asked for that project by name. Only an org owner can run them.', + "- `PROJECT_DELETED` means an org owner deleted this folder's project. Stop and tell the human. An org owner can restore it with `capy org restore-project ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`.", '- Never print, log, or commit secret values.', '- To set a value, pipe it from the command that produces it: ` | capy edit NAME --json`. Never put a value in a command argument, an `echo`, or a heredoc — it would land in your context and the shell history.', '', From faf7be8ca205c11d43a637d78c2df75031b3f7a4 Mon Sep 17 00:00:00 2001 From: cvince Date: Sun, 8 Nov 2026 21:52:40 -0700 Subject: [PATCH 08/27] feat(org): org's description names its project commands --- docs/cli-reference.md | 2 +- src/index-dev.ts | 2 +- src/index.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 6a1a0565..6b75f128 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -468,7 +468,7 @@ JSON support: yes (`--json`) ## `capy org` -Switch organization +Switch organization; create, delete or restore its projects ``` capy org [options] diff --git a/src/index-dev.ts b/src/index-dev.ts index 5f2fe78d..4f9f0199 100644 --- a/src/index-dev.ts +++ b/src/index-dev.ts @@ -735,7 +735,7 @@ program const orgCmd = program .command('org') - .description('Switch organization') + .description('Switch organization; create, delete or restore its projects') // COPY-FLAG .option('--non-tty', 'treat stdin as not a terminal; differences from Capy stop the switch with the conflict_id (agents/CI)') // COPY-FLAG .addOption(resolveOption()) .addOption(conflictOption()) diff --git a/src/index.ts b/src/index.ts index 30b1b7a9..9e7b60bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -768,7 +768,7 @@ systemCmd const orgCmd = program .command('org') - .description('Switch organization') + .description('Switch organization; create, delete or restore its projects') // COPY-FLAG .option('--non-tty', 'treat stdin as not a terminal; differences from Capy stop the switch with the conflict_id (agents/CI)') // COPY-FLAG .addOption(resolveOption()) .addOption(conflictOption()) From 8f2b1f19a2d6f591eb084914feaf85cdc64432eb Mon Sep 17 00:00:00 2001 From: cvince Date: Sun, 8 Nov 2026 21:54:54 -0700 Subject: [PATCH 09/27] feat(agents): running delete-project without --yes is how the agent asks --- src/core/agentsBlockPlan.ts | 2 +- tests/core/agentsBlockPlan.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/agentsBlockPlan.ts b/src/core/agentsBlockPlan.ts index 41d63af8..59c73c3b 100644 --- a/src/core/agentsBlockPlan.ts +++ b/src/core/agentsBlockPlan.ts @@ -32,7 +32,7 @@ export const AGENTS_BLOCK = [ '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', // COPY-FLAG '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', // COPY-FLAG '- If a JSON result has an `elicitation`, first write its `display` table into your reply text exactly as given (never shorten or reorder it, and never put it inside the question box or a preview), then ask only its short `question` (a yes/no for `confirm`, a form for `form`), then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', // COPY-FLAG - '- Never run `capy org delete-project` or `capy org restore-project` unless the human asked for that project by name. Only an org owner can run them.', // COPY-FLAG + '- Only when the human asks to delete a project, run `capy org delete-project --json` without `--yes`. It changes nothing and returns an `elicitation`: that is how you ask the human, so do not ask your own question first. Only an org owner can delete or restore a project.', // COPY-FLAG '- `PROJECT_DELETED` means an org owner deleted this folder\'s project. Stop and tell the human. An org owner can restore it with `capy org restore-project ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`.', // COPY-FLAG '- Never print, log, or commit secret values.', '- To set a value, pipe it from the command that produces it: ` | capy edit NAME --json`. Never put a value in a command argument, an `echo`, or a heredoc — it would land in your context and the shell history.', diff --git a/tests/core/agentsBlockPlan.test.ts b/tests/core/agentsBlockPlan.test.ts index c282a46d..1cb90680 100644 --- a/tests/core/agentsBlockPlan.test.ts +++ b/tests/core/agentsBlockPlan.test.ts @@ -40,7 +40,7 @@ describe('AGENTS_BLOCK', () => { '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', '- If a JSON result has an `elicitation`, first write its `display` table into your reply text exactly as given (never shorten or reorder it, and never put it inside the question box or a preview), then ask only its short `question` (a yes/no for `confirm`, a form for `form`), then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', - '- Never run `capy org delete-project` or `capy org restore-project` unless the human asked for that project by name. Only an org owner can run them.', + '- Only when the human asks to delete a project, run `capy org delete-project --json` without `--yes`. It changes nothing and returns an `elicitation`: that is how you ask the human, so do not ask your own question first. Only an org owner can delete or restore a project.', "- `PROJECT_DELETED` means an org owner deleted this folder's project. Stop and tell the human. An org owner can restore it with `capy org restore-project ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`.", '- Never print, log, or commit secret values.', '- To set a value, pipe it from the command that produces it: ` | capy edit NAME --json`. Never put a value in a command argument, an `echo`, or a heredoc — it would land in your context and the shell history.', From 9a3ecf0eb3f21b9c871ae1b2b7d8efbb2a16cd0d Mon Sep 17 00:00:00 2001 From: cvince Date: Sun, 8 Nov 2026 22:05:36 -0700 Subject: [PATCH 10/27] docs(agents): end the reply with the elicitation table and question; the human answers in chat --- docs/cli-reference.md | 2 +- src/core/agentsBlockPlan.ts | 2 +- src/ui/elicitation.ts | 2 +- tests/core/agentsBlockPlan.test.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 6b75f128..2687961c 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1161,4 +1161,4 @@ Every refusal carries a stable `code` — branch on it, never on message text. - **JSON**: Pass --json on any command whose supportsJson is true and parse stdout as JSON; prose (progress, prompts, errors) goes to stderr so stdout stays pure JSON. - **Codes**: Branch only on the machine-readable `code` field (from --json output or a CapyError), never on human-readable message text — messages may be reworded without notice. - **Conflicts**: Every command that writes from a folder (edit, add, remove, connect, rotate, push, checkout, org, deploy, deploy revoke, deploy targets-remove) runs bare `capy` first; `checkout -b` is the one exception (a new branch carries the uncommitted values of the folder). Off a terminal, if the folder and Capy differ on any variable, nothing is written, the exit code is 3, and stdout is {ok:false, code:"SECRETS_CONFLICT", conflict_id, conflicts:[{name, project, branch, type, pinned, local, remote}]} (fingerprints only, never values; a Dokploy value has {name, target, pinned, local, remote}). Ask the person, then rerun the same command with --resolve NAME=pinned|local|remote|delete for every row (NAME@TARGET=local|remote for a Dokploy value) and --conflict . A state that moved since is SECRETS_CONFLICT_STALE with the fresh rows. There is no resolve-all. -- **Elicitation**: A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. First write `display` (a markdown table) into your reply text exactly as given, without shortening or reordering it and never inside the question box or a preview, then ask only the short `question`. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, default?}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` with no name in agent mode refuses with PROJECT_NAME_REQUIRED and a form (details: {org:{id,name}}). Never answer it yourself and never add --yes on your own. +- **Elicitation**: A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. End your reply with `display` (a markdown table) exactly as given, without shortening or reordering it, then the short `question`, and stop. Never use a question box or a preview for it; the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, default?}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` with no name in agent mode refuses with PROJECT_NAME_REQUIRED and a form (details: {org:{id,name}}). Never answer it yourself and never add --yes on your own. diff --git a/src/core/agentsBlockPlan.ts b/src/core/agentsBlockPlan.ts index 59c73c3b..398e52a3 100644 --- a/src/core/agentsBlockPlan.ts +++ b/src/core/agentsBlockPlan.ts @@ -31,7 +31,7 @@ export const AGENTS_BLOCK = [ '- Commands that write run bare `capy` first. If your folder and Capy differ they write nothing and exit 3 with `SECRETS_CONFLICT`, a `conflict_id`, and the differing names (fingerprints, never values).', // COPY-FLAG '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', // COPY-FLAG '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', // COPY-FLAG - '- If a JSON result has an `elicitation`, first write its `display` table into your reply text exactly as given (never shorten or reorder it, and never put it inside the question box or a preview), then ask only its short `question` (a yes/no for `confirm`, a form for `form`), then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', // COPY-FLAG + '- If a JSON result has an `elicitation`, end your reply with its `display` table exactly as given (never shorten or reorder it), then its short `question`, and stop. Never use a question box or a preview for it. Wait for the human to answer in chat, then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', // COPY-FLAG '- Only when the human asks to delete a project, run `capy org delete-project --json` without `--yes`. It changes nothing and returns an `elicitation`: that is how you ask the human, so do not ask your own question first. Only an org owner can delete or restore a project.', // COPY-FLAG '- `PROJECT_DELETED` means an org owner deleted this folder\'s project. Stop and tell the human. An org owner can restore it with `capy org restore-project ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`.', // COPY-FLAG '- Never print, log, or commit secret values.', diff --git a/src/ui/elicitation.ts b/src/ui/elicitation.ts index a67a478c..cc69995b 100644 --- a/src/ui/elicitation.ts +++ b/src/ui/elicitation.ts @@ -44,4 +44,4 @@ export type Elicitation = ElicitationConfirm | ElicitationForm; /** What `capy help --json` says about elicitations (`conventions.elicitation`). */ export const ELICITATION_HELP = // COPY-FLAG: minimal and neutral. - 'A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. First write `display` (a markdown table) into your reply text exactly as given, without shortening or reordering it and never inside the question box or a preview, then ask only the short `question`. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, default?}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` with no name in agent mode refuses with PROJECT_NAME_REQUIRED and a form (details: {org:{id,name}}). Never answer it yourself and never add --yes on your own.'; + 'A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. End your reply with `display` (a markdown table) exactly as given, without shortening or reordering it, then the short `question`, and stop. Never use a question box or a preview for it; the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, default?}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` with no name in agent mode refuses with PROJECT_NAME_REQUIRED and a form (details: {org:{id,name}}). Never answer it yourself and never add --yes on your own.'; diff --git a/tests/core/agentsBlockPlan.test.ts b/tests/core/agentsBlockPlan.test.ts index 1cb90680..d5c8630d 100644 --- a/tests/core/agentsBlockPlan.test.ts +++ b/tests/core/agentsBlockPlan.test.ts @@ -39,7 +39,7 @@ describe('AGENTS_BLOCK', () => { '- Commands that write run bare `capy` first. If your folder and Capy differ they write nothing and exit 3 with `SECRETS_CONFLICT`, a `conflict_id`, and the differing names (fingerprints, never values).', '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', - '- If a JSON result has an `elicitation`, first write its `display` table into your reply text exactly as given (never shorten or reorder it, and never put it inside the question box or a preview), then ask only its short `question` (a yes/no for `confirm`, a form for `form`), then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', + '- If a JSON result has an `elicitation`, end your reply with its `display` table exactly as given (never shorten or reorder it), then its short `question`, and stop. Never use a question box or a preview for it. Wait for the human to answer in chat, then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', '- Only when the human asks to delete a project, run `capy org delete-project --json` without `--yes`. It changes nothing and returns an `elicitation`: that is how you ask the human, so do not ask your own question first. Only an org owner can delete or restore a project.', "- `PROJECT_DELETED` means an org owner deleted this folder's project. Stop and tell the human. An org owner can restore it with `capy org restore-project ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`.", '- Never print, log, or commit secret values.', From f53ffc3046134cc9a699fb06e5a29e2967af9aaf Mon Sep 17 00:00:00 2001 From: cvince Date: Sun, 8 Nov 2026 22:27:03 -0700 Subject: [PATCH 11/27] feat(info): capy info shows whether the folder's project is live or deleted --- src/commands/infoCommand.ts | 79 +++++++++++++++++-------- src/core/agentsBlockPlan.ts | 2 +- tests/commands/infoProjectState.test.ts | 26 ++++++++ tests/core/agentsBlockPlan.test.ts | 2 +- 4 files changed, 82 insertions(+), 27 deletions(-) create mode 100644 tests/commands/infoProjectState.test.ts diff --git a/src/commands/infoCommand.ts b/src/commands/infoCommand.ts index e1ced32e..96979373 100644 --- a/src/commands/infoCommand.ts +++ b/src/commands/infoCommand.ts @@ -1,6 +1,8 @@ import { AuthService } from '../auth/authService'; import { ProjectManager } from '../core/projectManager'; import { ServiceClient } from '../service/serviceClient'; +import { isProjectDeletedError } from '../errors/projectDeleted'; +import type { AuthResult } from '../types/index'; const DIM = '\x1b[90m'; const RESET = '\x1b[0m'; @@ -15,6 +17,50 @@ const ROLE_LABELS: Record = { member: 'Member', }; +/** Whether the folder's project is live, deleted (the service answers PROJECT_DELETED), or could not be checked. */ +export type ProjectStateLabel = 'live' | 'deleted' | 'unknown'; + +interface ProjectStateClient { + listBranches(projectId: string): Promise; +} + +/** Asks the service about the project: a branch list answers live, the PROJECT_DELETED code answers deleted. */ +export async function projectStateFor(client: ProjectStateClient, projectId: string): Promise { + try { + await client.listBranches(projectId); + return 'live'; + } catch (err) { + return isProjectDeletedError(err) ? 'deleted' : 'unknown'; + } +} + +/** Silent sign-in for the folder's org, then any org; only a folder with a keep.lock may fall back to a browser sign-in. */ +async function authenticateForInfo(authService: AuthService, organizationId: string | undefined, hasKeep: boolean): Promise { + const scoped = await authService.authenticateSilent(organizationId); + if (scoped.success) return scoped; + const any = await authService.authenticateSilent(); + if (any.success || !hasKeep) return any; + return authService.authenticate(organizationId!); +} + +/** The user's role in the org, best effort: `{ slug: null, label: '—' }` when it cannot be read. */ +async function roleIn( + serviceClient: ServiceClient, + orgId: string | undefined, + userId: string | undefined, + hasToken: boolean, +): Promise<{ readonly slug: string | null; readonly label: string }> { + const none = { slug: null, label: '—' } as const; + if (!orgId || !hasToken || !userId) return none; + try { + const { members } = await serviceClient.listMembers(orgId); + const slug: unknown = members.find((m: any) => m.userId === userId)?.role?.slug; + return typeof slug === 'string' && slug !== '' ? { slug, label: ROLE_LABELS[slug] || slug } : none; + } catch { + return none; + } +} + export class InfoCommand { private apiUrl?: string; private devMode: boolean; @@ -34,11 +80,7 @@ export class InfoCommand { // belong to, etc. Fall through to a session-only view when there's no // keep.lock instead of forcing the user to init a project first. const authService = new AuthService(this.apiUrl, this.devMode, projectState.userId); - let authResult = await authService.authenticateSilent(projectState.organizationId); - if (!authResult.success) authResult = await authService.authenticateSilent(); - if (!authResult.success && hasKeep) { - authResult = await authService.authenticate(projectState.organizationId!); - } + const authResult = await authenticateForInfo(authService, projectState.organizationId, hasKeep); if (!authResult.success) { console.error(`Not signed in. Run ${B('capy')} to authenticate.`); process.exit(1); @@ -56,24 +98,11 @@ export class InfoCommand { const workosOrgId = activeOrg?.workos_org_id; const branch = projectState.activeBranch; - // Resolve the user's role in the active org (best effort). - let roleLabel = '—'; - let roleSlug: string | null = null; - if (activeOrgId && authService.getToken() && authResult.user_id) { - try { - const serviceClient = new ServiceClient(this.apiUrl, this.devMode); - serviceClient.setTokenProvider(() => authService.getValidToken()); - const { members } = await serviceClient.listMembers(activeOrgId); - const me = members.find((m: any) => m.userId === authResult.user_id); - const slug = me?.role?.slug; - if (slug) { - roleLabel = ROLE_LABELS[slug] || slug; - roleSlug = slug; - } - } catch { - // leave as — - } - } + const serviceClient = new ServiceClient(this.apiUrl, this.devMode); + serviceClient.setTokenProvider(() => authService.getValidToken()); + const { slug: roleSlug, label: roleLabel } = await roleIn(serviceClient, activeOrgId, authResult.user_id, !!authService.getToken()); + const projectId = projectState.projectId; + const projectStatus: ProjectStateLabel | null = projectId ? await projectStateFor(serviceClient, projectId) : null; if (opts.json) { console.log( @@ -82,7 +111,7 @@ export class InfoCommand { user: { email: authResult.user_email ?? null, userId: authResult.user_id ?? null }, org: { id: activeOrgId ?? null, name: activeOrgName ?? null, workosOrgId: workosOrgId ?? null, fromKeepLock: hasKeep }, role: roleSlug, - project: { name: projectState.projectName ?? null, id: projectState.projectId ?? null }, + project: { name: projectState.projectName ?? null, id: projectState.projectId ?? null, state: projectStatus }, branch: hasKeep ? branch : null, memberships: allOrgs.map((o) => ({ id: o.id, name: o.name ?? null, workosOrgId: o.workos_org_id ?? null })), }, @@ -106,7 +135,7 @@ export class InfoCommand { ['Org ID', activeOrgId || '—'], ['WorkOS Org ID', workosOrgId || '—'], ['Role', roleLabel], - ['Project', projectState.projectName || '—'], + ['Project', projectStatus === 'deleted' ? `${projectState.projectName || '—'} ${YELLOW}(deleted)${RESET}` : projectState.projectName || '—'], // COPY-FLAG ['Project ID', projectState.projectId || '—'], ['Branch', (hasKeep && branch) || '—'], ]; diff --git a/src/core/agentsBlockPlan.ts b/src/core/agentsBlockPlan.ts index 398e52a3..5cd1faa3 100644 --- a/src/core/agentsBlockPlan.ts +++ b/src/core/agentsBlockPlan.ts @@ -33,7 +33,7 @@ export const AGENTS_BLOCK = [ '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', // COPY-FLAG '- If a JSON result has an `elicitation`, end your reply with its `display` table exactly as given (never shorten or reorder it), then its short `question`, and stop. Never use a question box or a preview for it. Wait for the human to answer in chat, then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', // COPY-FLAG '- Only when the human asks to delete a project, run `capy org delete-project --json` without `--yes`. It changes nothing and returns an `elicitation`: that is how you ask the human, so do not ask your own question first. Only an org owner can delete or restore a project.', // COPY-FLAG - '- `PROJECT_DELETED` means an org owner deleted this folder\'s project. Stop and tell the human. An org owner can restore it with `capy org restore-project ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`.', // COPY-FLAG + '- `PROJECT_DELETED`, or `project.state` `"deleted"` in `capy info --json`, means an org owner deleted this folder\'s project. Stop and tell the human. An org owner can restore it with `capy org restore-project ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`.', // COPY-FLAG '- Never print, log, or commit secret values.', '- To set a value, pipe it from the command that produces it: ` | capy edit NAME --json`. Never put a value in a command argument, an `echo`, or a heredoc — it would land in your context and the shell history.', AGENTS_BLOCK_END, diff --git a/tests/commands/infoProjectState.test.ts b/tests/commands/infoProjectState.test.ts new file mode 100644 index 00000000..3dccf033 --- /dev/null +++ b/tests/commands/infoProjectState.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'bun:test'; +import { projectStateFor } from '../../src/commands/infoCommand'; +import { CapyError, ERROR_CODES } from '../../src/types/index'; + +const client = (fail?: unknown) => ({ + listBranches: async () => { + if (fail !== undefined) throw fail; + return [{ name: 'main' }]; + }, +}); + +describe('capy info: project state', () => { + it('a branch list means the project is live', async () => { + expect(await projectStateFor(client(), 'proj_1')).toBe('live'); + }); + + it('the PROJECT_DELETED code means it was deleted', async () => { + const err = new CapyError('gone', ERROR_CODES.PROJECT_DELETED); + expect(await projectStateFor(client(err), 'proj_1')).toBe('deleted'); + }); + + it('any other failure is unknown, never live or deleted', async () => { + expect(await projectStateFor(client(new CapyError('down', ERROR_CODES.SERVICE_ERROR)), 'proj_1')).toBe('unknown'); + expect(await projectStateFor(client(new Error('network')), 'proj_1')).toBe('unknown'); + }); +}); diff --git a/tests/core/agentsBlockPlan.test.ts b/tests/core/agentsBlockPlan.test.ts index d5c8630d..aefd6e01 100644 --- a/tests/core/agentsBlockPlan.test.ts +++ b/tests/core/agentsBlockPlan.test.ts @@ -41,7 +41,7 @@ describe('AGENTS_BLOCK', () => { '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', '- If a JSON result has an `elicitation`, end your reply with its `display` table exactly as given (never shorten or reorder it), then its short `question`, and stop. Never use a question box or a preview for it. Wait for the human to answer in chat, then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', '- Only when the human asks to delete a project, run `capy org delete-project --json` without `--yes`. It changes nothing and returns an `elicitation`: that is how you ask the human, so do not ask your own question first. Only an org owner can delete or restore a project.', - "- `PROJECT_DELETED` means an org owner deleted this folder's project. Stop and tell the human. An org owner can restore it with `capy org restore-project ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`.", + "- `PROJECT_DELETED`, or `project.state` `\"deleted\"` in `capy info --json`, means an org owner deleted this folder's project. Stop and tell the human. An org owner can restore it with `capy org restore-project ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`.", '- Never print, log, or commit secret values.', '- To set a value, pipe it from the command that produces it: ` | capy edit NAME --json`. Never put a value in a command argument, an `echo`, or a heredoc — it would land in your context and the shell history.', '', From b2add29602c4781d88f53a4d904571e1fff3bdb1 Mon Sep 17 00:00:00 2001 From: cvince Date: Mon, 9 Nov 2026 00:20:38 -0700 Subject: [PATCH 12/27] fix(org): create-project refuses a name a live project already has; tests for create-project --- src/commands/projectCreateCommand.ts | 37 +++- tests/commands/projectCreateCommand.test.ts | 211 ++++++++++++++++++++ tests/run-tests.sh | 1 + 3 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 tests/commands/projectCreateCommand.test.ts diff --git a/src/commands/projectCreateCommand.ts b/src/commands/projectCreateCommand.ts index 824808ab..269f65fc 100644 --- a/src/commands/projectCreateCommand.ts +++ b/src/commands/projectCreateCommand.ts @@ -54,6 +54,7 @@ interface OrgRef { type CreateOutcome = | { readonly kind: 'needs-name'; readonly elicitation: ElicitationForm; readonly org: OrgRef } + | { readonly kind: 'exists'; readonly existing: { readonly id: string; readonly name: string }; readonly org: OrgRef } | { readonly kind: 'dry-run'; readonly name: string; readonly org: OrgRef } | { readonly kind: 'created'; readonly project: { readonly id: string; readonly name: string }; readonly org: OrgRef }; @@ -88,6 +89,27 @@ async function chooseName(typed: string | undefined, agentMode: boolean): Promis return new PromptEngine().promptForProjectName(new ProjectManager().getDefaultProjectName()); } +/** The client calls `createFlow` makes: resolving the org hands it over already signed in. */ +interface CreateClient { + listProjects(): Promise>; + initializeProject(name: string, orgId: string): Promise<{ readonly project_id: string; readonly project_name: string }>; +} + +/** + * A live project in `orgId` that already has this name, compared trimmed and ignoring case. The service itself + * allows two projects with one name, so this check is what stops a second one (an agent retrying, a typo in case). + * Deleted projects are not listed, so a deleted project's name can be used again. + */ +export function existingProjectNamed( + projects: ReadonlyArray<{ readonly id: string; readonly name: string; readonly organization_id: string }>, + orgId: string, + name: string, +): { readonly id: string; readonly name: string } | undefined { + const wanted = name.trim().toLowerCase(); + const match = projects.find((p) => p.organization_id === orgId && p.name.trim().toLowerCase() === wanted); + return match === undefined ? undefined : { id: match.id, name: match.name }; +} + async function createFlow(typed: string | undefined, opts: CreateProjectOpts, agentMode: boolean): Promise { // Agent mode never opens a prompt, not even to pick an organization. const { orgId, orgName, serviceClient } = await resolveOrgContext(opts.apiUrl, opts.devMode === true, { promptForOrg: !agentMode }); @@ -95,9 +117,13 @@ async function createFlow(typed: string | undefined, opts: CreateProjectOpts, ag const name = await chooseName(typed, agentMode); if (name === undefined) return { kind: 'needs-name', elicitation: nameForm(org), org }; + + const client: CreateClient = serviceClient; + const existing = existingProjectNamed(await client.listProjects(), orgId, name); + if (existing !== undefined) return { kind: 'exists', existing, org }; if (opts.dryRun === true) return { kind: 'dry-run', name, org }; - const created = await serviceClient.initializeProject(name, orgId); + const created = await client.initializeProject(name, orgId); return { kind: 'created', project: { id: created.project_id, name: created.project_name }, org }; } @@ -123,6 +149,15 @@ function emitCreateOutcome(json: boolean, outcome: CreateOutcome): void { lines: [` Organization: ${outcome.org.name}`], // COPY-FLAG }); } + if (outcome.kind === 'exists') { + return refuse(json, { + code: ERROR_CODES.PROJECT_NAME_EXISTS, + message: 'This organization already has a project with that name.', // COPY-FLAG + exitCode: 1, + extra: { project: outcome.existing, org: outcome.org }, + lines: [` ${outcome.existing.id} ${outcome.existing.name}`], + }); + } if (outcome.kind === 'dry-run') { if (json) { console.log(JSON.stringify({ ok: true, dry_run: true, project: { name: outcome.name }, org: outcome.org }, null, 2)); diff --git a/tests/commands/projectCreateCommand.test.ts b/tests/commands/projectCreateCommand.test.ts new file mode 100644 index 00000000..c8cc639c --- /dev/null +++ b/tests/commands/projectCreateCommand.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, spyOn, mock, afterAll, beforeEach, afterEach } from 'bun:test'; + +/** + * `capy org create-project` (CAP-748). + * + * The REAL `ServiceClient` talks HTTP to a local fake of the service, so "creates nothing" is asserted as + * "no POST /projects ever arrived". Only org resolution (auth) and the inquirer prompt are replaced. + * `mock.module()` is process-wide: tests/run-tests.sh runs this file isolated. + */ + +import { ServiceClient } from '../../src/service/serviceClient'; + +type Reply = { readonly status: number; readonly body: unknown }; +const reply = (status: number, body: unknown): Reply => ({ status, body }); + +const respond = mock((_method: string, _path: string, _body: string): Reply => reply(500, { error: 'unexpected request' })); + +const server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url); + const answer = respond(req.method, `${url.pathname}${url.search}`, await req.text()); + return new Response(JSON.stringify(answer.body), { status: answer.status, headers: { 'Content-Type': 'application/json' } }); + }, +}); + +const client = new ServiceClient(`http://localhost:${server.port}`); +client.setTokenProvider((async () => ({ access_token: 'test-token' })) as never); + +mock.module('../../src/core/orgContext', () => ({ + resolveOrgContext: mock(async () => ({ + orgId: 'org_1', + orgName: 'Acme', + userId: 'user_1', + userEmail: 'owner@example.com', + authService: {}, + serviceClient: client, + })), +})); + +const fakePrompt = mock(async (_questions: unknown) => ({ projectName: 'typed-in-terminal' })); +mock.module('inquirer', () => ({ + default: { prompt: fakePrompt, createPromptModule: mock(() => fakePrompt) }, +})); + +afterAll(() => { + server.stop(true); + mock.restore(); +}); + +class ExitSignal extends Error { + constructor(public readonly code: number | undefined) { + super('exit'); + } +} + +interface Captured { + readonly exitCode?: number; + readonly stdout: string; + readonly stderr: string; +} + +async function capture(fn: () => Promise): Promise { + const exitSpy = spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new ExitSignal(code); + }) as never); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + const errSpy = spyOn(console, 'error').mockImplementation(() => {}); + + const thrown: unknown = await fn().then(() => null, (err: unknown) => err); + + const stdout = logSpy.mock.calls.map((args) => args.map(String).join(' ')).join('\n'); + const stderr = errSpy.mock.calls.map((args) => args.map(String).join(' ')).join('\n'); + const exitCalls = exitSpy.mock.calls; + const exitCode = exitCalls.length > 0 ? (exitCalls[exitCalls.length - 1][0] as number | undefined) : undefined; + + exitSpy.mockRestore(); + logSpy.mockRestore(); + errSpy.mockRestore(); + + if (thrown !== null && !(thrown instanceof ExitSignal)) throw thrown; + return { exitCode, stdout, stderr }; +} + +const setTTY = (value: boolean) => Object.defineProperty(process.stdin, 'isTTY', { value, configurable: true }); + +const requests = () => respond.mock.calls.map(([method, path, body]) => ({ method, path, body })); +const creates = () => requests().filter((r) => r.method === 'POST' && r.path === '/projects'); + +const LIVE = [ + { id: 'proj_web', name: 'web', organization_id: 'org_1' }, + { id: 'proj_other_org', name: 'api', organization_id: 'org_2' }, +]; + +/** A service that lists `projects` and answers a create with `onCreate`. */ +function service(opts: { projects?: readonly object[]; onCreate?: (body: string) => Reply }) { + const { projects = LIVE, onCreate = (body: string) => { + const { name } = JSON.parse(body) as { name: string }; + return reply(201, { id: 'proj_new', name, organization_id: 'org_1', s3_prefix: 'org_1/proj_new' }); + } } = opts; + respond.mockImplementation((method, path, body) => { + if (method === 'GET' && path.startsWith('/projects')) return reply(200, { projects }); + if (method === 'POST' && path === '/projects') return onCreate(body); + return reply(500, { error: 'unexpected request' }); + }); +} + +const { createProjectCommand, existingProjectNamed } = await import('../../src/commands/projectCreateCommand'); + +describe('capy org create-project', () => { + beforeEach(() => { + respond.mockReset(); + fakePrompt.mockClear(); + setTTY(false); + }); + afterEach(() => setTTY(false)); + + it('with no name in agent mode: PROJECT_NAME_REQUIRED (exit 3) and a form elicitation, creating nothing', async () => { + service({}); + const out = await capture(() => createProjectCommand(undefined, { json: true })); + + expect(out.exitCode).toBe(3); + const body = JSON.parse(out.stdout); + expect(body).toMatchObject({ ok: false, code: 'PROJECT_NAME_REQUIRED', elicitation: { kind: 'form' } }); + expect(body.elicitation.fields[0]).toMatchObject({ name: 'name', type: 'string', pattern: '^[a-zA-Z0-9-_]+$' }); + expect(body.elicitation.details).toEqual({ org: { id: 'org_1', name: 'Acme' } }); + expect(body.elicitation.display).toContain('| Org | Acme |'); + expect(creates()).toEqual([]); + expect(fakePrompt).not.toHaveBeenCalled(); + }); + + it('creates the project with the name given', async () => { + service({}); + const out = await capture(() => createProjectCommand('new-app', { json: true })); + + expect(out.exitCode).toBeUndefined(); + expect(JSON.parse(out.stdout)).toEqual({ ok: true, project: { id: 'proj_new', name: 'new-app' }, org: { id: 'org_1', name: 'Acme' } }); + expect(creates().map((r) => JSON.parse(r.body))).toEqual([{ name: 'new-app', organization_id: 'org_1' }]); + }); + + it('a name a live project in the org already has: PROJECT_NAME_EXISTS, creating nothing (ignores case and spaces)', async () => { + service({}); + const out = await capture(() => createProjectCommand('WEB', { json: true })); + + expect(out.exitCode).toBe(1); + expect(JSON.parse(out.stdout)).toMatchObject({ ok: false, code: 'PROJECT_NAME_EXISTS', project: { id: 'proj_web', name: 'web' } }); + expect(creates()).toEqual([]); + }); + + it('a name used only in another org is not a clash', async () => { + service({}); + const out = await capture(() => createProjectCommand('api', { json: true })); + + expect(out.exitCode).toBeUndefined(); + expect(creates()).toHaveLength(1); + }); + + it('a name that breaks the rule: PROJECT_NAME_INVALID before any request', async () => { + service({}); + const out = await capture(() => createProjectCommand('has space', { json: true })); + + expect(out.exitCode).toBe(1); + expect(JSON.parse(out.stdout)).toMatchObject({ ok: false, code: 'PROJECT_NAME_INVALID', pattern: '^[a-zA-Z0-9-_]+$' }); + expect(requests()).toEqual([]); + }); + + it('the reserved system store name: PROJECT_NAME_RESERVED before any request', async () => { + service({}); + const out = await capture(() => createProjectCommand('_System', { json: true })); + + expect(out.exitCode).toBe(1); + expect(JSON.parse(out.stdout).code).toBe('PROJECT_NAME_RESERVED'); + expect(requests()).toEqual([]); + }); + + it('--dry-run checks the name (including clashes) and creates nothing', async () => { + service({}); + const ok = await capture(() => createProjectCommand('new-app', { json: true, dryRun: true })); + expect(JSON.parse(ok.stdout)).toMatchObject({ ok: true, dry_run: true, project: { name: 'new-app' } }); + + const clash = await capture(() => createProjectCommand('web', { json: true, dryRun: true })); + expect(JSON.parse(clash.stdout).code).toBe('PROJECT_NAME_EXISTS'); + expect(creates()).toEqual([]); + }); + + it('the free-tier limit keeps its code', async () => { + service({ onCreate: () => reply(402, { error: 'limit', code: 'QUOTA_EXCEEDED', kind: 'project', limit: 3 }) }); + const out = await capture(() => createProjectCommand('new-app', { json: true })); + + expect(out.exitCode).toBe(1); + expect(JSON.parse(out.stdout).code).toBe('QUOTA_EXCEEDED'); + }); + + it('in a terminal with no name: asks with the init prompt, then creates', async () => { + setTTY(true); + service({}); + const out = await capture(() => createProjectCommand(undefined, {})); + + expect(fakePrompt).toHaveBeenCalledTimes(1); + expect(out.exitCode).toBeUndefined(); + expect(creates().map((r) => JSON.parse(r.body).name)).toEqual(['typed-in-terminal']); + }); +}); + +describe('existingProjectNamed', () => { + it('matches only in the same org, trimmed and ignoring case', () => { + expect(existingProjectNamed(LIVE, 'org_1', ' Web ')).toEqual({ id: 'proj_web', name: 'web' }); + expect(existingProjectNamed(LIVE, 'org_1', 'api')).toBeUndefined(); + expect(existingProjectNamed(LIVE, 'org_1', 'webs')).toBeUndefined(); + }); +}); diff --git a/tests/run-tests.sh b/tests/run-tests.sh index 19d7367e..052e16ca 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -77,6 +77,7 @@ ISOLATED_FILES=( tests/commands/projectsCommand.test.ts tests/commands/projectsDeleted.test.ts tests/commands/projectLifecycleCommand.test.ts + tests/commands/projectCreateCommand.test.ts tests/commands/secretsCommand.test.ts tests/commands/runCommandSystemStore.test.ts tests/commands/connectDiscoveryRouting.test.ts From bf1e4b5032245c59e1682532d66b7e5097bae017 Mon Sep 17 00:00:00 2001 From: cvince Date: Mon, 9 Nov 2026 13:01:42 -0700 Subject: [PATCH 13/27] feat(org): create-project asks for and creates the first branch, saying what a branch is Agent mode: no name -> PROJECT_NAME_REQUIRED, name but no --branch -> BRANCH_NAME_REQUIRED; both exit 3 with one form asking for name and branch (branch default development, with a description). The branch name is checked against the service's rule first (BRANCH_NAME_INVALID). A project made without its branch is reported as BRANCH_CREATE_FAILED with the project. --- src/commands/projectCreateCommand.ts | 132 +++++++++++++++----- src/core/branchNameRule.ts | 45 +++++++ src/index-dev.ts | 5 +- src/index.ts | 5 +- src/types/index.ts | 6 + src/ui/elicitation.ts | 4 +- src/ui/elicitationDisplay.ts | 24 +++- src/ui/promptEngine.ts | 26 ++++ tests/commands/projectCreateCommand.test.ts | 127 +++++++++++++++++-- tests/core/branchNameRule.test.ts | 20 +++ tests/ui/elicitationDisplay.test.ts | 15 ++- 11 files changed, 353 insertions(+), 56 deletions(-) create mode 100644 src/core/branchNameRule.ts create mode 100644 tests/core/branchNameRule.test.ts diff --git a/src/commands/projectCreateCommand.ts b/src/commands/projectCreateCommand.ts index 269f65fc..b6dfbde0 100644 --- a/src/commands/projectCreateCommand.ts +++ b/src/commands/projectCreateCommand.ts @@ -2,12 +2,13 @@ * `capy org create-project [name]` (CAP-748). * * Creates a project in the ACTIVE organization with the same service call the init flow uses - * (`ServiceClient.initializeProject`), and nothing else: it does not link the current folder, write a keep.lock, - * make a branch or touch a key. Bare `capy` still does those. + * (`ServiceClient.initializeProject`), then its first branch (`createBranch`), and nothing else: it does not link the + * current folder, write a keep.lock or touch a key. Bare `capy` still does those. * * Agent mode (`--json`, `--non-tty`, or stdin that is not a terminal) never opens a prompt. With no name it refuses - * with PROJECT_NAME_REQUIRED (exit 3) and an `elicitation` of kind "form" (`ui/elicitation.ts`) that the agent puts - * to the human. In a terminal it asks for the name with the init prompt. + * with PROJECT_NAME_REQUIRED, and with a name but no `--branch` with BRANCH_NAME_REQUIRED (both exit 3), each with + * an `elicitation` of kind "form" (`ui/elicitation.ts`) asking for both, that the agent puts to the human. In a + * terminal it asks for the name with the init prompt and for the branch with the init branch question. * * Every refusal is coded, and every decision keys on a code, an HTTP status or a typed field, never on message * text. The service answers a duplicate name with a bare 409 and no `code`, so the STATUS decides that one. @@ -15,6 +16,7 @@ import { resolveOrgContext } from '../core/orgContext'; import { ProjectManager } from '../core/projectManager'; import { PROJECT_NAME_PATTERN_SOURCE, projectNameProblem } from '../core/projectNameRule'; +import { BRANCH_DESCRIPTION, BRANCH_NAME_PATTERN_SOURCE, DEFAULT_FIRST_BRANCH, branchNameProblem } from '../core/branchNameRule'; import { PromptEngine } from '../ui/promptEngine'; import { isInteractive, EXIT_NEEDS_INPUT } from '../ui/interactive'; import type { ElicitationForm } from '../ui/elicitation'; @@ -28,6 +30,8 @@ export interface CreateProjectOpts { nonTty?: boolean; /** The global `--dry-run`: check the name and the organization, create nothing. */ dryRun?: boolean; + /** `--branch `: the project's first branch. */ + branch?: string; apiUrl?: string; devMode?: boolean; } @@ -44,6 +48,7 @@ export function createProjectOptsFrom(command: CommandWithGlobals): CreateProjec json: merged.json === true, nonTty: merged.nonTty === true, dryRun: merged.dryRun === true, + ...(typeof merged.branch === 'string' ? { branch: merged.branch.trim() } : {}), }; } @@ -52,11 +57,17 @@ interface OrgRef { readonly name: string; } +interface ProjectRef { + readonly id: string; + readonly name: string; +} + type CreateOutcome = - | { readonly kind: 'needs-name'; readonly elicitation: ElicitationForm; readonly org: OrgRef } - | { readonly kind: 'exists'; readonly existing: { readonly id: string; readonly name: string }; readonly org: OrgRef } - | { readonly kind: 'dry-run'; readonly name: string; readonly org: OrgRef } - | { readonly kind: 'created'; readonly project: { readonly id: string; readonly name: string }; readonly org: OrgRef }; + | { readonly kind: 'needs-input'; readonly missing: 'name' | 'branch'; readonly elicitation: ElicitationForm; readonly org: OrgRef } + | { readonly kind: 'exists'; readonly existing: ProjectRef; readonly org: OrgRef } + | { readonly kind: 'dry-run'; readonly name: string; readonly branch: string; readonly org: OrgRef } + | { readonly kind: 'branch-failed'; readonly project: ProjectRef; readonly branch: string; readonly org: OrgRef } + | { readonly kind: 'created'; readonly project: ProjectRef; readonly branch: string; readonly org: OrgRef }; /** The folder's name as a project name, when it is allowed as one. The init prompt offers the same. */ function suggestedName(): string | undefined { @@ -64,21 +75,35 @@ function suggestedName(): string | undefined { return projectNameProblem(folderName) === null ? folderName : undefined; } -/** The form that asks for the name. `answer.rerun` keeps `` as the place the answer goes. */ -function nameForm(org: OrgRef): ElicitationForm { - const suggestion = suggestedName(); +/** + * The form that asks for the name and the first branch. Both fields are always there, filled with what was already + * given, so the human sees and confirms both. `answer.rerun` keeps `` and `` as the places the answers go. + */ +function createForm(org: OrgRef, name: string | undefined, branch: string | undefined): ElicitationForm { + const nameDefault = name ?? suggestedName(); return { kind: 'form', - question: 'What should the project be called?', // COPY-FLAG + question: name === undefined + ? 'What should the project and its first branch be called?' // COPY-FLAG + : 'What should the first branch be called?', // COPY-FLAG details: { org: { id: org.id, name: org.name } }, - display: createProjectDisplay(org), - fields: [{ - name: 'name', - type: 'string', - pattern: PROJECT_NAME_PATTERN_SOURCE, - ...(suggestion === undefined ? {} : { default: suggestion }), - }], - answer: { rerun: 'capy org create-project --json' }, + display: createProjectDisplay({ org, name, branch, branchDescription: BRANCH_DESCRIPTION }), + fields: [ + { + name: 'name', + type: 'string', + pattern: PROJECT_NAME_PATTERN_SOURCE, + ...(nameDefault === undefined ? {} : { default: nameDefault }), + }, + { + name: 'branch', + type: 'string', + pattern: BRANCH_NAME_PATTERN_SOURCE, + default: branch ?? DEFAULT_FIRST_BRANCH, + description: BRANCH_DESCRIPTION, + }, + ], + answer: { rerun: 'capy org create-project --branch --json' }, }; } @@ -89,10 +114,18 @@ async function chooseName(typed: string | undefined, agentMode: boolean): Promis return new PromptEngine().promptForProjectName(new ProjectManager().getDefaultProjectName()); } +/** The first branch: as typed, else (in a terminal) as asked, else none. */ +async function chooseBranch(typed: string | undefined, agentMode: boolean): Promise { + if (typed !== undefined) return typed; + if (agentMode) return undefined; + return new PromptEngine().promptForFirstBranch(); +} + /** The client calls `createFlow` makes: resolving the org hands it over already signed in. */ interface CreateClient { listProjects(): Promise>; initializeProject(name: string, orgId: string): Promise<{ readonly project_id: string; readonly project_name: string }>; + createBranch(projectId: string, name: string, isProtected?: boolean): Promise; } /** @@ -116,15 +149,28 @@ async function createFlow(typed: string | undefined, opts: CreateProjectOpts, ag const org: OrgRef = { id: orgId, name: orgName }; const name = await chooseName(typed, agentMode); - if (name === undefined) return { kind: 'needs-name', elicitation: nameForm(org), org }; + if (name === undefined) { + return { kind: 'needs-input', missing: 'name', elicitation: createForm(org, undefined, opts.branch), org }; + } + // The clash is checked before the branch is asked, so nobody picks a branch for a project that cannot be created. const client: CreateClient = serviceClient; const existing = existingProjectNamed(await client.listProjects(), orgId, name); if (existing !== undefined) return { kind: 'exists', existing, org }; - if (opts.dryRun === true) return { kind: 'dry-run', name, org }; + + const branch = await chooseBranch(opts.branch, agentMode); + if (branch === undefined) { + return { kind: 'needs-input', missing: 'branch', elicitation: createForm(org, name, undefined), org }; + } + if (opts.dryRun === true) return { kind: 'dry-run', name, branch, org }; const created = await client.initializeProject(name, orgId); - return { kind: 'created', project: { id: created.project_id, name: created.project_name }, org }; + const project: ProjectRef = { id: created.project_id, name: created.project_name }; + // The project exists from here on: a failed branch is reported with the project, never as "nothing happened". + const branchMade = await settle(() => client.createBranch(project.id, branch, false)); + return branchMade.ok + ? { kind: 'created', project, branch, org } + : { kind: 'branch-failed', project, branch, org }; } /** The refusal for a failed create: a duplicate name by its HTTP status, everything else as the lifecycle commands refuse it. */ @@ -140,15 +186,25 @@ function createRefusalFor(err: unknown): Refusal { } function emitCreateOutcome(json: boolean, outcome: CreateOutcome): void { - if (outcome.kind === 'needs-name') { + if (outcome.kind === 'needs-input') { return refuse(json, { - code: ERROR_CODES.PROJECT_NAME_REQUIRED, - message: '`capy org create-project` needs a project name. Pass it as an argument, or run it in a terminal.', // COPY-FLAG + code: outcome.missing === 'name' ? ERROR_CODES.PROJECT_NAME_REQUIRED : ERROR_CODES.BRANCH_NAME_REQUIRED, + message: outcome.missing === 'name' + ? '`capy org create-project` needs a project name. Pass it as an argument, or run it in a terminal.' // COPY-FLAG + : '`capy org create-project` needs a first branch. Pass it with --branch, or run it in a terminal.', // COPY-FLAG exitCode: EXIT_NEEDS_INPUT, extra: { elicitation: outcome.elicitation }, lines: [` Organization: ${outcome.org.name}`], // COPY-FLAG }); } + if (outcome.kind === 'branch-failed') { + return refuse(json, { + code: ERROR_CODES.BRANCH_CREATE_FAILED, + message: `Created project ${outcome.project.name}, but not its first branch ${outcome.branch}.`, // COPY-FLAG + exitCode: 1, + extra: { project: outcome.project, branch: { name: outcome.branch }, org: outcome.org }, + }); + } if (outcome.kind === 'exists') { return refuse(json, { code: ERROR_CODES.PROJECT_NAME_EXISTS, @@ -160,17 +216,17 @@ function emitCreateOutcome(json: boolean, outcome: CreateOutcome): void { } if (outcome.kind === 'dry-run') { if (json) { - console.log(JSON.stringify({ ok: true, dry_run: true, project: { name: outcome.name }, org: outcome.org }, null, 2)); + console.log(JSON.stringify({ ok: true, dry_run: true, project: { name: outcome.name }, branch: { name: outcome.branch }, org: outcome.org }, null, 2)); return; } - console.log(`Dry run: would create project ${outcome.name} in ${outcome.org.name}. Nothing was created.`); // COPY-FLAG + console.log(`Dry run: would create project ${outcome.name} with branch ${outcome.branch} in ${outcome.org.name}. Nothing was created.`); // COPY-FLAG return; } if (json) { - console.log(JSON.stringify({ ok: true, project: outcome.project, org: outcome.org }, null, 2)); + console.log(JSON.stringify({ ok: true, project: outcome.project, branch: { name: outcome.branch }, org: outcome.org }, null, 2)); return; } - console.log(`Created project ${outcome.project.name} in ${outcome.org.name}.`); // COPY-FLAG + console.log(`Created project ${outcome.project.name} with branch ${outcome.branch} in ${outcome.org.name}.`); // COPY-FLAG } /** `capy org create-project [name] [--dry-run] [--json]` */ @@ -189,11 +245,21 @@ export async function createProjectCommand(name: string | undefined, opts: Creat }); } - // A dry run asks nothing, and in a terminal the only thing left to do without a name is ask. - if (opts.dryRun === true && name === undefined && !agentMode) { + const branchProblem = opts.branch === undefined ? null : branchNameProblem(opts.branch); + if (branchProblem !== null) { + return refuse(json, { + code: branchProblem.code, + message: branchProblem.message, + exitCode: 1, + extra: { pattern: BRANCH_NAME_PATTERN_SOURCE }, + }); + } + + // A dry run asks nothing, and in a terminal the only thing left to do without a name or branch is ask. + if (opts.dryRun === true && (name === undefined || opts.branch === undefined) && !agentMode) { return refuse(json, { code: ERROR_CODES.DRY_RUN_UNSUPPORTED, - message: '`capy org create-project` would ask for the name, so it cannot preview. Pass the name.', // COPY-FLAG + message: '`capy org create-project` would ask for the name or branch, so it cannot preview. Pass both.', // COPY-FLAG exitCode: 1, }); } diff --git a/src/core/branchNameRule.ts b/src/core/branchNameRule.ts new file mode 100644 index 00000000..8ce47b81 --- /dev/null +++ b/src/core/branchNameRule.ts @@ -0,0 +1,45 @@ +/** + * The rule for a branch name, as the service checks it on `POST /projects/:id/branches` (service + * `routes/projects.ts` `validateBranchName`). The service answers a bad name with a bare 400 and no code, so the CLI + * checks first and refuses with BRANCH_NAME_INVALID before any request. Change both together. + */ +import { ERROR_CODES } from '../types/index'; + +/** Starts with a letter or number; then letters, numbers, `.`, `_` and `-`. */ +export const BRANCH_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; + +/** The same rule as a string, for JSON (`elicitation.fields[].pattern`). */ +export const BRANCH_NAME_PATTERN_SOURCE: string = BRANCH_NAME_PATTERN.source; + +export const BRANCH_NAME_MAX_LENGTH = 128; + +/** The branch a new project starts with unless someone picks another. Bare `capy` and `capy org` offer the same. */ +export const DEFAULT_FIRST_BRANCH = 'development'; + +/** What a Capy branch is, word for word from the docs (using/branches/overview.mdx). */ +export const BRANCH_DESCRIPTION = 'A Capy branch is an independent set of values for the same project.'; + +export interface BranchNameProblem { + readonly code: typeof ERROR_CODES.BRANCH_NAME_INVALID; + /** For a human to read; never branched on. These are the service's own sentences. */ + readonly message: string; +} + +/** Why `input` is not a branch name the service accepts, or null when it is. Same checks, same order as the service. */ +export function branchNameProblem(input: string): BranchNameProblem | null { + const message = branchNameMessage(input); + return message === null ? null : { code: ERROR_CODES.BRANCH_NAME_INVALID, message }; +} + +function branchNameMessage(input: string): string | null { + if (!input || input.length === 0) return 'Branch name cannot be empty'; + if (input.length > BRANCH_NAME_MAX_LENGTH) return `Branch name too long (max ${BRANCH_NAME_MAX_LENGTH})`; + if (input.includes('..')) return 'Branch name cannot contain ".."'; + if (!BRANCH_NAME_PATTERN.test(input)) return 'Branch name must start with a letter or number'; + return null; +} + +/** The shape an inquirer `validate` wants: `true`, or the sentence to show. */ +export function validateBranchNameInput(input: string): string | true { + return branchNameProblem(input.trim())?.message ?? true; +} diff --git a/src/index-dev.ts b/src/index-dev.ts index 4f9f0199..df947c79 100644 --- a/src/index-dev.ts +++ b/src/index-dev.ts @@ -747,8 +747,9 @@ const orgCmd = program orgCmd .command('create-project [name]') - .description('Create a project in the active organization (owners and admins only; does not link this folder)') // COPY-FLAG - .option('--non-tty', 'treat stdin as not a terminal: never ask for the name; with no name, refuse with a form to ask the human') // COPY-FLAG + .description('Create a project and its first branch in the active organization (owners and admins only; does not link this folder)') // COPY-FLAG + .option('--branch ', 'the first branch: an independent set of values for the project (offered by default: development)') // COPY-FLAG + .option('--non-tty', 'treat stdin as not a terminal: never ask for the name or branch; without them, refuse with a form to ask the human') // COPY-FLAG .option('--json', 'emit machine-readable JSON instead of the human UI') .action(async (name: string | undefined, _options: any, command: any) => { const { createProjectCommand, createProjectOptsFrom } = await import('./commands/projectCreateCommand'); diff --git a/src/index.ts b/src/index.ts index 9e7b60bc..07fbb569 100644 --- a/src/index.ts +++ b/src/index.ts @@ -783,8 +783,9 @@ const orgCmd = program orgCmd .command('create-project [name]') - .description('Create a project in the active organization (owners and admins only; does not link this folder)') // COPY-FLAG - .option('--non-tty', 'treat stdin as not a terminal: never ask for the name; with no name, refuse with a form to ask the human') // COPY-FLAG + .description('Create a project and its first branch in the active organization (owners and admins only; does not link this folder)') // COPY-FLAG + .option('--branch ', 'the first branch: an independent set of values for the project (offered by default: development)') // COPY-FLAG + .option('--non-tty', 'treat stdin as not a terminal: never ask for the name or branch; without them, refuse with a form to ask the human') // COPY-FLAG .option('--json', 'emit machine-readable JSON instead of the human UI') .action(async (name: string | undefined, _options: any, command: any) => { assertNotLocalOnly('org create-project'); diff --git a/src/types/index.ts b/src/types/index.ts index e6307b20..156f699c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -907,6 +907,12 @@ export const ERROR_CODES = { PROJECT_NAME_INVALID: 'PROJECT_NAME_INVALID', /** `capy org create-project`: the organization already has a project with that name (the service answered 409). Exit 1. */ PROJECT_NAME_EXISTS: 'PROJECT_NAME_EXISTS', + /** `capy org create-project` with a name but no `--branch`, and nobody to ask (agent mode). The refusal carries an `elicitation` form. Exit 3. */ + BRANCH_NAME_REQUIRED: 'BRANCH_NAME_REQUIRED', + /** `capy org create-project --branch`: a name the service would refuse (see `core/branchNameRule.ts`). Nothing is created. Exit 1. */ + BRANCH_NAME_INVALID: 'BRANCH_NAME_INVALID', + /** `capy org create-project`: the project was created but its first branch was not. The refusal carries the project. Exit 1. */ + BRANCH_CREATE_FAILED: 'BRANCH_CREATE_FAILED', /** A command that acts on "the active organization" has none (no keep.lock here) and the person belongs to several, with nobody to ask. Exit 3. */ ORG_SELECTION_REQUIRED: 'ORG_SELECTION_REQUIRED', /** `GET /orgs/:orgId/repos` answered 404: the service predates project -> repo links. Never a failure of the command that asked. */ diff --git a/src/ui/elicitation.ts b/src/ui/elicitation.ts index cc69995b..66e3125e 100644 --- a/src/ui/elicitation.ts +++ b/src/ui/elicitation.ts @@ -18,6 +18,8 @@ export interface ElicitationField { readonly pattern: string; /** A value the human can accept as is. Absent when there is no sensible one. */ readonly default?: string; + /** What the field is, for the human. Absent when the name says it all. */ + readonly description?: string; } export interface ElicitationConfirm { @@ -44,4 +46,4 @@ export type Elicitation = ElicitationConfirm | ElicitationForm; /** What `capy help --json` says about elicitations (`conventions.elicitation`). */ export const ELICITATION_HELP = // COPY-FLAG: minimal and neutral. - 'A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. End your reply with `display` (a markdown table) exactly as given, without shortening or reordering it, then the short `question`, and stop. Never use a question box or a preview for it; the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, default?}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` with no name in agent mode refuses with PROJECT_NAME_REQUIRED and a form (details: {org:{id,name}}). Never answer it yourself and never add --yes on your own.'; + 'A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. End your reply with `display` (a markdown table) exactly as given, without shortening or reordering it, then the short `question`, and stop. Never use a question box or a preview for it; the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, default?, description?}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` missing the name or `--branch` in agent mode refuses with PROJECT_NAME_REQUIRED or BRANCH_NAME_REQUIRED and a form (fields: name, branch; details: {org:{id,name}}). Never answer it yourself and never add --yes on your own.'; diff --git a/src/ui/elicitationDisplay.ts b/src/ui/elicitationDisplay.ts index 938b2d14..116d2ed5 100644 --- a/src/ui/elicitationDisplay.ts +++ b/src/ui/elicitationDisplay.ts @@ -61,7 +61,25 @@ export function deleteProjectDisplay(input: DeleteDisplayInput): string { return `${summary}\n\n${variablesSection(input)}`; } -/** The table for `capy org create-project` asking for a name: where the project will be created. */ -export function createProjectDisplay(org: { readonly id: string; readonly name: string }): string { - return table(['', ''], [['Org', org.name]]); +export interface CreateDisplayInput { + readonly org: { readonly id: string; readonly name: string }; + /** The name already given, if any. */ + readonly name?: string; + /** The branch already given, if any. */ + readonly branch?: string; + /** What a branch is, shown under the table. */ + readonly branchDescription: string; +} + +// COPY-FLAG: placeholder for a value still to be chosen. +const TO_CHOOSE = 'to choose'; + +/** The table for `capy org create-project` asking for its name and first branch: where it goes, what is chosen so far, and what a branch is. */ +export function createProjectDisplay(input: CreateDisplayInput): string { + const summary = table(['', ''], [ + ['Org', input.org.name], + ['Project', input.name ?? TO_CHOOSE], + ['First branch', input.branch ?? TO_CHOOSE], // COPY-FLAG + ]); + return `${summary}\n\n${input.branchDescription}`; } diff --git a/src/ui/promptEngine.ts b/src/ui/promptEngine.ts index e39e5c45..96e094a8 100644 --- a/src/ui/promptEngine.ts +++ b/src/ui/promptEngine.ts @@ -9,6 +9,7 @@ import { UserDecisions } from '../types/index'; import { validateProjectNameInput } from '../core/projectNameRule'; +import { BRANCH_DESCRIPTION, DEFAULT_FIRST_BRANCH, validateBranchNameInput } from '../core/branchNameRule'; export class PromptEngine { async promptForProjectName(defaultName: string): Promise { @@ -25,6 +26,31 @@ export class PromptEngine { return projectName; } + /** + * Asks for a new project's first branch with the same question bare `capy` asks (capyCommand + * `resolveInitialBranchChoice`), saying first what a branch is. `development` unless the person types another. + */ + async promptForFirstBranch(): Promise { + console.log(BRANCH_DESCRIPTION); + const { initialBranchChoice } = await inquirer.prompt([{ + type: 'list', + name: 'initialBranchChoice', + message: 'What branch should this project start with?', + choices: [ + { name: `${DEFAULT_FIRST_BRANCH} (default)`, value: DEFAULT_FIRST_BRANCH }, + { name: 'another branch', value: 'other' }, + ], + }]); + if (initialBranchChoice !== 'other') return String(initialBranchChoice); + const { branchName } = await inquirer.prompt([{ + type: 'input', + name: 'branchName', + message: 'Branch name:', + validate: validateBranchNameInput, + }]); + return String(branchName).trim(); + } + async promptForChanges(changeSet: ChangeSet, branch?: string): Promise { const decisions: UserDecisions = { pushVariables: [], diff --git a/tests/commands/projectCreateCommand.test.ts b/tests/commands/projectCreateCommand.test.ts index c8cc639c..35da8976 100644 --- a/tests/commands/projectCreateCommand.test.ts +++ b/tests/commands/projectCreateCommand.test.ts @@ -38,7 +38,17 @@ mock.module('../../src/core/orgContext', () => ({ })), })); -const fakePrompt = mock(async (_questions: unknown) => ({ projectName: 'typed-in-terminal' })); +/** What the person "types" in a terminal, keyed by the prompt's `name`. */ +const TERMINAL_ANSWERS: Readonly> = { + projectName: 'typed-in-terminal', + initialBranchChoice: 'development', + branchName: 'typed-branch', +}; +const answerFor = (questions: unknown, answers: Readonly>) => { + const name = (questions as ReadonlyArray<{ readonly name: string }>)[0].name; + return { [name]: answers[name] }; +}; +const fakePrompt = mock(async (questions: unknown) => answerFor(questions, TERMINAL_ANSWERS)); mock.module('inquirer', () => ({ default: { prompt: fakePrompt, createPromptModule: mock(() => fakePrompt) }, })); @@ -86,21 +96,29 @@ const setTTY = (value: boolean) => Object.defineProperty(process.stdin, 'isTTY', const requests = () => respond.mock.calls.map(([method, path, body]) => ({ method, path, body })); const creates = () => requests().filter((r) => r.method === 'POST' && r.path === '/projects'); +const branchCreates = () => requests() + .filter((r) => r.method === 'POST' && r.path === '/projects/proj_new/branches') + .map((r) => JSON.parse(r.body) as { name: string; is_protected: boolean }); +const BRANCH_DESCRIPTION = 'A Capy branch is an independent set of values for the same project.'; const LIVE = [ { id: 'proj_web', name: 'web', organization_id: 'org_1' }, { id: 'proj_other_org', name: 'api', organization_id: 'org_2' }, ]; -/** A service that lists `projects` and answers a create with `onCreate`. */ -function service(opts: { projects?: readonly object[]; onCreate?: (body: string) => Reply }) { +/** A service that lists `projects`, answers a create with `onCreate` and a branch create with `onBranch`. */ +function service(opts: { projects?: readonly object[]; onCreate?: (body: string) => Reply; onBranch?: (body: string) => Reply }) { const { projects = LIVE, onCreate = (body: string) => { const { name } = JSON.parse(body) as { name: string }; return reply(201, { id: 'proj_new', name, organization_id: 'org_1', s3_prefix: 'org_1/proj_new' }); + }, onBranch = (body: string) => { + const { name, is_protected } = JSON.parse(body) as { name: string; is_protected: boolean }; + return reply(201, { id: 'br_new', project_id: 'proj_new', name, is_protected }); } } = opts; respond.mockImplementation((method, path, body) => { if (method === 'GET' && path.startsWith('/projects')) return reply(200, { projects }); if (method === 'POST' && path === '/projects') return onCreate(body); + if (method === 'POST' && path === '/projects/proj_new/branches') return onBranch(body); return reply(500, { error: 'unexpected request' }); }); } @@ -111,11 +129,12 @@ describe('capy org create-project', () => { beforeEach(() => { respond.mockReset(); fakePrompt.mockClear(); + fakePrompt.mockImplementation(async (questions: unknown) => answerFor(questions, TERMINAL_ANSWERS)); setTTY(false); }); afterEach(() => setTTY(false)); - it('with no name in agent mode: PROJECT_NAME_REQUIRED (exit 3) and a form elicitation, creating nothing', async () => { + it('with no name in agent mode: PROJECT_NAME_REQUIRED (exit 3) and a form asking for name and branch, creating nothing', async () => { service({}); const out = await capture(() => createProjectCommand(undefined, { json: true })); @@ -123,19 +142,75 @@ describe('capy org create-project', () => { const body = JSON.parse(out.stdout); expect(body).toMatchObject({ ok: false, code: 'PROJECT_NAME_REQUIRED', elicitation: { kind: 'form' } }); expect(body.elicitation.fields[0]).toMatchObject({ name: 'name', type: 'string', pattern: '^[a-zA-Z0-9-_]+$' }); + expect(body.elicitation.fields[1]).toEqual({ + name: 'branch', + type: 'string', + pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$', + default: 'development', + description: BRANCH_DESCRIPTION, + }); + expect(body.elicitation.answer).toEqual({ rerun: 'capy org create-project --branch --json' }); expect(body.elicitation.details).toEqual({ org: { id: 'org_1', name: 'Acme' } }); expect(body.elicitation.display).toContain('| Org | Acme |'); + expect(body.elicitation.display).toContain(BRANCH_DESCRIPTION); expect(creates()).toEqual([]); expect(fakePrompt).not.toHaveBeenCalled(); }); - it('creates the project with the name given', async () => { + it('with a name but no --branch in agent mode: BRANCH_NAME_REQUIRED (exit 3), the form keeps the name, creating nothing', async () => { service({}); const out = await capture(() => createProjectCommand('new-app', { json: true })); + expect(out.exitCode).toBe(3); + const body = JSON.parse(out.stdout); + expect(body).toMatchObject({ ok: false, code: 'BRANCH_NAME_REQUIRED', elicitation: { kind: 'form' } }); + expect(body.elicitation.fields.map((f: { name: string; default?: string }) => [f.name, f.default])).toEqual([ + ['name', 'new-app'], + ['branch', 'development'], + ]); + expect(body.elicitation.display).toContain('| Project | new-app |'); + expect(creates()).toEqual([]); + expect(fakePrompt).not.toHaveBeenCalled(); + }); + + it('creates the project, then its first branch, unprotected', async () => { + service({}); + const out = await capture(() => createProjectCommand('new-app', { json: true, branch: 'staging' })); + expect(out.exitCode).toBeUndefined(); - expect(JSON.parse(out.stdout)).toEqual({ ok: true, project: { id: 'proj_new', name: 'new-app' }, org: { id: 'org_1', name: 'Acme' } }); + expect(JSON.parse(out.stdout)).toEqual({ + ok: true, + project: { id: 'proj_new', name: 'new-app' }, + branch: { name: 'staging' }, + org: { id: 'org_1', name: 'Acme' }, + }); expect(creates().map((r) => JSON.parse(r.body))).toEqual([{ name: 'new-app', organization_id: 'org_1' }]); + expect(branchCreates()).toEqual([{ name: 'staging', is_protected: false }]); + }); + + it('the project is made but its branch is not: BRANCH_CREATE_FAILED with the project, so nobody thinks nothing happened', async () => { + service({ onBranch: () => reply(500, { error: 'boom' }) }); + const out = await capture(() => createProjectCommand('new-app', { json: true, branch: 'development' })); + + expect(out.exitCode).toBe(1); + expect(JSON.parse(out.stdout)).toMatchObject({ + ok: false, + code: 'BRANCH_CREATE_FAILED', + project: { id: 'proj_new', name: 'new-app' }, + branch: { name: 'development' }, + }); + }); + + it('a branch name the service would refuse: BRANCH_NAME_INVALID before any request', async () => { + service({}); + const bad = ['-dev', 'a..b', 'has space', 'x'.repeat(129)]; + const outs = await bad.reduce>( + async (acc, branch) => [...(await acc), await capture(() => createProjectCommand('new-app', { json: true, branch }))], + Promise.resolve([]), + ); + + expect(outs.map((o) => [o.exitCode, JSON.parse(o.stdout).code])).toEqual(bad.map(() => [1, 'BRANCH_NAME_INVALID'])); + expect(requests()).toEqual([]); }); it('a name a live project in the org already has: PROJECT_NAME_EXISTS, creating nothing (ignores case and spaces)', async () => { @@ -149,7 +224,7 @@ describe('capy org create-project', () => { it('a name used only in another org is not a clash', async () => { service({}); - const out = await capture(() => createProjectCommand('api', { json: true })); + const out = await capture(() => createProjectCommand('api', { json: true, branch: 'development' })); expect(out.exitCode).toBeUndefined(); expect(creates()).toHaveLength(1); @@ -175,30 +250,56 @@ describe('capy org create-project', () => { it('--dry-run checks the name (including clashes) and creates nothing', async () => { service({}); - const ok = await capture(() => createProjectCommand('new-app', { json: true, dryRun: true })); - expect(JSON.parse(ok.stdout)).toMatchObject({ ok: true, dry_run: true, project: { name: 'new-app' } }); + const ok = await capture(() => createProjectCommand('new-app', { json: true, dryRun: true, branch: 'development' })); + expect(JSON.parse(ok.stdout)).toMatchObject({ ok: true, dry_run: true, project: { name: 'new-app' }, branch: { name: 'development' } }); - const clash = await capture(() => createProjectCommand('web', { json: true, dryRun: true })); + const clash = await capture(() => createProjectCommand('web', { json: true, dryRun: true, branch: 'development' })); expect(JSON.parse(clash.stdout).code).toBe('PROJECT_NAME_EXISTS'); expect(creates()).toEqual([]); + expect(branchCreates()).toEqual([]); }); it('the free-tier limit keeps its code', async () => { service({ onCreate: () => reply(402, { error: 'limit', code: 'QUOTA_EXCEEDED', kind: 'project', limit: 3 }) }); - const out = await capture(() => createProjectCommand('new-app', { json: true })); + const out = await capture(() => createProjectCommand('new-app', { json: true, branch: 'development' })); expect(out.exitCode).toBe(1); expect(JSON.parse(out.stdout).code).toBe('QUOTA_EXCEEDED'); + expect(branchCreates()).toEqual([]); }); - it('in a terminal with no name: asks with the init prompt, then creates', async () => { + it('in a terminal with nothing given: asks the name, says what a branch is, asks the branch, then creates both', async () => { setTTY(true); service({}); const out = await capture(() => createProjectCommand(undefined, {})); - expect(fakePrompt).toHaveBeenCalledTimes(1); + expect(fakePrompt.mock.calls.map(([q]) => (q as ReadonlyArray<{ name: string }>)[0].name)).toEqual(['projectName', 'initialBranchChoice']); + expect(out.stdout).toContain(BRANCH_DESCRIPTION); expect(out.exitCode).toBeUndefined(); expect(creates().map((r) => JSON.parse(r.body).name)).toEqual(['typed-in-terminal']); + expect(branchCreates()).toEqual([{ name: 'development', is_protected: false }]); + }); + + it('in a terminal, "another branch" asks for its name', async () => { + setTTY(true); + service({}); + fakePrompt.mockImplementation(async (questions: unknown) => + answerFor(questions, { ...TERMINAL_ANSWERS, initialBranchChoice: 'other' })); + const out = await capture(() => createProjectCommand('new-app', {})); + fakePrompt.mockImplementation(async (questions: unknown) => answerFor(questions, TERMINAL_ANSWERS)); + + expect(out.exitCode).toBeUndefined(); + expect(branchCreates()).toEqual([{ name: 'typed-branch', is_protected: false }]); + }); + + it('in a terminal, a name that clashes is refused before the branch is asked', async () => { + setTTY(true); + service({}); + const out = await capture(() => createProjectCommand('web', {})); + + expect(out.exitCode).toBe(1); + expect(fakePrompt).not.toHaveBeenCalled(); + expect(creates()).toEqual([]); }); }); diff --git a/tests/core/branchNameRule.test.ts b/tests/core/branchNameRule.test.ts new file mode 100644 index 00000000..d98e7f40 --- /dev/null +++ b/tests/core/branchNameRule.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'bun:test'; +import { branchNameProblem, validateBranchNameInput } from '../../src/core/branchNameRule'; + +/** The service's rule (routes/projects.ts validateBranchName): the CLI must refuse exactly what the service refuses. */ +describe('branchNameProblem', () => { + it('accepts what the service accepts', () => { + const ok = ['development', 'staging', 'production', 'main', 'v1.2', 'feature_x', 'a-b', '9lives', 'x'.repeat(128)]; + expect(ok.map((name) => [name, branchNameProblem(name)])).toEqual(ok.map((name) => [name, null])); + }); + + it('refuses what the service refuses, with BRANCH_NAME_INVALID', () => { + const bad = ['', '-dev', '.dev', '_dev', 'a..b', 'has space', 'a/b', 'x'.repeat(129)]; + expect(bad.map((name) => branchNameProblem(name)?.code)).toEqual(bad.map(() => 'BRANCH_NAME_INVALID')); + }); + + it('the prompt validator trims, then answers true or the sentence', () => { + expect(validateBranchNameInput(' staging ')).toBe(true); + expect(validateBranchNameInput('a..b')).toBe('Branch name cannot contain ".."'); + }); +}); diff --git a/tests/ui/elicitationDisplay.test.ts b/tests/ui/elicitationDisplay.test.ts index 0f35d370..e83d1421 100644 --- a/tests/ui/elicitationDisplay.test.ts +++ b/tests/ui/elicitationDisplay.test.ts @@ -43,7 +43,18 @@ describe('deleteProjectDisplay', () => { }); describe('createProjectDisplay', () => { - it('shows the org the project will be created in', () => { - expect(createProjectDisplay({ id: 'org_1', name: 'Demos' })).toContain('| Org | Demos |'); + const org = { id: 'org_1', name: 'Demos' }; + const branchDescription = 'A Capy branch is an independent set of values for the same project.'; + + it('shows the org, what is still to choose, and what a branch is', () => { + const out = createProjectDisplay({ org, branchDescription }); + expect(out).toContain('| Org | Demos |'); + expect(out).toContain('| Project | to choose |'); + expect(out).toContain('| First branch | to choose |'); + expect(out.endsWith(`\n\n${branchDescription}`)).toBe(true); + }); + + it('shows a name already given', () => { + expect(createProjectDisplay({ org, name: 'web', branchDescription })).toContain('| Project | web |'); }); }); From 974bc71b9e9fc69a1731cf82c0f3ebfabe10e43b Mon Sep 17 00:00:00 2001 From: cvince Date: Mon, 9 Nov 2026 13:28:34 -0700 Subject: [PATCH 14/27] feat(org): pick which project when a name is shared; refuse a name that matches nothing delete-project / restore-project: a name several projects share now asks which one (terminal: arrow-key list; agent: PROJECT_AMBIGUOUS exit 3 with a pick-one elicitation, options labelled name, variable count and how long ago it was created). --yes never chooses. A name that matches no project is refused with PROJECT_NOT_FOUND before anything is read or asked; an id still goes to the service. The agent line for deleting by name now holds even when this folder's own project is deleted. --- src/commands/projectLifecycleCommand.ts | 174 +++++++++++++++--- src/core/agentsBlockPlan.ts | 4 +- src/service/serviceClient.ts | 5 +- src/types/index.ts | 2 +- src/ui/elicitation.ts | 4 +- src/ui/elicitationDisplay.ts | 5 + .../commands/projectLifecycleCommand.test.ts | 145 ++++++++++++--- tests/core/agentsBlockPlan.test.ts | 4 +- 8 files changed, 287 insertions(+), 56 deletions(-) diff --git a/src/commands/projectLifecycleCommand.ts b/src/commands/projectLifecycleCommand.ts index 7071764f..15ff39c8 100644 --- a/src/commands/projectLifecycleCommand.ts +++ b/src/commands/projectLifecycleCommand.ts @@ -24,8 +24,9 @@ import { excludeSystemProject } from '../system/reservedProjectName'; import { isInteractive, EXIT_NEEDS_INPUT } from '../ui/interactive'; import { CapyError, ERROR_CODES } from '../types/index'; import type { DeleteProjectResponse, LiveDeployTokenInfo } from '../service/serviceClient'; -import type { ElicitationConfirm } from '../ui/elicitation'; -import { deleteProjectDisplay } from '../ui/elicitationDisplay'; +import type { ElicitationConfirm, ElicitationForm } from '../ui/elicitation'; +import { chooseProjectDisplay, deleteProjectDisplay } from '../ui/elicitationDisplay'; +import { formatRelativeTime } from '../ui/relativeTime'; import { variableBranchesFromKeep, type VariableBranches } from '../core/keepVariableNames'; export interface ProjectLifecycleOpts { @@ -71,6 +72,8 @@ interface ProjectRef { id: string; name: string; deleted_at?: string | null; + /** Absent from services that predate it. */ + created_at?: string | null; } /** The 403 codes the service sends that reach the client as a PERMISSION_DENIED with the server's code in `details.code`. */ @@ -127,16 +130,6 @@ export function refusalFor(err: unknown): Refusal { if (code === ERROR_CODES.ORG_SELECTION_REQUIRED) { return { code, message: err.message, exitCode: EXIT_NEEDS_INPUT, extra: { orgs: Array.isArray(err.details?.orgs) ? err.details.orgs : [] } }; } - if (code === ERROR_CODES.PROJECT_AMBIGUOUS) { - const matches: readonly ProjectRef[] = Array.isArray(err.details?.matches) ? err.details.matches : []; - return { - code, - message: err.message, - exitCode: 1, - extra: { matches: matches.map((m) => ({ id: m.id, name: m.name })) }, - lines: matches.map((m) => ` ${m.id} ${m.name}`), - }; - } return { code, message: err.message, exitCode: 1 }; } @@ -189,21 +182,132 @@ function matchProjects(projects: readonly ProjectRef[], ref: string, preferDelet return preferDeleted && deleted.length > 0 ? deleted : matches; } +/** A project id as the service mints them. */ +const PROJECT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +type Resolved = + | { readonly kind: 'one'; readonly project: ProjectRef } + | { readonly kind: 'many'; readonly matches: readonly ProjectRef[] }; + /** - * One project, or a coded refusal. No match is passed through as an id: the service answers PROJECT_NOT_FOUND for a typo, - * and a non-owner (whose listing hides deleted projects) still gets the owner-only refusal rather than a vague "not found". + * What the name or id the person typed points at. + * + * A name that matches nothing is refused here with PROJECT_NOT_FOUND, before anything is read or asked. An id that + * matches nothing still goes to the service, which answers for it: not found, or the owner-only refusal for a non-owner + * (whose listing hides deleted projects). */ -function pickOne(projects: readonly ProjectRef[], ref: string, preferDeleted: boolean): ProjectRef { +function resolveRef(projects: readonly ProjectRef[], ref: string, preferDeleted: boolean): Resolved { const matches = matchProjects(projects, ref, preferDeleted); - if (matches.length === 0) return { id: ref, name: ref }; - if (matches.length === 1) return matches[0]; + if (matches.length === 1) return { kind: 'one', project: matches[0] }; + if (matches.length > 1) return { kind: 'many', matches }; + if (PROJECT_ID_PATTERN.test(ref)) return { kind: 'one', project: { id: ref, name: ref } }; throw new CapyError( - `More than one project matches "${ref}". Use the id.`, // COPY-FLAG - ERROR_CODES.PROJECT_AMBIGUOUS, - { matches }, + `No project named ${ref} in this organization.`, // COPY-FLAG + ERROR_CODES.PROJECT_NOT_FOUND, + { name: ref }, ); } +/** One of several projects with the same name, with what tells them apart. `variables` is undefined when it could not be read. */ +interface Candidate { + readonly project: ProjectRef; + readonly variables: number | undefined; +} + +/** Each match with its variable count, oldest first. Best effort: an unreadable count is shown as such, never as 0. */ +async function candidatesOf(client: DeleteDetailsClient, matches: readonly ProjectRef[]): Promise { + const candidates = await Promise.all(matches.map(async (project): Promise => { + const details = await readDeleteDetails(client, project.id); + return { project, variables: details.unavailable.includes('variables') ? undefined : details.variables.length }; + })); + // A fresh copy sorted as it is built (the lib target predates toSorted); `candidates` itself is never changed. + return [...candidates].sort((a, b) => (a.project.created_at ?? '').localeCompare(b.project.created_at ?? '')); +} + +/** "web · 45 variables · created 2 days ago": the name, the variable count, and how long ago it was created. */ +function candidateLabel(c: Candidate, now: Date): string { + const variables = c.variables === undefined + ? 'variables could not be read' // COPY-FLAG + : `${c.variables} ${c.variables === 1 ? 'variable' : 'variables'}`; // COPY-FLAG + const created = c.project.created_at ? [`created ${formatRelativeTime(c.project.created_at, now)}`] : []; // COPY-FLAG + return [c.project.name, variables, ...created].join(' · '); +} + +/** The pick-one elicitation: the answer is a project id, and `rerun` carries it back into the same command. */ +function chooseElicitation(org: OrgRef, ref: string, candidates: readonly Candidate[], rerun: string): ElicitationForm { + const now = new Date(); + const labels = candidates.map((c) => candidateLabel(c, now)); + return { + kind: 'form', + question: `Which ${ref}?`, // COPY-FLAG + details: { + org: { id: org.id, name: org.name }, + matches: candidates.map((c) => ({ + id: c.project.id, + name: c.project.name, + created_at: c.project.created_at ?? null, + variables: c.variables ?? null, + })), + }, + display: chooseProjectDisplay(labels), + fields: [{ + name: 'project', + type: 'string', + pattern: `^(${candidates.map((c) => c.project.id).join('|')})$`, + options: candidates.map((c, i) => ({ value: c.project.id, label: labels[i] })), + }], + answer: { rerun }, + }; +} + +type Picked = + | { readonly kind: 'picked'; readonly project: ProjectRef } + | { readonly kind: 'needs-choice'; readonly elicitation: ElicitationForm; readonly lines: readonly string[] }; + +interface PickContext { + readonly json: boolean; + readonly agentMode: boolean; + readonly org: OrgRef; + readonly ref: string; + readonly client: DeleteDetailsClient; + /** The command that answers the choice, with `` where the chosen id goes. */ + readonly rerun: string; +} + +/** + * The one project to act on. Several with the same name: in a terminal, an arrow-key list; with nobody to ask, the + * pick-one elicitation. Never a guess, and `--yes` never chooses. + */ +async function pickProject(resolved: Resolved, ctx: PickContext): Promise { + if (resolved.kind === 'one') return { kind: 'picked', project: resolved.project }; + const candidates = await candidatesOf(ctx.client, resolved.matches); + if (ctx.agentMode) { + const elicitation = chooseElicitation(ctx.org, ctx.ref, candidates, ctx.rerun); + return { kind: 'needs-choice', elicitation, lines: elicitation.fields[0].options?.map((o) => ` ${o.value} ${o.label}`) ?? [] }; + } + const now = new Date(); + const { projectId } = await promptModuleFor(ctx.json)([{ + type: 'list', + name: 'projectId', + message: `Which ${ctx.ref}?`, // COPY-FLAG + choices: candidates.map((c) => ({ name: candidateLabel(c, now), value: c.project.id })), + }]); + const chosen = candidates.find((c) => c.project.id === projectId); + if (chosen === undefined) throw new CapyError(`No project named ${ctx.ref} in this organization.`, ERROR_CODES.PROJECT_NOT_FOUND); // COPY-FLAG + return { kind: 'picked', project: chosen.project }; +} + +/** The refusal that hands the choice to the agent: PROJECT_AMBIGUOUS, exit 3, with the elicitation. */ +function choiceRefusal(picked: Extract, ref: string): Refusal { + return { + code: ERROR_CODES.PROJECT_AMBIGUOUS, + message: `More than one project is named ${ref}.`, // COPY-FLAG + exitCode: EXIT_NEEDS_INPUT, + extra: { elicitation: picked.elicitation }, + lines: picked.lines, + }; +} + function refuseWithoutTerminal(json: boolean, what: string): never { return refuse(json, { code: ERROR_CODES.CONFIRMATION_REQUIRED, @@ -311,6 +415,7 @@ function deleteElicitation( type DeleteOutcome = | { readonly kind: 'dry-run'; readonly project: ProjectRef; readonly tokens: readonly LiveDeployTokenInfo[] } | { readonly kind: 'needs-confirmation'; readonly elicitation: ElicitationConfirm; readonly lines: readonly string[] } + | { readonly kind: 'needs-choice'; readonly refusal: Refusal } | { readonly kind: 'cancelled' } | { readonly kind: 'deleted'; readonly result: DeleteProjectResponse }; @@ -358,7 +463,16 @@ async function deleteFlow(ref: string, opts: DeleteProjectOpts): Promise ${force ? '--force ' : ''}--json`, + }); + if (picked.kind === 'needs-choice') return { kind: 'needs-choice', refusal: choiceRefusal(picked, ref) }; + const project = picked.project; // The token list is only read when it is shown: a dry run, or a person about to be asked. const asks = opts.dryRun !== true && opts.yes !== true; @@ -403,6 +517,7 @@ function emitDeleteOutcome(json: boolean, force: boolean, outcome: DeleteOutcome console.log('Dry run: nothing was deleted.'); // COPY-FLAG return; } + if (outcome.kind === 'needs-choice') return refuse(json, outcome.refusal); if (outcome.kind === 'needs-confirmation') { return refuse(json, { code: ERROR_CODES.CONFIRMATION_REQUIRED, @@ -434,13 +549,25 @@ export async function deleteProjectCommand(ref: string, opts: DeleteProjectOpts) type RestoreOutcome = | { readonly kind: 'cancelled' } + | { readonly kind: 'needs-choice'; readonly refusal: Refusal } | { readonly kind: 'restored'; readonly project: { readonly id: string; readonly name: string } }; async function restoreFlow(ref: string, opts: ProjectLifecycleOpts): Promise { const json = opts.json === true; - const { orgId, serviceClient } = await resolveOrgContext(opts.apiUrl, opts.devMode === true); + const agentMode = !isInteractive(opts.nonTty); + const { orgId, orgName, serviceClient } = await resolveOrgContext(opts.apiUrl, opts.devMode === true); // Only an owner's `include_deleted` is honoured; for anyone else the service answers with the live projects, and the id still works. - const project = pickOne(await serviceClient.listProjects({ includeDeleted: true }), ref, true); + // With nobody to ask, this point is only reached with --yes (see restoreProjectCommand), so the rerun keeps it. + const picked = await pickProject(resolveRef(await serviceClient.listProjects({ includeDeleted: true }), ref, true), { + json, + agentMode, + org: { id: orgId, name: orgName }, + ref, + client: serviceClient, + rerun: 'capy org restore-project --yes --json', + }); + if (picked.kind === 'needs-choice') return { kind: 'needs-choice', refusal: choiceRefusal(picked, ref) }; + const project = picked.project; if (opts.yes !== true && !(await confirm(json, `Restore project ${project.name}?`))) return { kind: 'cancelled' }; // COPY-FLAG @@ -449,6 +576,7 @@ async function restoreFlow(ref: string, opts: ProjectLifecycleOpts): Promise` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', // COPY-FLAG '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', // COPY-FLAG '- If a JSON result has an `elicitation`, end your reply with its `display` table exactly as given (never shorten or reorder it), then its short `question`, and stop. Never use a question box or a preview for it. Wait for the human to answer in chat, then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', // COPY-FLAG - '- Only when the human asks to delete a project, run `capy org delete-project --json` without `--yes`. It changes nothing and returns an `elicitation`: that is how you ask the human, so do not ask your own question first. Only an org owner can delete or restore a project.', // COPY-FLAG - '- `PROJECT_DELETED`, or `project.state` `"deleted"` in `capy info --json`, means an org owner deleted this folder\'s project. Stop and tell the human. An org owner can restore it with `capy org restore-project ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`.', // COPY-FLAG + '- Only when the human asks to delete a project, run `capy org delete-project --json` with the name they gave and without `--yes`, even if this folder\'s project is deleted or is a different one: it looks the name up in the organization, not in this folder. It changes nothing and returns an `elicitation`: that is how you ask the human, so do not ask your own question first. Only an org owner can delete or restore a project.', // COPY-FLAG + '- `PROJECT_DELETED`, or `project.state` `"deleted"` in `capy info --json`, means an org owner deleted this folder\'s project. Stop and tell the human. An org owner can restore it with `capy org restore-project ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`. This is about this folder only: it never stops a delete or restore the human asked for by name.', // COPY-FLAG '- Never print, log, or commit secret values.', '- To set a value, pipe it from the command that produces it: ` | capy edit NAME --json`. Never put a value in a command argument, an `echo`, or a heredoc — it would land in your context and the shell history.', AGENTS_BLOCK_END, diff --git a/src/service/serviceClient.ts b/src/service/serviceClient.ts index 5bf70cc3..8ab73c83 100644 --- a/src/service/serviceClient.ts +++ b/src/service/serviceClient.ts @@ -758,11 +758,12 @@ export class ServiceClient { * List all projects in the caller's organization. Used by the client to * bootstrap when running in a directory with no local keep.lock. */ - async listProjects(options?: { includeDeleted?: boolean }): Promise> { + async listProjects(options?: { includeDeleted?: boolean }): Promise> { // CAP-729: only an org owner's `include_deleted=1` is honoured; for anyone else the service ignores it. + // `created_at` is absent from services that predate it; callers treat it as unknown. const query = options?.includeDeleted === true ? '?include_deleted=1' : ''; const data = await this.request<{ - projects: Array<{ id: string; name: string; organization_id: string; deleted_at?: string | null }>; + projects: Array<{ id: string; name: string; organization_id: string; deleted_at?: string | null; created_at?: string | null }>; }>('GET', `/projects${query}`); return data.projects; } diff --git a/src/types/index.ts b/src/types/index.ts index 156f699c..c3deaa20 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -897,7 +897,7 @@ export const ERROR_CODES = { PROJECT_NOT_DELETED: 'PROJECT_NOT_DELETED', /** Delete called on the org's system store (403). Reaches the client as a PERMISSION_DENIED whose `details.code` is this. */ SYSTEM_STORE_IMMUTABLE: 'SYSTEM_STORE_IMMUTABLE', - /** `capy org delete-project` / `restore-project`: the name or id matched more than one project. The refusal lists the ids. */ + /** `capy org delete-project` / `restore-project`: the name matched more than one project and there is no terminal to ask which. Exit 3 with a pick-one `elicitation` (a form whose field has `options`). */ PROJECT_AMBIGUOUS: 'PROJECT_AMBIGUOUS', /** A destructive command needs a yes and there is no terminal to ask, and `--yes` was not passed. Exit 3. */ CONFIRMATION_REQUIRED: 'CONFIRMATION_REQUIRED', diff --git a/src/ui/elicitation.ts b/src/ui/elicitation.ts index 66e3125e..367133df 100644 --- a/src/ui/elicitation.ts +++ b/src/ui/elicitation.ts @@ -20,6 +20,8 @@ export interface ElicitationField { readonly default?: string; /** What the field is, for the human. Absent when the name says it all. */ readonly description?: string; + /** When present, the answer must be one of these `value`s; `label` is what the human is shown. */ + readonly options?: ReadonlyArray<{ readonly value: string; readonly label: string }>; } export interface ElicitationConfirm { @@ -46,4 +48,4 @@ export type Elicitation = ElicitationConfirm | ElicitationForm; /** What `capy help --json` says about elicitations (`conventions.elicitation`). */ export const ELICITATION_HELP = // COPY-FLAG: minimal and neutral. - 'A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. End your reply with `display` (a markdown table) exactly as given, without shortening or reordering it, then the short `question`, and stop. Never use a question box or a preview for it; the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, default?, description?}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` missing the name or `--branch` in agent mode refuses with PROJECT_NAME_REQUIRED or BRANCH_NAME_REQUIRED and a form (fields: name, branch; details: {org:{id,name}}). Never answer it yourself and never add --yes on your own.'; + 'A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. End your reply with `display` (a markdown table) exactly as given, without shortening or reordering it, then the short `question`, and stop. Never use a question box or a preview for it; the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, default?, description?, options?:[{value, label}]}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. A field with `options` is a choice: the human picks a `label`, and you fill in its `value`. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` missing the name or `--branch` in agent mode refuses with PROJECT_NAME_REQUIRED or BRANCH_NAME_REQUIRED and a form (fields: name, branch; details: {org:{id,name}}); `capy org delete-project` / `restore-project` with a name several projects share refuses with PROJECT_AMBIGUOUS and a form with one `project` field whose options are the projects (details: {org:{id,name}, matches:[{id, name, created_at, variables}]}). Never answer it yourself and never add --yes on your own.'; diff --git a/src/ui/elicitationDisplay.ts b/src/ui/elicitationDisplay.ts index 116d2ed5..7e3db1b2 100644 --- a/src/ui/elicitationDisplay.ts +++ b/src/ui/elicitationDisplay.ts @@ -61,6 +61,11 @@ export function deleteProjectDisplay(input: DeleteDisplayInput): string { return `${summary}\n\n${variablesSection(input)}`; } +/** The table for a name that matches several projects: one numbered row per project, labelled as the options are. */ +export function chooseProjectDisplay(labels: readonly string[]): string { + return table(['', 'Project'], labels.map((label, i) => [String(i + 1), label] as const)); // COPY-FLAG +} + export interface CreateDisplayInput { readonly org: { readonly id: string; readonly name: string }; /** The name already given, if any. */ diff --git a/tests/commands/projectLifecycleCommand.test.ts b/tests/commands/projectLifecycleCommand.test.ts index 431dbec7..8557d1d5 100644 --- a/tests/commands/projectLifecycleCommand.test.ts +++ b/tests/commands/projectLifecycleCommand.test.ts @@ -107,13 +107,31 @@ const TOKEN = { deploy_id: 'dep_1', label: 'prod web', created_at: '2026-10-01T0 const OWNER_ONLY = reply(403, { error: 'Only the organization owner can delete a project', code: 'PROJECT_DELETE_OWNER_ONLY' }); /** A service that lists `projects`, reports `tokens` as unrevoked, and answers the write with `onWrite`. */ +/** A keep.lock with `count` variables, each on `branch`. Names only matter; nothing else is read. */ +const keepWith = (count: number, branch: string) => JSON.stringify({ + version: '3.0', + variables: Object.fromEntries(Array.from({ length: count }, (_, i) => [`VAR_${i}`, [{ branch }]])), +}); + function service(opts: { projects?: readonly object[]; tokens?: readonly object[]; + /** Per project id: its branches and how many variables its keep.lock has. Absent = unreadable. */ + contents?: Readonly>; onWrite?: (method: string, path: string, body: string) => Reply; }) { - const { projects = LIVE, tokens = [], onWrite = () => reply(500, { error: 'no write expected' }) } = opts; + const { projects = LIVE, tokens = [], contents = {}, onWrite = () => reply(500, { error: 'no write expected' }) } = opts; respond.mockImplementation((method, path, body) => { + const branchList = method === 'GET' ? /^\/projects\/([^/?]+)\/branches$/.exec(path) : null; + if (branchList) { + const c = contents[branchList[1]]; + return c ? reply(200, { branches: c.branches.map((name) => ({ name })) }) : reply(500, { error: 'unreadable' }); + } + const latest = method === 'GET' ? /^\/secrets\/([^/?]+)\?branch=([^&]+)$/.exec(path) : null; + if (latest) { + const c = contents[latest[1]]; + return c ? reply(200, { env_file: '', keep_hash: 'h', keep_file: keepWith(c.variables, decodeURIComponent(latest[2])) }) : reply(500, { error: 'unreadable' }); + } if (method === 'GET' && path.startsWith('/projects')) return reply(200, { projects }); if (method === 'GET' && path.endsWith('/deploy-tokens')) { return reply(200, { tokens: tokens.map((t) => ({ created_by: 'user_1', revoked_at: null, ...t })) }); @@ -373,36 +391,99 @@ describe('capy org delete-project', () => { expect(JSON.parse(out.stdout).code).toBe('PROJECT_DELETED'); }); - it('no such project: the name is sent on as an id and the service answers PROJECT_NOT_FOUND', async () => { + it('a name that matches no project: PROJECT_NOT_FOUND before anything is read, asked or sent', async () => { + service({ onWrite: () => reply(404, { error: 'Project not found', code: 'PROJECT_NOT_FOUND' }) }); + + const out = await capture(() => deleteProjectCommand('nope', { json: true })); + + expect(out.exitCode).toBe(1); + expect(JSON.parse(out.stdout)).toMatchObject({ ok: false, code: 'PROJECT_NOT_FOUND' }); + expect(JSON.parse(out.stdout).elicitation).toBeUndefined(); + expect(requests().filter((r) => r.path !== '/projects')).toEqual([]); + }); + + it('an id that matches no listed project still goes to the service, which answers for it', async () => { + const id = '5b0c1d2e-3f40-4a5b-8c6d-7e8f90a1b2c3'; service({ onWrite: () => reply(404, { error: 'Project not found', code: 'PROJECT_NOT_FOUND' }) }); - const out = await capture(() => deleteProjectCommand('nope', { yes: true, json: true })); + const out = await capture(() => deleteProjectCommand(id, { yes: true, json: true })); - expect(deletes()[0].path).toBe('/orgs/org_1/projects/nope'); + expect(deletes()[0].path).toBe(`/orgs/org_1/projects/${id}`); expect(JSON.parse(out.stdout).code).toBe('PROJECT_NOT_FOUND'); }); }); describe('resolving the name', () => { - it('an ambiguous name is refused with every id listed, and nothing is deleted', async () => { - const twins = [ - { id: 'proj_a1', name: 'web', organization_id: 'org_1' }, - { id: 'proj_a2', name: 'web', organization_id: 'org_1' }, - ]; - service({ projects: twins, onWrite: () => deleted('proj_a1', 'web') }); + const DAY = 24 * 60 * 60 * 1000; + const daysAgo = (n: number) => new Date(Date.now() - n * DAY).toISOString(); + const TWINS = [ + { id: 'proj_new', name: 'web', organization_id: 'org_1', created_at: daysAgo(1) }, + { id: 'proj_old', name: 'web', organization_id: 'org_1', created_at: daysAgo(3) }, + ]; + const TWIN_CONTENTS = { + proj_old: { branches: ['development'], variables: 45 }, + proj_new: { branches: ['development'], variables: 1 }, + }; + + it('an ambiguous name, agent: PROJECT_AMBIGUOUS (exit 3) with a pick-one elicitation, oldest first, nothing deleted even with --yes', async () => { + service({ projects: TWINS, contents: TWIN_CONTENTS, onWrite: () => deleted('proj_old', 'web') }); const out = await capture(() => deleteProjectCommand('web', { yes: true, json: true })); - expect(out.exitCode).toBe(1); - expect(JSON.parse(out.stdout)).toEqual({ - ok: false, - code: 'PROJECT_AMBIGUOUS', - error: expect.any(String), - matches: [{ id: 'proj_a1', name: 'web' }, { id: 'proj_a2', name: 'web' }], - }); + expect(out.exitCode).toBe(3); + const body = JSON.parse(out.stdout); + expect(body).toMatchObject({ ok: false, code: 'PROJECT_AMBIGUOUS', elicitation: { kind: 'form', question: 'Which web?' } }); + expect(body.elicitation.fields).toEqual([{ + name: 'project', + type: 'string', + pattern: '^(proj_old|proj_new)$', + options: [ + { value: 'proj_old', label: 'web · 45 variables · created 3 days ago' }, + { value: 'proj_new', label: 'web · 1 variable · created 1 day ago' }, + ], + }]); + expect(body.elicitation.answer).toEqual({ rerun: 'capy org delete-project --json' }); + expect(body.elicitation.display).toContain('| 1 | web · 45 variables · created 3 days ago |'); expect(deletes()).toEqual([]); }); + it('an ambiguous name keeps --force in the rerun, and an unreadable count is said, never 0', async () => { + service({ projects: TWINS, contents: { proj_old: TWIN_CONTENTS.proj_old } }); + + const out = await capture(() => deleteProjectCommand('web', { force: true, json: true })); + + const body = JSON.parse(out.stdout); + expect(body.elicitation.answer.rerun).toBe('capy org delete-project --force --json'); + expect(body.elicitation.fields[0].options[1].label).toBe('web · variables could not be read · created 1 day ago'); + expect(body.elicitation.details.matches[1]).toMatchObject({ id: 'proj_new', variables: null }); + }); + + it('an ambiguous name with no creation dates (an older service) still asks, without the dates', async () => { + service({ projects: TWINS.map(({ created_at: _c, ...p }) => p), contents: TWIN_CONTENTS }); + + const out = await capture(() => deleteProjectCommand('web', { json: true })); + + expect(JSON.parse(out.stdout).elicitation.fields[0].options.map((o: { label: string }) => o.label)) + .toEqual(['web · 1 variable', 'web · 45 variables']); + }); + + it('an ambiguous name, terminal: an arrow-key list, then the usual yes/no, then the chosen one is deleted', async () => { + setTTY(true); + service({ projects: TWINS, contents: TWIN_CONTENTS, onWrite: () => deleted('proj_old', 'web') }); + fakePrompt.mockImplementation(async (questions: unknown) => { + const q = (questions as ReadonlyArray<{ name: string; choices?: ReadonlyArray<{ name: string; value: string }> }>)[0]; + return q.name === 'projectId' ? { projectId: q.choices?.[0].value } : { confirmed: true }; + }); + + await capture(() => deleteProjectCommand('web', {})); + + const asked = fakePrompt.mock.calls.map(([q]) => (q as ReadonlyArray<{ name: string; message: string; choices?: ReadonlyArray<{ name: string }> }>)[0]); + expect(asked.map((q) => q.name)).toEqual(['projectId', 'confirmed']); + expect(asked[0].message).toBe('Which web?'); + expect(asked[0].choices?.map((c) => c.name)).toEqual(['web · 45 variables · created 3 days ago', 'web · 1 variable · created 1 day ago']); + expect(deletes().map((d) => d.path)).toEqual(['/orgs/org_1/projects/proj_old']); + }); + it('an ambiguous name, human: both ids on stderr', async () => { service({ projects: [ @@ -429,13 +510,13 @@ describe('capy org delete-project', () => { expect(deletes()[0].path).toBe('/orgs/org_1/projects/proj_web'); }); - it('the org system store is never a match', async () => { + it('the org system store is never a match: its name is not found, and nothing is sent', async () => { service({ projects: [{ id: 'proj_sys', name: '_system', organization_id: 'org_1' }], onWrite: () => reply(404, { error: 'Project not found', code: 'PROJECT_NOT_FOUND' }) }); - await capture(() => deleteProjectCommand('_system', { yes: true, json: true })); + const out = await capture(() => deleteProjectCommand('_system', { yes: true, json: true })); - // Not resolved from the listing: it goes on as a bare id and the service decides. - expect(deletes()[0].path).toBe('/orgs/org_1/projects/_system'); + expect(JSON.parse(out.stdout).code).toBe('PROJECT_NOT_FOUND'); + expect(deletes()).toEqual([]); }); }); }); @@ -518,11 +599,12 @@ describe('capy org restore-project', () => { }); it('owner only: PROJECT_DELETE_OWNER_ONLY, and an id still reaches the service when the listing hides deleted projects', async () => { + const id = '9d8c7b6a-5f4e-4d3c-8b2a-1f0e9d8c7b6a'; service({ projects: [], onWrite: () => reply(403, { error: 'Only the organization owner can restore a project', code: 'PROJECT_DELETE_OWNER_ONLY' }) }); - const out = await capture(() => restoreProjectCommand('proj_old', { yes: true, json: true })); + const out = await capture(() => restoreProjectCommand(id, { yes: true, json: true })); - expect(writes()[0].path).toBe('/orgs/org_1/projects/proj_old/restore'); + expect(writes()[0].path).toBe(`/orgs/org_1/projects/${id}/restore`); expect(JSON.parse(out.stdout).code).toBe('PROJECT_DELETE_OWNER_ONLY'); }); @@ -537,12 +619,25 @@ describe('capy org restore-project', () => { expect(writes()[0].path).toBe('/orgs/org_1/projects/proj_old/restore'); }); - it('two deleted projects with the same name: PROJECT_AMBIGUOUS with the ids', async () => { + it('two deleted projects with the same name: PROJECT_AMBIGUOUS (exit 3) with a pick-one elicitation whose rerun keeps --yes', async () => { service({ projects: [DELETED, { ...DELETED, id: 'proj_old2' }], onWrite: () => restored }); const out = await capture(() => restoreProjectCommand('old', { yes: true, json: true })); - expect(JSON.parse(out.stdout)).toMatchObject({ code: 'PROJECT_AMBIGUOUS', matches: [{ id: 'proj_old' }, { id: 'proj_old2' }] }); + expect(out.exitCode).toBe(3); + const body = JSON.parse(out.stdout); + expect(body).toMatchObject({ code: 'PROJECT_AMBIGUOUS', elicitation: { kind: 'form' } }); + expect(body.elicitation.fields[0].options.map((o: { value: string }) => o.value)).toEqual(['proj_old', 'proj_old2']); + expect(body.elicitation.answer.rerun).toBe('capy org restore-project --yes --json'); + expect(writes()).toEqual([]); + }); + + it('a name that matches no project: PROJECT_NOT_FOUND, nothing sent', async () => { + service({ projects: [DELETED], onWrite: () => restored }); + + const out = await capture(() => restoreProjectCommand('nope', { yes: true, json: true })); + + expect(JSON.parse(out.stdout).code).toBe('PROJECT_NOT_FOUND'); expect(writes()).toEqual([]); }); }); diff --git a/tests/core/agentsBlockPlan.test.ts b/tests/core/agentsBlockPlan.test.ts index aefd6e01..ad4aac7f 100644 --- a/tests/core/agentsBlockPlan.test.ts +++ b/tests/core/agentsBlockPlan.test.ts @@ -40,8 +40,8 @@ describe('AGENTS_BLOCK', () => { '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', '- If a JSON result has an `elicitation`, end your reply with its `display` table exactly as given (never shorten or reorder it), then its short `question`, and stop. Never use a question box or a preview for it. Wait for the human to answer in chat, then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', - '- Only when the human asks to delete a project, run `capy org delete-project --json` without `--yes`. It changes nothing and returns an `elicitation`: that is how you ask the human, so do not ask your own question first. Only an org owner can delete or restore a project.', - "- `PROJECT_DELETED`, or `project.state` `\"deleted\"` in `capy info --json`, means an org owner deleted this folder's project. Stop and tell the human. An org owner can restore it with `capy org restore-project ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`.", + "- Only when the human asks to delete a project, run `capy org delete-project --json` with the name they gave and without `--yes`, even if this folder's project is deleted or is a different one: it looks the name up in the organization, not in this folder. It changes nothing and returns an `elicitation`: that is how you ask the human, so do not ask your own question first. Only an org owner can delete or restore a project.", + "- `PROJECT_DELETED`, or `project.state` `\"deleted\"` in `capy info --json`, means an org owner deleted this folder's project. Stop and tell the human. An org owner can restore it with `capy org restore-project ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`. This is about this folder only: it never stops a delete or restore the human asked for by name.", '- Never print, log, or commit secret values.', '- To set a value, pipe it from the command that produces it: ` | capy edit NAME --json`. Never put a value in a command argument, an `echo`, or a heredoc — it would land in your context and the shell history.', '', From 3cfd7bafc36b0c9a996583288e762bcf950b77a7 Mon Sep 17 00:00:00 2001 From: cvince Date: Mon, 9 Nov 2026 13:30:55 -0700 Subject: [PATCH 15/27] docs: regenerate cli-reference for create-project --branch and the new codes --- docs/cli-reference.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 2687961c..3e78aa87 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -484,7 +484,7 @@ JSON support: no ### `capy org create-project` -Create a project in the active organization (owners and admins only; does not link this folder) +Create a project and its first branch in the active organization (owners and admins only; does not link this folder) ``` capy org create-project [name] [options] @@ -492,7 +492,8 @@ capy org create-project [name] [options] | Option | Description | Default | |---|---|---| -| `--non-tty` | treat stdin as not a terminal: never ask for the name; with no name, refuse with a form to ask the human | | +| `--branch ` | the first branch: an independent set of values for the project (offered by default: development) | | +| `--non-tty` | treat stdin as not a terminal: never ask for the name or branch; without them, refuse with a form to ask the human | | | `--json` | emit machine-readable JSON instead of the human UI | | JSON support: yes (`--json`) @@ -1134,6 +1135,9 @@ Every refusal carries a stable `code` — branch on it, never on message text. - `PROJECT_NAME_REQUIRED` - `PROJECT_NAME_INVALID` - `PROJECT_NAME_EXISTS` +- `BRANCH_NAME_REQUIRED` +- `BRANCH_NAME_INVALID` +- `BRANCH_CREATE_FAILED` - `ORG_SELECTION_REQUIRED` - `REPO_LINKS_UNSUPPORTED` - `DRY_RUN_UNSUPPORTED` @@ -1161,4 +1165,4 @@ Every refusal carries a stable `code` — branch on it, never on message text. - **JSON**: Pass --json on any command whose supportsJson is true and parse stdout as JSON; prose (progress, prompts, errors) goes to stderr so stdout stays pure JSON. - **Codes**: Branch only on the machine-readable `code` field (from --json output or a CapyError), never on human-readable message text — messages may be reworded without notice. - **Conflicts**: Every command that writes from a folder (edit, add, remove, connect, rotate, push, checkout, org, deploy, deploy revoke, deploy targets-remove) runs bare `capy` first; `checkout -b` is the one exception (a new branch carries the uncommitted values of the folder). Off a terminal, if the folder and Capy differ on any variable, nothing is written, the exit code is 3, and stdout is {ok:false, code:"SECRETS_CONFLICT", conflict_id, conflicts:[{name, project, branch, type, pinned, local, remote}]} (fingerprints only, never values; a Dokploy value has {name, target, pinned, local, remote}). Ask the person, then rerun the same command with --resolve NAME=pinned|local|remote|delete for every row (NAME@TARGET=local|remote for a Dokploy value) and --conflict . A state that moved since is SECRETS_CONFLICT_STALE with the fresh rows. There is no resolve-all. -- **Elicitation**: A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. End your reply with `display` (a markdown table) exactly as given, without shortening or reordering it, then the short `question`, and stop. Never use a question box or a preview for it; the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, default?}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` with no name in agent mode refuses with PROJECT_NAME_REQUIRED and a form (details: {org:{id,name}}). Never answer it yourself and never add --yes on your own. +- **Elicitation**: A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. End your reply with `display` (a markdown table) exactly as given, without shortening or reordering it, then the short `question`, and stop. Never use a question box or a preview for it; the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, default?, description?, options?:[{value, label}]}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. A field with `options` is a choice: the human picks a `label`, and you fill in its `value`. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` missing the name or `--branch` in agent mode refuses with PROJECT_NAME_REQUIRED or BRANCH_NAME_REQUIRED and a form (fields: name, branch; details: {org:{id,name}}); `capy org delete-project` / `restore-project` with a name several projects share refuses with PROJECT_AMBIGUOUS and a form with one `project` field whose options are the projects (details: {org:{id,name}, matches:[{id, name, created_at, variables}]}). Never answer it yourself and never add --yes on your own. From d45c720eafe0889a6dca208491698483f04ff488 Mon Sep 17 00:00:00 2001 From: cvince Date: Mon, 9 Nov 2026 13:36:50 -0700 Subject: [PATCH 16/27] feat(agents): tell agents to run create-project and let its elicitation ask, as for delete --- src/core/agentsBlockPlan.ts | 1 + tests/core/agentsBlockPlan.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/core/agentsBlockPlan.ts b/src/core/agentsBlockPlan.ts index 10fd054a..a0d99090 100644 --- a/src/core/agentsBlockPlan.ts +++ b/src/core/agentsBlockPlan.ts @@ -32,6 +32,7 @@ export const AGENTS_BLOCK = [ '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', // COPY-FLAG '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', // COPY-FLAG '- If a JSON result has an `elicitation`, end your reply with its `display` table exactly as given (never shorten or reorder it), then its short `question`, and stop. Never use a question box or a preview for it. Wait for the human to answer in chat, then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', // COPY-FLAG + '- Only when the human asks to create a project, run `capy org create-project --json`, adding the name and `--branch ` only if the human already gave them. It creates nothing until it has both and returns an `elicitation` asking for the rest: that is how you ask the human, so do not ask your own question first.', // COPY-FLAG '- Only when the human asks to delete a project, run `capy org delete-project --json` with the name they gave and without `--yes`, even if this folder\'s project is deleted or is a different one: it looks the name up in the organization, not in this folder. It changes nothing and returns an `elicitation`: that is how you ask the human, so do not ask your own question first. Only an org owner can delete or restore a project.', // COPY-FLAG '- `PROJECT_DELETED`, or `project.state` `"deleted"` in `capy info --json`, means an org owner deleted this folder\'s project. Stop and tell the human. An org owner can restore it with `capy org restore-project ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`. This is about this folder only: it never stops a delete or restore the human asked for by name.', // COPY-FLAG '- Never print, log, or commit secret values.', diff --git a/tests/core/agentsBlockPlan.test.ts b/tests/core/agentsBlockPlan.test.ts index ad4aac7f..cad92edd 100644 --- a/tests/core/agentsBlockPlan.test.ts +++ b/tests/core/agentsBlockPlan.test.ts @@ -40,6 +40,7 @@ describe('AGENTS_BLOCK', () => { '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', '- If a JSON result has an `elicitation`, end your reply with its `display` table exactly as given (never shorten or reorder it), then its short `question`, and stop. Never use a question box or a preview for it. Wait for the human to answer in chat, then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', + '- Only when the human asks to create a project, run `capy org create-project --json`, adding the name and `--branch ` only if the human already gave them. It creates nothing until it has both and returns an `elicitation` asking for the rest: that is how you ask the human, so do not ask your own question first.', "- Only when the human asks to delete a project, run `capy org delete-project --json` with the name they gave and without `--yes`, even if this folder's project is deleted or is a different one: it looks the name up in the organization, not in this folder. It changes nothing and returns an `elicitation`: that is how you ask the human, so do not ask your own question first. Only an org owner can delete or restore a project.", "- `PROJECT_DELETED`, or `project.state` `\"deleted\"` in `capy info --json`, means an org owner deleted this folder's project. Stop and tell the human. An org owner can restore it with `capy org restore-project ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`. This is about this folder only: it never stops a delete or restore the human asked for by name.", '- Never print, log, or commit secret values.', From 92f1f356644b8398b48a1a535fa89502d0a37ef5 Mon Sep 17 00:00:00 2001 From: cvince Date: Mon, 9 Nov 2026 13:40:40 -0700 Subject: [PATCH 17/27] fix(org): create-project never suggests a name a live project already has --- src/commands/projectCreateCommand.ts | 28 ++++++++++++++------- tests/commands/projectCreateCommand.test.ts | 15 +++++++++++ 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/commands/projectCreateCommand.ts b/src/commands/projectCreateCommand.ts index b6dfbde0..38b14824 100644 --- a/src/commands/projectCreateCommand.ts +++ b/src/commands/projectCreateCommand.ts @@ -69,18 +69,26 @@ type CreateOutcome = | { readonly kind: 'branch-failed'; readonly project: ProjectRef; readonly branch: string; readonly org: OrgRef } | { readonly kind: 'created'; readonly project: ProjectRef; readonly branch: string; readonly org: OrgRef }; -/** The folder's name as a project name, when it is allowed as one. The init prompt offers the same. */ -function suggestedName(): string | undefined { +/** + * The folder's name as a project name, when it is allowed as one and no live project in the org has it already. + * The init prompt offers the same name; a taken one would only be refused with PROJECT_NAME_EXISTS, so it is never offered. + */ +function suggestedName(isTaken: (name: string) => boolean): string | undefined { const folderName = new ProjectManager().getDefaultProjectName(); - return projectNameProblem(folderName) === null ? folderName : undefined; + return projectNameProblem(folderName) === null && !isTaken(folderName) ? folderName : undefined; } /** * The form that asks for the name and the first branch. Both fields are always there, filled with what was already * given, so the human sees and confirms both. `answer.rerun` keeps `` and `` as the places the answers go. */ -function createForm(org: OrgRef, name: string | undefined, branch: string | undefined): ElicitationForm { - const nameDefault = name ?? suggestedName(); +function createForm( + org: OrgRef, + name: string | undefined, + branch: string | undefined, + isTaken: (name: string) => boolean, +): ElicitationForm { + const nameDefault = name ?? suggestedName(isTaken); return { kind: 'form', question: name === undefined @@ -149,18 +157,20 @@ async function createFlow(typed: string | undefined, opts: CreateProjectOpts, ag const org: OrgRef = { id: orgId, name: orgName }; const name = await chooseName(typed, agentMode); + const client: CreateClient = serviceClient; + const projects = await client.listProjects(); + const isTaken = (candidate: string) => existingProjectNamed(projects, orgId, candidate) !== undefined; if (name === undefined) { - return { kind: 'needs-input', missing: 'name', elicitation: createForm(org, undefined, opts.branch), org }; + return { kind: 'needs-input', missing: 'name', elicitation: createForm(org, undefined, opts.branch, isTaken), org }; } // The clash is checked before the branch is asked, so nobody picks a branch for a project that cannot be created. - const client: CreateClient = serviceClient; - const existing = existingProjectNamed(await client.listProjects(), orgId, name); + const existing = existingProjectNamed(projects, orgId, name); if (existing !== undefined) return { kind: 'exists', existing, org }; const branch = await chooseBranch(opts.branch, agentMode); if (branch === undefined) { - return { kind: 'needs-input', missing: 'branch', elicitation: createForm(org, name, undefined), org }; + return { kind: 'needs-input', missing: 'branch', elicitation: createForm(org, name, undefined, isTaken), org }; } if (opts.dryRun === true) return { kind: 'dry-run', name, branch, org }; diff --git a/tests/commands/projectCreateCommand.test.ts b/tests/commands/projectCreateCommand.test.ts index 35da8976..2fc375fd 100644 --- a/tests/commands/projectCreateCommand.test.ts +++ b/tests/commands/projectCreateCommand.test.ts @@ -9,6 +9,7 @@ import { describe, it, expect, spyOn, mock, afterAll, beforeEach, afterEach } fr */ import { ServiceClient } from '../../src/service/serviceClient'; +import { ProjectManager } from '../../src/core/projectManager'; type Reply = { readonly status: number; readonly body: unknown }; const reply = (status: number, body: unknown): Reply => ({ status, body }); @@ -157,6 +158,20 @@ describe('capy org create-project', () => { expect(fakePrompt).not.toHaveBeenCalled(); }); + it('the folder name is suggested only when no live project in the org has it', async () => { + service({}); + const folder = spyOn(ProjectManager.prototype, 'getDefaultProjectName'); + + folder.mockReturnValue('web'); + const taken = JSON.parse((await capture(() => createProjectCommand(undefined, { json: true }))).stdout); + folder.mockReturnValue('fresh-folder'); + const free = JSON.parse((await capture(() => createProjectCommand(undefined, { json: true }))).stdout); + folder.mockRestore(); + + expect(taken.elicitation.fields[0].default).toBeUndefined(); + expect(free.elicitation.fields[0].default).toBe('fresh-folder'); + }); + it('with a name but no --branch in agent mode: BRANCH_NAME_REQUIRED (exit 3), the form keeps the name, creating nothing', async () => { service({}); const out = await capture(() => createProjectCommand('new-app', { json: true })); From ca4f7c1b82092592981d63a079a301f10010d653 Mon Sep 17 00:00:00 2001 From: cvince Date: Mon, 9 Nov 2026 16:24:17 -0700 Subject: [PATCH 18/27] fix(agents): ask elicitations with the agent's question tool; capy agents writes both AGENTS.md and CLAUDE.md The display table goes in the reply; the short question is asked as an elicitation with the agent's question tool (in chat only when there is none). capy agents now always writes the block to both AGENTS.md and CLAUDE.md, creating whichever is missing, since Claude Code reads CLAUDE.md. --- docs/cli-reference.md | 2 +- src/commands/agentsCommand.ts | 11 ++++--- src/core/agentsBlockPlan.ts | 2 +- src/ui/elicitation.ts | 2 +- tests/commands/agentsCommand.test.ts | 43 +++++++++++++++++----------- tests/core/agentsBlockPlan.test.ts | 2 +- 6 files changed, 37 insertions(+), 25 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 3e78aa87..c336ef6f 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1165,4 +1165,4 @@ Every refusal carries a stable `code` — branch on it, never on message text. - **JSON**: Pass --json on any command whose supportsJson is true and parse stdout as JSON; prose (progress, prompts, errors) goes to stderr so stdout stays pure JSON. - **Codes**: Branch only on the machine-readable `code` field (from --json output or a CapyError), never on human-readable message text — messages may be reworded without notice. - **Conflicts**: Every command that writes from a folder (edit, add, remove, connect, rotate, push, checkout, org, deploy, deploy revoke, deploy targets-remove) runs bare `capy` first; `checkout -b` is the one exception (a new branch carries the uncommitted values of the folder). Off a terminal, if the folder and Capy differ on any variable, nothing is written, the exit code is 3, and stdout is {ok:false, code:"SECRETS_CONFLICT", conflict_id, conflicts:[{name, project, branch, type, pinned, local, remote}]} (fingerprints only, never values; a Dokploy value has {name, target, pinned, local, remote}). Ask the person, then rerun the same command with --resolve NAME=pinned|local|remote|delete for every row (NAME@TARGET=local|remote for a Dokploy value) and --conflict . A state that moved since is SECRETS_CONFLICT_STALE with the fresh rows. There is no resolve-all. -- **Elicitation**: A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. End your reply with `display` (a markdown table) exactly as given, without shortening or reordering it, then the short `question`, and stop. Never use a question box or a preview for it; the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, default?, description?, options?:[{value, label}]}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. A field with `options` is a choice: the human picks a `label`, and you fill in its `value`. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` missing the name or `--branch` in agent mode refuses with PROJECT_NAME_REQUIRED or BRANCH_NAME_REQUIRED and a form (fields: name, branch; details: {org:{id,name}}); `capy org delete-project` / `restore-project` with a name several projects share refuses with PROJECT_AMBIGUOUS and a form with one `project` field whose options are the projects (details: {org:{id,name}, matches:[{id, name, created_at, variables}]}). Never answer it yourself and never add --yes on your own. +- **Elicitation**: A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. Write `display` (a markdown table) in your reply exactly as given, without shortening or reordering it and never inside the question. Then ask the short `question` as an elicitation with your question tool (one question per field, its `options` or `default` as the choices); without a question tool, end your reply with the `question` and the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, default?, description?, options?:[{value, label}]}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. A field with `options` is a choice: the human picks a `label`, and you fill in its `value`. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` missing the name or `--branch` in agent mode refuses with PROJECT_NAME_REQUIRED or BRANCH_NAME_REQUIRED and a form (fields: name, branch; details: {org:{id,name}}); `capy org delete-project` / `restore-project` with a name several projects share refuses with PROJECT_AMBIGUOUS and a form with one `project` field whose options are the projects (details: {org:{id,name}, matches:[{id, name, created_at, variables}]}). Never answer it yourself and never add --yes on your own. diff --git a/src/commands/agentsCommand.ts b/src/commands/agentsCommand.ts index 0b1de65f..2cd48fff 100644 --- a/src/commands/agentsCommand.ts +++ b/src/commands/agentsCommand.ts @@ -96,10 +96,13 @@ function existingAgentsFileNames(root: string): string[] { return AGENTS_FILE_NAMES.filter((name) => existsSync(join(root, name))); } -/** Which file(s) a write targets: whichever of AGENTS.md/CLAUDE.md exist; if neither exists, AGENTS.md only. */ -function writeTargetFileNames(root: string): string[] { - const existing = existingAgentsFileNames(root); - return existing.length > 0 ? existing : ['AGENTS.md']; +/** + * Which file(s) a write targets: always both AGENTS.md and CLAUDE.md, creating whichever is missing. Agents read + * different files (Claude Code reads CLAUDE.md, others read AGENTS.md), and an agent that never sees the block asks its + * own questions instead of letting the CLI's elicitations ask. + */ +function writeTargetFileNames(_root: string): string[] { + return [...AGENTS_FILE_NAMES]; } function malformedError(name: string): CapyError { diff --git a/src/core/agentsBlockPlan.ts b/src/core/agentsBlockPlan.ts index a0d99090..3fb43665 100644 --- a/src/core/agentsBlockPlan.ts +++ b/src/core/agentsBlockPlan.ts @@ -31,7 +31,7 @@ export const AGENTS_BLOCK = [ '- Commands that write run bare `capy` first. If your folder and Capy differ they write nothing and exit 3 with `SECRETS_CONFLICT`, a `conflict_id`, and the differing names (fingerprints, never values).', // COPY-FLAG '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', // COPY-FLAG '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', // COPY-FLAG - '- If a JSON result has an `elicitation`, end your reply with its `display` table exactly as given (never shorten or reorder it), then its short `question`, and stop. Never use a question box or a preview for it. Wait for the human to answer in chat, then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', // COPY-FLAG + '- If a JSON result has an `elicitation`, write its `display` table in your reply exactly as given (never shorten or reorder it, never put it inside the question). Then ask its short `question` as an elicitation with your question tool: one question per field, offering the field\'s `options` (or its `default`) as the choices. Without a question tool, end your reply with the `question` and wait for the human to answer in chat. Then run the elicitation\'s answer command with what the human chose. Never answer it yourself and never add `--yes` on your own.', // COPY-FLAG '- Only when the human asks to create a project, run `capy org create-project --json`, adding the name and `--branch ` only if the human already gave them. It creates nothing until it has both and returns an `elicitation` asking for the rest: that is how you ask the human, so do not ask your own question first.', // COPY-FLAG '- Only when the human asks to delete a project, run `capy org delete-project --json` with the name they gave and without `--yes`, even if this folder\'s project is deleted or is a different one: it looks the name up in the organization, not in this folder. It changes nothing and returns an `elicitation`: that is how you ask the human, so do not ask your own question first. Only an org owner can delete or restore a project.', // COPY-FLAG '- `PROJECT_DELETED`, or `project.state` `"deleted"` in `capy info --json`, means an org owner deleted this folder\'s project. Stop and tell the human. An org owner can restore it with `capy org restore-project ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`. This is about this folder only: it never stops a delete or restore the human asked for by name.', // COPY-FLAG diff --git a/src/ui/elicitation.ts b/src/ui/elicitation.ts index 367133df..d8799a46 100644 --- a/src/ui/elicitation.ts +++ b/src/ui/elicitation.ts @@ -48,4 +48,4 @@ export type Elicitation = ElicitationConfirm | ElicitationForm; /** What `capy help --json` says about elicitations (`conventions.elicitation`). */ export const ELICITATION_HELP = // COPY-FLAG: minimal and neutral. - 'A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. End your reply with `display` (a markdown table) exactly as given, without shortening or reordering it, then the short `question`, and stop. Never use a question box or a preview for it; the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, default?, description?, options?:[{value, label}]}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. A field with `options` is a choice: the human picks a `label`, and you fill in its `value`. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` missing the name or `--branch` in agent mode refuses with PROJECT_NAME_REQUIRED or BRANCH_NAME_REQUIRED and a form (fields: name, branch; details: {org:{id,name}}); `capy org delete-project` / `restore-project` with a name several projects share refuses with PROJECT_AMBIGUOUS and a form with one `project` field whose options are the projects (details: {org:{id,name}, matches:[{id, name, created_at, variables}]}). Never answer it yourself and never add --yes on your own.'; + 'A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. Write `display` (a markdown table) in your reply exactly as given, without shortening or reordering it and never inside the question. Then ask the short `question` as an elicitation with your question tool (one question per field, its `options` or `default` as the choices); without a question tool, end your reply with the `question` and the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, default?, description?, options?:[{value, label}]}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. A field with `options` is a choice: the human picks a `label`, and you fill in its `value`. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` missing the name or `--branch` in agent mode refuses with PROJECT_NAME_REQUIRED or BRANCH_NAME_REQUIRED and a form (fields: name, branch; details: {org:{id,name}}); `capy org delete-project` / `restore-project` with a name several projects share refuses with PROJECT_AMBIGUOUS and a form with one `project` field whose options are the projects (details: {org:{id,name}, matches:[{id, name, created_at, variables}]}). Never answer it yourself and never add --yes on your own.'; diff --git a/tests/commands/agentsCommand.test.ts b/tests/commands/agentsCommand.test.ts index f9536d36..5244f618 100644 --- a/tests/commands/agentsCommand.test.ts +++ b/tests/commands/agentsCommand.test.ts @@ -138,7 +138,7 @@ describe('agentsCommand', () => { it('write plan reports the change and writes nothing', async () => { await withTempRoot((root) => { const files = writeAgentsBlock(root, false); - expect(files).toEqual([{ path: 'AGENTS.md', action: 'created' }]); + expect(files).toEqual([{ path: 'AGENTS.md', action: 'created' }, { path: 'CLAUDE.md', action: 'created' }]); expect(existsSync(join(root, 'AGENTS.md'))).toBe(false); }); }); @@ -148,37 +148,46 @@ describe('agentsCommand', () => { writeAgentsBlock(root); const before = readFileSync(join(root, 'AGENTS.md'), 'utf-8'); const files = removeAgentsBlockFromFiles(root, false); - expect(files).toEqual([{ path: 'AGENTS.md', action: 'removed' }]); + expect(files).toEqual([{ path: 'AGENTS.md', action: 'removed' }, { path: 'CLAUDE.md', action: 'removed' }]); expect(readFileSync(join(root, 'AGENTS.md'), 'utf-8')).toBe(before); }); }); }); describe('writeAgentsBlock (file targeting)', () => { - it('neither AGENTS.md nor CLAUDE.md exists: creates AGENTS.md only', async () => { + it('neither AGENTS.md nor CLAUDE.md exists: creates both, so every agent sees the block', async () => { await withTempRoot((root) => { const files = writeAgentsBlock(root); - expect(files).toEqual([{ path: 'AGENTS.md', action: 'created' }]); - expect(existsSync(join(root, 'CLAUDE.md'))).toBe(false); + expect(files).toEqual([ + { path: 'AGENTS.md', action: 'created' }, + { path: 'CLAUDE.md', action: 'created' }, + ]); expect(readFileSync(join(root, 'AGENTS.md'), 'utf-8')).toContain(''); + expect(readFileSync(join(root, 'CLAUDE.md'), 'utf-8')).toContain(''); }); }); - it('only AGENTS.md exists: updates AGENTS.md only, never creates CLAUDE.md', async () => { + it('only AGENTS.md exists: updates it and creates CLAUDE.md (Claude Code reads CLAUDE.md)', async () => { await withTempRoot((root) => { writeFileSync(join(root, 'AGENTS.md'), '# Repo\n'); const files = writeAgentsBlock(root); - expect(files).toEqual([{ path: 'AGENTS.md', action: 'updated' }]); - expect(existsSync(join(root, 'CLAUDE.md'))).toBe(false); + expect(files).toEqual([ + { path: 'AGENTS.md', action: 'updated' }, + { path: 'CLAUDE.md', action: 'created' }, + ]); + expect(readFileSync(join(root, 'AGENTS.md'), 'utf-8').startsWith('# Repo\n')).toBe(true); }); }); - it('only CLAUDE.md exists: updates CLAUDE.md only, never creates AGENTS.md', async () => { + it('only CLAUDE.md exists: updates it and creates AGENTS.md', async () => { await withTempRoot((root) => { writeFileSync(join(root, 'CLAUDE.md'), '# Repo\n'); const files = writeAgentsBlock(root); - expect(files).toEqual([{ path: 'CLAUDE.md', action: 'updated' }]); - expect(existsSync(join(root, 'AGENTS.md'))).toBe(false); + expect(files).toEqual([ + { path: 'AGENTS.md', action: 'created' }, + { path: 'CLAUDE.md', action: 'updated' }, + ]); + expect(readFileSync(join(root, 'CLAUDE.md'), 'utf-8').startsWith('# Repo\n')).toBe(true); }); }); @@ -201,7 +210,7 @@ describe('agentsCommand', () => { writeAgentsBlock(root); const before = readFileSync(join(root, 'AGENTS.md'), 'utf-8'); const files = writeAgentsBlock(root); - expect(files).toEqual([{ path: 'AGENTS.md', action: 'unchanged' }]); + expect(files).toEqual([{ path: 'AGENTS.md', action: 'unchanged' }, { path: 'CLAUDE.md', action: 'unchanged' }]); expect(readFileSync(join(root, 'AGENTS.md'), 'utf-8')).toBe(before); }); }); @@ -276,7 +285,7 @@ describe('agentsCommand', () => { writeAgentsBlock(root); // AGENTS.md now holds ONLY the block expect(existsSync(join(root, 'AGENTS.md'))).toBe(true); const files = removeAgentsBlockFromFiles(root); - expect(files).toEqual([{ path: 'AGENTS.md', action: 'removed' }]); + expect(files).toEqual([{ path: 'AGENTS.md', action: 'removed' }, { path: 'CLAUDE.md', action: 'removed' }]); expect(existsSync(join(root, 'AGENTS.md'))).toBe(false); }); }); @@ -399,7 +408,7 @@ describe('agentsCommand', () => { const { exitCode, stdout } = await capture(() => agentsCommand({ json: true })); expect(exitCode).toBeUndefined(); const payload = JSON.parse(stdout); - expect(payload).toEqual({ ok: true, files: [{ path: 'AGENTS.md', action: 'created' }] }); + expect(payload).toEqual({ ok: true, files: [{ path: 'AGENTS.md', action: 'created' }, { path: 'CLAUDE.md', action: 'created' }] }); expect(existsSync(join(root, 'AGENTS.md'))).toBe(true); // --json keeps stdout pure JSON, so the confirm prompt must not render there. expect(createPromptModuleMock.mock.calls.length).toBeGreaterThan(0); @@ -433,7 +442,7 @@ describe('agentsCommand', () => { fakePrompt.mockImplementationOnce(async () => ({ confirmed: true })); const { stdout } = await capture(() => agentsCommand({ remove: true, json: true })); const payload = JSON.parse(stdout); - expect(payload).toEqual({ ok: true, files: [{ path: 'AGENTS.md', action: 'removed' }] }); + expect(payload).toEqual({ ok: true, files: [{ path: 'AGENTS.md', action: 'removed' }, { path: 'CLAUDE.md', action: 'removed' }] }); expect(existsSync(join(root, 'AGENTS.md'))).toBe(false); }); }); @@ -551,13 +560,13 @@ describe('agentsCommand', () => { expect(first.exitCode).toBeUndefined(); expect(fakePrompt).not.toHaveBeenCalled(); const payload = JSON.parse(first.stdout.trim()); // throws if stdout carries anything but one JSON value - expect(payload).toEqual({ ok: true, files: [{ path: 'AGENTS.md', action: 'created' }] }); + expect(payload).toEqual({ ok: true, files: [{ path: 'AGENTS.md', action: 'created' }, { path: 'CLAUDE.md', action: 'created' }] }); expect(existsSync(join(root, 'AGENTS.md'))).toBe(true); const before = readFileSync(join(root, 'AGENTS.md'), 'utf-8'); const second = await runCli(['agents', '--json', '--yes']); const secondPayload = JSON.parse(second.stdout.trim()); - expect(secondPayload).toEqual({ ok: true, files: [{ path: 'AGENTS.md', action: 'unchanged' }] }); + expect(secondPayload).toEqual({ ok: true, files: [{ path: 'AGENTS.md', action: 'unchanged' }, { path: 'CLAUDE.md', action: 'unchanged' }] }); expect(readFileSync(join(root, 'AGENTS.md'), 'utf-8')).toBe(before); }); }); diff --git a/tests/core/agentsBlockPlan.test.ts b/tests/core/agentsBlockPlan.test.ts index cad92edd..b3dfb44d 100644 --- a/tests/core/agentsBlockPlan.test.ts +++ b/tests/core/agentsBlockPlan.test.ts @@ -39,7 +39,7 @@ describe('AGENTS_BLOCK', () => { '- Commands that write run bare `capy` first. If your folder and Capy differ they write nothing and exit 3 with `SECRETS_CONFLICT`, a `conflict_id`, and the differing names (fingerprints, never values).', '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', - '- If a JSON result has an `elicitation`, end your reply with its `display` table exactly as given (never shorten or reorder it), then its short `question`, and stop. Never use a question box or a preview for it. Wait for the human to answer in chat, then rerun with their answer. Never answer it yourself and never add `--yes` on your own.', + '- If a JSON result has an `elicitation`, write its `display` table in your reply exactly as given (never shorten or reorder it, never put it inside the question). Then ask its short `question` as an elicitation with your question tool: one question per field, offering the field\'s `options` (or its `default`) as the choices. Without a question tool, end your reply with the `question` and wait for the human to answer in chat. Then run the elicitation\'s answer command with what the human chose. Never answer it yourself and never add `--yes` on your own.', '- Only when the human asks to create a project, run `capy org create-project --json`, adding the name and `--branch ` only if the human already gave them. It creates nothing until it has both and returns an `elicitation` asking for the rest: that is how you ask the human, so do not ask your own question first.', "- Only when the human asks to delete a project, run `capy org delete-project --json` with the name they gave and without `--yes`, even if this folder's project is deleted or is a different one: it looks the name up in the organization, not in this folder. It changes nothing and returns an `elicitation`: that is how you ask the human, so do not ask your own question first. Only an org owner can delete or restore a project.", "- `PROJECT_DELETED`, or `project.state` `\"deleted\"` in `capy info --json`, means an org owner deleted this folder's project. Stop and tell the human. An org owner can restore it with `capy org restore-project ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`. This is about this folder only: it never stops a delete or restore the human asked for by name.", From 66b46ca67d1c09c90d0f86ca15968958e365d3f2 Mon Sep 17 00:00:00 2001 From: cvince Date: Mon, 9 Nov 2026 16:36:11 -0700 Subject: [PATCH 19/27] feat(org): create-project asks whether the first branch is protected; approved branch wording Same yes/no as capy checkout -b, in the terminal and as an elicitation field (options yes/no, default no); --protected yes|no; BRANCH_PROTECTION_REQUIRED / BRANCH_PROTECTION_INVALID. Each elicitation field can carry its own question. Branch question and description use Vince's approved wording. --- docs/cli-reference.md | 7 +- src/commands/projectCreateCommand.ts | 137 ++++++++++++++++---- src/core/agentsBlockPlan.ts | 4 +- src/core/branchNameRule.ts | 7 +- src/index-dev.ts | 3 +- src/index.ts | 3 +- src/types/index.ts | 4 + src/ui/elicitation.ts | 4 +- src/ui/elicitationDisplay.ts | 8 ++ src/ui/promptEngine.ts | 11 ++ tests/commands/projectCreateCommand.test.ts | 84 +++++++++--- tests/core/agentsBlockPlan.test.ts | 4 +- tests/ui/elicitationDisplay.test.ts | 3 +- 13 files changed, 226 insertions(+), 53 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index c336ef6f..ff7e8af9 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -492,7 +492,8 @@ capy org create-project [name] [options] | Option | Description | Default | |---|---|---| -| `--branch ` | the first branch: an independent set of values for the project (offered by default: development) | | +| `--branch ` | the first branch; a Capy branch works like a git branch (offered by default: development) | | +| `--protected ` | whether the first branch is protected (invite-only) | | | `--non-tty` | treat stdin as not a terminal: never ask for the name or branch; without them, refuse with a form to ask the human | | | `--json` | emit machine-readable JSON instead of the human UI | | @@ -1138,6 +1139,8 @@ Every refusal carries a stable `code` — branch on it, never on message text. - `BRANCH_NAME_REQUIRED` - `BRANCH_NAME_INVALID` - `BRANCH_CREATE_FAILED` +- `BRANCH_PROTECTION_REQUIRED` +- `BRANCH_PROTECTION_INVALID` - `ORG_SELECTION_REQUIRED` - `REPO_LINKS_UNSUPPORTED` - `DRY_RUN_UNSUPPORTED` @@ -1165,4 +1168,4 @@ Every refusal carries a stable `code` — branch on it, never on message text. - **JSON**: Pass --json on any command whose supportsJson is true and parse stdout as JSON; prose (progress, prompts, errors) goes to stderr so stdout stays pure JSON. - **Codes**: Branch only on the machine-readable `code` field (from --json output or a CapyError), never on human-readable message text — messages may be reworded without notice. - **Conflicts**: Every command that writes from a folder (edit, add, remove, connect, rotate, push, checkout, org, deploy, deploy revoke, deploy targets-remove) runs bare `capy` first; `checkout -b` is the one exception (a new branch carries the uncommitted values of the folder). Off a terminal, if the folder and Capy differ on any variable, nothing is written, the exit code is 3, and stdout is {ok:false, code:"SECRETS_CONFLICT", conflict_id, conflicts:[{name, project, branch, type, pinned, local, remote}]} (fingerprints only, never values; a Dokploy value has {name, target, pinned, local, remote}). Ask the person, then rerun the same command with --resolve NAME=pinned|local|remote|delete for every row (NAME@TARGET=local|remote for a Dokploy value) and --conflict . A state that moved since is SECRETS_CONFLICT_STALE with the fresh rows. There is no resolve-all. -- **Elicitation**: A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. Write `display` (a markdown table) in your reply exactly as given, without shortening or reordering it and never inside the question. Then ask the short `question` as an elicitation with your question tool (one question per field, its `options` or `default` as the choices); without a question tool, end your reply with the `question` and the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, default?, description?, options?:[{value, label}]}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. A field with `options` is a choice: the human picks a `label`, and you fill in its `value`. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` missing the name or `--branch` in agent mode refuses with PROJECT_NAME_REQUIRED or BRANCH_NAME_REQUIRED and a form (fields: name, branch; details: {org:{id,name}}); `capy org delete-project` / `restore-project` with a name several projects share refuses with PROJECT_AMBIGUOUS and a form with one `project` field whose options are the projects (details: {org:{id,name}, matches:[{id, name, created_at, variables}]}). Never answer it yourself and never add --yes on your own. +- **Elicitation**: A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. Write `display` (a markdown table) in your reply exactly as given, without shortening or reordering it and never inside the question. Then ask the short `question` as an elicitation with your question tool: one question per field, using the field's own `question` followed by its `description` when it has them, and its `options` or `default` as the choices; without a question tool, end your reply with the `question` and the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, question?, default?, description?, options?:[{value, label}]}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. A field with `options` is a choice: the human picks a `label`, and you fill in its `value`. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` missing the name, `--branch` or `--protected` in agent mode refuses with PROJECT_NAME_REQUIRED, BRANCH_NAME_REQUIRED or BRANCH_PROTECTION_REQUIRED and a form (fields: name, branch, protected (options yes/no); details: {org:{id,name}}); `capy org delete-project` / `restore-project` with a name several projects share refuses with PROJECT_AMBIGUOUS and a form with one `project` field whose options are the projects (details: {org:{id,name}, matches:[{id, name, created_at, variables}]}). Never answer it yourself and never add --yes on your own. diff --git a/src/commands/projectCreateCommand.ts b/src/commands/projectCreateCommand.ts index 38b14824..f3922deb 100644 --- a/src/commands/projectCreateCommand.ts +++ b/src/commands/projectCreateCommand.ts @@ -10,13 +10,16 @@ * an `elicitation` of kind "form" (`ui/elicitation.ts`) asking for both, that the agent puts to the human. In a * terminal it asks for the name with the init prompt and for the branch with the init branch question. * + * Whether the first branch is protected (invite-only) is asked the way `capy checkout -b` asks it: a yes/no in a + * terminal, and the same yes/no as a field of the elicitation (BRANCH_PROTECTION_REQUIRED when only that is missing). + * * Every refusal is coded, and every decision keys on a code, an HTTP status or a typed field, never on message * text. The service answers a duplicate name with a bare 409 and no `code`, so the STATUS decides that one. */ import { resolveOrgContext } from '../core/orgContext'; import { ProjectManager } from '../core/projectManager'; import { PROJECT_NAME_PATTERN_SOURCE, projectNameProblem } from '../core/projectNameRule'; -import { BRANCH_DESCRIPTION, BRANCH_NAME_PATTERN_SOURCE, DEFAULT_FIRST_BRANCH, branchNameProblem } from '../core/branchNameRule'; +import { BRANCH_DESCRIPTION, BRANCH_NAME_PATTERN_SOURCE, DEFAULT_FIRST_BRANCH, FIRST_BRANCH_QUESTION, branchNameProblem } from '../core/branchNameRule'; import { PromptEngine } from '../ui/promptEngine'; import { isInteractive, EXIT_NEEDS_INPUT } from '../ui/interactive'; import type { ElicitationForm } from '../ui/elicitation'; @@ -32,6 +35,8 @@ export interface CreateProjectOpts { dryRun?: boolean; /** `--branch `: the project's first branch. */ branch?: string; + /** `--protected yes|no` as typed: whether the first branch is invite-only. Checked before anything is sent. */ + protectedAnswer?: string; apiUrl?: string; devMode?: boolean; } @@ -49,6 +54,7 @@ export function createProjectOptsFrom(command: CommandWithGlobals): CreateProjec nonTty: merged.nonTty === true, dryRun: merged.dryRun === true, ...(typeof merged.branch === 'string' ? { branch: merged.branch.trim() } : {}), + ...(typeof merged.protected === 'string' ? { protectedAnswer: merged.protected.trim().toLowerCase() } : {}), }; } @@ -62,12 +68,35 @@ interface ProjectRef { readonly name: string; } +/** The first branch as it will be created. */ +interface FirstBranch { + readonly name: string; + readonly protected: boolean; +} + type CreateOutcome = - | { readonly kind: 'needs-input'; readonly missing: 'name' | 'branch'; readonly elicitation: ElicitationForm; readonly org: OrgRef } + | { readonly kind: 'needs-input'; readonly missing: 'name' | 'branch' | 'protected'; readonly elicitation: ElicitationForm; readonly org: OrgRef } | { readonly kind: 'exists'; readonly existing: ProjectRef; readonly org: OrgRef } - | { readonly kind: 'dry-run'; readonly name: string; readonly branch: string; readonly org: OrgRef } - | { readonly kind: 'branch-failed'; readonly project: ProjectRef; readonly branch: string; readonly org: OrgRef } - | { readonly kind: 'created'; readonly project: ProjectRef; readonly branch: string; readonly org: OrgRef }; + | { readonly kind: 'dry-run'; readonly name: string; readonly branch: FirstBranch; readonly org: OrgRef } + | { readonly kind: 'branch-failed'; readonly project: ProjectRef; readonly branch: FirstBranch; readonly org: OrgRef } + | { readonly kind: 'created'; readonly project: ProjectRef; readonly branch: FirstBranch; readonly org: OrgRef }; + +/** `--protected` answers, as the elicitation's options carry them. */ +const PROTECTED_YES = 'yes'; +const PROTECTED_NO = 'no'; + +/** The answer typed with `--protected`, as a boolean; undefined when none was typed. Checked to be yes or no first. */ +function protectedFrom(answer: string | undefined): boolean | undefined { + if (answer === undefined) return undefined; + return answer === PROTECTED_YES; +} + +/** The yes/no question, worded as `capy checkout -b` asks it. Before the branch has a name it says "the first branch". */ +function protectedQuestion(branch: string | undefined): string { + return branch === undefined + ? 'Make the first branch a protected branch? (invite-only)' // COPY-FLAG: checkout's wording, adapted for an unnamed branch + : `Make "${branch}" a protected branch? (invite-only)`; +} /** * The folder's name as a project name, when it is allowed as one and no live project in the org has it already. @@ -86,6 +115,7 @@ function createForm( org: OrgRef, name: string | undefined, branch: string | undefined, + isProtected: boolean | undefined, isTaken: (name: string) => boolean, ): ElicitationForm { const nameDefault = name ?? suggestedName(isTaken); @@ -95,23 +125,36 @@ function createForm( ? 'What should the project and its first branch be called?' // COPY-FLAG : 'What should the first branch be called?', // COPY-FLAG details: { org: { id: org.id, name: org.name } }, - display: createProjectDisplay({ org, name, branch, branchDescription: BRANCH_DESCRIPTION }), + display: createProjectDisplay({ org, name, branch, isProtected, branchDescription: BRANCH_DESCRIPTION }), fields: [ { name: 'name', type: 'string', pattern: PROJECT_NAME_PATTERN_SOURCE, + question: 'What should the project be called?', // COPY-FLAG ...(nameDefault === undefined ? {} : { default: nameDefault }), }, { name: 'branch', type: 'string', pattern: BRANCH_NAME_PATTERN_SOURCE, + question: FIRST_BRANCH_QUESTION, default: branch ?? DEFAULT_FIRST_BRANCH, description: BRANCH_DESCRIPTION, }, + { + name: 'protected', + type: 'string', + pattern: `^(${PROTECTED_YES}|${PROTECTED_NO})$`, + question: protectedQuestion(branch), + default: isProtected === true ? PROTECTED_YES : PROTECTED_NO, + options: [ + { value: PROTECTED_YES, label: 'Yes' }, // COPY-FLAG + { value: PROTECTED_NO, label: 'No' }, // COPY-FLAG + ], + }, ], - answer: { rerun: 'capy org create-project --branch --json' }, + answer: { rerun: 'capy org create-project --branch --protected --json' }, }; } @@ -129,6 +172,13 @@ async function chooseBranch(typed: string | undefined, agentMode: boolean): Prom return new PromptEngine().promptForFirstBranch(); } +/** Whether the first branch is protected: as typed, else (in a terminal) as asked, else none. */ +async function chooseProtected(typed: boolean | undefined, branch: string, agentMode: boolean): Promise { + if (typed !== undefined) return typed; + if (agentMode) return undefined; + return new PromptEngine().promptForBranchProtected(branch); +} + /** The client calls `createFlow` makes: resolving the org hands it over already signed in. */ interface CreateClient { listProjects(): Promise>; @@ -160,24 +210,30 @@ async function createFlow(typed: string | undefined, opts: CreateProjectOpts, ag const client: CreateClient = serviceClient; const projects = await client.listProjects(); const isTaken = (candidate: string) => existingProjectNamed(projects, orgId, candidate) !== undefined; + const typedProtected = protectedFrom(opts.protectedAnswer); if (name === undefined) { - return { kind: 'needs-input', missing: 'name', elicitation: createForm(org, undefined, opts.branch, isTaken), org }; + return { kind: 'needs-input', missing: 'name', elicitation: createForm(org, undefined, opts.branch, typedProtected, isTaken), org }; } // The clash is checked before the branch is asked, so nobody picks a branch for a project that cannot be created. const existing = existingProjectNamed(projects, orgId, name); if (existing !== undefined) return { kind: 'exists', existing, org }; - const branch = await chooseBranch(opts.branch, agentMode); - if (branch === undefined) { - return { kind: 'needs-input', missing: 'branch', elicitation: createForm(org, name, undefined, isTaken), org }; + const branchName = await chooseBranch(opts.branch, agentMode); + if (branchName === undefined) { + return { kind: 'needs-input', missing: 'branch', elicitation: createForm(org, name, undefined, typedProtected, isTaken), org }; } + const isProtected = await chooseProtected(typedProtected, branchName, agentMode); + if (isProtected === undefined) { + return { kind: 'needs-input', missing: 'protected', elicitation: createForm(org, name, branchName, undefined, isTaken), org }; + } + const branch: FirstBranch = { name: branchName, protected: isProtected }; if (opts.dryRun === true) return { kind: 'dry-run', name, branch, org }; const created = await client.initializeProject(name, orgId); const project: ProjectRef = { id: created.project_id, name: created.project_name }; // The project exists from here on: a failed branch is reported with the project, never as "nothing happened". - const branchMade = await settle(() => client.createBranch(project.id, branch, false)); + const branchMade = await settle(() => client.createBranch(project.id, branch.name, branch.protected)); return branchMade.ok ? { kind: 'created', project, branch, org } : { kind: 'branch-failed', project, branch, org }; @@ -195,13 +251,33 @@ function createRefusalFor(err: unknown): Refusal { return refusalFor(err); } +/** The refusal for each thing that can still be missing, with nobody to ask. */ +const NEEDS_INPUT: Readonly> = { + name: { + code: ERROR_CODES.PROJECT_NAME_REQUIRED, + message: '`capy org create-project` needs a project name. Pass it as an argument, or run it in a terminal.', // COPY-FLAG + }, + branch: { + code: ERROR_CODES.BRANCH_NAME_REQUIRED, + message: '`capy org create-project` needs a first branch. Pass it with --branch, or run it in a terminal.', // COPY-FLAG + }, + protected: { + code: ERROR_CODES.BRANCH_PROTECTION_REQUIRED, + message: '`capy org create-project` needs to know whether the first branch is protected. Pass --protected yes or no, or run it in a terminal.', // COPY-FLAG + }, +}; + +/** The branch as JSON reports it. */ +function branchJson(branch: FirstBranch): { readonly name: string; readonly protected: boolean } { + return { name: branch.name, protected: branch.protected }; +} + function emitCreateOutcome(json: boolean, outcome: CreateOutcome): void { if (outcome.kind === 'needs-input') { + const { code, message } = NEEDS_INPUT[outcome.missing]; return refuse(json, { - code: outcome.missing === 'name' ? ERROR_CODES.PROJECT_NAME_REQUIRED : ERROR_CODES.BRANCH_NAME_REQUIRED, - message: outcome.missing === 'name' - ? '`capy org create-project` needs a project name. Pass it as an argument, or run it in a terminal.' // COPY-FLAG - : '`capy org create-project` needs a first branch. Pass it with --branch, or run it in a terminal.', // COPY-FLAG + code, + message, exitCode: EXIT_NEEDS_INPUT, extra: { elicitation: outcome.elicitation }, lines: [` Organization: ${outcome.org.name}`], // COPY-FLAG @@ -210,9 +286,9 @@ function emitCreateOutcome(json: boolean, outcome: CreateOutcome): void { if (outcome.kind === 'branch-failed') { return refuse(json, { code: ERROR_CODES.BRANCH_CREATE_FAILED, - message: `Created project ${outcome.project.name}, but not its first branch ${outcome.branch}.`, // COPY-FLAG + message: `Created project ${outcome.project.name}, but not its first branch ${outcome.branch.name}.`, // COPY-FLAG exitCode: 1, - extra: { project: outcome.project, branch: { name: outcome.branch }, org: outcome.org }, + extra: { project: outcome.project, branch: branchJson(outcome.branch), org: outcome.org }, }); } if (outcome.kind === 'exists') { @@ -226,17 +302,18 @@ function emitCreateOutcome(json: boolean, outcome: CreateOutcome): void { } if (outcome.kind === 'dry-run') { if (json) { - console.log(JSON.stringify({ ok: true, dry_run: true, project: { name: outcome.name }, branch: { name: outcome.branch }, org: outcome.org }, null, 2)); + console.log(JSON.stringify({ ok: true, dry_run: true, project: { name: outcome.name }, branch: branchJson(outcome.branch), org: outcome.org }, null, 2)); return; } - console.log(`Dry run: would create project ${outcome.name} with branch ${outcome.branch} in ${outcome.org.name}. Nothing was created.`); // COPY-FLAG + console.log(`Dry run: would create project ${outcome.name} with branch ${outcome.branch.name} in ${outcome.org.name}. Nothing was created.`); // COPY-FLAG return; } if (json) { - console.log(JSON.stringify({ ok: true, project: outcome.project, branch: { name: outcome.branch }, org: outcome.org }, null, 2)); + console.log(JSON.stringify({ ok: true, project: outcome.project, branch: branchJson(outcome.branch), org: outcome.org }, null, 2)); return; } - console.log(`Created project ${outcome.project.name} with branch ${outcome.branch} in ${outcome.org.name}.`); // COPY-FLAG + console.log(`Created project ${outcome.project.name} with branch ${outcome.branch.name} in ${outcome.org.name}.`); // COPY-FLAG + if (outcome.branch.protected) console.log(`"${outcome.branch.name}" is a protected branch — access is invite-only`); } /** `capy org create-project [name] [--dry-run] [--json]` */ @@ -265,11 +342,21 @@ export async function createProjectCommand(name: string | undefined, opts: Creat }); } - // A dry run asks nothing, and in a terminal the only thing left to do without a name or branch is ask. - if (opts.dryRun === true && (name === undefined || opts.branch === undefined) && !agentMode) { + if (opts.protectedAnswer !== undefined && opts.protectedAnswer !== PROTECTED_YES && opts.protectedAnswer !== PROTECTED_NO) { + return refuse(json, { + code: ERROR_CODES.BRANCH_PROTECTION_INVALID, + message: '--protected takes yes or no.', // COPY-FLAG + exitCode: 1, + extra: { options: [PROTECTED_YES, PROTECTED_NO] }, + }); + } + + // A dry run asks nothing, and in a terminal the only thing left to do without a name, branch or protection is ask. + const missingSomething = name === undefined || opts.branch === undefined || opts.protectedAnswer === undefined; + if (opts.dryRun === true && missingSomething && !agentMode) { return refuse(json, { code: ERROR_CODES.DRY_RUN_UNSUPPORTED, - message: '`capy org create-project` would ask for the name or branch, so it cannot preview. Pass both.', // COPY-FLAG + message: '`capy org create-project` would ask for the name, branch or protection, so it cannot preview. Pass all three.', // COPY-FLAG exitCode: 1, }); } diff --git a/src/core/agentsBlockPlan.ts b/src/core/agentsBlockPlan.ts index 3fb43665..c0679c53 100644 --- a/src/core/agentsBlockPlan.ts +++ b/src/core/agentsBlockPlan.ts @@ -31,8 +31,8 @@ export const AGENTS_BLOCK = [ '- Commands that write run bare `capy` first. If your folder and Capy differ they write nothing and exit 3 with `SECRETS_CONFLICT`, a `conflict_id`, and the differing names (fingerprints, never values).', // COPY-FLAG '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', // COPY-FLAG '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', // COPY-FLAG - '- If a JSON result has an `elicitation`, write its `display` table in your reply exactly as given (never shorten or reorder it, never put it inside the question). Then ask its short `question` as an elicitation with your question tool: one question per field, offering the field\'s `options` (or its `default`) as the choices. Without a question tool, end your reply with the `question` and wait for the human to answer in chat. Then run the elicitation\'s answer command with what the human chose. Never answer it yourself and never add `--yes` on your own.', // COPY-FLAG - '- Only when the human asks to create a project, run `capy org create-project --json`, adding the name and `--branch ` only if the human already gave them. It creates nothing until it has both and returns an `elicitation` asking for the rest: that is how you ask the human, so do not ask your own question first.', // COPY-FLAG + '- If a JSON result has an `elicitation`, write its `display` table in your reply exactly as given (never shorten or reorder it, never put it inside the question). Then ask its short `question` as an elicitation with your question tool: one question per field, using the field\'s own `question` followed by its `description` when it has them, and offering its `options` (or its `default`) as the choices. Without a question tool, end your reply with the `question` and wait for the human to answer in chat. Then run the elicitation\'s answer command with what the human chose. Never answer it yourself and never add `--yes` on your own.', // COPY-FLAG + '- Only when the human asks to create a project, run `capy org create-project --json`, adding the name, `--branch ` and `--protected yes|no` only if the human already gave them. It creates nothing until it has all three and returns an `elicitation` asking for the rest: that is how you ask the human, so do not ask your own question first.', // COPY-FLAG '- Only when the human asks to delete a project, run `capy org delete-project --json` with the name they gave and without `--yes`, even if this folder\'s project is deleted or is a different one: it looks the name up in the organization, not in this folder. It changes nothing and returns an `elicitation`: that is how you ask the human, so do not ask your own question first. Only an org owner can delete or restore a project.', // COPY-FLAG '- `PROJECT_DELETED`, or `project.state` `"deleted"` in `capy info --json`, means an org owner deleted this folder\'s project. Stop and tell the human. An org owner can restore it with `capy org restore-project ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`. This is about this folder only: it never stops a delete or restore the human asked for by name.', // COPY-FLAG '- Never print, log, or commit secret values.', diff --git a/src/core/branchNameRule.ts b/src/core/branchNameRule.ts index 8ce47b81..c09ad66d 100644 --- a/src/core/branchNameRule.ts +++ b/src/core/branchNameRule.ts @@ -16,8 +16,11 @@ export const BRANCH_NAME_MAX_LENGTH = 128; /** The branch a new project starts with unless someone picks another. Bare `capy` and `capy org` offer the same. */ export const DEFAULT_FIRST_BRANCH = 'development'; -/** What a Capy branch is, word for word from the docs (using/branches/overview.mdx). */ -export const BRANCH_DESCRIPTION = 'A Capy branch is an independent set of values for the same project.'; +/** What a Capy branch is. Approved wording (Vince, 2026-11-09): use it verbatim. */ +export const BRANCH_DESCRIPTION = 'A Capy branch works like a git branch. Capy branches can be used to set different variables for different environments.'; + +/** The question for the first branch. Approved wording (Vince, 2026-11-09): use it verbatim. */ +export const FIRST_BRANCH_QUESTION = 'What should its first branch be called?'; export interface BranchNameProblem { readonly code: typeof ERROR_CODES.BRANCH_NAME_INVALID; diff --git a/src/index-dev.ts b/src/index-dev.ts index df947c79..3da5d2ac 100644 --- a/src/index-dev.ts +++ b/src/index-dev.ts @@ -748,7 +748,8 @@ const orgCmd = program orgCmd .command('create-project [name]') .description('Create a project and its first branch in the active organization (owners and admins only; does not link this folder)') // COPY-FLAG - .option('--branch ', 'the first branch: an independent set of values for the project (offered by default: development)') // COPY-FLAG + .option('--branch ', 'the first branch; a Capy branch works like a git branch (offered by default: development)') // COPY-FLAG + .option('--protected ', 'whether the first branch is protected (invite-only)') // COPY-FLAG .option('--non-tty', 'treat stdin as not a terminal: never ask for the name or branch; without them, refuse with a form to ask the human') // COPY-FLAG .option('--json', 'emit machine-readable JSON instead of the human UI') .action(async (name: string | undefined, _options: any, command: any) => { diff --git a/src/index.ts b/src/index.ts index 07fbb569..418267b7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -784,7 +784,8 @@ const orgCmd = program orgCmd .command('create-project [name]') .description('Create a project and its first branch in the active organization (owners and admins only; does not link this folder)') // COPY-FLAG - .option('--branch ', 'the first branch: an independent set of values for the project (offered by default: development)') // COPY-FLAG + .option('--branch ', 'the first branch; a Capy branch works like a git branch (offered by default: development)') // COPY-FLAG + .option('--protected ', 'whether the first branch is protected (invite-only)') // COPY-FLAG .option('--non-tty', 'treat stdin as not a terminal: never ask for the name or branch; without them, refuse with a form to ask the human') // COPY-FLAG .option('--json', 'emit machine-readable JSON instead of the human UI') .action(async (name: string | undefined, _options: any, command: any) => { diff --git a/src/types/index.ts b/src/types/index.ts index c3deaa20..828c8d06 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -913,6 +913,10 @@ export const ERROR_CODES = { BRANCH_NAME_INVALID: 'BRANCH_NAME_INVALID', /** `capy org create-project`: the project was created but its first branch was not. The refusal carries the project. Exit 1. */ BRANCH_CREATE_FAILED: 'BRANCH_CREATE_FAILED', + /** `capy org create-project` with a name and branch but no `--protected`, and nobody to ask (agent mode). The refusal carries an `elicitation` form. Exit 3. */ + BRANCH_PROTECTION_REQUIRED: 'BRANCH_PROTECTION_REQUIRED', + /** `capy org create-project --protected` with something other than yes or no. Nothing is created. Exit 1. */ + BRANCH_PROTECTION_INVALID: 'BRANCH_PROTECTION_INVALID', /** A command that acts on "the active organization" has none (no keep.lock here) and the person belongs to several, with nobody to ask. Exit 3. */ ORG_SELECTION_REQUIRED: 'ORG_SELECTION_REQUIRED', /** `GET /orgs/:orgId/repos` answered 404: the service predates project -> repo links. Never a failure of the command that asked. */ diff --git a/src/ui/elicitation.ts b/src/ui/elicitation.ts index d8799a46..1f8cc2b4 100644 --- a/src/ui/elicitation.ts +++ b/src/ui/elicitation.ts @@ -18,6 +18,8 @@ export interface ElicitationField { readonly pattern: string; /** A value the human can accept as is. Absent when there is no sensible one. */ readonly default?: string; + /** The question to ask the human for this field. Ask it, then its `description`. */ + readonly question?: string; /** What the field is, for the human. Absent when the name says it all. */ readonly description?: string; /** When present, the answer must be one of these `value`s; `label` is what the human is shown. */ @@ -48,4 +50,4 @@ export type Elicitation = ElicitationConfirm | ElicitationForm; /** What `capy help --json` says about elicitations (`conventions.elicitation`). */ export const ELICITATION_HELP = // COPY-FLAG: minimal and neutral. - 'A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. Write `display` (a markdown table) in your reply exactly as given, without shortening or reordering it and never inside the question. Then ask the short `question` as an elicitation with your question tool (one question per field, its `options` or `default` as the choices); without a question tool, end your reply with the `question` and the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, default?, description?, options?:[{value, label}]}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. A field with `options` is a choice: the human picks a `label`, and you fill in its `value`. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` missing the name or `--branch` in agent mode refuses with PROJECT_NAME_REQUIRED or BRANCH_NAME_REQUIRED and a form (fields: name, branch; details: {org:{id,name}}); `capy org delete-project` / `restore-project` with a name several projects share refuses with PROJECT_AMBIGUOUS and a form with one `project` field whose options are the projects (details: {org:{id,name}, matches:[{id, name, created_at, variables}]}). Never answer it yourself and never add --yes on your own.'; + 'A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. Write `display` (a markdown table) in your reply exactly as given, without shortening or reordering it and never inside the question. Then ask the short `question` as an elicitation with your question tool: one question per field, using the field\'s own `question` followed by its `description` when it has them, and its `options` or `default` as the choices; without a question tool, end your reply with the `question` and the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, question?, default?, description?, options?:[{value, label}]}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. A field with `options` is a choice: the human picks a `label`, and you fill in its `value`. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` missing the name, `--branch` or `--protected` in agent mode refuses with PROJECT_NAME_REQUIRED, BRANCH_NAME_REQUIRED or BRANCH_PROTECTION_REQUIRED and a form (fields: name, branch, protected (options yes/no); details: {org:{id,name}}); `capy org delete-project` / `restore-project` with a name several projects share refuses with PROJECT_AMBIGUOUS and a form with one `project` field whose options are the projects (details: {org:{id,name}, matches:[{id, name, created_at, variables}]}). Never answer it yourself and never add --yes on your own.'; diff --git a/src/ui/elicitationDisplay.ts b/src/ui/elicitationDisplay.ts index 7e3db1b2..353ba4c4 100644 --- a/src/ui/elicitationDisplay.ts +++ b/src/ui/elicitationDisplay.ts @@ -72,6 +72,8 @@ export interface CreateDisplayInput { readonly name?: string; /** The branch already given, if any. */ readonly branch?: string; + /** Whether the branch is protected, if already given. */ + readonly isProtected?: boolean; /** What a branch is, shown under the table. */ readonly branchDescription: string; } @@ -79,12 +81,18 @@ export interface CreateDisplayInput { // COPY-FLAG: placeholder for a value still to be chosen. const TO_CHOOSE = 'to choose'; +function protectedCell(isProtected: boolean | undefined): string { + if (isProtected === undefined) return TO_CHOOSE; + return isProtected ? 'yes (invite-only)' : 'no'; // COPY-FLAG +} + /** The table for `capy org create-project` asking for its name and first branch: where it goes, what is chosen so far, and what a branch is. */ export function createProjectDisplay(input: CreateDisplayInput): string { const summary = table(['', ''], [ ['Org', input.org.name], ['Project', input.name ?? TO_CHOOSE], ['First branch', input.branch ?? TO_CHOOSE], // COPY-FLAG + ['Protected', protectedCell(input.isProtected)], // COPY-FLAG ]); return `${summary}\n\n${input.branchDescription}`; } diff --git a/src/ui/promptEngine.ts b/src/ui/promptEngine.ts index 96e094a8..0da48454 100644 --- a/src/ui/promptEngine.ts +++ b/src/ui/promptEngine.ts @@ -51,6 +51,17 @@ export class PromptEngine { return String(branchName).trim(); } + /** Asks whether a new branch is invite-only, with the same question `capy checkout -b` asks. Defaults to No. */ + async promptForBranchProtected(branchName: string): Promise { + const { protect } = await inquirer.prompt([{ + type: 'confirm', + name: 'protect', + message: `Make "${branchName}" a protected branch? \x1b[90m(invite-only)\x1b[0m`, + default: false, + }]); + return protect === true; + } + async promptForChanges(changeSet: ChangeSet, branch?: string): Promise { const decisions: UserDecisions = { pushVariables: [], diff --git a/tests/commands/projectCreateCommand.test.ts b/tests/commands/projectCreateCommand.test.ts index 2fc375fd..119d1b80 100644 --- a/tests/commands/projectCreateCommand.test.ts +++ b/tests/commands/projectCreateCommand.test.ts @@ -40,12 +40,13 @@ mock.module('../../src/core/orgContext', () => ({ })); /** What the person "types" in a terminal, keyed by the prompt's `name`. */ -const TERMINAL_ANSWERS: Readonly> = { +const TERMINAL_ANSWERS: Readonly> = { projectName: 'typed-in-terminal', initialBranchChoice: 'development', branchName: 'typed-branch', + protect: false, }; -const answerFor = (questions: unknown, answers: Readonly>) => { +const answerFor = (questions: unknown, answers: Readonly>) => { const name = (questions as ReadonlyArray<{ readonly name: string }>)[0].name; return { [name]: answers[name] }; }; @@ -100,7 +101,7 @@ const creates = () => requests().filter((r) => r.method === 'POST' && r.path === const branchCreates = () => requests() .filter((r) => r.method === 'POST' && r.path === '/projects/proj_new/branches') .map((r) => JSON.parse(r.body) as { name: string; is_protected: boolean }); -const BRANCH_DESCRIPTION = 'A Capy branch is an independent set of values for the same project.'; +const BRANCH_DESCRIPTION = 'A Capy branch works like a git branch. Capy branches can be used to set different variables for different environments.'; const LIVE = [ { id: 'proj_web', name: 'web', organization_id: 'org_1' }, @@ -135,7 +136,7 @@ describe('capy org create-project', () => { }); afterEach(() => setTTY(false)); - it('with no name in agent mode: PROJECT_NAME_REQUIRED (exit 3) and a form asking for name and branch, creating nothing', async () => { + it('with no name in agent mode: PROJECT_NAME_REQUIRED (exit 3) and a form asking for name, branch and protection, creating nothing', async () => { service({}); const out = await capture(() => createProjectCommand(undefined, { json: true })); @@ -143,14 +144,25 @@ describe('capy org create-project', () => { const body = JSON.parse(out.stdout); expect(body).toMatchObject({ ok: false, code: 'PROJECT_NAME_REQUIRED', elicitation: { kind: 'form' } }); expect(body.elicitation.fields[0]).toMatchObject({ name: 'name', type: 'string', pattern: '^[a-zA-Z0-9-_]+$' }); + expect(body.elicitation.fields[0].question).toBe('What should the project be called?'); expect(body.elicitation.fields[1]).toEqual({ name: 'branch', type: 'string', pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$', + question: 'What should its first branch be called?', default: 'development', description: BRANCH_DESCRIPTION, }); - expect(body.elicitation.answer).toEqual({ rerun: 'capy org create-project --branch --json' }); + expect(body.elicitation.fields[2]).toEqual({ + name: 'protected', + type: 'string', + pattern: '^(yes|no)$', + question: 'Make the first branch a protected branch? (invite-only)', + default: 'no', + options: [{ value: 'yes', label: 'Yes' }, { value: 'no', label: 'No' }], + }); + expect(body.elicitation.answer).toEqual({ rerun: 'capy org create-project --branch --protected --json' }); + expect(body.elicitation.display).toContain('| Protected | to choose |'); expect(body.elicitation.details).toEqual({ org: { id: 'org_1', name: 'Acme' } }); expect(body.elicitation.display).toContain('| Org | Acme |'); expect(body.elicitation.display).toContain(BRANCH_DESCRIPTION); @@ -182,21 +194,51 @@ describe('capy org create-project', () => { expect(body.elicitation.fields.map((f: { name: string; default?: string }) => [f.name, f.default])).toEqual([ ['name', 'new-app'], ['branch', 'development'], + ['protected', 'no'], ]); expect(body.elicitation.display).toContain('| Project | new-app |'); expect(creates()).toEqual([]); expect(fakePrompt).not.toHaveBeenCalled(); }); - it('creates the project, then its first branch, unprotected', async () => { + it('with a name and branch but no --protected in agent mode: BRANCH_PROTECTION_REQUIRED (exit 3), the question names the branch', async () => { service({}); const out = await capture(() => createProjectCommand('new-app', { json: true, branch: 'staging' })); + expect(out.exitCode).toBe(3); + const body = JSON.parse(out.stdout); + expect(body).toMatchObject({ ok: false, code: 'BRANCH_PROTECTION_REQUIRED', elicitation: { kind: 'form' } }); + expect(body.elicitation.fields[2].question).toBe('Make "staging" a protected branch? (invite-only)'); + expect(body.elicitation.display).toContain('| First branch | staging |'); + expect(creates()).toEqual([]); + }); + + it('--protected yes creates the first branch protected', async () => { + service({}); + const out = await capture(() => createProjectCommand('new-app', { json: true, branch: 'production', protectedAnswer: 'yes' })); + + expect(JSON.parse(out.stdout).branch).toEqual({ name: 'production', protected: true }); + expect(branchCreates()).toEqual([{ name: 'production', is_protected: true }]); + }); + + it('--protected with anything but yes or no: BRANCH_PROTECTION_INVALID before any request', async () => { + service({}); + const out = await capture(() => createProjectCommand('new-app', { json: true, branch: 'staging', protectedAnswer: 'maybe' })); + + expect(out.exitCode).toBe(1); + expect(JSON.parse(out.stdout)).toMatchObject({ code: 'BRANCH_PROTECTION_INVALID', options: ['yes', 'no'] }); + expect(requests()).toEqual([]); + }); + + it('creates the project, then its first branch, unprotected', async () => { + service({}); + const out = await capture(() => createProjectCommand('new-app', { json: true, branch: 'staging', protectedAnswer: 'no' })); + expect(out.exitCode).toBeUndefined(); expect(JSON.parse(out.stdout)).toEqual({ ok: true, project: { id: 'proj_new', name: 'new-app' }, - branch: { name: 'staging' }, + branch: { name: 'staging', protected: false }, org: { id: 'org_1', name: 'Acme' }, }); expect(creates().map((r) => JSON.parse(r.body))).toEqual([{ name: 'new-app', organization_id: 'org_1' }]); @@ -205,14 +247,14 @@ describe('capy org create-project', () => { it('the project is made but its branch is not: BRANCH_CREATE_FAILED with the project, so nobody thinks nothing happened', async () => { service({ onBranch: () => reply(500, { error: 'boom' }) }); - const out = await capture(() => createProjectCommand('new-app', { json: true, branch: 'development' })); + const out = await capture(() => createProjectCommand('new-app', { json: true, branch: 'development', protectedAnswer: 'no' })); expect(out.exitCode).toBe(1); expect(JSON.parse(out.stdout)).toMatchObject({ ok: false, code: 'BRANCH_CREATE_FAILED', project: { id: 'proj_new', name: 'new-app' }, - branch: { name: 'development' }, + branch: { name: 'development', protected: false }, }); }); @@ -239,7 +281,7 @@ describe('capy org create-project', () => { it('a name used only in another org is not a clash', async () => { service({}); - const out = await capture(() => createProjectCommand('api', { json: true, branch: 'development' })); + const out = await capture(() => createProjectCommand('api', { json: true, branch: 'development', protectedAnswer: 'no' })); expect(out.exitCode).toBeUndefined(); expect(creates()).toHaveLength(1); @@ -265,10 +307,10 @@ describe('capy org create-project', () => { it('--dry-run checks the name (including clashes) and creates nothing', async () => { service({}); - const ok = await capture(() => createProjectCommand('new-app', { json: true, dryRun: true, branch: 'development' })); - expect(JSON.parse(ok.stdout)).toMatchObject({ ok: true, dry_run: true, project: { name: 'new-app' }, branch: { name: 'development' } }); + const ok = await capture(() => createProjectCommand('new-app', { json: true, dryRun: true, branch: 'development', protectedAnswer: 'no' })); + expect(JSON.parse(ok.stdout)).toMatchObject({ ok: true, dry_run: true, project: { name: 'new-app' }, branch: { name: 'development', protected: false } }); - const clash = await capture(() => createProjectCommand('web', { json: true, dryRun: true, branch: 'development' })); + const clash = await capture(() => createProjectCommand('web', { json: true, dryRun: true, branch: 'development', protectedAnswer: 'no' })); expect(JSON.parse(clash.stdout).code).toBe('PROJECT_NAME_EXISTS'); expect(creates()).toEqual([]); expect(branchCreates()).toEqual([]); @@ -276,19 +318,19 @@ describe('capy org create-project', () => { it('the free-tier limit keeps its code', async () => { service({ onCreate: () => reply(402, { error: 'limit', code: 'QUOTA_EXCEEDED', kind: 'project', limit: 3 }) }); - const out = await capture(() => createProjectCommand('new-app', { json: true, branch: 'development' })); + const out = await capture(() => createProjectCommand('new-app', { json: true, branch: 'development', protectedAnswer: 'no' })); expect(out.exitCode).toBe(1); expect(JSON.parse(out.stdout).code).toBe('QUOTA_EXCEEDED'); expect(branchCreates()).toEqual([]); }); - it('in a terminal with nothing given: asks the name, says what a branch is, asks the branch, then creates both', async () => { + it('in a terminal with nothing given: asks the name, says what a branch is, asks the branch and whether it is protected, then creates both', async () => { setTTY(true); service({}); const out = await capture(() => createProjectCommand(undefined, {})); - expect(fakePrompt.mock.calls.map(([q]) => (q as ReadonlyArray<{ name: string }>)[0].name)).toEqual(['projectName', 'initialBranchChoice']); + expect(fakePrompt.mock.calls.map(([q]) => (q as ReadonlyArray<{ name: string }>)[0].name)).toEqual(['projectName', 'initialBranchChoice', 'protect']); expect(out.stdout).toContain(BRANCH_DESCRIPTION); expect(out.exitCode).toBeUndefined(); expect(creates().map((r) => JSON.parse(r.body).name)).toEqual(['typed-in-terminal']); @@ -307,6 +349,16 @@ describe('capy org create-project', () => { expect(branchCreates()).toEqual([{ name: 'typed-branch', is_protected: false }]); }); + it('in a terminal, yes to the protected question creates the branch protected', async () => { + setTTY(true); + service({}); + fakePrompt.mockImplementation(async (questions: unknown) => answerFor(questions, { ...TERMINAL_ANSWERS, protect: true })); + await capture(() => createProjectCommand('new-app', {})); + fakePrompt.mockImplementation(async (questions: unknown) => answerFor(questions, TERMINAL_ANSWERS)); + + expect(branchCreates()).toEqual([{ name: 'development', is_protected: true }]); + }); + it('in a terminal, a name that clashes is refused before the branch is asked', async () => { setTTY(true); service({}); diff --git a/tests/core/agentsBlockPlan.test.ts b/tests/core/agentsBlockPlan.test.ts index b3dfb44d..77958ad3 100644 --- a/tests/core/agentsBlockPlan.test.ts +++ b/tests/core/agentsBlockPlan.test.ts @@ -39,8 +39,8 @@ describe('AGENTS_BLOCK', () => { '- Commands that write run bare `capy` first. If your folder and Capy differ they write nothing and exit 3 with `SECRETS_CONFLICT`, a `conflict_id`, and the differing names (fingerprints, never values).', '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', - '- If a JSON result has an `elicitation`, write its `display` table in your reply exactly as given (never shorten or reorder it, never put it inside the question). Then ask its short `question` as an elicitation with your question tool: one question per field, offering the field\'s `options` (or its `default`) as the choices. Without a question tool, end your reply with the `question` and wait for the human to answer in chat. Then run the elicitation\'s answer command with what the human chose. Never answer it yourself and never add `--yes` on your own.', - '- Only when the human asks to create a project, run `capy org create-project --json`, adding the name and `--branch ` only if the human already gave them. It creates nothing until it has both and returns an `elicitation` asking for the rest: that is how you ask the human, so do not ask your own question first.', + '- If a JSON result has an `elicitation`, write its `display` table in your reply exactly as given (never shorten or reorder it, never put it inside the question). Then ask its short `question` as an elicitation with your question tool: one question per field, using the field\'s own `question` followed by its `description` when it has them, and offering its `options` (or its `default`) as the choices. Without a question tool, end your reply with the `question` and wait for the human to answer in chat. Then run the elicitation\'s answer command with what the human chose. Never answer it yourself and never add `--yes` on your own.', + '- Only when the human asks to create a project, run `capy org create-project --json`, adding the name, `--branch ` and `--protected yes|no` only if the human already gave them. It creates nothing until it has all three and returns an `elicitation` asking for the rest: that is how you ask the human, so do not ask your own question first.', "- Only when the human asks to delete a project, run `capy org delete-project --json` with the name they gave and without `--yes`, even if this folder's project is deleted or is a different one: it looks the name up in the organization, not in this folder. It changes nothing and returns an `elicitation`: that is how you ask the human, so do not ask your own question first. Only an org owner can delete or restore a project.", "- `PROJECT_DELETED`, or `project.state` `\"deleted\"` in `capy info --json`, means an org owner deleted this folder's project. Stop and tell the human. An org owner can restore it with `capy org restore-project ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`. This is about this folder only: it never stops a delete or restore the human asked for by name.", '- Never print, log, or commit secret values.', diff --git a/tests/ui/elicitationDisplay.test.ts b/tests/ui/elicitationDisplay.test.ts index e83d1421..8d416aeb 100644 --- a/tests/ui/elicitationDisplay.test.ts +++ b/tests/ui/elicitationDisplay.test.ts @@ -44,13 +44,14 @@ describe('deleteProjectDisplay', () => { describe('createProjectDisplay', () => { const org = { id: 'org_1', name: 'Demos' }; - const branchDescription = 'A Capy branch is an independent set of values for the same project.'; + const branchDescription = 'A Capy branch works like a git branch. Capy branches can be used to set different variables for different environments.'; it('shows the org, what is still to choose, and what a branch is', () => { const out = createProjectDisplay({ org, branchDescription }); expect(out).toContain('| Org | Demos |'); expect(out).toContain('| Project | to choose |'); expect(out).toContain('| First branch | to choose |'); + expect(out).toContain('| Protected | to choose |'); expect(out.endsWith(`\n\n${branchDescription}`)).toBe(true); }); From ec0943354c2781b29d795244fbcb57b3704a2f9f Mon Sep 17 00:00:00 2001 From: cvince Date: Mon, 9 Nov 2026 17:15:59 -0700 Subject: [PATCH 20/27] feat(project): capy project create|delete|restore replace capy org create-project|delete-project|restore-project Never released (npm latest is 0.9.14), so no aliases. capy projects stays the listing command. org is back to switching organization only. --- docs/cli-reference.md | 58 ++++++++++++------- src/commands/projectCreateCommand.ts | 14 ++--- src/commands/projectLifecycleCommand.ts | 22 +++---- src/core/agentsBlockPlan.ts | 6 +- src/core/cliHelpDoc.ts | 3 +- src/core/projectNameRule.ts | 2 +- src/errors/projectDeleted.ts | 2 +- src/index-dev.ts | 22 ++++--- src/index.ts | 28 +++++---- src/types/index.ts | 18 +++--- src/ui/elicitation.ts | 2 +- src/ui/elicitationDisplay.ts | 4 +- src/ui/errorScreen.ts | 2 +- tests/commands/projectCreateCommand.test.ts | 6 +- tests/commands/projectDeletedCli.test.ts | 41 +++++++------ .../commands/projectLifecycleCommand.test.ts | 14 ++--- tests/core/agentsBlockPlan.test.ts | 6 +- tests/ui/projectDeletedFolder.test.ts | 6 +- 18 files changed, 139 insertions(+), 117 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index ff7e8af9..11da7c7a 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -468,7 +468,7 @@ JSON support: yes (`--json`) ## `capy org` -Switch organization; create, delete or restore its projects +Switch organization ``` capy org [options] @@ -482,69 +482,83 @@ capy org [options] JSON support: no -### `capy org create-project` +### `capy org projects` -Create a project and its first branch in the active organization (owners and admins only; does not link this folder) +List projects in the active organization; --deleted also lists deleted ones (owners only) ``` -capy org create-project [name] [options] +capy org projects [options] ``` | Option | Description | Default | |---|---|---| -| `--branch ` | the first branch; a Capy branch works like a git branch (offered by default: development) | | -| `--protected ` | whether the first branch is protected (invite-only) | | -| `--non-tty` | treat stdin as not a terminal: never ask for the name or branch; without them, refuse with a form to ask the human | | +| `--deleted` | also list deleted projects, marked as deleted, with the date | | | `--json` | emit machine-readable JSON instead of the human UI | | JSON support: yes (`--json`) -Dry run: yes (`--dry-run`) +## `capy project` -### `capy org delete-project` +Create, delete or restore a project in the active organization -Delete a project (owners only; hidden, not destroyed; an owner can restore it) +``` +capy project +``` + +_No options._ + +JSON support: no + +### `capy project create` + +Create a project and its first branch in the active organization (owners and admins only; does not link this folder) ``` -capy org delete-project [options] +capy project create [name] [options] ``` | Option | Description | Default | |---|---|---| -| `--force` | delete even if the project has live deploy tokens (those deploys stop on their next restart) | | -| `--yes` | skip the confirmation question | | -| `--non-tty` | treat stdin as not a terminal: refuse unless --yes is passed | | +| `--branch ` | the first branch; a Capy branch works like a git branch (offered by default: development) | | +| `--protected ` | whether the first branch is protected (invite-only) | | +| `--non-tty` | treat stdin as not a terminal: never ask for the name or branch; without them, refuse with a form to ask the human | | | `--json` | emit machine-readable JSON instead of the human UI | | JSON support: yes (`--json`) -### `capy org restore-project` +Dry run: yes (`--dry-run`) -Restore a deleted project (owners only) +### `capy project delete` + +Delete a project (owners only; hidden, not destroyed; an owner can restore it) ``` -capy org restore-project [options] +capy project delete [options] ``` | Option | Description | Default | |---|---|---| +| `--force` | delete even if the project has live deploy tokens (those deploys stop on their next restart) | | | `--yes` | skip the confirmation question | | | `--non-tty` | treat stdin as not a terminal: refuse unless --yes is passed | | | `--json` | emit machine-readable JSON instead of the human UI | | JSON support: yes (`--json`) -### `capy org projects` +Dry run: yes (`--dry-run`) -List projects in the active organization; --deleted also lists deleted ones (owners only) +### `capy project restore` + +Restore a deleted project (owners only) ``` -capy org projects [options] +capy project restore [options] ``` | Option | Description | Default | |---|---|---| -| `--deleted` | also list deleted projects, marked as deleted, with the date | | +| `--yes` | skip the confirmation question | | +| `--non-tty` | treat stdin as not a terminal: refuse unless --yes is passed | | | `--json` | emit machine-readable JSON instead of the human UI | | JSON support: yes (`--json`) @@ -1168,4 +1182,4 @@ Every refusal carries a stable `code` — branch on it, never on message text. - **JSON**: Pass --json on any command whose supportsJson is true and parse stdout as JSON; prose (progress, prompts, errors) goes to stderr so stdout stays pure JSON. - **Codes**: Branch only on the machine-readable `code` field (from --json output or a CapyError), never on human-readable message text — messages may be reworded without notice. - **Conflicts**: Every command that writes from a folder (edit, add, remove, connect, rotate, push, checkout, org, deploy, deploy revoke, deploy targets-remove) runs bare `capy` first; `checkout -b` is the one exception (a new branch carries the uncommitted values of the folder). Off a terminal, if the folder and Capy differ on any variable, nothing is written, the exit code is 3, and stdout is {ok:false, code:"SECRETS_CONFLICT", conflict_id, conflicts:[{name, project, branch, type, pinned, local, remote}]} (fingerprints only, never values; a Dokploy value has {name, target, pinned, local, remote}). Ask the person, then rerun the same command with --resolve NAME=pinned|local|remote|delete for every row (NAME@TARGET=local|remote for a Dokploy value) and --conflict . A state that moved since is SECRETS_CONFLICT_STALE with the fresh rows. There is no resolve-all. -- **Elicitation**: A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. Write `display` (a markdown table) in your reply exactly as given, without shortening or reordering it and never inside the question. Then ask the short `question` as an elicitation with your question tool: one question per field, using the field's own `question` followed by its `description` when it has them, and its `options` or `default` as the choices; without a question tool, end your reply with the `question` and the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, question?, default?, description?, options?:[{value, label}]}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. A field with `options` is a choice: the human picks a `label`, and you fill in its `value`. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` missing the name, `--branch` or `--protected` in agent mode refuses with PROJECT_NAME_REQUIRED, BRANCH_NAME_REQUIRED or BRANCH_PROTECTION_REQUIRED and a form (fields: name, branch, protected (options yes/no); details: {org:{id,name}}); `capy org delete-project` / `restore-project` with a name several projects share refuses with PROJECT_AMBIGUOUS and a form with one `project` field whose options are the projects (details: {org:{id,name}, matches:[{id, name, created_at, variables}]}). Never answer it yourself and never add --yes on your own. +- **Elicitation**: A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. Write `display` (a markdown table) in your reply exactly as given, without shortening or reordering it and never inside the question. Then ask the short `question` as an elicitation with your question tool: one question per field, using the field's own `question` followed by its `description` when it has them, and its `options` or `default` as the choices; without a question tool, end your reply with the `question` and the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, question?, default?, description?, options?:[{value, label}]}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. A field with `options` is a choice: the human picks a `label`, and you fill in its `value`. `details` holds names and ids only, never a value. Today: `capy project delete` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy project create` missing the name, `--branch` or `--protected` in agent mode refuses with PROJECT_NAME_REQUIRED, BRANCH_NAME_REQUIRED or BRANCH_PROTECTION_REQUIRED and a form (fields: name, branch, protected (options yes/no); details: {org:{id,name}}); `capy project delete` / `project restore` with a name several projects share refuses with PROJECT_AMBIGUOUS and a form with one `project` field whose options are the projects (details: {org:{id,name}, matches:[{id, name, created_at, variables}]}). Never answer it yourself and never add --yes on your own. diff --git a/src/commands/projectCreateCommand.ts b/src/commands/projectCreateCommand.ts index f3922deb..f9689a51 100644 --- a/src/commands/projectCreateCommand.ts +++ b/src/commands/projectCreateCommand.ts @@ -1,5 +1,5 @@ /** - * `capy org create-project [name]` (CAP-748). + * `capy project create [name]` (CAP-748). * * Creates a project in the ACTIVE organization with the same service call the init flow uses * (`ServiceClient.initializeProject`), then its first branch (`createBranch`), and nothing else: it does not link the @@ -154,7 +154,7 @@ function createForm( ], }, ], - answer: { rerun: 'capy org create-project --branch --protected --json' }, + answer: { rerun: 'capy project create --branch --protected --json' }, }; } @@ -255,15 +255,15 @@ function createRefusalFor(err: unknown): Refusal { const NEEDS_INPUT: Readonly> = { name: { code: ERROR_CODES.PROJECT_NAME_REQUIRED, - message: '`capy org create-project` needs a project name. Pass it as an argument, or run it in a terminal.', // COPY-FLAG + message: '`capy project create` needs a project name. Pass it as an argument, or run it in a terminal.', // COPY-FLAG }, branch: { code: ERROR_CODES.BRANCH_NAME_REQUIRED, - message: '`capy org create-project` needs a first branch. Pass it with --branch, or run it in a terminal.', // COPY-FLAG + message: '`capy project create` needs a first branch. Pass it with --branch, or run it in a terminal.', // COPY-FLAG }, protected: { code: ERROR_CODES.BRANCH_PROTECTION_REQUIRED, - message: '`capy org create-project` needs to know whether the first branch is protected. Pass --protected yes or no, or run it in a terminal.', // COPY-FLAG + message: '`capy project create` needs to know whether the first branch is protected. Pass --protected yes or no, or run it in a terminal.', // COPY-FLAG }, }; @@ -316,7 +316,7 @@ function emitCreateOutcome(json: boolean, outcome: CreateOutcome): void { if (outcome.branch.protected) console.log(`"${outcome.branch.name}" is a protected branch — access is invite-only`); } -/** `capy org create-project [name] [--dry-run] [--json]` */ +/** `capy project create [name] [--dry-run] [--json]` */ export async function createProjectCommand(name: string | undefined, opts: CreateProjectOpts): Promise { const json = opts.json === true; const agentMode = json || !isInteractive(opts.nonTty); @@ -356,7 +356,7 @@ export async function createProjectCommand(name: string | undefined, opts: Creat if (opts.dryRun === true && missingSomething && !agentMode) { return refuse(json, { code: ERROR_CODES.DRY_RUN_UNSUPPORTED, - message: '`capy org create-project` would ask for the name, branch or protection, so it cannot preview. Pass all three.', // COPY-FLAG + message: '`capy project create` would ask for the name, branch or protection, so it cannot preview. Pass all three.', // COPY-FLAG exitCode: 1, }); } diff --git a/src/commands/projectLifecycleCommand.ts b/src/commands/projectLifecycleCommand.ts index 15ff39c8..c7f42b45 100644 --- a/src/commands/projectLifecycleCommand.ts +++ b/src/commands/projectLifecycleCommand.ts @@ -1,5 +1,5 @@ /** - * `capy org delete-project` and `capy org restore-project` (CAP-729). + * `capy project delete` and `capy project restore` (CAP-729). * * A delete hides a project; it destroys nothing, and an org owner can restore * it. The service decides everything that matters (who may delete, whether live @@ -13,7 +13,7 @@ * `--dry-run` reads the project and its deploy tokens and stops. The service has * no dry run, so the DELETE is simply never sent. * - * Without `--yes` and with nobody to ask (agent mode), `delete-project` refuses with CONFIRMATION_REQUIRED (exit 3) + * Without `--yes` and with nobody to ask (agent mode), `project delete` refuses with CONFIRMATION_REQUIRED (exit 3) * and an `elicitation` (kind "confirm", `ui/elicitation.ts`): the project, the org, its branches, its variable * NAMES (never a value or a hash) and its live deploy tokens, so the agent can put the question to the human. * In a terminal the same summary is printed before the Yes/No question. @@ -51,7 +51,7 @@ interface CommandWithGlobals { } /** - * The options of `delete-project` / `restore-project` as typed, wherever commander put them. + * The options of `project delete` / `project restore` as typed, wherever commander put them. * * A flag that the program or `org` also defines (`--force`, `--non-tty`, `--dry-run`) is taken by that outer command * and never reaches the subcommand's own options, so reading `options.force` there is always undefined. The merged view @@ -378,7 +378,7 @@ interface OrgRef { /** The exact command that answers Yes: the project's id (never its name), and `--force` only if it was passed. */ function deleteYesCommand(projectId: string, force: boolean): string { - return `capy org delete-project ${projectId} ${force ? '--force ' : ''}--yes --json`; + return `capy project delete ${projectId} ${force ? '--force ' : ''}--yes --json`; } /** The confirm elicitation for a delete. Names and ids only: a variable is its name and its branches, never more. */ @@ -469,7 +469,7 @@ async function deleteFlow(ref: string, opts: DeleteProjectOpts): Promise ${force ? '--force ' : ''}--json`, + rerun: `capy project delete ${force ? '--force ' : ''}--json`, }); if (picked.kind === 'needs-choice') return { kind: 'needs-choice', refusal: choiceRefusal(picked, ref) }; const project = picked.project; @@ -492,7 +492,7 @@ async function deleteFlow(ref: string, opts: DeleteProjectOpts): Promise [--force] [--yes] [--dry-run] [--json]` */ +/** `capy project delete [--force] [--yes] [--dry-run] [--json]` */ export async function deleteProjectCommand(ref: string, opts: DeleteProjectOpts): Promise { const json = opts.json === true; @@ -564,7 +564,7 @@ async function restoreFlow(ref: string, opts: ProjectLifecycleOpts): Promise --yes --json', + rerun: 'capy project restore --yes --json', }); if (picked.kind === 'needs-choice') return { kind: 'needs-choice', refusal: choiceRefusal(picked, ref) }; const project = picked.project; @@ -589,12 +589,12 @@ function emitRestoreOutcome(json: boolean, outcome: RestoreOutcome): void { console.log(`Restored project ${outcome.project.name}.`); // COPY-FLAG } -/** `capy org restore-project [--yes] [--json]` */ +/** `capy project restore [--yes] [--json]` */ export async function restoreProjectCommand(ref: string, opts: ProjectLifecycleOpts): Promise { const json = opts.json === true; if (opts.yes !== true && !isInteractive(opts.nonTty)) { - refuseWithoutTerminal(json, 'capy org restore-project'); + refuseWithoutTerminal(json, 'capy project restore'); } const settled = await settle(() => restoreFlow(ref, opts)); diff --git a/src/core/agentsBlockPlan.ts b/src/core/agentsBlockPlan.ts index c0679c53..86a5590b 100644 --- a/src/core/agentsBlockPlan.ts +++ b/src/core/agentsBlockPlan.ts @@ -32,9 +32,9 @@ export const AGENTS_BLOCK = [ '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', // COPY-FLAG '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', // COPY-FLAG '- If a JSON result has an `elicitation`, write its `display` table in your reply exactly as given (never shorten or reorder it, never put it inside the question). Then ask its short `question` as an elicitation with your question tool: one question per field, using the field\'s own `question` followed by its `description` when it has them, and offering its `options` (or its `default`) as the choices. Without a question tool, end your reply with the `question` and wait for the human to answer in chat. Then run the elicitation\'s answer command with what the human chose. Never answer it yourself and never add `--yes` on your own.', // COPY-FLAG - '- Only when the human asks to create a project, run `capy org create-project --json`, adding the name, `--branch ` and `--protected yes|no` only if the human already gave them. It creates nothing until it has all three and returns an `elicitation` asking for the rest: that is how you ask the human, so do not ask your own question first.', // COPY-FLAG - '- Only when the human asks to delete a project, run `capy org delete-project --json` with the name they gave and without `--yes`, even if this folder\'s project is deleted or is a different one: it looks the name up in the organization, not in this folder. It changes nothing and returns an `elicitation`: that is how you ask the human, so do not ask your own question first. Only an org owner can delete or restore a project.', // COPY-FLAG - '- `PROJECT_DELETED`, or `project.state` `"deleted"` in `capy info --json`, means an org owner deleted this folder\'s project. Stop and tell the human. An org owner can restore it with `capy org restore-project ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`. This is about this folder only: it never stops a delete or restore the human asked for by name.', // COPY-FLAG + '- Only when the human asks to create a project, run `capy project create --json`, adding the name, `--branch ` and `--protected yes|no` only if the human already gave them. It creates nothing until it has all three and returns an `elicitation` asking for the rest: that is how you ask the human, so do not ask your own question first.', // COPY-FLAG + '- Only when the human asks to delete a project, run `capy project delete --json` with the name they gave and without `--yes`, even if this folder\'s project is deleted or is a different one: it looks the name up in the organization, not in this folder. It changes nothing and returns an `elicitation`: that is how you ask the human, so do not ask your own question first. Only an org owner can delete or restore a project.', // COPY-FLAG + '- `PROJECT_DELETED`, or `project.state` `"deleted"` in `capy info --json`, means an org owner deleted this folder\'s project. Stop and tell the human. An org owner can restore it with `capy project restore ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`. This is about this folder only: it never stops a delete or restore the human asked for by name.', // COPY-FLAG '- Never print, log, or commit secret values.', '- To set a value, pipe it from the command that produces it: ` | capy edit NAME --json`. Never put a value in a command argument, an `echo`, or a heredoc — it would land in your context and the shell history.', AGENTS_BLOCK_END, diff --git a/src/core/cliHelpDoc.ts b/src/core/cliHelpDoc.ts index b7c8b279..7bb8fb50 100644 --- a/src/core/cliHelpDoc.ts +++ b/src/core/cliHelpDoc.ts @@ -118,7 +118,8 @@ export const DRY_RUN_COMMANDS: ReadonlySet = new Set([ 'connect', 'deploy', 'edit', - 'org create-project', + 'project create', + 'project delete', 'remove', 'secrets', 'secrets set', diff --git a/src/core/projectNameRule.ts b/src/core/projectNameRule.ts index 10e2ad2e..2ac28907 100644 --- a/src/core/projectNameRule.ts +++ b/src/core/projectNameRule.ts @@ -1,6 +1,6 @@ /** * The rule for a project name, in one place: the init prompt (`PromptEngine.promptForProjectName`) and - * `capy org create-project` both use it, so they only ever change together. + * `capy project create` both use it, so they only ever change together. * * Non-empty, letters/numbers/hyphens/underscores only, and not the reserved system store name. */ diff --git a/src/errors/projectDeleted.ts b/src/errors/projectDeleted.ts index e2ceb336..5c97167b 100644 --- a/src/errors/projectDeleted.ts +++ b/src/errors/projectDeleted.ts @@ -31,5 +31,5 @@ export function keepLockProject(dir: string): { name?: string; id?: string } | u /** The command an owner runs to bring the folder's project back, or `undefined` when keep.lock names no project id. */ export function restoreCommandFor(dir: string): string | undefined { const id = keepLockProject(dir)?.id; - return id === undefined ? undefined : `capy org restore-project ${id}`; + return id === undefined ? undefined : `capy project restore ${id}`; } diff --git a/src/index-dev.ts b/src/index-dev.ts index 3da5d2ac..9dddb96a 100644 --- a/src/index-dev.ts +++ b/src/index-dev.ts @@ -735,7 +735,7 @@ program const orgCmd = program .command('org') - .description('Switch organization; create, delete or restore its projects') // COPY-FLAG + .description('Switch organization') .option('--non-tty', 'treat stdin as not a terminal; differences from Capy stop the switch with the conflict_id (agents/CI)') // COPY-FLAG .addOption(resolveOption()) .addOption(conflictOption()) @@ -745,8 +745,12 @@ const orgCmd = program await cmd.execute({ nonTty: options.nonTty === true, conflictFlags: rawConflictFlagsOf(command) }); }); -orgCmd - .command('create-project [name]') +const projectCmd = program + .command('project') + .description('Create, delete or restore a project in the active organization'); // COPY-FLAG + +projectCmd + .command('create [name]') .description('Create a project and its first branch in the active organization (owners and admins only; does not link this folder)') // COPY-FLAG .option('--branch ', 'the first branch; a Capy branch works like a git branch (offered by default: development)') // COPY-FLAG .option('--protected ', 'whether the first branch is protected (invite-only)') // COPY-FLAG @@ -754,12 +758,12 @@ orgCmd .option('--json', 'emit machine-readable JSON instead of the human UI') .action(async (name: string | undefined, _options: any, command: any) => { const { createProjectCommand, createProjectOptsFrom } = await import('./commands/projectCreateCommand'); - // Read from the merged view: `org`'s `--non-tty` and the program's `--dry-run` take those flags before this command sees them. + // Read from the merged view: the program's `--dry-run` takes that flag before this command sees it. await createProjectCommand(name, { ...createProjectOptsFrom(command), apiUrl: process.env.CAPY_API_URL, devMode: true }); }); -orgCmd - .command('delete-project ') +projectCmd + .command('delete ') .description('Delete a project (owners only; hidden, not destroyed; an owner can restore it)') // COPY-FLAG .option('--force', 'delete even if the project has live deploy tokens (those deploys stop on their next restart)') // COPY-FLAG .option('--yes', 'skip the confirmation question') @@ -767,12 +771,12 @@ orgCmd .option('--json', 'emit machine-readable JSON instead of the human UI') .action(async (ref: string, _options: any, command: any) => { const { deleteProjectCommand, lifecycleOptsFrom } = await import('./commands/projectLifecycleCommand'); - // Read from the merged view: the program's `-f/--force` and `org`'s `--non-tty` take those flags before this command sees them. + // Read from the merged view: the program's `-f/--force` takes that flag before this command sees it. await deleteProjectCommand(ref, { ...lifecycleOptsFrom(command), apiUrl: process.env.CAPY_API_URL, devMode: true }); }); -orgCmd - .command('restore-project ') +projectCmd + .command('restore ') .description('Restore a deleted project (owners only)') // COPY-FLAG .option('--yes', 'skip the confirmation question') .option('--non-tty', 'treat stdin as not a terminal: refuse unless --yes is passed') // COPY-FLAG diff --git a/src/index.ts b/src/index.ts index 418267b7..c4740537 100644 --- a/src/index.ts +++ b/src/index.ts @@ -768,7 +768,7 @@ systemCmd const orgCmd = program .command('org') - .description('Switch organization; create, delete or restore its projects') // COPY-FLAG + .description('Switch organization') .option('--non-tty', 'treat stdin as not a terminal; differences from Capy stop the switch with the conflict_id (agents/CI)') // COPY-FLAG .addOption(resolveOption()) .addOption(conflictOption()) @@ -781,42 +781,46 @@ const orgCmd = program await cmd.execute({ nonTty: options.nonTty === true, conflictFlags: rawConflictFlagsOf(command) }); }); -orgCmd - .command('create-project [name]') +const projectCmd = program + .command('project') + .description('Create, delete or restore a project in the active organization'); // COPY-FLAG + +projectCmd + .command('create [name]') .description('Create a project and its first branch in the active organization (owners and admins only; does not link this folder)') // COPY-FLAG .option('--branch ', 'the first branch; a Capy branch works like a git branch (offered by default: development)') // COPY-FLAG .option('--protected ', 'whether the first branch is protected (invite-only)') // COPY-FLAG .option('--non-tty', 'treat stdin as not a terminal: never ask for the name or branch; without them, refuse with a form to ask the human') // COPY-FLAG .option('--json', 'emit machine-readable JSON instead of the human UI') .action(async (name: string | undefined, _options: any, command: any) => { - assertNotLocalOnly('org create-project'); + assertNotLocalOnly('project create'); const { createProjectCommand, createProjectOptsFrom } = await import('./commands/projectCreateCommand'); - // Read from the merged view: `org`'s `--non-tty` and the program's `--dry-run` take those flags before this command sees them. + // Read from the merged view: the program's `--dry-run` takes that flag before this command sees it. await createProjectCommand(name, createProjectOptsFrom(command)); }); -orgCmd - .command('delete-project ') +projectCmd + .command('delete ') .description('Delete a project (owners only; hidden, not destroyed; an owner can restore it)') // COPY-FLAG .option('--force', 'delete even if the project has live deploy tokens (those deploys stop on their next restart)') // COPY-FLAG .option('--yes', 'skip the confirmation question') .option('--non-tty', 'treat stdin as not a terminal: refuse unless --yes is passed') // COPY-FLAG .option('--json', 'emit machine-readable JSON instead of the human UI') .action(async (ref: string, _options: any, command: any) => { - assertNotLocalOnly('org delete-project'); + assertNotLocalOnly('project delete'); const { deleteProjectCommand, lifecycleOptsFrom } = await import('./commands/projectLifecycleCommand'); - // Read from the merged view: the program's `-f/--force` and `org`'s `--non-tty` take those flags before this command sees them. + // Read from the merged view: the program's `-f/--force` takes that flag before this command sees it. await deleteProjectCommand(ref, lifecycleOptsFrom(command)); }); -orgCmd - .command('restore-project ') +projectCmd + .command('restore ') .description('Restore a deleted project (owners only)') // COPY-FLAG .option('--yes', 'skip the confirmation question') .option('--non-tty', 'treat stdin as not a terminal: refuse unless --yes is passed') // COPY-FLAG .option('--json', 'emit machine-readable JSON instead of the human UI') .action(async (ref: string, _options: any, command: any) => { - assertNotLocalOnly('org restore-project'); + assertNotLocalOnly('project restore'); const { restoreProjectCommand, lifecycleOptsFrom } = await import('./commands/projectLifecycleCommand'); const { yes, nonTty, json } = lifecycleOptsFrom(command); await restoreProjectCommand(ref, { yes, nonTty, json }); diff --git a/src/types/index.ts b/src/types/index.ts index 828c8d06..08f32798 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -897,25 +897,25 @@ export const ERROR_CODES = { PROJECT_NOT_DELETED: 'PROJECT_NOT_DELETED', /** Delete called on the org's system store (403). Reaches the client as a PERMISSION_DENIED whose `details.code` is this. */ SYSTEM_STORE_IMMUTABLE: 'SYSTEM_STORE_IMMUTABLE', - /** `capy org delete-project` / `restore-project`: the name matched more than one project and there is no terminal to ask which. Exit 3 with a pick-one `elicitation` (a form whose field has `options`). */ + /** `capy project delete` / `project restore`: the name matched more than one project and there is no terminal to ask which. Exit 3 with a pick-one `elicitation` (a form whose field has `options`). */ PROJECT_AMBIGUOUS: 'PROJECT_AMBIGUOUS', /** A destructive command needs a yes and there is no terminal to ask, and `--yes` was not passed. Exit 3. */ CONFIRMATION_REQUIRED: 'CONFIRMATION_REQUIRED', - /** `capy org create-project` with no name and nobody to ask (agent mode). The refusal carries an `elicitation` form. Exit 3. */ + /** `capy project create` with no name and nobody to ask (agent mode). The refusal carries an `elicitation` form. Exit 3. */ PROJECT_NAME_REQUIRED: 'PROJECT_NAME_REQUIRED', - /** `capy org create-project`: the name is empty or has characters other than letters, numbers, hyphens and underscores. Exit 1. */ + /** `capy project create`: the name is empty or has characters other than letters, numbers, hyphens and underscores. Exit 1. */ PROJECT_NAME_INVALID: 'PROJECT_NAME_INVALID', - /** `capy org create-project`: the organization already has a project with that name (the service answered 409). Exit 1. */ + /** `capy project create`: the organization already has a project with that name (the service answered 409). Exit 1. */ PROJECT_NAME_EXISTS: 'PROJECT_NAME_EXISTS', - /** `capy org create-project` with a name but no `--branch`, and nobody to ask (agent mode). The refusal carries an `elicitation` form. Exit 3. */ + /** `capy project create` with a name but no `--branch`, and nobody to ask (agent mode). The refusal carries an `elicitation` form. Exit 3. */ BRANCH_NAME_REQUIRED: 'BRANCH_NAME_REQUIRED', - /** `capy org create-project --branch`: a name the service would refuse (see `core/branchNameRule.ts`). Nothing is created. Exit 1. */ + /** `capy project create --branch`: a name the service would refuse (see `core/branchNameRule.ts`). Nothing is created. Exit 1. */ BRANCH_NAME_INVALID: 'BRANCH_NAME_INVALID', - /** `capy org create-project`: the project was created but its first branch was not. The refusal carries the project. Exit 1. */ + /** `capy project create`: the project was created but its first branch was not. The refusal carries the project. Exit 1. */ BRANCH_CREATE_FAILED: 'BRANCH_CREATE_FAILED', - /** `capy org create-project` with a name and branch but no `--protected`, and nobody to ask (agent mode). The refusal carries an `elicitation` form. Exit 3. */ + /** `capy project create` with a name and branch but no `--protected`, and nobody to ask (agent mode). The refusal carries an `elicitation` form. Exit 3. */ BRANCH_PROTECTION_REQUIRED: 'BRANCH_PROTECTION_REQUIRED', - /** `capy org create-project --protected` with something other than yes or no. Nothing is created. Exit 1. */ + /** `capy project create --protected` with something other than yes or no. Nothing is created. Exit 1. */ BRANCH_PROTECTION_INVALID: 'BRANCH_PROTECTION_INVALID', /** A command that acts on "the active organization" has none (no keep.lock here) and the person belongs to several, with nobody to ask. Exit 3. */ ORG_SELECTION_REQUIRED: 'ORG_SELECTION_REQUIRED', diff --git a/src/ui/elicitation.ts b/src/ui/elicitation.ts index 1f8cc2b4..79ad3236 100644 --- a/src/ui/elicitation.ts +++ b/src/ui/elicitation.ts @@ -50,4 +50,4 @@ export type Elicitation = ElicitationConfirm | ElicitationForm; /** What `capy help --json` says about elicitations (`conventions.elicitation`). */ export const ELICITATION_HELP = // COPY-FLAG: minimal and neutral. - 'A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. Write `display` (a markdown table) in your reply exactly as given, without shortening or reordering it and never inside the question. Then ask the short `question` as an elicitation with your question tool: one question per field, using the field\'s own `question` followed by its `description` when it has them, and its `options` or `default` as the choices; without a question tool, end your reply with the `question` and the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, question?, default?, description?, options?:[{value, label}]}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. A field with `options` is a choice: the human picks a `label`, and you fill in its `value`. `details` holds names and ids only, never a value. Today: `capy org delete-project` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy org create-project` missing the name, `--branch` or `--protected` in agent mode refuses with PROJECT_NAME_REQUIRED, BRANCH_NAME_REQUIRED or BRANCH_PROTECTION_REQUIRED and a form (fields: name, branch, protected (options yes/no); details: {org:{id,name}}); `capy org delete-project` / `restore-project` with a name several projects share refuses with PROJECT_AMBIGUOUS and a form with one `project` field whose options are the projects (details: {org:{id,name}, matches:[{id, name, created_at, variables}]}). Never answer it yourself and never add --yes on your own.'; + 'A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. Write `display` (a markdown table) in your reply exactly as given, without shortening or reordering it and never inside the question. Then ask the short `question` as an elicitation with your question tool: one question per field, using the field\'s own `question` followed by its `description` when it has them, and its `options` or `default` as the choices; without a question tool, end your reply with the `question` and the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, question?, default?, description?, options?:[{value, label}]}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. A field with `options` is a choice: the human picks a `label`, and you fill in its `value`. `details` holds names and ids only, never a value. Today: `capy project delete` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy project create` missing the name, `--branch` or `--protected` in agent mode refuses with PROJECT_NAME_REQUIRED, BRANCH_NAME_REQUIRED or BRANCH_PROTECTION_REQUIRED and a form (fields: name, branch, protected (options yes/no); details: {org:{id,name}}); `capy project delete` / `project restore` with a name several projects share refuses with PROJECT_AMBIGUOUS and a form with one `project` field whose options are the projects (details: {org:{id,name}, matches:[{id, name, created_at, variables}]}). Never answer it yourself and never add --yes on your own.'; diff --git a/src/ui/elicitationDisplay.ts b/src/ui/elicitationDisplay.ts index 353ba4c4..c3cd53ab 100644 --- a/src/ui/elicitationDisplay.ts +++ b/src/ui/elicitationDisplay.ts @@ -49,7 +49,7 @@ function variablesSection(input: DeleteDisplayInput): string { ); } -/** The table for `capy org delete-project`: the project, then every variable, one row each, in keep.lock order. */ +/** The table for `capy project delete`: the project, then every variable, one row each, in keep.lock order. */ export function deleteProjectDisplay(input: DeleteDisplayInput): string { const summary = table(['', ''], [ ['Org', input.org.name], @@ -86,7 +86,7 @@ function protectedCell(isProtected: boolean | undefined): string { return isProtected ? 'yes (invite-only)' : 'no'; // COPY-FLAG } -/** The table for `capy org create-project` asking for its name and first branch: where it goes, what is chosen so far, and what a branch is. */ +/** The table for `capy project create` asking for its name and first branch: where it goes, what is chosen so far, and what a branch is. */ export function createProjectDisplay(input: CreateDisplayInput): string { const summary = table(['', ''], [ ['Org', input.org.name], diff --git a/src/ui/errorScreen.ts b/src/ui/errorScreen.ts index 49c4ae9f..da583457 100644 --- a/src/ui/errorScreen.ts +++ b/src/ui/errorScreen.ts @@ -236,7 +236,7 @@ function renderProjectDeleted(ctx: ErrorContext): string { id ? ` ${grey(`ID: ${id}`)}` : '', // COPY-FLAG '', ` To restore it, an organization owner runs:`, // COPY-FLAG - ` ${bold(`capy org restore-project ${target}`)}`, + ` ${bold(`capy project restore ${target}`)}`, '', ` ${grey('Only an owner can restore a project.')}`, // COPY-FLAG '', diff --git a/tests/commands/projectCreateCommand.test.ts b/tests/commands/projectCreateCommand.test.ts index 119d1b80..68bf9d16 100644 --- a/tests/commands/projectCreateCommand.test.ts +++ b/tests/commands/projectCreateCommand.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, spyOn, mock, afterAll, beforeEach, afterEach } from 'bun:test'; /** - * `capy org create-project` (CAP-748). + * `capy project create` (CAP-748). * * The REAL `ServiceClient` talks HTTP to a local fake of the service, so "creates nothing" is asserted as * "no POST /projects ever arrived". Only org resolution (auth) and the inquirer prompt are replaced. @@ -127,7 +127,7 @@ function service(opts: { projects?: readonly object[]; onCreate?: (body: string) const { createProjectCommand, existingProjectNamed } = await import('../../src/commands/projectCreateCommand'); -describe('capy org create-project', () => { +describe('capy project create', () => { beforeEach(() => { respond.mockReset(); fakePrompt.mockClear(); @@ -161,7 +161,7 @@ describe('capy org create-project', () => { default: 'no', options: [{ value: 'yes', label: 'Yes' }, { value: 'no', label: 'No' }], }); - expect(body.elicitation.answer).toEqual({ rerun: 'capy org create-project --branch --protected --json' }); + expect(body.elicitation.answer).toEqual({ rerun: 'capy project create --branch --protected --json' }); expect(body.elicitation.display).toContain('| Protected | to choose |'); expect(body.elicitation.details).toEqual({ org: { id: 'org_1', name: 'Acme' } }); expect(body.elicitation.display).toContain('| Org | Acme |'); diff --git a/tests/commands/projectDeletedCli.test.ts b/tests/commands/projectDeletedCli.test.ts index a16f3463..8bf0746d 100644 --- a/tests/commands/projectDeletedCli.test.ts +++ b/tests/commands/projectDeletedCli.test.ts @@ -2,8 +2,8 @@ * CAP-729, through the BUILT cli and its real argument parser (the piped harness: an isolated HOME and a mock service). * Needs `bun run build` first. * - * 1. `org delete-project --force` reaches the command. The program defines `-f/--force` and `org` defines `--non-tty`, - * so commander gives those flags to the outer command and the subcommand's own options never hold them. + * 1. `project delete --force` reaches the command. The program defines `-f/--force`, so commander gives that flag to + * the outer command and the subcommand's own options never hold it. * 2. A folder whose project was deleted (the service answers 410 `PROJECT_DELETED`): bare `capy`, `capy status` and * `capy push` end non-zero with that code, offer no recreate, and leave keep.lock byte-identical. */ @@ -15,7 +15,7 @@ import { withHarness, PROJECT_ID, type Harness } from '../helpers/pipedHarness'; import { lifecycleOptsFrom } from '../../src/commands/projectLifecycleCommand'; const NAME = 'DELETED_FOLDER_TOKEN'; -const RESTORE = `capy org restore-project ${PROJECT_ID}`; +const RESTORE = `capy project restore ${PROJECT_ID}`; const QUIET = { CAPY_NO_REPO_LINK: '1' } as const; const requests = (h: Harness, method: string) => h.requests().filter((r) => r.method === method && r.path.includes('/projects/')); @@ -28,12 +28,12 @@ async function deletedFolder(h: Harness): Promise { h.markProjectDeleted(); } -describe('org delete-project / restore-project / projects: flags the program or `org` also defines', () => { +describe('project delete / project restore / org projects: flags the program also defines', () => { test('--force arrives: the DELETE body is {"force":true}; without it there is no body', async () => { await withHarness(async (h) => { - const forced = await h.run(['org', 'delete-project', PROJECT_ID, '--force', '--yes', '--json']); + const forced = await h.run(['project', 'delete', PROJECT_ID, '--force', '--yes', '--json']); expect(forced.code).toBe(0); - const without = await h.run(['org', 'delete-project', PROJECT_ID, '--yes', '--json']); + const without = await h.run(['project', 'delete', PROJECT_ID, '--yes', '--json']); expect(without.code).toBe(0); const deletes = requests(h, 'DELETE'); @@ -43,24 +43,24 @@ describe('org delete-project / restore-project / projects: flags the program or test('-f, typed before the subcommand or after it, is the same flag', async () => { await withHarness(async (h) => { - await h.run(['-f', 'org', 'delete-project', PROJECT_ID, '--yes', '--json']); - await h.run(['org', 'delete-project', PROJECT_ID, '-f', '--yes', '--json']); + await h.run(['-f', 'project', 'delete', PROJECT_ID, '--yes', '--json']); + await h.run(['project', 'delete', PROJECT_ID, '-f', '--yes', '--json']); expect(requests(h, 'DELETE').map((r) => r.body)).toEqual([JSON.stringify({ force: true }), JSON.stringify({ force: true })]); }); }); test('--dry-run still sends no DELETE', async () => { await withHarness(async (h) => { - const out = await h.run(['org', 'delete-project', PROJECT_ID, '--dry-run', '--json']); + const out = await h.run(['project', 'delete', PROJECT_ID, '--dry-run', '--json']); expect(out.code).toBe(0); expect(JSON.parse(out.stdout)).toMatchObject({ ok: true, dry_run: true }); expect(requests(h, 'DELETE')).toHaveLength(0); }); }); - test('restore-project --yes --json posts the restore', async () => { + test('project restore --yes --json posts the restore', async () => { await withHarness(async (h) => { - const out = await h.run(['org', 'restore-project', PROJECT_ID, '--yes', '--json']); + const out = await h.run(['project', 'restore', PROJECT_ID, '--yes', '--json']); expect(out.code).toBe(0); expect(JSON.parse(out.stdout)).toMatchObject({ ok: true, project: { id: PROJECT_ID } }); expect(requests(h, 'POST').map((r) => r.path)).toEqual([`/orgs/org_test_piped/projects/${PROJECT_ID}/restore`]); @@ -78,13 +78,13 @@ describe('org delete-project / restore-project / projects: flags the program or }); describe('lifecycleOptsFrom: --non-tty and --force, wherever the outer command took them', () => { - /** The same shape as src/index.ts: the program has -f/--force and -d/--dry-run, `org` has --non-tty. */ + /** The same shape as src/index.ts: the program has -f/--force and -d/--dry-run; `project` has no options of its own. */ const parse = (argv: readonly string[]) => { const seen: Array> = []; const program = new Command().option('-f, --force').option('-d, --dry-run').exitOverride(); - const org = program.command('org').option('--non-tty').action(() => undefined); - org - .command('delete-project ') + const project = program.command('project'); + project + .command('delete ') .option('--force') .option('--yes') .option('--non-tty') @@ -94,18 +94,17 @@ describe('lifecycleOptsFrom: --non-tty and --force, wherever the outer command t return seen[0]; }; - test('--non-tty reaches the command (org takes it)', () => { - expect(parse(['org', 'delete-project', 'web', '--non-tty'])?.nonTty).toBe(true); - expect(parse(['org', '--non-tty', 'delete-project', 'web'])?.nonTty).toBe(true); + test('--non-tty reaches the command', () => { + expect(parse(['project', 'delete', 'web', '--non-tty'])?.nonTty).toBe(true); }); test('--force reaches the command (the program takes it)', () => { - expect(parse(['org', 'delete-project', 'web', '--force'])?.force).toBe(true); - expect(parse(['org', 'delete-project', 'web'])?.force).toBe(false); + expect(parse(['project', 'delete', 'web', '--force'])?.force).toBe(true); + expect(parse(['project', 'delete', 'web'])?.force).toBe(false); }); test('--yes, --json and --dry-run come through too', () => { - expect(parse(['org', 'delete-project', 'web', '--yes', '--json', '--dry-run'])).toEqual({ + expect(parse(['project', 'delete', 'web', '--yes', '--json', '--dry-run'])).toEqual({ force: false, yes: true, nonTty: false, diff --git a/tests/commands/projectLifecycleCommand.test.ts b/tests/commands/projectLifecycleCommand.test.ts index 8557d1d5..50e57387 100644 --- a/tests/commands/projectLifecycleCommand.test.ts +++ b/tests/commands/projectLifecycleCommand.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, spyOn, mock, afterAll, beforeEach, afterEach } from 'bun:test'; /** - * `capy org delete-project` and `capy org restore-project` (CAP-729). + * `capy project delete` and `capy project restore` (CAP-729). * * The REAL `ServiceClient` talks HTTP to a local fake of the service, so these * tests see exactly what goes on the wire (the method, the path, the body) and @@ -146,7 +146,7 @@ const deleted = (id: string, name: string) => // After the mocks above: the command module picks up the replaced org resolution and prompt. const { deleteProjectCommand, restoreProjectCommand } = await import('../../src/commands/projectLifecycleCommand'); -describe('capy org delete-project', () => { +describe('capy project delete', () => { beforeEach(() => { respond.mockReset(); fakePrompt.mockReset(); @@ -219,7 +219,7 @@ describe('capy org delete-project', () => { expect(deletes()).toEqual([{ method: 'DELETE', path: '/orgs/org_1/projects/proj_web', body: '' }]); expect(out.stdout).toContain('Deleted project web'); - expect(out.stdout).toContain('capy org restore-project proj_web'); + expect(out.stdout).toContain('capy project restore proj_web'); }); it('--yes skips the question and the token read, even off a terminal', async () => { @@ -442,7 +442,7 @@ describe('capy org delete-project', () => { { value: 'proj_new', label: 'web · 1 variable · created 1 day ago' }, ], }]); - expect(body.elicitation.answer).toEqual({ rerun: 'capy org delete-project --json' }); + expect(body.elicitation.answer).toEqual({ rerun: 'capy project delete --json' }); expect(body.elicitation.display).toContain('| 1 | web · 45 variables · created 3 days ago |'); expect(deletes()).toEqual([]); }); @@ -453,7 +453,7 @@ describe('capy org delete-project', () => { const out = await capture(() => deleteProjectCommand('web', { force: true, json: true })); const body = JSON.parse(out.stdout); - expect(body.elicitation.answer.rerun).toBe('capy org delete-project --force --json'); + expect(body.elicitation.answer.rerun).toBe('capy project delete --force --json'); expect(body.elicitation.fields[0].options[1].label).toBe('web · variables could not be read · created 1 day ago'); expect(body.elicitation.details.matches[1]).toMatchObject({ id: 'proj_new', variables: null }); }); @@ -521,7 +521,7 @@ describe('capy org delete-project', () => { }); }); -describe('capy org restore-project', () => { +describe('capy project restore', () => { beforeEach(() => { respond.mockReset(); fakePrompt.mockReset(); @@ -628,7 +628,7 @@ describe('capy org restore-project', () => { const body = JSON.parse(out.stdout); expect(body).toMatchObject({ code: 'PROJECT_AMBIGUOUS', elicitation: { kind: 'form' } }); expect(body.elicitation.fields[0].options.map((o: { value: string }) => o.value)).toEqual(['proj_old', 'proj_old2']); - expect(body.elicitation.answer.rerun).toBe('capy org restore-project --yes --json'); + expect(body.elicitation.answer.rerun).toBe('capy project restore --yes --json'); expect(writes()).toEqual([]); }); diff --git a/tests/core/agentsBlockPlan.test.ts b/tests/core/agentsBlockPlan.test.ts index 77958ad3..8cfb0b5f 100644 --- a/tests/core/agentsBlockPlan.test.ts +++ b/tests/core/agentsBlockPlan.test.ts @@ -40,9 +40,9 @@ describe('AGENTS_BLOCK', () => { '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', '- If a JSON result has an `elicitation`, write its `display` table in your reply exactly as given (never shorten or reorder it, never put it inside the question). Then ask its short `question` as an elicitation with your question tool: one question per field, using the field\'s own `question` followed by its `description` when it has them, and offering its `options` (or its `default`) as the choices. Without a question tool, end your reply with the `question` and wait for the human to answer in chat. Then run the elicitation\'s answer command with what the human chose. Never answer it yourself and never add `--yes` on your own.', - '- Only when the human asks to create a project, run `capy org create-project --json`, adding the name, `--branch ` and `--protected yes|no` only if the human already gave them. It creates nothing until it has all three and returns an `elicitation` asking for the rest: that is how you ask the human, so do not ask your own question first.', - "- Only when the human asks to delete a project, run `capy org delete-project --json` with the name they gave and without `--yes`, even if this folder's project is deleted or is a different one: it looks the name up in the organization, not in this folder. It changes nothing and returns an `elicitation`: that is how you ask the human, so do not ask your own question first. Only an org owner can delete or restore a project.", - "- `PROJECT_DELETED`, or `project.state` `\"deleted\"` in `capy info --json`, means an org owner deleted this folder's project. Stop and tell the human. An org owner can restore it with `capy org restore-project ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`. This is about this folder only: it never stops a delete or restore the human asked for by name.", + '- Only when the human asks to create a project, run `capy project create --json`, adding the name, `--branch ` and `--protected yes|no` only if the human already gave them. It creates nothing until it has all three and returns an `elicitation` asking for the rest: that is how you ask the human, so do not ask your own question first.', + "- Only when the human asks to delete a project, run `capy project delete --json` with the name they gave and without `--yes`, even if this folder's project is deleted or is a different one: it looks the name up in the organization, not in this folder. It changes nothing and returns an `elicitation`: that is how you ask the human, so do not ask your own question first. Only an org owner can delete or restore a project.", + "- `PROJECT_DELETED`, or `project.state` `\"deleted\"` in `capy info --json`, means an org owner deleted this folder's project. Stop and tell the human. An org owner can restore it with `capy project restore ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`. This is about this folder only: it never stops a delete or restore the human asked for by name.", '- Never print, log, or commit secret values.', '- To set a value, pipe it from the command that produces it: ` | capy edit NAME --json`. Never put a value in a command argument, an `echo`, or a heredoc — it would land in your context and the shell history.', '', diff --git a/tests/ui/projectDeletedFolder.test.ts b/tests/ui/projectDeletedFolder.test.ts index ffa5b05b..0a29228a 100644 --- a/tests/ui/projectDeletedFolder.test.ts +++ b/tests/ui/projectDeletedFolder.test.ts @@ -117,7 +117,7 @@ describe('a folder whose project was deleted', () => { expect(screen).toContain('Project deleted'); expect(screen).toContain('web'); expect(screen).toContain('proj_web'); - expect(screen).toContain('capy org restore-project proj_web'); + expect(screen).toContain('capy project restore proj_web'); expect(screen.toLowerCase()).toContain('only an owner'); }); @@ -129,7 +129,7 @@ describe('a folder whose project was deleted', () => { expect(out.stdout).toContain('Project deleted'); expect(out.stdout).toContain('"web"'); - expect(out.stdout).toContain('capy org restore-project proj_web'); + expect(out.stdout).toContain('capy project restore proj_web'); rmSync(dir, { recursive: true, force: true }); }); @@ -208,7 +208,7 @@ describe('a folder whose project was deleted', () => { expect(out.exitCode).toBe(1); expect(out.stdout).toContain('Project deleted'); - expect(out.stdout).toContain('capy org restore-project proj_web'); + expect(out.stdout).toContain('capy project restore proj_web'); expect(out.stdout.toLowerCase()).not.toContain('recreate'); rmSync(dir, { recursive: true, force: true }); }); From f5e732a26b789367a7d1bf52002853f1cf65c403 Mon Sep 17 00:00:00 2001 From: cvince Date: Mon, 9 Nov 2026 17:20:50 -0700 Subject: [PATCH 21/27] docs(readme): list capy project create|delete|restore --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index eb8c1daf..86477370 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,7 @@ Good when you want to see drift across the whole branch, edit one variable witho | [`capy kick `](https://docs.capy.sc/cli/kick) | Remove a teammate. | | [`capy users`](https://docs.capy.sc/cli/users) | Interactive member management. | | [`capy projects`](https://docs.capy.sc/cli/projects) | List projects in the org and their branches. | +| [`capy project create\|delete\|restore`](https://docs.capy.sc/cli/project) | Create, delete or restore a project in the org. | | [`capy secrets`](https://docs.capy.sc/cli/secrets) | List every secret name in the org, grouped by value (never shows a value). | | [`capy org`](https://docs.capy.sc/cli/org) | List or switch organizations. | | [`capy branch`](https://docs.capy.sc/cli/branch) | List or switch branches. | From b4f0e2a3d5ea97e3207b9bf1b0448b97c7805b9f Mon Sep 17 00:00:00 2001 From: cvince Date: Mon, 9 Nov 2026 17:52:04 -0700 Subject: [PATCH 22/27] fix(project): restore asks its yes/no as an elicitation; --json is agent mode for delete and restore; approved first-branch question in the terminal --- docs/cli-reference.md | 2 +- src/commands/projectLifecycleCommand.ts | 56 ++++++++++++------- src/ui/elicitation.ts | 2 +- src/ui/elicitationDisplay.ts | 13 +++++ src/ui/promptEngine.ts | 8 +-- .../commands/projectLifecycleCommand.test.ts | 34 ++++++++--- 6 files changed, 82 insertions(+), 33 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 11da7c7a..df5dd3f9 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1182,4 +1182,4 @@ Every refusal carries a stable `code` — branch on it, never on message text. - **JSON**: Pass --json on any command whose supportsJson is true and parse stdout as JSON; prose (progress, prompts, errors) goes to stderr so stdout stays pure JSON. - **Codes**: Branch only on the machine-readable `code` field (from --json output or a CapyError), never on human-readable message text — messages may be reworded without notice. - **Conflicts**: Every command that writes from a folder (edit, add, remove, connect, rotate, push, checkout, org, deploy, deploy revoke, deploy targets-remove) runs bare `capy` first; `checkout -b` is the one exception (a new branch carries the uncommitted values of the folder). Off a terminal, if the folder and Capy differ on any variable, nothing is written, the exit code is 3, and stdout is {ok:false, code:"SECRETS_CONFLICT", conflict_id, conflicts:[{name, project, branch, type, pinned, local, remote}]} (fingerprints only, never values; a Dokploy value has {name, target, pinned, local, remote}). Ask the person, then rerun the same command with --resolve NAME=pinned|local|remote|delete for every row (NAME@TARGET=local|remote for a Dokploy value) and --conflict . A state that moved since is SECRETS_CONFLICT_STALE with the fresh rows. There is no resolve-all. -- **Elicitation**: A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. Write `display` (a markdown table) in your reply exactly as given, without shortening or reordering it and never inside the question. Then ask the short `question` as an elicitation with your question tool: one question per field, using the field's own `question` followed by its `description` when it has them, and its `options` or `default` as the choices; without a question tool, end your reply with the `question` and the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, question?, default?, description?, options?:[{value, label}]}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. A field with `options` is a choice: the human picks a `label`, and you fill in its `value`. `details` holds names and ids only, never a value. Today: `capy project delete` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy project create` missing the name, `--branch` or `--protected` in agent mode refuses with PROJECT_NAME_REQUIRED, BRANCH_NAME_REQUIRED or BRANCH_PROTECTION_REQUIRED and a form (fields: name, branch, protected (options yes/no); details: {org:{id,name}}); `capy project delete` / `project restore` with a name several projects share refuses with PROJECT_AMBIGUOUS and a form with one `project` field whose options are the projects (details: {org:{id,name}, matches:[{id, name, created_at, variables}]}). Never answer it yourself and never add --yes on your own. +- **Elicitation**: A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. Write `display` (a markdown table) in your reply exactly as given, without shortening or reordering it and never inside the question. Then ask the short `question` as an elicitation with your question tool: one question per field, using the field's own `question` followed by its `description` when it has them, and its `options` or `default` as the choices; without a question tool, end your reply with the `question` and the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, question?, default?, description?, options?:[{value, label}]}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. A field with `options` is a choice: the human picks a `label`, and you fill in its `value`. `details` holds names and ids only, never a value. Today: `capy project restore` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name,deleted_at}}); `capy project delete` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy project create` missing the name, `--branch` or `--protected` in agent mode refuses with PROJECT_NAME_REQUIRED, BRANCH_NAME_REQUIRED or BRANCH_PROTECTION_REQUIRED and a form (fields: name, branch, protected (options yes/no); details: {org:{id,name}}); `capy project delete` / `project restore` with a name several projects share refuses with PROJECT_AMBIGUOUS and a form with one `project` field whose options are the projects (details: {org:{id,name}, matches:[{id, name, created_at, variables}]}). Never answer it yourself and never add --yes on your own. diff --git a/src/commands/projectLifecycleCommand.ts b/src/commands/projectLifecycleCommand.ts index c7f42b45..f893d4a0 100644 --- a/src/commands/projectLifecycleCommand.ts +++ b/src/commands/projectLifecycleCommand.ts @@ -25,7 +25,7 @@ import { isInteractive, EXIT_NEEDS_INPUT } from '../ui/interactive'; import { CapyError, ERROR_CODES } from '../types/index'; import type { DeleteProjectResponse, LiveDeployTokenInfo } from '../service/serviceClient'; import type { ElicitationConfirm, ElicitationForm } from '../ui/elicitation'; -import { chooseProjectDisplay, deleteProjectDisplay } from '../ui/elicitationDisplay'; +import { chooseProjectDisplay, deleteProjectDisplay, restoreProjectDisplay } from '../ui/elicitationDisplay'; import { formatRelativeTime } from '../ui/relativeTime'; import { variableBranchesFromKeep, type VariableBranches } from '../core/keepVariableNames'; @@ -308,14 +308,6 @@ function choiceRefusal(picked: Extract, ref: s }; } -function refuseWithoutTerminal(json: boolean, what: string): never { - return refuse(json, { - code: ERROR_CODES.CONFIRMATION_REQUIRED, - message: `\`${what}\` needs confirmation. Pass --yes, or run it in a terminal.`, // COPY-FLAG - exitCode: EXIT_NEEDS_INPUT, - }); -} - function liveOnly(tokens: readonly T[]): readonly T[] { return tokens.filter((t) => t.revoked_at === null); } @@ -459,7 +451,7 @@ function showPreview(json: boolean, lines: readonly string[]): void { async function deleteFlow(ref: string, opts: DeleteProjectOpts): Promise { const json = opts.json === true; const force = opts.force === true; - const agentMode = !isInteractive(opts.nonTty); + const agentMode = json || !isInteractive(opts.nonTty); // Agent mode never opens a prompt, not even to pick an organization. const { orgId, orgName, serviceClient } = await resolveOrgContext(opts.apiUrl, opts.devMode === true, { promptForOrg: !agentMode }); const org: OrgRef = { id: orgId, name: orgName }; @@ -550,26 +542,46 @@ export async function deleteProjectCommand(ref: string, opts: DeleteProjectOpts) type RestoreOutcome = | { readonly kind: 'cancelled' } | { readonly kind: 'needs-choice'; readonly refusal: Refusal } + | { readonly kind: 'needs-confirmation'; readonly elicitation: ElicitationConfirm } | { readonly kind: 'restored'; readonly project: { readonly id: string; readonly name: string } }; +/** The confirm elicitation for a restore: the project, its org and when it was deleted. The yes command names the project by id. */ +function restoreElicitation(org: OrgRef, project: ProjectRef): ElicitationConfirm { + const deletedAt = project.deleted_at ?? null; + return { + kind: 'confirm', + question: `Restore project ${project.name}?`, // COPY-FLAG: the terminal question, word for word + details: { + org: { id: org.id, name: org.name }, + project: { id: project.id, name: project.name, deleted_at: deletedAt }, + }, + display: restoreProjectDisplay({ org, project: { id: project.id, name: project.name }, deletedAt }), + answer: { yes: `capy project restore ${project.id} --yes --json` }, + }; +} + async function restoreFlow(ref: string, opts: ProjectLifecycleOpts): Promise { const json = opts.json === true; - const agentMode = !isInteractive(opts.nonTty); + const agentMode = json || !isInteractive(opts.nonTty); const { orgId, orgName, serviceClient } = await resolveOrgContext(opts.apiUrl, opts.devMode === true); + const org: OrgRef = { id: orgId, name: orgName }; // Only an owner's `include_deleted` is honoured; for anyone else the service answers with the live projects, and the id still works. - // With nobody to ask, this point is only reached with --yes (see restoreProjectCommand), so the rerun keeps it. const picked = await pickProject(resolveRef(await serviceClient.listProjects({ includeDeleted: true }), ref, true), { json, agentMode, - org: { id: orgId, name: orgName }, + org, ref, client: serviceClient, - rerun: 'capy project restore --yes --json', + rerun: `capy project restore ${opts.yes === true ? '--yes ' : ''}--json`, }); if (picked.kind === 'needs-choice') return { kind: 'needs-choice', refusal: choiceRefusal(picked, ref) }; const project = picked.project; - if (opts.yes !== true && !(await confirm(json, `Restore project ${project.name}?`))) return { kind: 'cancelled' }; // COPY-FLAG + if (opts.yes !== true) { + // Nobody to ask: hand the question to the agent that ran this, and restore nothing. + if (agentMode) return { kind: 'needs-confirmation', elicitation: restoreElicitation(org, project) }; + if (!(await confirm(json, `Restore project ${project.name}?`))) return { kind: 'cancelled' }; // COPY-FLAG + } const result = await serviceClient.restoreProject(orgId, project.id); return { kind: 'restored', project: result.project }; @@ -577,6 +589,14 @@ async function restoreFlow(ref: string, opts: ProjectLifecycleOpts): Promise { const json = opts.json === true; - if (opts.yes !== true && !isInteractive(opts.nonTty)) { - refuseWithoutTerminal(json, 'capy project restore'); - } - + // No terminal and no --yes means nobody can answer: the flow stops with the question as an elicitation (exit 3). + // It reads the project first, because the question has to say what would be restored. const settled = await settle(() => restoreFlow(ref, opts)); if (!settled.ok) return refuse(json, refusalFor(settled.error)); emitRestoreOutcome(json, settled.value); diff --git a/src/ui/elicitation.ts b/src/ui/elicitation.ts index 79ad3236..f3e86dcc 100644 --- a/src/ui/elicitation.ts +++ b/src/ui/elicitation.ts @@ -50,4 +50,4 @@ export type Elicitation = ElicitationConfirm | ElicitationForm; /** What `capy help --json` says about elicitations (`conventions.elicitation`). */ export const ELICITATION_HELP = // COPY-FLAG: minimal and neutral. - 'A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. Write `display` (a markdown table) in your reply exactly as given, without shortening or reordering it and never inside the question. Then ask the short `question` as an elicitation with your question tool: one question per field, using the field\'s own `question` followed by its `description` when it has them, and its `options` or `default` as the choices; without a question tool, end your reply with the `question` and the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, question?, default?, description?, options?:[{value, label}]}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. A field with `options` is a choice: the human picks a `label`, and you fill in its `value`. `details` holds names and ids only, never a value. Today: `capy project delete` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy project create` missing the name, `--branch` or `--protected` in agent mode refuses with PROJECT_NAME_REQUIRED, BRANCH_NAME_REQUIRED or BRANCH_PROTECTION_REQUIRED and a form (fields: name, branch, protected (options yes/no); details: {org:{id,name}}); `capy project delete` / `project restore` with a name several projects share refuses with PROJECT_AMBIGUOUS and a form with one `project` field whose options are the projects (details: {org:{id,name}, matches:[{id, name, created_at, variables}]}). Never answer it yourself and never add --yes on your own.'; + 'A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. Write `display` (a markdown table) in your reply exactly as given, without shortening or reordering it and never inside the question. Then ask the short `question` as an elicitation with your question tool: one question per field, using the field\'s own `question` followed by its `description` when it has them, and its `options` or `default` as the choices; without a question tool, end your reply with the `question` and the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, question?, default?, description?, options?:[{value, label}]}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. A field with `options` is a choice: the human picks a `label`, and you fill in its `value`. `details` holds names and ids only, never a value. Today: `capy project restore` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name,deleted_at}}); `capy project delete` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy project create` missing the name, `--branch` or `--protected` in agent mode refuses with PROJECT_NAME_REQUIRED, BRANCH_NAME_REQUIRED or BRANCH_PROTECTION_REQUIRED and a form (fields: name, branch, protected (options yes/no); details: {org:{id,name}}); `capy project delete` / `project restore` with a name several projects share refuses with PROJECT_AMBIGUOUS and a form with one `project` field whose options are the projects (details: {org:{id,name}, matches:[{id, name, created_at, variables}]}). Never answer it yourself and never add --yes on your own.'; diff --git a/src/ui/elicitationDisplay.ts b/src/ui/elicitationDisplay.ts index c3cd53ab..7d2e4937 100644 --- a/src/ui/elicitationDisplay.ts +++ b/src/ui/elicitationDisplay.ts @@ -61,6 +61,19 @@ export function deleteProjectDisplay(input: DeleteDisplayInput): string { return `${summary}\n\n${variablesSection(input)}`; } +/** The table for `capy project restore`: the org, the project and when it was deleted. */ +export function restoreProjectDisplay(input: { + readonly org: { readonly id: string; readonly name: string }; + readonly project: { readonly id: string; readonly name: string }; + readonly deletedAt: string | null; +}): string { + return table(['', ''], [ + ['Org', input.org.name], + ['Project', `${input.project.name} (${input.project.id})`], + ['Deleted', input.deletedAt ?? UNREADABLE], // COPY-FLAG + ]); +} + /** The table for a name that matches several projects: one numbered row per project, labelled as the options are. */ export function chooseProjectDisplay(labels: readonly string[]): string { return table(['', 'Project'], labels.map((label, i) => [String(i + 1), label] as const)); // COPY-FLAG diff --git a/src/ui/promptEngine.ts b/src/ui/promptEngine.ts index 0da48454..5c54383e 100644 --- a/src/ui/promptEngine.ts +++ b/src/ui/promptEngine.ts @@ -9,7 +9,7 @@ import { UserDecisions } from '../types/index'; import { validateProjectNameInput } from '../core/projectNameRule'; -import { BRANCH_DESCRIPTION, DEFAULT_FIRST_BRANCH, validateBranchNameInput } from '../core/branchNameRule'; +import { BRANCH_DESCRIPTION, DEFAULT_FIRST_BRANCH, FIRST_BRANCH_QUESTION, validateBranchNameInput } from '../core/branchNameRule'; export class PromptEngine { async promptForProjectName(defaultName: string): Promise { @@ -27,15 +27,15 @@ export class PromptEngine { } /** - * Asks for a new project's first branch with the same question bare `capy` asks (capyCommand - * `resolveInitialBranchChoice`), saying first what a branch is. `development` unless the person types another. + * Asks for a new project's first branch with the approved question, the one the agent elicitation asks too, + * saying first what a branch is. `development` unless the person types another. */ async promptForFirstBranch(): Promise { console.log(BRANCH_DESCRIPTION); const { initialBranchChoice } = await inquirer.prompt([{ type: 'list', name: 'initialBranchChoice', - message: 'What branch should this project start with?', + message: FIRST_BRANCH_QUESTION, choices: [ { name: `${DEFAULT_FIRST_BRANCH} (default)`, value: DEFAULT_FIRST_BRANCH }, { name: 'another branch', value: 'other' }, diff --git a/tests/commands/projectLifecycleCommand.test.ts b/tests/commands/projectLifecycleCommand.test.ts index 50e57387..81af53a4 100644 --- a/tests/commands/projectLifecycleCommand.test.ts +++ b/tests/commands/projectLifecycleCommand.test.ts @@ -198,16 +198,15 @@ describe('capy project delete', () => { expect(deletes()).toEqual([]); }); - it('answering no with --json says CANCELLED on stdout, deletes nothing, and asks on stderr', async () => { + it('--json in a terminal is agent mode, as for create: the confirm elicitation, no prompt, nothing deleted', async () => { setTTY(true); service({}); - fakePrompt.mockImplementation(async () => ({ confirmed: false })); const out = await capture(() => deleteProjectCommand('web', { json: true })); - expect(JSON.parse(out.stdout)).toMatchObject({ ok: false, code: 'CANCELLED' }); - expect(createPromptModule.mock.calls[0][0]).toEqual({ output: process.stderr }); - expect(out.stdout).not.toContain('Project:'); + expect(out.exitCode).toBe(3); + expect(JSON.parse(out.stdout)).toMatchObject({ ok: false, code: 'CONFIRMATION_REQUIRED', elicitation: { kind: 'confirm' } }); + expect(fakePrompt).not.toHaveBeenCalled(); expect(deletes()).toEqual([]); }); @@ -576,14 +575,33 @@ describe('capy project restore', () => { expect(writes()).toEqual([]); }); - it('off a terminal without --yes: CONFIRMATION_REQUIRED before any request', async () => { + it('off a terminal without --yes: CONFIRMATION_REQUIRED (exit 3) with a confirm elicitation, restoring nothing', async () => { service({ projects: [DELETED], onWrite: () => restored }); const out = await capture(() => restoreProjectCommand('old', { json: true })); expect(out.exitCode).toBe(3); - expect(JSON.parse(out.stdout).code).toBe('CONFIRMATION_REQUIRED'); - expect(requests()).toEqual([]); + const body = JSON.parse(out.stdout); + expect(body).toMatchObject({ ok: false, code: 'CONFIRMATION_REQUIRED' }); + expect(body.elicitation).toMatchObject({ + kind: 'confirm', + question: 'Restore project old?', + details: { org: { id: 'org_1' }, project: { id: 'proj_old', name: 'old', deleted_at: '2026-11-05T12:00:00.000Z' } }, + answer: { yes: 'capy project restore proj_old --yes --json' }, + }); + expect(body.elicitation.display).toContain('| Project | old (proj_old) |'); + expect(body.elicitation.display).toContain('| Deleted | 2026-11-05T12:00:00.000Z |'); + expect(fakePrompt).not.toHaveBeenCalled(); + expect(writes()).toEqual([]); + }); + + it('two deleted projects with one name, no --yes: the pick-one rerun has no --yes, so the yes/no comes next', async () => { + service({ projects: [DELETED, { ...DELETED, id: 'proj_old2' }], onWrite: () => restored }); + + const out = await capture(() => restoreProjectCommand('old', { json: true })); + + expect(JSON.parse(out.stdout).elicitation.answer.rerun).toBe('capy project restore --json'); + expect(writes()).toEqual([]); }); it('a project that is not deleted: 409 PROJECT_NOT_DELETED', async () => { From 364f4027e7db2c0bba3c56c9a686f23203660da8 Mon Sep 17 00:00:00 2001 From: cvince Date: Mon, 9 Nov 2026 23:53:41 -0700 Subject: [PATCH 23/27] fix(project): restore in agent mode never opens the organization picker --- src/commands/projectLifecycleCommand.ts | 3 +- .../commands/projectLifecycleCommand.test.ts | 35 ++++++++++++++----- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/commands/projectLifecycleCommand.ts b/src/commands/projectLifecycleCommand.ts index f893d4a0..1bb91f66 100644 --- a/src/commands/projectLifecycleCommand.ts +++ b/src/commands/projectLifecycleCommand.ts @@ -563,7 +563,8 @@ function restoreElicitation(org: OrgRef, project: ProjectRef): ElicitationConfir async function restoreFlow(ref: string, opts: ProjectLifecycleOpts): Promise { const json = opts.json === true; const agentMode = json || !isInteractive(opts.nonTty); - const { orgId, orgName, serviceClient } = await resolveOrgContext(opts.apiUrl, opts.devMode === true); + // Agent mode never opens a prompt, not even to pick an organization. + const { orgId, orgName, serviceClient } = await resolveOrgContext(opts.apiUrl, opts.devMode === true, { promptForOrg: !agentMode }); const org: OrgRef = { id: orgId, name: orgName }; // Only an owner's `include_deleted` is honoured; for anyone else the service answers with the live projects, and the id still works. const picked = await pickProject(resolveRef(await serviceClient.listProjects({ includeDeleted: true }), ref, true), { diff --git a/tests/commands/projectLifecycleCommand.test.ts b/tests/commands/projectLifecycleCommand.test.ts index 81af53a4..26362c52 100644 --- a/tests/commands/projectLifecycleCommand.test.ts +++ b/tests/commands/projectLifecycleCommand.test.ts @@ -33,16 +33,19 @@ const server = Bun.serve({ const client = new ServiceClient(`http://localhost:${server.port}`); client.setTokenProvider((async () => ({ access_token: 'test-token' })) as never); -mock.module('../../src/core/orgContext', () => ({ - resolveOrgContext: mock(async () => ({ - orgId: 'org_1', - orgName: 'Acme', - userId: 'user_1', - userEmail: 'owner@example.com', - authService: {}, - serviceClient: client, - })), +/** The org context every test gets. Its calls show whether a command allowed the organization picker. */ +const resolveOrgContext = mock(async (..._args: unknown[]) => ({ + orgId: 'org_1', + orgName: 'Acme', + userId: 'user_1', + userEmail: 'owner@example.com', + authService: {}, + serviceClient: client, })); +mock.module('../../src/core/orgContext', () => ({ resolveOrgContext })); + +/** The options the last command passed to resolveOrgContext. */ +const lastOrgContextOptions = (): unknown => resolveOrgContext.mock.calls.at(-1)?.[2]; /** What the person answers when asked. Replaced per test. */ const fakePrompt = mock(async (_questions: unknown) => ({ confirmed: true })); @@ -595,6 +598,20 @@ describe('capy project restore', () => { expect(writes()).toEqual([]); }); + it('agent mode never allows the organization picker; a terminal does', async () => { + service({ projects: [DELETED], onWrite: () => restored }); + + await capture(() => restoreProjectCommand('old', { json: true })); + expect(lastOrgContextOptions()).toEqual({ promptForOrg: false }); + + await capture(() => restoreProjectCommand('old', { yes: true, nonTty: true })); + expect(lastOrgContextOptions()).toEqual({ promptForOrg: false }); + + setTTY(true); + await capture(() => restoreProjectCommand('old', { yes: true })); + expect(lastOrgContextOptions()).toEqual({ promptForOrg: true }); + }); + it('two deleted projects with one name, no --yes: the pick-one rerun has no --yes, so the yes/no comes next', async () => { service({ projects: [DELETED, { ...DELETED, id: 'proj_old2' }], onWrite: () => restored }); From 2fdf469df53033704c92bf729056aff90f40486f Mon Sep 17 00:00:00 2001 From: cvince Date: Mon, 9 Nov 2026 23:59:34 -0700 Subject: [PATCH 24/27] fix(project): restore honors --dry-run and restores nothing --- docs/cli-reference.md | 2 ++ src/commands/projectLifecycleCommand.ts | 24 ++++++++++++++++--- src/core/cliHelpDoc.ts | 1 + src/index-dev.ts | 4 ++-- src/index.ts | 4 ++-- .../commands/projectLifecycleCommand.test.ts | 13 ++++++++++ 6 files changed, 41 insertions(+), 7 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index df5dd3f9..144677ac 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -563,6 +563,8 @@ capy project restore [options] JSON support: yes (`--json`) +Dry run: yes (`--dry-run`) + ## `capy info` Show current session info diff --git a/src/commands/projectLifecycleCommand.ts b/src/commands/projectLifecycleCommand.ts index 1bb91f66..23cf5a5a 100644 --- a/src/commands/projectLifecycleCommand.ts +++ b/src/commands/projectLifecycleCommand.ts @@ -45,6 +45,11 @@ export interface DeleteProjectOpts extends ProjectLifecycleOpts { dryRun?: boolean; } +export interface RestoreProjectOpts extends ProjectLifecycleOpts { + /** The global `--dry-run`: show which project would be restored; send nothing that writes. */ + dryRun?: boolean; +} + /** What commander hands an action's `command`: only the merged view is needed. */ interface CommandWithGlobals { optsWithGlobals(): Record; @@ -540,6 +545,7 @@ export async function deleteProjectCommand(ref: string, opts: DeleteProjectOpts) } type RestoreOutcome = + | { readonly kind: 'dry-run'; readonly project: ProjectRef } | { readonly kind: 'cancelled' } | { readonly kind: 'needs-choice'; readonly refusal: Refusal } | { readonly kind: 'needs-confirmation'; readonly elicitation: ElicitationConfirm } @@ -560,7 +566,7 @@ function restoreElicitation(org: OrgRef, project: ProjectRef): ElicitationConfir }; } -async function restoreFlow(ref: string, opts: ProjectLifecycleOpts): Promise { +async function restoreFlow(ref: string, opts: RestoreProjectOpts): Promise { const json = opts.json === true; const agentMode = json || !isInteractive(opts.nonTty); // Agent mode never opens a prompt, not even to pick an organization. @@ -577,6 +583,8 @@ async function restoreFlow(ref: string, opts: ProjectLifecycleOpts): Promise [--yes] [--json]` */ -export async function restoreProjectCommand(ref: string, opts: ProjectLifecycleOpts): Promise { +/** `capy project restore [--yes] [--dry-run] [--json]` */ +export async function restoreProjectCommand(ref: string, opts: RestoreProjectOpts): Promise { const json = opts.json === true; // No terminal and no --yes means nobody can answer: the flow stops with the question as an elicitation (exit 3). diff --git a/src/core/cliHelpDoc.ts b/src/core/cliHelpDoc.ts index 7bb8fb50..44edc1a3 100644 --- a/src/core/cliHelpDoc.ts +++ b/src/core/cliHelpDoc.ts @@ -120,6 +120,7 @@ export const DRY_RUN_COMMANDS: ReadonlySet = new Set([ 'edit', 'project create', 'project delete', + 'project restore', 'remove', 'secrets', 'secrets set', diff --git a/src/index-dev.ts b/src/index-dev.ts index 9dddb96a..348f1313 100644 --- a/src/index-dev.ts +++ b/src/index-dev.ts @@ -783,8 +783,8 @@ projectCmd .option('--json', 'emit machine-readable JSON instead of the human UI') .action(async (ref: string, _options: any, command: any) => { const { restoreProjectCommand, lifecycleOptsFrom } = await import('./commands/projectLifecycleCommand'); - const { yes, nonTty, json } = lifecycleOptsFrom(command); - await restoreProjectCommand(ref, { yes, nonTty, json, apiUrl: process.env.CAPY_API_URL, devMode: true }); + const { yes, nonTty, json, dryRun } = lifecycleOptsFrom(command); + await restoreProjectCommand(ref, { yes, nonTty, json, dryRun, apiUrl: process.env.CAPY_API_URL, devMode: true }); }); orgCmd diff --git a/src/index.ts b/src/index.ts index c4740537..48bec23e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -822,8 +822,8 @@ projectCmd .action(async (ref: string, _options: any, command: any) => { assertNotLocalOnly('project restore'); const { restoreProjectCommand, lifecycleOptsFrom } = await import('./commands/projectLifecycleCommand'); - const { yes, nonTty, json } = lifecycleOptsFrom(command); - await restoreProjectCommand(ref, { yes, nonTty, json }); + const { yes, nonTty, json, dryRun } = lifecycleOptsFrom(command); + await restoreProjectCommand(ref, { yes, nonTty, json, dryRun }); }); orgCmd diff --git a/tests/commands/projectLifecycleCommand.test.ts b/tests/commands/projectLifecycleCommand.test.ts index 26362c52..61a15692 100644 --- a/tests/commands/projectLifecycleCommand.test.ts +++ b/tests/commands/projectLifecycleCommand.test.ts @@ -598,6 +598,19 @@ describe('capy project restore', () => { expect(writes()).toEqual([]); }); + it('--dry-run, even with --yes: names the project and sends nothing that writes', async () => { + service({ projects: [DELETED], onWrite: () => restored }); + + const out = await capture(() => restoreProjectCommand('old', { yes: true, dryRun: true, json: true })); + expect(out.exitCode).toBeUndefined(); + expect(JSON.parse(out.stdout)).toEqual({ ok: true, dry_run: true, project: { id: 'proj_old', name: 'old' } }); + expect(writes()).toEqual([]); + + const text = await capture(() => restoreProjectCommand('old', { yes: true, dryRun: true })); + expect(text.stdout).toContain('Dry run: nothing was restored.'); + expect(writes()).toEqual([]); + }); + it('agent mode never allows the organization picker; a terminal does', async () => { service({ projects: [DELETED], onWrite: () => restored }); From c5aa111190a5300f218ee800f34e233c30740999 Mon Sep 17 00:00:00 2001 From: cvince Date: Tue, 10 Nov 2026 00:34:25 -0700 Subject: [PATCH 25/27] feat(project): --non-tty means --json for project create, delete and restore --- docs/cli-reference.md | 6 +++--- src/commands/projectCreateCommand.ts | 3 ++- src/commands/projectLifecycleCommand.ts | 3 ++- src/index-dev.ts | 6 +++--- src/index.ts | 6 +++--- tests/commands/projectCreateCommand.test.ts | 12 +++++++++++- tests/commands/projectDeletedCli.test.ts | 5 +++++ 7 files changed, 29 insertions(+), 12 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 144677ac..d3ce2aac 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -521,7 +521,7 @@ capy project create [name] [options] |---|---|---| | `--branch ` | the first branch; a Capy branch works like a git branch (offered by default: development) | | | `--protected ` | whether the first branch is protected (invite-only) | | -| `--non-tty` | treat stdin as not a terminal: never ask for the name or branch; without them, refuse with a form to ask the human | | +| `--non-tty` | same as --json: never ask, and answer in JSON (agents/CI) | | | `--json` | emit machine-readable JSON instead of the human UI | | JSON support: yes (`--json`) @@ -540,7 +540,7 @@ capy project delete [options] |---|---|---| | `--force` | delete even if the project has live deploy tokens (those deploys stop on their next restart) | | | `--yes` | skip the confirmation question | | -| `--non-tty` | treat stdin as not a terminal: refuse unless --yes is passed | | +| `--non-tty` | same as --json: never ask, and answer in JSON (agents/CI) | | | `--json` | emit machine-readable JSON instead of the human UI | | JSON support: yes (`--json`) @@ -558,7 +558,7 @@ capy project restore [options] | Option | Description | Default | |---|---|---| | `--yes` | skip the confirmation question | | -| `--non-tty` | treat stdin as not a terminal: refuse unless --yes is passed | | +| `--non-tty` | same as --json: never ask, and answer in JSON (agents/CI) | | | `--json` | emit machine-readable JSON instead of the human UI | | JSON support: yes (`--json`) diff --git a/src/commands/projectCreateCommand.ts b/src/commands/projectCreateCommand.ts index f9689a51..751a6192 100644 --- a/src/commands/projectCreateCommand.ts +++ b/src/commands/projectCreateCommand.ts @@ -50,7 +50,8 @@ interface CommandWithGlobals { export function createProjectOptsFrom(command: CommandWithGlobals): CreateProjectOpts { const merged = command.optsWithGlobals(); return { - json: merged.json === true, + // `--non-tty` is agent mode, and agent mode always answers in JSON: it means the same as `--json`. + json: merged.json === true || merged.nonTty === true, nonTty: merged.nonTty === true, dryRun: merged.dryRun === true, ...(typeof merged.branch === 'string' ? { branch: merged.branch.trim() } : {}), diff --git a/src/commands/projectLifecycleCommand.ts b/src/commands/projectLifecycleCommand.ts index 23cf5a5a..bb8f3482 100644 --- a/src/commands/projectLifecycleCommand.ts +++ b/src/commands/projectLifecycleCommand.ts @@ -68,7 +68,8 @@ export function lifecycleOptsFrom(command: CommandWithGlobals): DeleteProjectOpt force: merged.force === true, yes: merged.yes === true, nonTty: merged.nonTty === true, - json: merged.json === true, + // `--non-tty` is agent mode, and agent mode always answers in JSON: it means the same as `--json`. + json: merged.json === true || merged.nonTty === true, dryRun: merged.dryRun === true, }; } diff --git a/src/index-dev.ts b/src/index-dev.ts index 348f1313..d3d405e8 100644 --- a/src/index-dev.ts +++ b/src/index-dev.ts @@ -754,7 +754,7 @@ projectCmd .description('Create a project and its first branch in the active organization (owners and admins only; does not link this folder)') // COPY-FLAG .option('--branch ', 'the first branch; a Capy branch works like a git branch (offered by default: development)') // COPY-FLAG .option('--protected ', 'whether the first branch is protected (invite-only)') // COPY-FLAG - .option('--non-tty', 'treat stdin as not a terminal: never ask for the name or branch; without them, refuse with a form to ask the human') // COPY-FLAG + .option('--non-tty', 'same as --json: never ask, and answer in JSON (agents/CI)') // COPY-FLAG .option('--json', 'emit machine-readable JSON instead of the human UI') .action(async (name: string | undefined, _options: any, command: any) => { const { createProjectCommand, createProjectOptsFrom } = await import('./commands/projectCreateCommand'); @@ -767,7 +767,7 @@ projectCmd .description('Delete a project (owners only; hidden, not destroyed; an owner can restore it)') // COPY-FLAG .option('--force', 'delete even if the project has live deploy tokens (those deploys stop on their next restart)') // COPY-FLAG .option('--yes', 'skip the confirmation question') - .option('--non-tty', 'treat stdin as not a terminal: refuse unless --yes is passed') // COPY-FLAG + .option('--non-tty', 'same as --json: never ask, and answer in JSON (agents/CI)') // COPY-FLAG .option('--json', 'emit machine-readable JSON instead of the human UI') .action(async (ref: string, _options: any, command: any) => { const { deleteProjectCommand, lifecycleOptsFrom } = await import('./commands/projectLifecycleCommand'); @@ -779,7 +779,7 @@ projectCmd .command('restore ') .description('Restore a deleted project (owners only)') // COPY-FLAG .option('--yes', 'skip the confirmation question') - .option('--non-tty', 'treat stdin as not a terminal: refuse unless --yes is passed') // COPY-FLAG + .option('--non-tty', 'same as --json: never ask, and answer in JSON (agents/CI)') // COPY-FLAG .option('--json', 'emit machine-readable JSON instead of the human UI') .action(async (ref: string, _options: any, command: any) => { const { restoreProjectCommand, lifecycleOptsFrom } = await import('./commands/projectLifecycleCommand'); diff --git a/src/index.ts b/src/index.ts index 48bec23e..00e6154e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -790,7 +790,7 @@ projectCmd .description('Create a project and its first branch in the active organization (owners and admins only; does not link this folder)') // COPY-FLAG .option('--branch ', 'the first branch; a Capy branch works like a git branch (offered by default: development)') // COPY-FLAG .option('--protected ', 'whether the first branch is protected (invite-only)') // COPY-FLAG - .option('--non-tty', 'treat stdin as not a terminal: never ask for the name or branch; without them, refuse with a form to ask the human') // COPY-FLAG + .option('--non-tty', 'same as --json: never ask, and answer in JSON (agents/CI)') // COPY-FLAG .option('--json', 'emit machine-readable JSON instead of the human UI') .action(async (name: string | undefined, _options: any, command: any) => { assertNotLocalOnly('project create'); @@ -804,7 +804,7 @@ projectCmd .description('Delete a project (owners only; hidden, not destroyed; an owner can restore it)') // COPY-FLAG .option('--force', 'delete even if the project has live deploy tokens (those deploys stop on their next restart)') // COPY-FLAG .option('--yes', 'skip the confirmation question') - .option('--non-tty', 'treat stdin as not a terminal: refuse unless --yes is passed') // COPY-FLAG + .option('--non-tty', 'same as --json: never ask, and answer in JSON (agents/CI)') // COPY-FLAG .option('--json', 'emit machine-readable JSON instead of the human UI') .action(async (ref: string, _options: any, command: any) => { assertNotLocalOnly('project delete'); @@ -817,7 +817,7 @@ projectCmd .command('restore ') .description('Restore a deleted project (owners only)') // COPY-FLAG .option('--yes', 'skip the confirmation question') - .option('--non-tty', 'treat stdin as not a terminal: refuse unless --yes is passed') // COPY-FLAG + .option('--non-tty', 'same as --json: never ask, and answer in JSON (agents/CI)') // COPY-FLAG .option('--json', 'emit machine-readable JSON instead of the human UI') .action(async (ref: string, _options: any, command: any) => { assertNotLocalOnly('project restore'); diff --git a/tests/commands/projectCreateCommand.test.ts b/tests/commands/projectCreateCommand.test.ts index 68bf9d16..2ce3a942 100644 --- a/tests/commands/projectCreateCommand.test.ts +++ b/tests/commands/projectCreateCommand.test.ts @@ -125,7 +125,7 @@ function service(opts: { projects?: readonly object[]; onCreate?: (body: string) }); } -const { createProjectCommand, existingProjectNamed } = await import('../../src/commands/projectCreateCommand'); +const { createProjectCommand, createProjectOptsFrom, existingProjectNamed } = await import('../../src/commands/projectCreateCommand'); describe('capy project create', () => { beforeEach(() => { @@ -377,3 +377,13 @@ describe('existingProjectNamed', () => { expect(existingProjectNamed(LIVE, 'org_1', 'webs')).toBeUndefined(); }); }); + +describe('createProjectOptsFrom', () => { + const merged = (opts: Record) => createProjectOptsFrom({ optsWithGlobals: () => opts }); + + it('--non-tty means --json: agent mode always answers in JSON', () => { + expect(merged({ nonTty: true }).json).toBe(true); + expect(merged({ json: true }).json).toBe(true); + expect(merged({}).json).toBe(false); + }); +}); diff --git a/tests/commands/projectDeletedCli.test.ts b/tests/commands/projectDeletedCli.test.ts index 8bf0746d..cbe17b5c 100644 --- a/tests/commands/projectDeletedCli.test.ts +++ b/tests/commands/projectDeletedCli.test.ts @@ -98,6 +98,11 @@ describe('lifecycleOptsFrom: --non-tty and --force, wherever the outer command t expect(parse(['project', 'delete', 'web', '--non-tty'])?.nonTty).toBe(true); }); + test('--non-tty means --json: agent mode always answers in JSON', () => { + expect(parse(['project', 'delete', 'web', '--non-tty'])?.json).toBe(true); + expect(parse(['project', 'delete', 'web'])?.json).toBe(false); + }); + test('--force reaches the command (the program takes it)', () => { expect(parse(['project', 'delete', 'web', '--force'])?.force).toBe(true); expect(parse(['project', 'delete', 'web'])?.force).toBe(false); From 13d14075531e5159599edcb38357b8f5bcbb9c9b Mon Sep 17 00:00:00 2001 From: cvince Date: Tue, 10 Nov 2026 00:38:00 -0700 Subject: [PATCH 26/27] feat(agents): typed elicitation fields offer their default or Cancel, and Type a name --- docs/cli-reference.md | 2 +- src/core/agentsBlockPlan.ts | 2 +- src/ui/elicitation.ts | 2 +- tests/core/agentsBlockPlan.test.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index d3ce2aac..f53d4548 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1184,4 +1184,4 @@ Every refusal carries a stable `code` — branch on it, never on message text. - **JSON**: Pass --json on any command whose supportsJson is true and parse stdout as JSON; prose (progress, prompts, errors) goes to stderr so stdout stays pure JSON. - **Codes**: Branch only on the machine-readable `code` field (from --json output or a CapyError), never on human-readable message text — messages may be reworded without notice. - **Conflicts**: Every command that writes from a folder (edit, add, remove, connect, rotate, push, checkout, org, deploy, deploy revoke, deploy targets-remove) runs bare `capy` first; `checkout -b` is the one exception (a new branch carries the uncommitted values of the folder). Off a terminal, if the folder and Capy differ on any variable, nothing is written, the exit code is 3, and stdout is {ok:false, code:"SECRETS_CONFLICT", conflict_id, conflicts:[{name, project, branch, type, pinned, local, remote}]} (fingerprints only, never values; a Dokploy value has {name, target, pinned, local, remote}). Ask the person, then rerun the same command with --resolve NAME=pinned|local|remote|delete for every row (NAME@TARGET=local|remote for a Dokploy value) and --conflict . A state that moved since is SECRETS_CONFLICT_STALE with the fresh rows. There is no resolve-all. -- **Elicitation**: A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. Write `display` (a markdown table) in your reply exactly as given, without shortening or reordering it and never inside the question. Then ask the short `question` as an elicitation with your question tool: one question per field, using the field's own `question` followed by its `description` when it has them, and its `options` or `default` as the choices; without a question tool, end your reply with the `question` and the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, question?, default?, description?, options?:[{value, label}]}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. A field with `options` is a choice: the human picks a `label`, and you fill in its `value`. `details` holds names and ids only, never a value. Today: `capy project restore` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name,deleted_at}}); `capy project delete` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy project create` missing the name, `--branch` or `--protected` in agent mode refuses with PROJECT_NAME_REQUIRED, BRANCH_NAME_REQUIRED or BRANCH_PROTECTION_REQUIRED and a form (fields: name, branch, protected (options yes/no); details: {org:{id,name}}); `capy project delete` / `project restore` with a name several projects share refuses with PROJECT_AMBIGUOUS and a form with one `project` field whose options are the projects (details: {org:{id,name}, matches:[{id, name, created_at, variables}]}). Never answer it yourself and never add --yes on your own. +- **Elicitation**: A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. Write `display` (a markdown table) in your reply exactly as given, without shortening or reordering it and never inside the question. Then ask the short `question` as an elicitation with your question tool: one question per field, using the field's own `question` followed by its `description` when it has them, and its `options` or `default` as the choices; without a question tool, end your reply with the `question` and the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, question?, default?, description?, options?:[{value, label}]}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. A field with `options` is a choice: the human picks a `label`, and you fill in its `value`. A field without `options` is typed in: offer its `default` and "Type a name" as the two choices, or "Cancel" and "Type a name" when it has no `default`. Never add any other choice. If the human picks "Type a name", ask for it in chat; on "Cancel", stop. `details` holds names and ids only, never a value. Today: `capy project restore` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name,deleted_at}}); `capy project delete` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy project create` missing the name, `--branch` or `--protected` in agent mode refuses with PROJECT_NAME_REQUIRED, BRANCH_NAME_REQUIRED or BRANCH_PROTECTION_REQUIRED and a form (fields: name, branch, protected (options yes/no); details: {org:{id,name}}); `capy project delete` / `project restore` with a name several projects share refuses with PROJECT_AMBIGUOUS and a form with one `project` field whose options are the projects (details: {org:{id,name}, matches:[{id, name, created_at, variables}]}). Never answer it yourself and never add --yes on your own. diff --git a/src/core/agentsBlockPlan.ts b/src/core/agentsBlockPlan.ts index 86a5590b..2de2c8f8 100644 --- a/src/core/agentsBlockPlan.ts +++ b/src/core/agentsBlockPlan.ts @@ -31,7 +31,7 @@ export const AGENTS_BLOCK = [ '- Commands that write run bare `capy` first. If your folder and Capy differ they write nothing and exit 3 with `SECRETS_CONFLICT`, a `conflict_id`, and the differing names (fingerprints, never values).', // COPY-FLAG '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', // COPY-FLAG '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', // COPY-FLAG - '- If a JSON result has an `elicitation`, write its `display` table in your reply exactly as given (never shorten or reorder it, never put it inside the question). Then ask its short `question` as an elicitation with your question tool: one question per field, using the field\'s own `question` followed by its `description` when it has them, and offering its `options` (or its `default`) as the choices. Without a question tool, end your reply with the `question` and wait for the human to answer in chat. Then run the elicitation\'s answer command with what the human chose. Never answer it yourself and never add `--yes` on your own.', // COPY-FLAG + '- If a JSON result has an `elicitation`, write its `display` table in your reply exactly as given (never shorten or reorder it, never put it inside the question). Then ask its short `question` as an elicitation with your question tool: one question per field, using the field\'s own `question` followed by its `description` when it has them, and offering its `options` (or its `default`) as the choices. A field without `options` is typed in: offer its `default` and "Type a name" as the two choices, or "Cancel" and "Type a name" when it has no `default`; never add any other choice. If the human picks "Type a name", ask for it in chat; on "Cancel", stop. Without a question tool, end your reply with the `question` and wait for the human to answer in chat. Then run the elicitation\'s answer command with what the human chose. Never answer it yourself and never add `--yes` on your own.', // COPY-FLAG '- Only when the human asks to create a project, run `capy project create --json`, adding the name, `--branch ` and `--protected yes|no` only if the human already gave them. It creates nothing until it has all three and returns an `elicitation` asking for the rest: that is how you ask the human, so do not ask your own question first.', // COPY-FLAG '- Only when the human asks to delete a project, run `capy project delete --json` with the name they gave and without `--yes`, even if this folder\'s project is deleted or is a different one: it looks the name up in the organization, not in this folder. It changes nothing and returns an `elicitation`: that is how you ask the human, so do not ask your own question first. Only an org owner can delete or restore a project.', // COPY-FLAG '- `PROJECT_DELETED`, or `project.state` `"deleted"` in `capy info --json`, means an org owner deleted this folder\'s project. Stop and tell the human. An org owner can restore it with `capy project restore ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`. This is about this folder only: it never stops a delete or restore the human asked for by name.', // COPY-FLAG diff --git a/src/ui/elicitation.ts b/src/ui/elicitation.ts index f3e86dcc..d9b88622 100644 --- a/src/ui/elicitation.ts +++ b/src/ui/elicitation.ts @@ -50,4 +50,4 @@ export type Elicitation = ElicitationConfirm | ElicitationForm; /** What `capy help --json` says about elicitations (`conventions.elicitation`). */ export const ELICITATION_HELP = // COPY-FLAG: minimal and neutral. - 'A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. Write `display` (a markdown table) in your reply exactly as given, without shortening or reordering it and never inside the question. Then ask the short `question` as an elicitation with your question tool: one question per field, using the field\'s own `question` followed by its `description` when it has them, and its `options` or `default` as the choices; without a question tool, end your reply with the `question` and the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, question?, default?, description?, options?:[{value, label}]}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. A field with `options` is a choice: the human picks a `label`, and you fill in its `value`. `details` holds names and ids only, never a value. Today: `capy project restore` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name,deleted_at}}); `capy project delete` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy project create` missing the name, `--branch` or `--protected` in agent mode refuses with PROJECT_NAME_REQUIRED, BRANCH_NAME_REQUIRED or BRANCH_PROTECTION_REQUIRED and a form (fields: name, branch, protected (options yes/no); details: {org:{id,name}}); `capy project delete` / `project restore` with a name several projects share refuses with PROJECT_AMBIGUOUS and a form with one `project` field whose options are the projects (details: {org:{id,name}, matches:[{id, name, created_at, variables}]}). Never answer it yourself and never add --yes on your own.'; + 'A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. Write `display` (a markdown table) in your reply exactly as given, without shortening or reordering it and never inside the question. Then ask the short `question` as an elicitation with your question tool: one question per field, using the field\'s own `question` followed by its `description` when it has them, and its `options` or `default` as the choices; without a question tool, end your reply with the `question` and the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, question?, default?, description?, options?:[{value, label}]}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. A field with `options` is a choice: the human picks a `label`, and you fill in its `value`. A field without `options` is typed in: offer its `default` and "Type a name" as the two choices, or "Cancel" and "Type a name" when it has no `default`. Never add any other choice. If the human picks "Type a name", ask for it in chat; on "Cancel", stop. `details` holds names and ids only, never a value. Today: `capy project restore` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name,deleted_at}}); `capy project delete` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy project create` missing the name, `--branch` or `--protected` in agent mode refuses with PROJECT_NAME_REQUIRED, BRANCH_NAME_REQUIRED or BRANCH_PROTECTION_REQUIRED and a form (fields: name, branch, protected (options yes/no); details: {org:{id,name}}); `capy project delete` / `project restore` with a name several projects share refuses with PROJECT_AMBIGUOUS and a form with one `project` field whose options are the projects (details: {org:{id,name}, matches:[{id, name, created_at, variables}]}). Never answer it yourself and never add --yes on your own.'; diff --git a/tests/core/agentsBlockPlan.test.ts b/tests/core/agentsBlockPlan.test.ts index 8cfb0b5f..3a0ae751 100644 --- a/tests/core/agentsBlockPlan.test.ts +++ b/tests/core/agentsBlockPlan.test.ts @@ -39,7 +39,7 @@ describe('AGENTS_BLOCK', () => { '- Commands that write run bare `capy` first. If your folder and Capy differ they write nothing and exit 3 with `SECRETS_CONFLICT`, a `conflict_id`, and the differing names (fingerprints, never values).', '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', - '- If a JSON result has an `elicitation`, write its `display` table in your reply exactly as given (never shorten or reorder it, never put it inside the question). Then ask its short `question` as an elicitation with your question tool: one question per field, using the field\'s own `question` followed by its `description` when it has them, and offering its `options` (or its `default`) as the choices. Without a question tool, end your reply with the `question` and wait for the human to answer in chat. Then run the elicitation\'s answer command with what the human chose. Never answer it yourself and never add `--yes` on your own.', + '- If a JSON result has an `elicitation`, write its `display` table in your reply exactly as given (never shorten or reorder it, never put it inside the question). Then ask its short `question` as an elicitation with your question tool: one question per field, using the field\'s own `question` followed by its `description` when it has them, and offering its `options` (or its `default`) as the choices. A field without `options` is typed in: offer its `default` and "Type a name" as the two choices, or "Cancel" and "Type a name" when it has no `default`; never add any other choice. If the human picks "Type a name", ask for it in chat; on "Cancel", stop. Without a question tool, end your reply with the `question` and wait for the human to answer in chat. Then run the elicitation\'s answer command with what the human chose. Never answer it yourself and never add `--yes` on your own.', '- Only when the human asks to create a project, run `capy project create --json`, adding the name, `--branch ` and `--protected yes|no` only if the human already gave them. It creates nothing until it has all three and returns an `elicitation` asking for the rest: that is how you ask the human, so do not ask your own question first.', "- Only when the human asks to delete a project, run `capy project delete --json` with the name they gave and without `--yes`, even if this folder's project is deleted or is a different one: it looks the name up in the organization, not in this folder. It changes nothing and returns an `elicitation`: that is how you ask the human, so do not ask your own question first. Only an org owner can delete or restore a project.", "- `PROJECT_DELETED`, or `project.state` `\"deleted\"` in `capy info --json`, means an org owner deleted this folder's project. Stop and tell the human. An org owner can restore it with `capy project restore ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`. This is about this folder only: it never stops a delete or restore the human asked for by name.", From 9babded7ef1905addcfb82cc642bfd9598fd8e5f Mon Sep 17 00:00:00 2001 From: cvince Date: Tue, 10 Nov 2026 00:49:54 -0700 Subject: [PATCH 27/27] feat(agents): a typed field offers its default and Cancel; with no default, ask it in chat --- docs/cli-reference.md | 2 +- src/core/agentsBlockPlan.ts | 2 +- src/ui/elicitation.ts | 2 +- tests/core/agentsBlockPlan.test.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index f53d4548..9a7dffc0 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1184,4 +1184,4 @@ Every refusal carries a stable `code` — branch on it, never on message text. - **JSON**: Pass --json on any command whose supportsJson is true and parse stdout as JSON; prose (progress, prompts, errors) goes to stderr so stdout stays pure JSON. - **Codes**: Branch only on the machine-readable `code` field (from --json output or a CapyError), never on human-readable message text — messages may be reworded without notice. - **Conflicts**: Every command that writes from a folder (edit, add, remove, connect, rotate, push, checkout, org, deploy, deploy revoke, deploy targets-remove) runs bare `capy` first; `checkout -b` is the one exception (a new branch carries the uncommitted values of the folder). Off a terminal, if the folder and Capy differ on any variable, nothing is written, the exit code is 3, and stdout is {ok:false, code:"SECRETS_CONFLICT", conflict_id, conflicts:[{name, project, branch, type, pinned, local, remote}]} (fingerprints only, never values; a Dokploy value has {name, target, pinned, local, remote}). Ask the person, then rerun the same command with --resolve NAME=pinned|local|remote|delete for every row (NAME@TARGET=local|remote for a Dokploy value) and --conflict . A state that moved since is SECRETS_CONFLICT_STALE with the fresh rows. There is no resolve-all. -- **Elicitation**: A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. Write `display` (a markdown table) in your reply exactly as given, without shortening or reordering it and never inside the question. Then ask the short `question` as an elicitation with your question tool: one question per field, using the field's own `question` followed by its `description` when it has them, and its `options` or `default` as the choices; without a question tool, end your reply with the `question` and the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, question?, default?, description?, options?:[{value, label}]}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. A field with `options` is a choice: the human picks a `label`, and you fill in its `value`. A field without `options` is typed in: offer its `default` and "Type a name" as the two choices, or "Cancel" and "Type a name" when it has no `default`. Never add any other choice. If the human picks "Type a name", ask for it in chat; on "Cancel", stop. `details` holds names and ids only, never a value. Today: `capy project restore` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name,deleted_at}}); `capy project delete` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy project create` missing the name, `--branch` or `--protected` in agent mode refuses with PROJECT_NAME_REQUIRED, BRANCH_NAME_REQUIRED or BRANCH_PROTECTION_REQUIRED and a form (fields: name, branch, protected (options yes/no); details: {org:{id,name}}); `capy project delete` / `project restore` with a name several projects share refuses with PROJECT_AMBIGUOUS and a form with one `project` field whose options are the projects (details: {org:{id,name}, matches:[{id, name, created_at, variables}]}). Never answer it yourself and never add --yes on your own. +- **Elicitation**: A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. Write `display` (a markdown table) in your reply exactly as given, without shortening or reordering it and never inside the question. Then ask the short `question` as an elicitation with your question tool: one question per field, using the field's own `question` followed by its `description` when it has them, and its `options` or `default` as the choices; without a question tool, end your reply with the `question` and the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, question?, default?, description?, options?:[{value, label}]}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. A field with `options` is a choice: the human picks a `label`, and you fill in its `value`. A field without `options` is typed in. With a `default`, offer exactly two choices, the `default` and "Cancel"; the human types any other answer in your question tool's own free-text answer. With no `default`, leave it out of the question tool: after the other questions, ask its `question` in chat and wait for the human to type the answer. On "Cancel", stop. `details` holds names and ids only, never a value. Today: `capy project restore` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name,deleted_at}}); `capy project delete` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy project create` missing the name, `--branch` or `--protected` in agent mode refuses with PROJECT_NAME_REQUIRED, BRANCH_NAME_REQUIRED or BRANCH_PROTECTION_REQUIRED and a form (fields: name, branch, protected (options yes/no); details: {org:{id,name}}); `capy project delete` / `project restore` with a name several projects share refuses with PROJECT_AMBIGUOUS and a form with one `project` field whose options are the projects (details: {org:{id,name}, matches:[{id, name, created_at, variables}]}). Never answer it yourself and never add --yes on your own. diff --git a/src/core/agentsBlockPlan.ts b/src/core/agentsBlockPlan.ts index 2de2c8f8..71e0c7e1 100644 --- a/src/core/agentsBlockPlan.ts +++ b/src/core/agentsBlockPlan.ts @@ -31,7 +31,7 @@ export const AGENTS_BLOCK = [ '- Commands that write run bare `capy` first. If your folder and Capy differ they write nothing and exit 3 with `SECRETS_CONFLICT`, a `conflict_id`, and the differing names (fingerprints, never values).', // COPY-FLAG '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', // COPY-FLAG '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', // COPY-FLAG - '- If a JSON result has an `elicitation`, write its `display` table in your reply exactly as given (never shorten or reorder it, never put it inside the question). Then ask its short `question` as an elicitation with your question tool: one question per field, using the field\'s own `question` followed by its `description` when it has them, and offering its `options` (or its `default`) as the choices. A field without `options` is typed in: offer its `default` and "Type a name" as the two choices, or "Cancel" and "Type a name" when it has no `default`; never add any other choice. If the human picks "Type a name", ask for it in chat; on "Cancel", stop. Without a question tool, end your reply with the `question` and wait for the human to answer in chat. Then run the elicitation\'s answer command with what the human chose. Never answer it yourself and never add `--yes` on your own.', // COPY-FLAG + '- If a JSON result has an `elicitation`, write its `display` table in your reply exactly as given (never shorten or reorder it, never put it inside the question). Then ask its short `question` as an elicitation with your question tool: one question per field, using the field\'s own `question` followed by its `description` when it has them, and offering its `options` (or its `default`) as the choices. A field without `options` is typed in. With a `default`, offer exactly two choices, the `default` and "Cancel"; the human types any other answer in your question tool\'s own free-text answer. With no `default`, leave it out of the question tool: after the other questions, ask its `question` in chat and wait for the human to type the answer. On "Cancel", stop. Without a question tool, end your reply with the `question` and wait for the human to answer in chat. Then run the elicitation\'s answer command with what the human chose. Never answer it yourself and never add `--yes` on your own.', // COPY-FLAG '- Only when the human asks to create a project, run `capy project create --json`, adding the name, `--branch ` and `--protected yes|no` only if the human already gave them. It creates nothing until it has all three and returns an `elicitation` asking for the rest: that is how you ask the human, so do not ask your own question first.', // COPY-FLAG '- Only when the human asks to delete a project, run `capy project delete --json` with the name they gave and without `--yes`, even if this folder\'s project is deleted or is a different one: it looks the name up in the organization, not in this folder. It changes nothing and returns an `elicitation`: that is how you ask the human, so do not ask your own question first. Only an org owner can delete or restore a project.', // COPY-FLAG '- `PROJECT_DELETED`, or `project.state` `"deleted"` in `capy info --json`, means an org owner deleted this folder\'s project. Stop and tell the human. An org owner can restore it with `capy project restore ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`. This is about this folder only: it never stops a delete or restore the human asked for by name.', // COPY-FLAG diff --git a/src/ui/elicitation.ts b/src/ui/elicitation.ts index d9b88622..751781d3 100644 --- a/src/ui/elicitation.ts +++ b/src/ui/elicitation.ts @@ -50,4 +50,4 @@ export type Elicitation = ElicitationConfirm | ElicitationForm; /** What `capy help --json` says about elicitations (`conventions.elicitation`). */ export const ELICITATION_HELP = // COPY-FLAG: minimal and neutral. - 'A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. Write `display` (a markdown table) in your reply exactly as given, without shortening or reordering it and never inside the question. Then ask the short `question` as an elicitation with your question tool: one question per field, using the field\'s own `question` followed by its `description` when it has them, and its `options` or `default` as the choices; without a question tool, end your reply with the `question` and the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, question?, default?, description?, options?:[{value, label}]}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. A field with `options` is a choice: the human picks a `label`, and you fill in its `value`. A field without `options` is typed in: offer its `default` and "Type a name" as the two choices, or "Cancel" and "Type a name" when it has no `default`. Never add any other choice. If the human picks "Type a name", ask for it in chat; on "Cancel", stop. `details` holds names and ids only, never a value. Today: `capy project restore` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name,deleted_at}}); `capy project delete` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy project create` missing the name, `--branch` or `--protected` in agent mode refuses with PROJECT_NAME_REQUIRED, BRANCH_NAME_REQUIRED or BRANCH_PROTECTION_REQUIRED and a form (fields: name, branch, protected (options yes/no); details: {org:{id,name}}); `capy project delete` / `project restore` with a name several projects share refuses with PROJECT_AMBIGUOUS and a form with one `project` field whose options are the projects (details: {org:{id,name}, matches:[{id, name, created_at, variables}]}). Never answer it yourself and never add --yes on your own.'; + 'A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. Write `display` (a markdown table) in your reply exactly as given, without shortening or reordering it and never inside the question. Then ask the short `question` as an elicitation with your question tool: one question per field, using the field\'s own `question` followed by its `description` when it has them, and its `options` or `default` as the choices; without a question tool, end your reply with the `question` and the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, question?, default?, description?, options?:[{value, label}]}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. A field with `options` is a choice: the human picks a `label`, and you fill in its `value`. A field without `options` is typed in. With a `default`, offer exactly two choices, the `default` and "Cancel"; the human types any other answer in your question tool\'s own free-text answer. With no `default`, leave it out of the question tool: after the other questions, ask its `question` in chat and wait for the human to type the answer. On "Cancel", stop. `details` holds names and ids only, never a value. Today: `capy project restore` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name,deleted_at}}); `capy project delete` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy project create` missing the name, `--branch` or `--protected` in agent mode refuses with PROJECT_NAME_REQUIRED, BRANCH_NAME_REQUIRED or BRANCH_PROTECTION_REQUIRED and a form (fields: name, branch, protected (options yes/no); details: {org:{id,name}}); `capy project delete` / `project restore` with a name several projects share refuses with PROJECT_AMBIGUOUS and a form with one `project` field whose options are the projects (details: {org:{id,name}, matches:[{id, name, created_at, variables}]}). Never answer it yourself and never add --yes on your own.'; diff --git a/tests/core/agentsBlockPlan.test.ts b/tests/core/agentsBlockPlan.test.ts index 3a0ae751..76257223 100644 --- a/tests/core/agentsBlockPlan.test.ts +++ b/tests/core/agentsBlockPlan.test.ts @@ -39,7 +39,7 @@ describe('AGENTS_BLOCK', () => { '- Commands that write run bare `capy` first. If your folder and Capy differ they write nothing and exit 3 with `SECRETS_CONFLICT`, a `conflict_id`, and the differing names (fingerprints, never values).', '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', - '- If a JSON result has an `elicitation`, write its `display` table in your reply exactly as given (never shorten or reorder it, never put it inside the question). Then ask its short `question` as an elicitation with your question tool: one question per field, using the field\'s own `question` followed by its `description` when it has them, and offering its `options` (or its `default`) as the choices. A field without `options` is typed in: offer its `default` and "Type a name" as the two choices, or "Cancel" and "Type a name" when it has no `default`; never add any other choice. If the human picks "Type a name", ask for it in chat; on "Cancel", stop. Without a question tool, end your reply with the `question` and wait for the human to answer in chat. Then run the elicitation\'s answer command with what the human chose. Never answer it yourself and never add `--yes` on your own.', + '- If a JSON result has an `elicitation`, write its `display` table in your reply exactly as given (never shorten or reorder it, never put it inside the question). Then ask its short `question` as an elicitation with your question tool: one question per field, using the field\'s own `question` followed by its `description` when it has them, and offering its `options` (or its `default`) as the choices. A field without `options` is typed in. With a `default`, offer exactly two choices, the `default` and "Cancel"; the human types any other answer in your question tool\'s own free-text answer. With no `default`, leave it out of the question tool: after the other questions, ask its `question` in chat and wait for the human to type the answer. On "Cancel", stop. Without a question tool, end your reply with the `question` and wait for the human to answer in chat. Then run the elicitation\'s answer command with what the human chose. Never answer it yourself and never add `--yes` on your own.', '- Only when the human asks to create a project, run `capy project create --json`, adding the name, `--branch ` and `--protected yes|no` only if the human already gave them. It creates nothing until it has all three and returns an `elicitation` asking for the rest: that is how you ask the human, so do not ask your own question first.', "- Only when the human asks to delete a project, run `capy project delete --json` with the name they gave and without `--yes`, even if this folder's project is deleted or is a different one: it looks the name up in the organization, not in this folder. It changes nothing and returns an `elicitation`: that is how you ask the human, so do not ask your own question first. Only an org owner can delete or restore a project.", "- `PROJECT_DELETED`, or `project.state` `\"deleted\"` in `capy info --json`, means an org owner deleted this folder's project. Stop and tell the human. An org owner can restore it with `capy project restore ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`. This is about this folder only: it never stops a delete or restore the human asked for by name.",