From 8c9e89abdb4fa3667c7766537650be8cadcb3627 Mon Sep 17 00:00:00 2001 From: sonpiaz Date: Tue, 20 Oct 2026 17:52:49 -0700 Subject: [PATCH] feat: agent setup with a one-time code, and settings drafts from the CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec .internal/specs/2026-10-20-SPEC-agent-draft.md slice P3 (needs cms#686 and the setup-code PR deployed). - affitor agent setup --client --code : redeems the dashboard's single-use code with no session and installs that one agent's private credential; a failed local write names the key to revoke. - affitor draft show / draft set: read the open draft, then replace it with its draft_id + revision (or open one); prints where an owner applies it. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/cli/CHANGELOG.md | 4 + packages/cli/src/cli.ts | 2 + packages/cli/src/commands/agent.ts | 19 ++- packages/cli/src/commands/draft.ts | 73 +++++++++ packages/cli/src/lib/draft-client.ts | 63 ++++++++ packages/cli/src/lib/program-agent-client.ts | 58 +++++++ packages/cli/tests/agent-draft.test.ts | 157 +++++++++++++++++++ 7 files changed, 369 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/commands/draft.ts create mode 100644 packages/cli/src/lib/draft-client.ts create mode 100644 packages/cli/tests/agent-draft.test.ts diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 909139f..6ecc3cd 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog — affitor (CLI) +## Unreleased +- `affitor agent setup --client --code `: install an agent with the one-time setup code from the dashboard (Setup, Tracking, With AI), no `affitor login` needed. The code works once and for 10 minutes; the key it makes can also write a settings draft. +- `affitor draft show` and `affitor draft set --name … --website … --type recurring --rate 20 --months 12` (or `--file draft.json`): the agent proposes business details and the commission; nothing changes until an owner or admin applies the draft in the dashboard. Only for programs that are not live yet. + ## 0.4.4 - `affitor init` writes the program's partner link parameter to `ref_param` (`via` for programs created from now on, `aff` for older ones), from the server's answer. An older server without that field still gives `aff`. The setup notes say which parameter partner links use; the tracker reads both `?aff=` and `?via=`. diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index d85cf43..cfd1907 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -14,6 +14,7 @@ import { registerStatusCommand } from "./commands/status.js"; import { registerTestCommand } from "./commands/test.js"; import { registerProgramsCommand } from "./commands/programs.js"; import { registerAgentCommands } from "./commands/agent.js"; +import { registerDraftCommands } from "./commands/draft.js"; // Read the version from package.json at runtime so `affitor --version` never // drifts from the published package version (dist/cli.js → ../package.json). @@ -74,5 +75,6 @@ registerStatusCommand(program); registerTestCommand(program); registerProgramsCommand(program); registerAgentCommands(program); +registerDraftCommands(program); program.parse(); diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index 7d3a7f6..cd3f3ea 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -5,10 +5,10 @@ import { join } from "node:path"; import { CLIENTS, applySetup, callerClient, doctor, planSetup, selectedClients, setupProgramId } from "../lib/agent-setup.js"; import { readCredentials } from "../lib/config.js"; import { DEFAULT_API_URL } from "../types.js"; -import { AgentSetupError, ProgramAgentClient, agentApiOrigin, installProgramAgents } from "../lib/program-agent-client.js"; +import { AgentSetupError, ProgramAgentClient, agentApiOrigin, installFromSetupCode, installProgramAgents } from "../lib/program-agent-client.js"; import { getFlags } from "../lib/flags.js"; -interface SetupOptions { client?: string[]; dryRun?: boolean; yes?: boolean; programId?: string } +interface SetupOptions { client?: string[]; dryRun?: boolean; yes?: boolean; programId?: string; code?: string } export function registerAgentCommands(program: Command) { program.command("agent").description("Configure an AI coding agent for this project") @@ -16,6 +16,7 @@ export function registerAgentCommands(program: Command) { .description("Preview and install project-scoped MCP configuration and an Affitor skill") .option("--client ", "claude_code, cursor or codex (repeat to select)", (value: string, prior: string[]) => [...prior, value], []) .option("--program-id ", "Use this program for independent agent credentials (otherwise use the linked project)") + .option("--code ", "Use the one-time setup code from the dashboard (Setup → Tracking → With AI) instead of signing in") .option("--dry-run", "Show the plan without writing files") .option("--yes", "Apply the selected client's plan") .action(async (opts: SetupOptions, cmd: Command) => { @@ -29,16 +30,16 @@ export function registerAgentCommands(program: Command) { choices: CLIENTS.map(value => ({ value, name: `${value}${existsSync(join(process.cwd(), value === "claude_code" ? ".claude" : value === "cursor" ? ".cursor" : ".agents")) ? " (project directory found)" : ""}`, checked: false })), }), process.env); } - let programId = setupProgramId(process.cwd(), opts.programId); + let programId = opts.code ? null : setupProgramId(process.cwd(), opts.programId); const origin = agentApiOrigin(flags.apiUrl ?? DEFAULT_API_URL); - if (!programId && clients.length && interactive && !opts.dryRun) { + if (!programId && !opts.code && clients.length && interactive && !opts.dryRun) { const session = readCredentials(); if (session?.token && Number.isFinite(Date.parse(session.expires_at)) && Date.parse(session.expires_at) > Date.now()) { const choices = await new ProgramAgentClient(session.token, origin).programs(); if (choices.length) programId = await select({ message: "Which program should these agents use?", choices: choices.map(row => ({ value: row.id, name: `${row.name} (${row.id})` })) }); } } - const plan = planSetup(process.cwd(), clients, { agentKeys: programId !== null }); + const plan = planSetup(process.cwd(), clients, { agentKeys: programId !== null || Boolean(opts.code) }); // Never serialize plan.actions.content: existing configs can contain // unrelated credentials that must stay local, including in a dry-run. const preview = { @@ -56,7 +57,11 @@ export function registerAgentCommands(program: Command) { approved = await confirm({ message: "Apply this plan? Existing files are backed up first.", default: false }); } if (!opts.dryRun && approved && clients.length) { - if (programId !== null) { + if (opts.code) { + const installed = await installFromSetupCode(plan, opts.code, origin); + preview.agent_keys = installed.map(({ client, path, state }) => ({ client, path, state })); + status = "needs_next_step"; + } else if (programId !== null) { const session = readCredentials(); if (!session?.token || !Number.isFinite(Date.parse(session.expires_at)) || Date.parse(session.expires_at) <= Date.now()) status = "needs_login"; else { @@ -71,7 +76,7 @@ export function registerAgentCommands(program: Command) { : status === "needs_login" ? "Run affitor login, then rerun this setup command. No agent key was minted or local files written." : status === "needs_confirmation" || status === "planned" - ? `Review the plan, then rerun affitor agent setup --client ${programId !== null ? ` --program-id ${programId}` : ""} --yes with the same API origin.` + ? `Review the plan, then rerun affitor agent setup --client ${opts.code ? " --code " : programId !== null ? ` --program-id ${programId}` : ""} --yes with the same API origin.` : status === "needs_next_step" ? "Installed, not verified. Restart the selected agent and ask it: Call affitor_readiness from Affitor and tell me the result. For Codex, first follow its printed manual configuration instructions." : "Local installer only: link a program with affitor init or rerun with --program-id to mint separate agent credentials. Installed is not verified. Configure the program key privately as AFFITOR_API_KEY or in .affitor/.env; restart the selected agent, then ask it to call affitor_readiness. For Codex, first follow the printed manual configuration instructions."; diff --git a/packages/cli/src/commands/draft.ts b/packages/cli/src/commands/draft.ts new file mode 100644 index 0000000..6f194bc --- /dev/null +++ b/packages/cli/src/commands/draft.ts @@ -0,0 +1,73 @@ +import type { Command } from "commander"; +import { readFileSync } from "node:fs"; +import { AgentSetupError } from "../lib/program-agent-client.js"; +import { DraftClient, agentCredential, type DraftPayload } from "../lib/draft-client.js"; + +interface SetOptions { + client?: string; file?: string; name?: string; website?: string; tagline?: string; description?: string; + type?: string; rate?: string; months?: string; +} + +/** Flags (or --file) → the draft payload. Validation is the server's; this only shapes the JSON. */ +export function draftPayload(opts: SetOptions, readText: (path: string) => string = path => readFileSync(path, "utf8")): DraftPayload { + if (opts.file) { + const parsed = JSON.parse(readText(opts.file)); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new AgentSetupError("--file must hold one JSON object."); + return parsed as DraftPayload; + } + const payload: DraftPayload = {}; + if (opts.name !== undefined) payload.name = opts.name; + if (opts.website !== undefined) payload.website = opts.website; + if (opts.tagline !== undefined) payload.tagline = opts.tagline; + if (opts.description !== undefined) payload.description = opts.description; + if (opts.type !== undefined || opts.rate !== undefined || opts.months !== undefined) { + if (!opts.type || opts.rate === undefined) throw new AgentSetupError("A commission needs --type (one_time, recurring or lifetime) and --rate."); + payload.commission = { + type: opts.type as "one_time" | "recurring" | "lifetime", + rate_pct: Number(opts.rate), + ...(opts.months !== undefined ? { duration_months: Number(opts.months) } : {}), + }; + } + if (!Object.keys(payload).length) throw new AgentSetupError("Nothing to draft. Pass --name, --website, --tagline, --description, --type/--rate/--months or --file."); + return payload; +} + +function fail(error: unknown) { + const message = error instanceof AgentSetupError ? error.message : error instanceof SyntaxError ? "--file is not valid JSON." : "The draft command could not finish."; + process.stdout.write(JSON.stringify({ status: "error", message }) + "\n"); + process.exitCode = 1; +} + +export function registerDraftCommands(program: Command) { + const draft = program.command("draft").description("Propose business details and the commission as a draft an owner applies in one click"); + draft.command("show") + .description("Show the open draft and what it would change") + .option("--client ", "Which agent's key to use when several are set up") + .action(async (opts: { client?: string }) => { + try { + const api = new DraftClient(agentCredential(process.cwd(), opts.client)); + process.stdout.write(JSON.stringify({ status: "ok", draft: await api.show(), apply_at: api.applyUrl() }, null, 2) + "\n"); + } catch (error) { fail(error); } + }); + draft.command("set") + .description("Write the draft (replaces the open one). Nothing changes until an owner or admin applies it") + .option("--client ", "Which agent's key to use when several are set up") + .option("--name ", "Program name") + .option("--website ", "Website (http or https)") + .option("--tagline ", "One-line tagline") + .option("--description ", "Description") + .option("--type ", "Commission type: one_time, recurring or lifetime") + .option("--rate ", "Commission rate in percent") + .option("--months ", "Months a recurring commission pays (1-36)") + .option("--file ", "A JSON object with the same fields instead of flags") + .action(async (opts: SetOptions) => { + try { + const api = new DraftClient(agentCredential(process.cwd(), opts.client)); + const result = await api.set(draftPayload(opts)); + process.stdout.write(JSON.stringify({ + status: result ? "drafted" : "no_changes", draft: result, + next: result ? `Ask an owner or admin to apply it in the dashboard: ${api.applyUrl()}` : "These values are already the program's settings.", + }, null, 2) + "\n"); + } catch (error) { fail(error); } + }); +} diff --git a/packages/cli/src/lib/draft-client.ts b/packages/cli/src/lib/draft-client.ts new file mode 100644 index 0000000..32a4579 --- /dev/null +++ b/packages/cli/src/lib/draft-client.ts @@ -0,0 +1,63 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { CLIENTS, type AgentClient } from "./agent-setup.js"; +import { parseStoredAgentCredential, type StoredAgentCredential } from "./agent-launcher.js"; +import { AgentSetupError, agentApiOrigin } from "./program-agent-client.js"; + +/** What an agent may put in a settings draft (spec 2026-10-20 agent draft §3.1). */ +export interface DraftPayload { + name?: string; website?: string; tagline?: string | null; description?: string; + commission?: { type: "one_time" | "recurring" | "lifetime"; rate_pct: number; duration_months?: number | null }; +} +export interface Draft { id: number; revision: number; payload_hash: string; status: string; payload: DraftPayload; changes: { field: string; from: unknown; to: unknown }[]; stale_fields: string[] } + +const DRAFT_ERRORS: Record = { + unauthorized: "This agent key cannot write drafts. Create a key in Setup → Tracking → With AI (it carries the draft permission).", + program_not_draft: "This program is live. Drafts are only for setup; change settings in the dashboard.", + draft_changed: "The draft changed since you read it. Run affitor draft show and try again.", + revision_required: "A draft is already open. Run affitor draft show and try again.", + draft_budget: "Too many draft writes for this program this hour. Try again later.", + validation_failed: "Some fields are not valid.", +}; + +/** The agent credential `affitor agent setup` saved for this project: --client, or the only one there is. */ +export function agentCredential(cwd: string, client?: string): StoredAgentCredential { + const candidates = (client ? [client] : [...CLIENTS]).filter(c => (CLIENTS as readonly string[]).includes(c) && existsSync(join(cwd, ".affitor/agents", `${c}.json`))); + if (candidates.length !== 1) { + throw new AgentSetupError(candidates.length ? "More than one agent is set up here. Pass --client claude_code, cursor or codex." : "No agent key in this project. Run affitor agent setup --code first."); + } + const record = parseStoredAgentCredential(readFileSync(join(cwd, ".affitor/agents", `${candidates[0]}.json`), "utf8"), candidates[0] as AgentClient); + if (!record) throw new AgentSetupError("The saved agent key is not valid. Run affitor agent setup again."); + return record; +} + +export class DraftClient { + private origin: string; + constructor(private credential: StoredAgentCredential) { this.origin = agentApiOrigin(credential.api_url); } + private async request(method: "GET" | "PUT", body?: unknown): Promise { + let response: Response; + try { + response = await fetch(`${this.origin}/api/v1/programs/${this.credential.program_id}/draft`, { + method, redirect: "error", signal: AbortSignal.timeout(15_000), + headers: { Authorization: `Bearer ${this.credential.api_key}`, "Content-Type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + } catch { throw new AgentSetupError("The draft request did not complete. Check the connection and try again."); } + let result: any = null; + try { result = await response.json(); } catch { /* checked below */ } + if (!response.ok) { + const code = typeof result?.error?.code === "string" ? result.error.code : ""; + const fields = result?.error?.fields && typeof result.error.fields === "object" ? ` ${Object.entries(result.error.fields).map(([k, v]) => `${k}: ${String(v)}`).join("; ")}` : ""; + throw new AgentSetupError((DRAFT_ERRORS[code] ?? `Draft request failed (HTTP ${response.status}).`) + fields); + } + if (!result || typeof result !== "object" || !("draft" in result)) throw new AgentSetupError("Draft response is invalid."); + return result.draft as Draft | null; + } + show() { return this.request("GET"); } + /** Replaces the open draft (the one just read) or opens a new one. */ + async set(payload: DraftPayload) { + const current = await this.show(); + return this.request("PUT", current ? { payload, draft_id: current.id, revision: current.revision } : { payload }); + } + applyUrl() { return `https://affitor.com/brand/setup/business?programId=${this.credential.program_id}`; } +} diff --git a/packages/cli/src/lib/program-agent-client.ts b/packages/cli/src/lib/program-agent-client.ts index 85e60d1..d9d17b9 100644 --- a/packages/cli/src/lib/program-agent-client.ts +++ b/packages/cli/src/lib/program-agent-client.ts @@ -83,6 +83,64 @@ export class ProgramAgentClient { } } +const SETUP_CODE = /^aff_sc_[A-Za-z0-9_-]{43}$/; +const CODE_ERRORS: Record = { + code_used: "This code was already used. If that was not you, revoke the new key in Settings → Agents, then create a new code.", + code_expired: "This code expired. Create a new one in Setup → Tracking → With AI.", + code_cancelled: "This code was cancelled. Create a new one in Setup → Tracking → With AI.", + invalid_code: "Unknown setup code. Copy it again from the dashboard.", + issuer_changed: "The person who created this code can no longer create agent keys. Ask a program owner for a new code.", +}; + +/** + * No session: the dashboard's 10-minute, single-use setup code is the credential. Never retried: + * a lost answer may already have minted a key (it shows as never used and expires in a day). + */ +export async function redeemSetupCode(origin: string, code: string, client: AgentClient): Promise { + if (!SETUP_CODE.test(code)) throw new AgentSetupError("Expected a setup code (aff_sc_…) copied from the dashboard."); + const base = agentApiOrigin(origin); + let response: Response; + try { + response = await fetch(base + "/api/v1/agent-setup-codes/redeem", { + method: "POST", redirect: "error", signal: AbortSignal.timeout(15_000), + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code, client, name: client }), + }); + } catch { + throw new AgentSetupError("The setup code request did not complete. If it was used, its key shows in Settings → Agents as never used: revoke it, then create a new code.", [], true); + } + let result: unknown = null; + try { result = await response.json(); } catch { /* checked below */ } + if (!response.ok) { + const reason = object(result) && object(result.error) && typeof result.error.code === "string" ? result.error.code : ""; + throw new AgentSetupError(CODE_ERRORS[reason] ?? `Setup code request failed (HTTP ${response.status}).`); + } + const row = object(result) && object(result.data) ? result.data : null; + if (!row || !positive(row.id) || !positive(row.program_id) || row.client !== client || typeof row.api_key !== "string" || !/^aff_ag_[A-Za-z0-9_-]{43}$/.test(row.api_key)) { + throw new AgentSetupError("Setup code response is invalid. Review never-used keys in Settings → Agents.", row && positive(row.id) ? [row.id] : [], true); + } + const created = new Date().toISOString(); + return { version: 1, id: row.id, program_id: row.program_id, client, api_url: base, created_at: created, + credential_note: `Setup code; program ${row.program_id}; ${client}; readiness/status/synthetic-test/click/test-only lead/settings draft; created ${created}; not verified. Never commit this file.`, + api_key: row.api_key }; +} + +/** One client from one setup code. No session, so a failed local write can only be reported, not revoked. */ +export async function installFromSetupCode(plan: SetupPlan, code: string, origin: string, apply: typeof applySetup = applySetup) { + if (!plan.agentKeys || plan.clients.length !== 1) throw new AgentSetupError("A setup code installs one agent: pass exactly one --client."); + const client = plan.clients[0]; + const warnings = credentialWarnings(plan.project, `.affitor/agents/${client}.json`); + if (warnings.includes("credential_tracked")) throw new AgentSetupError("A private agent credential is tracked by Git. Rotate it and resolve tracking/history before setup."); + if (warnings.length) throw new AgentSetupError("Git credential tracking could not be checked. Resolve Git access and retry; the code was not used."); + const credential = await redeemSetupCode(origin, code, client); + try { + stageAgentCredential(plan, client, credential, true); + apply(plan); + } catch { + throw new AgentSetupError(`The key was created but local files could not be written. Revoke key ${credential.id} in Settings → Agents, then create a new code.`, [credential.id]); + } + return plan.agentKeys.map(({ client, path, id, state }) => ({ client, path, id, state })); +} + /** Prepare every client before local writes; compensate only keys minted by this run. */ export async function installProgramAgents(plan: SetupPlan, programId: number, api: ProgramAgentClient, apply: typeof applySetup = applySetup) { if (!positive(programId) || !plan.agentKeys || plan.agentKeys.length !== plan.clients.length) throw new AgentSetupError("Generate a fresh agent-key setup plan."); diff --git a/packages/cli/tests/agent-draft.test.ts b/packages/cli/tests/agent-draft.test.ts new file mode 100644 index 0000000..4d8ad07 --- /dev/null +++ b/packages/cli/tests/agent-draft.test.ts @@ -0,0 +1,157 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createServer, type Server } from "node:http"; +import { spawn } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { planSetup } from "../src/lib/agent-setup.js"; +import { AgentSetupError, installFromSetupCode, redeemSetupCode } from "../src/lib/program-agent-client.js"; +import { DraftClient, agentCredential } from "../src/lib/draft-client.js"; +import { draftPayload } from "../src/commands/draft.js"; + +const CODE = "aff_sc_" + "c".repeat(43); +const KEY = "aff_ag_" + "k".repeat(43); +const dirs: string[] = [], servers: Server[] = []; +afterEach(() => { + vi.unstubAllEnvs(); + for (const s of servers.splice(0)) s.close(); + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); +function project() { const dir = realpathSync(mkdtempSync(join(tmpdir(), "agent-draft-"))); dirs.push(dir); return dir; } + +async function api(handler: (method: string, path: string, body: any, auth?: string) => { status?: number; body: unknown }) { + const requests: { method: string; path: string; body: any; auth?: string }[] = []; + const server = createServer(async (req, res) => { + let raw = ""; for await (const chunk of req) raw += chunk; + const body = raw ? JSON.parse(raw) : undefined; + requests.push({ method: req.method!, path: req.url!, body, auth: req.headers.authorization }); + const answer = handler(req.method!, req.url!, body, req.headers.authorization); + res.statusCode = answer.status ?? 200; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(answer.body)); + }); + servers.push(server); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const origin = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + vi.stubEnv("AFFITOR_AGENT_DEV_API_ORIGIN", origin); + return { origin, requests }; +} + +describe("setup code", () => { + it("redeems once, with no session, for the program the code names", async () => { + const { origin, requests } = await api(() => ({ status: 201, body: { data: { id: 31, client: "claude_code", program_id: 10, api_key: KEY, grants: ["draft"] } } })); + const credential = await redeemSetupCode(origin, CODE, "claude_code"); + expect(credential).toMatchObject({ id: 31, program_id: 10, client: "claude_code", api_url: origin, api_key: KEY }); + expect(requests).toEqual([{ method: "POST", path: "/api/v1/agent-setup-codes/redeem", body: { code: CODE, client: "claude_code", name: "claude_code" }, auth: undefined }]); + }); + + it.each([ + ["code_used", /already used.*revoke the new key/], + ["code_expired", /expired/], + ["code_cancelled", /cancelled/], + ["issuer_changed", /can no longer create agent keys/], + ])("%s answers in plain words", async (code, message) => { + const { origin } = await api(() => ({ status: 410, body: { error: { code, message: "server text" } } })); + await expect(redeemSetupCode(origin, CODE, "cursor")).rejects.toThrow(message); + }); + + it("refuses a malformed code before any request, and non-Affitor origins", async () => { + const { origin, requests } = await api(() => ({ body: {} })); + await expect(redeemSetupCode(origin, "aff_sc_short", "codex")).rejects.toThrow(/Expected a setup code/); + await expect(redeemSetupCode("https://evil.example", CODE, "codex")).rejects.toThrow(/only to https:\/\/api.affitor.com/); + expect(requests).toEqual([]); + }); + + it("installs one client's private credential; more than one client is refused before redeeming", async () => { + const { origin, requests } = await api(() => ({ status: 201, body: { data: { id: 32, client: "claude_code", program_id: 10, api_key: KEY } } })); + const root = project(); + await expect(installFromSetupCode(planSetup(root, ["claude_code", "cursor"], { agentKeys: true }), CODE, origin)).rejects.toBeInstanceOf(AgentSetupError); + expect(requests).toEqual([]); + const installed = await installFromSetupCode(planSetup(root, ["claude_code"], { agentKeys: true }), CODE, origin); + expect(installed).toEqual([expect.objectContaining({ client: "claude_code", id: 32, state: "minted" })]); + expect(JSON.parse(readFileSync(join(root, ".affitor/agents/claude_code.json"), "utf8"))).toMatchObject({ id: 32, program_id: 10, api_key: KEY }); + }); +}); + +describe("draft", () => { + function setUp(root: string, origin: string, clients = ["claude_code"]) { + mkdirSync(join(root, ".affitor/agents"), { recursive: true }); + for (const client of clients) { + writeFileSync(join(root, ".affitor/agents", `${client}.json`), JSON.stringify({ version: 1, id: 7, program_id: 10, client, api_url: origin, created_at: "2026-10-20T00:00:00Z", credential_note: "fake", api_key: KEY })); + } + } + + it("uses the only saved agent key, or asks for --client", async () => { + const root = project(); + expect(() => agentCredential(root)).toThrow(/No agent key/); + setUp(root, "http://127.0.0.1:1", ["claude_code", "cursor"]); + expect(() => agentCredential(root)).toThrow(/More than one agent/); + expect(agentCredential(root, "cursor").client).toBe("cursor"); + }); + + it("set replaces the draft it just read (draft_id + revision); with none it opens one", async () => { + let open: any = { id: 5, revision: 2, payload_hash: "h", status: "open", payload: { name: "A" }, changes: [], stale_fields: [] }; + const { origin, requests } = await api((method, _path, body) => { + if (method === "GET") return { body: { draft: open } }; + open = open ? { ...open, revision: open.revision + 1, payload: body.payload } : { id: 6, revision: 1, payload_hash: "h", status: "open", payload: body.payload, changes: [], stale_fields: [] }; + return { body: { draft: open } }; + }); + const root = project(); setUp(root, origin); + const client = new DraftClient(agentCredential(root)); + expect((await client.set({ name: "B" }))?.revision).toBe(3); + expect(requests.map(r => [r.method, r.path, r.body, r.auth])).toEqual([ + ["GET", "/api/v1/programs/10/draft", undefined, `Bearer ${KEY}`], + ["PUT", "/api/v1/programs/10/draft", { payload: { name: "B" }, draft_id: 5, revision: 2 }, `Bearer ${KEY}`], + ]); + open = null; + await client.set({ name: "C" }); + expect(requests.at(-1)!.body).toEqual({ payload: { name: "C" } }); + expect(client.applyUrl()).toBe("https://affitor.com/brand/setup/business?programId=10"); + }); + + it("server refusals come back as plain words with the field reasons", async () => { + const { origin } = await api(() => ({ status: 400, body: { error: { code: "validation_failed", message: "x", fields: { payment: "An agent cannot set this field." } } } })); + const root = project(); setUp(root, origin); + await expect(new DraftClient(agentCredential(root)).show()).rejects.toThrow("Some fields are not valid. payment: An agent cannot set this field."); + }); + + it("flags shape the payload; a commission needs type and rate", () => { + expect(draftPayload({ name: "Kyma", website: "https://kyma.dev", type: "recurring", rate: "20", months: "12" })) + .toEqual({ name: "Kyma", website: "https://kyma.dev", commission: { type: "recurring", rate_pct: 20, duration_months: 12 } }); + expect(() => draftPayload({ rate: "20" })).toThrow(/needs --type/); + expect(() => draftPayload({})).toThrow(/Nothing to draft/); + expect(draftPayload({ file: "x.json" }, () => '{"tagline":"Fast"}')).toEqual({ tagline: "Fast" }); + }); +}); + +describe("built command", () => { + function run(root: string, args: string[], origin: string) { + return new Promise<{ status: number | null; stdout: string }>((resolveRun, reject) => { + const child = spawn(process.execPath, [resolve("dist/cli.js"), ...args], { + cwd: root, windowsHide: true, + env: { PATH: process.env.PATH ?? "", HOME: root, USERPROFILE: root, AFFITOR_AGENT_DEV_API_ORIGIN: origin, CLAUDECODE: "", CODEX: "", CODEX_THREAD_ID: "", CURSOR_AGENT: "" }, + }); + let stdout = ""; + child.stdout.on("data", chunk => stdout += chunk); + const timer = setTimeout(() => { child.kill(); reject(new Error("CLI timed out")); }, 10_000); + child.on("error", reject); + child.on("close", status => { clearTimeout(timer); resolveRun({ status, stdout }); }); + }); + } + + it("agent setup --code installs the key without a login, prints no secret; draft set then writes", async () => { + const { origin, requests } = await api((method, path, body) => { + if (path === "/api/v1/agent-setup-codes/redeem") return { status: 201, body: { data: { id: 40, client: "claude_code", program_id: 10, api_key: KEY } } }; + if (method === "GET") return { body: { draft: null } }; + return { body: { draft: { id: 1, revision: 1, payload_hash: "h", status: "open", payload: body.payload, changes: [], stale_fields: [] } } }; + }); + const root = project(); + const setup = await run(root, ["--api-url", origin, "--json", "agent", "setup", "--client", "claude_code", "--code", CODE, "--yes"], origin); + expect(setup.stdout).not.toContain(KEY); + expect(JSON.parse(setup.stdout.trim().split("\n").at(-1)!)).toMatchObject({ status: "needs_next_step" }); + expect(requests.map(r => r.path)).toEqual(["/api/v1/agent-setup-codes/redeem"]); + const drafted = await run(root, ["draft", "set", "--name", "Kyma", "--type", "recurring", "--rate", "20", "--months", "12"], origin); + expect(JSON.parse(drafted.stdout)).toMatchObject({ status: "drafted", next: expect.stringContaining("programId=10") }); + expect(requests.at(-1)).toMatchObject({ method: "PUT", path: "/api/v1/programs/10/draft", auth: `Bearer ${KEY}`, body: { payload: { name: "Kyma", commission: { type: "recurring", rate_pct: 20, duration_months: 12 } } } }); + }); +});