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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions commands/__tests__/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,29 @@ describe("parseResumeArgs", () => {
expect(r).toMatchObject({ id: "ag-1", workspace: "reviews", tab: "⟲ !5", prompt: "go" });
});
});

describe("withCallerAccount", () => {
const { withCallerAccount } = __test__;
const caller = async () => "alex@acme.test";

test("a claude launch with no --account takes the caller's cswap account", async () => {
expect(await withCallerAccount({}, () => "claude", caller)).toEqual({ account: "alex@acme.test" });
expect(await withCallerAccount({ provider: "claude" }, () => "codex", caller)).toEqual({ provider: "claude", account: "alex@acme.test" });
});

test("an explicit --account wins and cswap is never asked", async () => {
let asked = false;
const spy = async () => { asked = true; return "alex@acme.test"; };
expect(await withCallerAccount({ account: "other@example.com" }, () => "claude", spy)).toEqual({ account: "other@example.com" });
expect(asked).toBe(false);
});

test("codex, by flag or by default, never gets an account", async () => {
expect(await withCallerAccount({ provider: "codex" }, () => "claude", caller)).toEqual({ provider: "codex" });
expect(await withCallerAccount({}, () => "codex", caller)).toEqual({});
});

test("a default-profile caller leaves the account unset", async () => {
expect(await withCallerAccount({}, () => "claude", async () => undefined)).toEqual({});
});
});
26 changes: 24 additions & 2 deletions commands/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { readFileSync, realpathSync } from "fs";
import { verbHelpRequested } from "../lib/cli-verb-help.ts";
import { isDaemonRunning } from "../lib/daemon-client.ts";
import { currentRepoIdentity, repoLabel, resolveRepoArg } from "../lib/repo-arg.ts";
import { callerCswapAccount } from "../lib/cswap.ts";
import { getSetting } from "../lib/settings/resolve.ts";
import {
agentGet, agentList, agentResume, agentStart,
type AgentRecord, type AgentSurface,
Expand Down Expand Up @@ -132,6 +134,26 @@ function parseStartArgs(args: string[]): StartArgs {
return out;
}

function defaultProvider(): string {
try {
return getSetting<string>("agent.provider").value ?? "claude";
} catch {
return "claude";
}
}

/** Mirrors agent:start's provider resolution, since codex rejects --account. */
async function withCallerAccount(
parsed: StartArgs,
resolveProvider: () => string = defaultProvider,
resolveAccount: () => Promise<string | undefined> = () => callerCswapAccount(process.env),
): Promise<StartArgs> {
if (parsed.account) return parsed;
if ((parsed.provider ?? resolveProvider()) !== "claude") return parsed;
const account = await resolveAccount();
return account ? { ...parsed, account } : parsed;
}

function parseResumeArgs(args: string[]): { id: string; prompt?: string; surface?: AgentSurface; workspace?: string; tab?: string } {
const id = positional(args);
if (!id) throw new Error("missing id: rt agent resume <id|session-uuid>");
Expand Down Expand Up @@ -189,7 +211,7 @@ async function runStart(args: string[]): Promise<void> {
fail(err instanceof Error ? err.message : String(err));
}
const { repo, cwd } = await repoAndCwd(args);
const payload = { repo, cwd, ...parsed };
const payload = { repo, cwd, ...(await withCallerAccount(parsed)) };
const data = unwrap(await dispatch("agent:start", payload, () => agentStart(payload)), "start");
if (args.includes("--json")) {
// Deliberately unannotated: a machine consumer needs the raw record, and
Expand Down Expand Up @@ -292,4 +314,4 @@ export async function agent(args: string[]): Promise<void> {
await handler(rest);
}

export const __test__ = { parseStartArgs, parseResumeArgs };
export const __test__ = { parseStartArgs, parseResumeArgs, withCallerAccount };
12 changes: 11 additions & 1 deletion commands/herd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type { Commands, HerdListRow, HerdStatusData, RtResponse } from "../packa
import { resolveRepoArg, currentRepoIdentity } from "../lib/repo-arg.ts";
import { assembleBrief, type BriefInputs } from "../lib/herd-brief.ts";
import { selfPaneRef } from "../lib/self-pane.ts";
import { callerCswapAccount } from "../lib/cswap.ts";

function fail(msg: string): never {
console.error(`rt herd: ${msg}`);
Expand Down Expand Up @@ -122,6 +123,15 @@ export function buildSpawnPayload(args: string[]): Commands["herd:spawn"]["paylo
return p;
}

export async function withCallerAccount(
p: Commands["herd:spawn"]["payload"],
resolveAccount: () => Promise<string | undefined> = () => callerCswapAccount(process.env),
): Promise<Commands["herd:spawn"]["payload"]> {
if (p.account) return p;
const account = await resolveAccount();
return account ? { ...p, account } : p;
}

export function buildWrapUpPayload(args: string[]): Commands["herd:wrap-up"]["payload"] {
const herd = positional(args);
if (!herd) throw new Error("usage: rt herd wrap-up <id> [--close-panes] [--dispose <job>...] [--delete-job-dirs] [--archive-room]");
Expand Down Expand Up @@ -176,7 +186,7 @@ export async function spawn(args: string[]): Promise<void> {
} catch (e) {
fail((e as Error).message);
}
const data = unwrap(await herdSpawn(payload), "spawn");
const data = unwrap(await herdSpawn(await withCallerAccount(payload)), "spawn");
const trustNote = data.trust === "stuck" ? " (STUCK AT TRUST MODAL)" : data.trust === "accepted" ? " (trust dialog accepted)" : "";
emit(json, data, `${data.job} pane ${data.pane} worktree ${data.worktree} session ${data.sessionId}${data.wasOnDeck === false ? " (cold provision)" : ""}${trustNote}`);
}
Expand Down
38 changes: 37 additions & 1 deletion lib/__tests__/cswap.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test";
import { listCswapAccounts, parseCswapList } from "../cswap.ts";
import { callerCswapAccount, listCswapAccounts, parseCswapList } from "../cswap.ts";

const CAPTURED = `
A newer version of claude-swap is available (0.25.0). You are using 0.23.0. Run \`cswap upgrade\` to update.
Expand Down Expand Up @@ -38,3 +38,39 @@ test("listCswapAccounts is empty when the binary is missing or fails", async ()
const missing = async () => ({ stdout: "", stderr: "", exitCode: -1 });
expect(await listCswapAccounts(missing)).toEqual([]);
});

const LIST_JSON = JSON.stringify({
schemaVersion: 1,
activeAccountNumber: 1,
accounts: [
{ number: 1, email: "alex@acme.test", active: true },
{ number: 4, email: "other@example.com", active: false },
],
});
const SESSION_DIR = "/home/x/.claude-swap-backup/sessions/1-alex_acme.test";

test("callerCswapAccount returns the active account a cswap-run caller sees", async () => {
let seen: { argv: string[]; env?: Record<string, string | undefined> } | undefined;
const exec = async (argv: string[], opts: { env?: Record<string, string | undefined> } = {}) => {
seen = { argv, env: opts.env };
return { stdout: LIST_JSON, stderr: "", exitCode: 0 };
};
expect(await callerCswapAccount({ CLAUDE_CONFIG_DIR: SESSION_DIR }, exec)).toBe("alex@acme.test");
expect(seen!.argv.slice(1)).toEqual(["list", "--json"]);
expect(seen!.env?.CLAUDE_CONFIG_DIR).toBe(SESSION_DIR);
});

test("callerCswapAccount is undefined for a default-profile caller, without spawning cswap", async () => {
let calls = 0;
const exec = async () => { calls++; return { stdout: LIST_JSON, stderr: "", exitCode: 0 }; };
expect(await callerCswapAccount({}, exec)).toBeUndefined();
expect(calls).toBe(0);
});

test("callerCswapAccount is undefined when cswap fails or reports no active account", async () => {
const env = { CLAUDE_CONFIG_DIR: SESSION_DIR };
expect(await callerCswapAccount(env, async () => ({ stdout: "", stderr: "", exitCode: 1 }))).toBeUndefined();
expect(await callerCswapAccount(env, async () => ({ stdout: "not json", stderr: "", exitCode: 0 }))).toBeUndefined();
const none = JSON.stringify({ activeAccountNumber: null, accounts: [] });
expect(await callerCswapAccount(env, async () => ({ stdout: none, stderr: "", exitCode: 0 }))).toBeUndefined();
});
18 changes: 17 additions & 1 deletion lib/__tests__/herd-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, test, expect, spyOn } from "bun:test";
import { mkdtempSync, writeFileSync, readFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { buildAskPayload, buildBriefInputs, buildSpawnPayload, buildWrapUpPayload, brief, jobEnv, renderAnswer, renderHerdRow, renderStatus, soleHerdId, workerEnv } from "../../commands/herd.ts";
import { buildAskPayload, buildBriefInputs, buildSpawnPayload, buildWrapUpPayload, brief, jobEnv, renderAnswer, renderHerdRow, renderStatus, soleHerdId, withCallerAccount, workerEnv } from "../../commands/herd.ts";
import type { Commands, HerdListRow, HerdStatusData } from "../../packages/rt-client/src/index.ts";

async function run(fn: (args: string[]) => Promise<void>, args: string[]) {
Expand Down Expand Up @@ -44,6 +44,22 @@ describe("rt herd payload builders", () => {
expect(p).toMatchObject({ herd: "h", job: "job-a", brief: "# brief", model: "opus" });
});

test("withCallerAccount fills a missing account from the caller's cswap account", async () => {
const resolve = async () => "alex@acme.test";
expect(await withCallerAccount({ herd: "h", job: "j" }, resolve)).toEqual({ herd: "h", job: "j", account: "alex@acme.test" });
});

test("withCallerAccount keeps an explicit --account and never asks cswap", async () => {
let asked = false;
const resolve = async () => { asked = true; return "alex@acme.test"; };
expect(await withCallerAccount({ herd: "h", job: "j", account: "other@example.com" }, resolve)).toEqual({ herd: "h", job: "j", account: "other@example.com" });
expect(asked).toBe(false);
});

test("withCallerAccount leaves the payload alone when the caller has no cswap account", async () => {
expect(await withCallerAccount({ herd: "h", job: "j" }, async () => undefined)).toEqual({ herd: "h", job: "j" });
});

test("buildWrapUpPayload collects repeated --dispose values and booleans", () => {
expect(buildWrapUpPayload(["h-1", "--close-panes", "--dispose", "a", "--dispose", "b", "--archive-room"])).toEqual({ herd: "h-1", closePanes: true, dispose: ["a", "b"], deleteJobDirs: false, archiveRoom: true });
});
Expand Down
4 changes: 2 additions & 2 deletions lib/command-tree-def.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ const herdSubcommands: Record<string, CommandNode> = {
{ name: "Dir", flag: "--dir", type: "text", placeholder: "~/Documents/GitHub/x", hint: "Existing directory to run in instead of a fresh worktree" },
{ name: "Model", flag: "--model", type: "text", placeholder: "opus", hint: "Override agent.claude.model for this worker" },
{ name: "Effort", flag: "--effort", type: "text", placeholder: "high", hint: "Override agent.claude.effort for this worker" },
{ name: "Account", flag: "--account", type: "text", placeholder: "me@example.com", hint: "cswap account for this worker" },
{ name: "Account", flag: "--account", type: "text", placeholder: "me@example.com", hint: "cswap account for this worker; defaults to the account this session runs under" },
{ name: "Disposable", flag: "--disposable", type: "boolean", default: false, hint: "Wrap-up may dispose this job's worktree" },
{ name: "JSON", flag: "--json", type: "boolean", default: false, hint: "Emit the spawn record as JSON" },
],
Expand Down Expand Up @@ -1497,7 +1497,7 @@ export const TREE: Record<string, CommandNode> = {
{ name: "Effort", flag: "--effort", type: "text", placeholder: "high", hint: "Override agent.<provider>.effort" },
{ name: "Yolo", flag: "--yolo", type: "boolean", hint: "Bypass permission prompts for this launch (override agent.<provider>.yolo; omitted falls back to the setting, not false)" },
{ name: "No yolo", flag: "--no-yolo", type: "boolean", hint: "Keep permission prompts for this launch even when agent.<provider>.yolo is true (omitted falls back to the setting, not false)" },
{ name: "Account", flag: "--account", type: "text", placeholder: "me@example.com", hint: "cswap account, claude only (override agent.claude.account)" },
{ name: "Account", flag: "--account", type: "text", placeholder: "me@example.com", hint: "cswap account, claude only; defaults to the account this session runs under, then agent.claude.account" },
{ name: "Label", flag: "--label", type: "text", placeholder: "job7", hint: "Caller's display label; used as the herdr tab name" },
{ name: "Caller", flag: "--caller", type: "text", placeholder: "board:review", hint: "Identifies what invoked this handoff" },
{ name: "Workspace", flag: "--workspace", type: "text", placeholder: "reviews", hint: "herdr workspace label (default: the repo label)" },
Expand Down
20 changes: 20 additions & 0 deletions lib/cswap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,26 @@ export function cswapBin(): string {
return Bun.which("cswap") ?? join(homedir(), ".local", "bin", "cswap");
}

/** The account a `cswap run` caller is actually running as. Only the caller's
env knows it: `cswap run` sets CLAUDE_CONFIG_DIR per terminal, while a
bare `claude` launched by the daemon falls back to the global default
profile, which can be a different account. Undefined for a default-profile
caller, whose spawns already land on the same account. */
export async function callerCswapAccount(
env: Record<string, string | undefined>,
exec: typeof runCapture = runCapture,
): Promise<string | undefined> {
if (!env.CLAUDE_CONFIG_DIR) return undefined;
const res = await exec([cswapBin(), "list", "--json"], { timeoutMs: 5_000, env });
if (res.exitCode !== 0) return undefined;
try {
const parsed = JSON.parse(res.stdout) as { activeAccountNumber?: number | null; accounts?: Array<{ number: number; email: string }> };
return parsed.accounts?.find((a) => a.number === parsed.activeAccountNumber)?.email;
} catch {
return undefined;
}
}

export async function listCswapAccounts(exec: typeof runCapture = runCapture): Promise<PaneAccount[]> {
const res = await exec([cswapBin(), "list"], { timeoutMs: 5_000 });
if (res.exitCode !== 0) return [];
Expand Down
2 changes: 1 addition & 1 deletion website/docs/reference/agent.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ rt agent <verb> <id> [flags]
| `--effort` | text | | Override agent.&lt;provider&gt;.effort |
| `--yolo` | boolean | | Bypass permission prompts for this launch (override agent.&lt;provider&gt;.yolo; omitted falls back to the setting, not false) |
| `--no-yolo` | boolean | | Keep permission prompts for this launch even when agent.&lt;provider&gt;.yolo is true (omitted falls back to the setting, not false) |
| `--account` | text | | cswap account, claude only (override agent.claude.account) |
| `--account` | text | | cswap account, claude only; defaults to the account this session runs under, then agent.claude.account |
| `--label` | text | | Caller's display label; used as the herdr tab name |
| `--caller` | text | | Identifies what invoked this handoff |
| `--workspace` | text | | herdr workspace label (default: the repo label) |
Expand Down
2 changes: 1 addition & 1 deletion website/docs/reference/herd/spawn.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ rt herd spawn [flags]
| `--dir` | text | | Existing directory to run in instead of a fresh worktree |
| `--model` | text | | Override agent.claude.model for this worker |
| `--effort` | text | | Override agent.claude.effort for this worker |
| `--account` | text | | cswap account for this worker |
| `--account` | text | | cswap account for this worker; defaults to the account this session runs under |
| `--disposable` | boolean | `false` | Wrap-up may dispose this job's worktree |
| [`--json`](/guides/common-flags) | boolean | `false` | Emit the spawn record as JSON |

Expand Down
Loading