Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
14cf4f8
feat(agents): teach agents to check org/project/branch, stop on a bro…
cvince Nov 9, 2026
73bdf6b
feat(org): elicitations for delete-project and create-project
cvince Nov 9, 2026
9c84714
test(org): delete confirmation may read the project, never writes
cvince Nov 9, 2026
1a2ee22
feat(agents): show elicitation details as a table, then ask the short…
cvince Nov 9, 2026
8bc9f48
feat(org): elicitations carry a display table agents print as given
cvince Nov 9, 2026
7eb397d
docs(agents): elicitation table goes in the reply text, only the ques…
cvince Nov 9, 2026
e552d22
feat(agents): deleted-project lines in the AGENTS block
cvince Nov 9, 2026
faf7be8
feat(org): org's description names its project commands
cvince Nov 9, 2026
8f2b1f1
feat(agents): running delete-project without --yes is how the agent asks
cvince Nov 9, 2026
9a3ecf0
docs(agents): end the reply with the elicitation table and question; …
cvince Nov 9, 2026
f53ffc3
feat(info): capy info shows whether the folder's project is live or d…
cvince Nov 9, 2026
b2add29
fix(org): create-project refuses a name a live project already has; t…
cvince Nov 9, 2026
bf1e4b5
feat(org): create-project asks for and creates the first branch, sayi…
cvince Nov 9, 2026
974bc71
feat(org): pick which project when a name is shared; refuse a name th…
cvince Nov 9, 2026
3cfd7ba
docs: regenerate cli-reference for create-project --branch and the ne…
cvince Nov 9, 2026
d45c720
feat(agents): tell agents to run create-project and let its elicitati…
cvince Nov 9, 2026
92f1f35
fix(org): create-project never suggests a name a live project already…
cvince Nov 9, 2026
ca4f7c1
fix(agents): ask elicitations with the agent's question tool; capy ag…
cvince Nov 9, 2026
66b46ca
feat(org): create-project asks whether the first branch is protected;…
cvince Nov 9, 2026
ec09433
feat(project): capy project create|delete|restore replace capy org cr…
cvince Nov 10, 2026
f5e732a
docs(readme): list capy project create|delete|restore
cvince Nov 10, 2026
b4f0e2a
fix(project): restore asks its yes/no as an elicitation; --json is ag…
cvince Nov 10, 2026
364f402
fix(project): restore in agent mode never opens the organization picker
cvince Nov 10, 2026
2fdf469
fix(project): restore honors --dry-run and restores nothing
cvince Nov 10, 2026
c5aa111
feat(project): --non-tty means --json for project create, delete and …
cvince Nov 10, 2026
13d1407
feat(agents): typed elicitation fields offer their default or Cancel,…
cvince Nov 10, 2026
9babded
feat(agents): a typed field offers its default and Cancel; with no de…
cvince Nov 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ Good when you want to see drift across the whole branch, edit one variable witho
| [`capy kick <email>`](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. |
Expand Down
73 changes: 59 additions & 14 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -482,54 +482,89 @@ capy org [options]

JSON support: no

### `capy org delete-project`
### `capy org projects`

Delete a project (owners only; hidden, not destroyed; an owner can restore it)
List projects in the active organization; --deleted also lists deleted ones (owners only)

```
capy org delete-project <name-or-id> [options]
capy org projects [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 | |
| `--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`)

### `capy org restore-project`
## `capy project`

Restore a deleted project (owners only)
Create, delete or restore a project in the active organization

```
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 project create [name] [options]
```

| Option | Description | Default |
|---|---|---|
| `--branch <name>` | the first branch; a Capy branch works like a git branch (offered by default: development) | |
| `--protected <yes|no>` | whether the first branch is protected (invite-only) | |
| `--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`)

Dry run: yes (`--dry-run`)

### `capy project delete`

Delete a project (owners only; hidden, not destroyed; an owner can restore it)

```
capy org restore-project <name-or-id> [options]
capy project delete <name-or-id> [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 | |
| `--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`)

### `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 <name-or-id> [options]
```

| Option | Description | Default |
|---|---|---|
| `--deleted` | also list deleted projects, marked as deleted, with the date | |
| `--yes` | skip the confirmation question | |
| `--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`)

Dry run: yes (`--dry-run`)

## `capy info`

Show current session info
Expand Down Expand Up @@ -1114,6 +1149,15 @@ 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`
- `BRANCH_NAME_REQUIRED`
- `BRANCH_NAME_INVALID`
- `BRANCH_CREATE_FAILED`
- `BRANCH_PROTECTION_REQUIRED`
- `BRANCH_PROTECTION_INVALID`
- `ORG_SELECTION_REQUIRED`
- `REPO_LINKS_UNSUPPORTED`
- `DRY_RUN_UNSUPPORTED`
- `GITHUB_TIMEOUT`
Expand All @@ -1140,3 +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 <conflict_id>. 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. 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.
11 changes: 7 additions & 4 deletions src/commands/agentsCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
79 changes: 54 additions & 25 deletions src/commands/infoCommand.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,6 +17,50 @@ const ROLE_LABELS: Record<string, string> = {
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<unknown>;
}

/** 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<ProjectStateLabel> {
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<AuthResult> {
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;
Expand All @@ -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);
Expand All @@ -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(
Expand All @@ -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 })),
},
Expand All @@ -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) || '—'],
];
Expand Down
Loading
Loading