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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog — affitor (CLI)

## Unreleased
- `affitor agent setup --client <name> --code <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=`.

Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -74,5 +75,6 @@ registerStatusCommand(program);
registerTestCommand(program);
registerProgramsCommand(program);
registerAgentCommands(program);
registerDraftCommands(program);

program.parse();
19 changes: 12 additions & 7 deletions packages/cli/src/commands/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,18 @@ 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")
.command("setup")
.description("Preview and install project-scoped MCP configuration and an Affitor skill")
.option("--client <name>", "claude_code, cursor or codex (repeat to select)", (value: string, prior: string[]) => [...prior, value], [])
.option("--program-id <id>", "Use this program for independent agent credentials (otherwise use the linked project)")
.option("--code <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) => {
Expand All @@ -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 = {
Expand All @@ -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 {
Expand All @@ -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 <name>${programId !== null ? ` --program-id ${programId}` : ""} --yes with the same API origin.`
? `Review the plan, then rerun affitor agent setup --client <name>${opts.code ? " --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 <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.";
Expand Down
73 changes: 73 additions & 0 deletions packages/cli/src/commands/draft.ts
Original file line number Diff line number Diff line change
@@ -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 <name>", "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 <name>", "Which agent's key to use when several are set up")
.option("--name <text>", "Program name")
.option("--website <url>", "Website (http or https)")
.option("--tagline <text>", "One-line tagline")
.option("--description <text>", "Description")
.option("--type <type>", "Commission type: one_time, recurring or lifetime")
.option("--rate <percent>", "Commission rate in percent")
.option("--months <n>", "Months a recurring commission pays (1-36)")
.option("--file <path>", "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); }
});
}
63 changes: 63 additions & 0 deletions packages/cli/src/lib/draft-client.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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 <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<Draft | null> {
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}`; }
}
58 changes: 58 additions & 0 deletions packages/cli/src/lib/program-agent-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,64 @@ export class ProgramAgentClient {
}
}

const SETUP_CODE = /^aff_sc_[A-Za-z0-9_-]{43}$/;
const CODE_ERRORS: Record<string, string> = {
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<StoredAgentCredential> {
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.");
Expand Down
Loading
Loading