ccqa 0.10.1 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -69,7 +69,7 @@ ccqa run tasks/create-and-complete # vitest replays test.spec.ts; no LLM
69
69
  ccqa run tasks/create-and-complete # Claude drives the browser every time
70
70
  ```
71
71
 
72
- Live specs can start already-signed-in by pointing `statePath:` at a saved agent-browser state file (cookies + localStorage). Run an interactive login locally once, save the state with `agent-browser state save .ccqa/sessions/<name>.json`, then commit the path (not the file) — see [Pre-authenticated state](#pre-authenticated-state-statepath) below for the local bootstrap and the CI restore pattern.
72
+ Live specs can start already-signed-in by naming a saved session with `session:`. Create it once with `ccqa session bootstrap <name>` (log in by hand, ccqa saves the cookies + localStorage), then specs restore it — see [Saved sessions](#saved-sessions-session) below for the bootstrap and the CI restore pattern.
73
73
 
74
74
  By default deterministic runs write step-boundary screenshots and metadata to `ccqa-report/evidence/<feature>/<spec>/` so a reviewer can confirm a passing spec actually reached the states its `expected` clauses describe. Disable with `--no-evidence`.
75
75
 
@@ -207,71 +207,67 @@ ccqa run --retry 2 tasks/create-and-complete
207
207
 
208
208
  Constraints on selectors / `agent-browser` subcommands that apply during `ccqa record` (no `eval`, no `@ref`, no bare-tag positional `find`, no chained agent-browser calls) are **relaxed** for live specs — Claude can use any subcommand and any selector style because there is no replay contract to honour.
209
209
 
210
- ### Pre-authenticated state (`statePath:`)
210
+ ### Saved sessions (`session:`)
211
211
 
212
- By default each `ccqa run` of a live spec spins up a fresh `agent-browser` session and starts signed-out. That keeps runs hermetic but forces every device-trust gate (Slack "we don't recognize this browser", Google's unfamiliar-device prompt, MFA challenges, …) to fire on every run.
212
+ By default each `ccqa run` of a live spec starts signed-out and logs in through its own steps. That's fine for plain form logins, but some providers gate every fresh browser with a device-trust check (an "unrecognized device" e-mail code, an MFA prompt) that a human has to clear by hand — impractical to repeat on every run, impossible in CI.
213
213
 
214
- To skip them, save an authenticated browser state to a JSON file once locally and point the spec at it:
214
+ For those, save the signed-in browser state once and let the spec **restore** it. ccqa does not manage authentication — `session` is purely an optional restore of cookies + localStorage. Specs that can just log in normally don't use it.
215
215
 
216
216
  ```yaml
217
- title: Slack App Home non-admin access denied
217
+ title: Admin can open the settings page
218
218
  mode: live
219
- statePath: .ccqa/sessions/slack-stg.json # cookies + localStorage to restore
219
+ session: admin # restore the saved "admin" session before step 1
220
220
  steps:
221
- - ...
221
+ - ... # no login steps — the spec starts signed-in
222
222
  ```
223
223
 
224
- ccqa resolves the path against the project root and passes `--state <path>` to every `agent-browser` invocation in the run (including ccqa's own screenshot calls). The file is **read-only** — `--state` loads it but never writes back to it. Re-running locally or in CI does not mutate it.
224
+ A spec can also restore several sessions at once (e.g. one provider in each), and ccqa merges them:
225
225
 
226
- Bootstrap once locally:
226
+ ```yaml
227
+ session:
228
+ - admin # one provider, signed in as admin
229
+ - admin-chat # another provider, same person
230
+ ```
227
231
 
228
- ```bash
229
- # 1. Log in interactively in a headed browser.
230
- agent-browser --headed open https://app.slack.com
231
- # …complete login + device-trust prompts by hand…
232
+ #### Create a session — `ccqa session bootstrap`
232
233
 
233
- # 2. Snapshot cookies + localStorage to the path the spec references.
234
- mkdir -p .ccqa/sessions
235
- agent-browser state save .ccqa/sessions/slack-stg.json
236
- agent-browser close
234
+ ```bash
235
+ # Opens a headed browser. Log in by hand (clear any device-trust gate),
236
+ # then press Enter and ccqa saves the session.
237
+ ccqa session bootstrap admin --url https://app.example.com/login
237
238
 
238
- # 3. ccqa run reuses the saved state no login prompt.
239
- ccqa run slack/app-home-non-admin-access-denied
239
+ # List saved sessions (names + save times; no secret values shown).
240
+ ccqa session ls
240
241
  ```
241
242
 
242
- Add `.ccqa/sessions/` to `.gitignore` — these files contain live auth cookies and must never be committed.
243
+ Sessions are saved to `.ccqa/sessions/<profile>/<name>.json`. The `<profile>` is the same `--profile` that selects the `.ccqa/profiles/<profile>.env` file, so one flag picks both the environment and its sessions bucket; with no `--profile` the bucket is `default`. `ccqa init` drops a self-ignoring `.ccqa/sessions/.gitignore` so saved sessions stay out of git **they contain live auth cookies and must never be committed.**
243
244
 
244
- #### CI: bring the state file with you
245
+ When a spec names a session that hasn't been created yet, the run stops and tells you which `ccqa session bootstrap` to run, rather than starting unauthenticated.
245
246
 
246
- `statePath:` lives entirely inside `.ccqa/` and never touches `~/`. CI re-uses the state by writing the file into the same path the spec already references:
247
+ #### CI: restore the session
248
+
249
+ Saved sessions live entirely inside `.ccqa/` and never touch `~/`. In CI, write each session file back to the path the run expects (read it from your secret store):
247
250
 
248
251
  ```bash
249
- # Locally, after the interactive bootstrap above:
250
- base64 -i .ccqa/sessions/slack-stg.json | pbcopy
251
- # paste into your CI secret store as CCQA_SLACK_STG_STATE_B64
252
+ # Locally, after bootstrapping, copy the file into your CI secret store:
253
+ base64 -i .ccqa/sessions/default/admin.json | pbcopy # paste as CCQA_SESSION_ADMIN_B64
252
254
  ```
253
255
 
254
256
  ```yaml
255
- # .github/workflows/ccqa.yml (sketch)
256
- - name: Restore agent-browser state
257
+ # CI step (sketch) — restore before `ccqa run`
258
+ - name: Restore session
257
259
  env:
258
- CCQA_SLACK_STG_STATE_B64: ${{ secrets.CCQA_SLACK_STG_STATE_B64 }}
260
+ CCQA_SESSION_ADMIN_B64: ${{ secrets.CCQA_SESSION_ADMIN_B64 }}
259
261
  run: |
260
- mkdir -p .ccqa/sessions
261
- printf '%s' "$CCQA_SLACK_STG_STATE_B64" | base64 -d \
262
- > .ccqa/sessions/slack-stg.json
263
-
264
- - name: Run live specs
265
- env:
266
- ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
267
- run: pnpm ccqa run --report
262
+ mkdir -p .ccqa/sessions/default
263
+ printf '%s' "$CCQA_SESSION_ADMIN_B64" | base64 -d > .ccqa/sessions/default/admin.json
268
264
  ```
269
265
 
270
- Caveats:
266
+ Notes:
271
267
 
272
- - **Expiry.** Whatever the upstream service's "remember this device" window is (Slack ≈ 30 days, others vary), the cookies in the state file eventually expire and CI starts failing on the device-trust gate again. Re-bootstrap locally and rotate the secret.
273
- - **Treat the file as a credential.** It contains live auth cookies. Store it in your CI secret manager (GitHub Actions encrypted secrets, Vault, …) and never commit it.
274
- - **Deterministic specs ignore `statePath:`.** Today it only affects `mode: live`; vitest-replayed specs always run isolated.
268
+ - **Expiry.** The provider's "remember this device" window eventually lapses and the saved cookies stop working. Re-run `ccqa session bootstrap` locally and rotate the secret.
269
+ - **Treat session files as credentials.** They hold live auth cookies. Keep them in a secret manager; never commit them.
270
+ - **Deterministic specs ignore `session:`.** It only affects `mode: live`; vitest-replayed specs always run isolated.
275
271
 
276
272
  ### Per-project guidance (`.ccqa/prompts/live.user.md` + `live.agent.md`)
277
273
 
package/dist/bin/ccqa.mjs CHANGED
@@ -6,7 +6,7 @@ import { accessSync, existsSync, readFileSync, statSync } from "node:fs";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { access, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
8
8
  import { homedir, tmpdir } from "node:os";
9
- import { delimiter, dirname, isAbsolute, join, posix, relative, resolve } from "node:path";
9
+ import { delimiter, dirname, join, posix, relative, resolve } from "node:path";
10
10
  import { parse, stringify } from "yaml";
11
11
  import { ZodError, z } from "zod";
12
12
  import { execFile, spawn, spawnSync } from "node:child_process";
@@ -136,12 +136,27 @@ const StepSchema = z.union([ActionStepSchema, IncludeStepSchema]);
136
136
  * codegen is impractical). Cost ~$0.5 per spec.
137
137
  */
138
138
  const SpecModeSchema = z.enum(["deterministic", "live"]);
139
+ /**
140
+ * A session name: the identifier of a saved browser session (cookies +
141
+ * localStorage) to restore before the spec runs. Resolved to
142
+ * `.ccqa/sessions/<profile>/<name>.json` at run time. Restricted to a safe
143
+ * slug so the name can't escape the sessions directory.
144
+ */
145
+ const SessionNameSchema = z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "session name must be a slug (letters, digits, '.', '_', '-'; no path separators)");
146
+ /**
147
+ * Sessions to restore before a `mode: live` spec runs: a single name or a
148
+ * list. Always normalized to an array. Each name maps to a saved
149
+ * agent-browser state file; multiple names are merged (their cookies +
150
+ * localStorage are unioned) and restored together, so a spec can start
151
+ * signed-in to several providers at once.
152
+ */
153
+ const SessionFieldSchema = z.union([SessionNameSchema, z.array(SessionNameSchema).min(1)]).transform((v) => Array.isArray(v) ? v : [v]);
139
154
  /** Top-level spec schema. `.strict()` rejects any unknown key. */
140
155
  const TestSpecSchema = z.object({
141
156
  title: z.string().min(1),
142
157
  relatedPaths: z.array(z.string().min(1)).optional(),
143
158
  mode: SpecModeSchema.optional(),
144
- statePath: z.string().min(1).optional(),
159
+ session: SessionFieldSchema.optional(),
145
160
  steps: z.array(StepSchema).min(1)
146
161
  }).strict();
147
162
  /** Default mode when `mode:` is absent. */
@@ -4584,6 +4599,55 @@ function oneLine$1(s) {
4584
4599
  return s.replace(/\s+/g, " ").trim();
4585
4600
  }
4586
4601
  //#endregion
4602
+ //#region src/runtime/session-state.ts
4603
+ /** Default per-profile sessions root, relative to the project (`--cwd`). */
4604
+ const SESSIONS_SUBDIR = ".ccqa/sessions";
4605
+ /** The per-profile sessions directory: `<cwd>/.ccqa/sessions/<profile>/`. */
4606
+ function sessionsDir(profile, cwd) {
4607
+ return join(cwd, SESSIONS_SUBDIR, profile ?? "default");
4608
+ }
4609
+ /**
4610
+ * Resolve a session name to its state file path:
4611
+ * `<cwd>/.ccqa/sessions/<profile>/<name>.json`. The name is a slug (validated
4612
+ * by SessionNameSchema), so it can't escape the sessions directory.
4613
+ */
4614
+ function sessionFilePath(name, profile, cwd) {
4615
+ return join(sessionsDir(profile, cwd), `${name}.json`);
4616
+ }
4617
+ /** Read and parse a saved session file. Throws if missing or malformed. */
4618
+ async function loadStorageState(path) {
4619
+ const raw = await readFile(path, "utf8");
4620
+ const parsed = JSON.parse(raw);
4621
+ if (!Array.isArray(parsed?.cookies) || !Array.isArray(parsed?.origins)) throw new Error(`not a valid agent-browser state file (expected { cookies, origins }): ${path}`);
4622
+ return parsed;
4623
+ }
4624
+ /**
4625
+ * Merge several saved sessions into one. Cookies are unioned by
4626
+ * (name, domain, path); origins by `origin`. Later states win on collision.
4627
+ * Distinct providers don't collide (different domains / origins), so merging
4628
+ * two single-provider sessions yields a combined signed-in state.
4629
+ */
4630
+ function mergeStorageStates(states) {
4631
+ const cookies = /* @__PURE__ */ new Map();
4632
+ for (const s of states) for (const c of s.cookies) cookies.set(`${c.name}\t${c.domain}\t${c.path}`, c);
4633
+ const origins = /* @__PURE__ */ new Map();
4634
+ for (const s of states) for (const o of s.origins) origins.set(o.origin, o);
4635
+ return {
4636
+ cookies: [...cookies.values()],
4637
+ origins: [...origins.values()]
4638
+ };
4639
+ }
4640
+ /**
4641
+ * Write a merged state to a fresh temp file and return its path. Source
4642
+ * session files are never modified; the temp file is what gets restored via
4643
+ * `--state`, so re-runs (local or CI) leave the source-of-truth untouched.
4644
+ */
4645
+ async function writeMergedTempState(state) {
4646
+ const file = join(await mkdtemp(join(tmpdir(), "ccqa-session-")), "merged-state.json");
4647
+ await writeFile(file, JSON.stringify(state), "utf8");
4648
+ return file;
4649
+ }
4650
+ //#endregion
4587
4651
  //#region src/claude/agent-browser-invoke.ts
4588
4652
  function agentBrowserInvokeBase(input) {
4589
4653
  const env = {
@@ -4641,7 +4705,7 @@ function buildLiveSystemPromptPrefix(input) {
4641
4705
  const stepsText = input.allSteps.map((s) => `### ${s.id} [${s.source}]
4642
4706
  - **Instruction**: ${s.instruction}
4643
4707
  - **Expected**: ${s.expected}`).join("\n\n");
4644
- const stateLine = input.statePath ? `\n\nA pre-recorded auth-state file is provided at \`${input.statePath}\` (also in the env var \`CCQA_AB_STATE\`). **Always also pass \`--state "$CCQA_AB_STATE"\`** to every \`agent-browser\` command — this restores cookies and localStorage from a prior interactive login, so the user is already signed in to the application under test from step 1. The file is loaded read-only; do not run \`agent-browser state save\`.` : "";
4708
+ const stateLine = input.statePath ? `\n\nA pre-recorded auth-state file is provided at \`${input.statePath}\` (also in the env var \`CCQA_AB_STATE\`). **Always also pass \`--state "$CCQA_AB_STATE"\`** to every \`agent-browser\` command — this restores cookies and localStorage saved from a prior interactive login (one or more providers), so the user is already signed in to the application under test from step 1. The file is loaded read-only; do not run \`agent-browser state save\`.` : "";
4645
4709
  return `You are a QA execution agent. You are executing ONE step of a browser-based end-to-end test and judging whether the step's expected outcome was achieved. You are NOT recording a replayable test script — be flexible, explore the DOM as needed, and make a clear pass / fail call at the end.
4646
4710
 
4647
4711
  ## Session
@@ -5174,6 +5238,44 @@ async function runDriftAudit(runs, opts, cwd) {
5174
5238
  }
5175
5239
  return out;
5176
5240
  }
5241
+ /**
5242
+ * Resolve `spec.session` names to a single state file to restore. Each name
5243
+ * maps to `.ccqa/sessions/<profile>/<name>.json` and must load as a valid
5244
+ * agent-browser state (the spec assumes it starts signed-in). One name is
5245
+ * restored from its file directly; several are merged into a temp file. A
5246
+ * missing or malformed session fails with a `ccqa session bootstrap` hint
5247
+ * instead of running unauthenticated.
5248
+ */
5249
+ async function resolveSessionState(names, profile, cwd) {
5250
+ const paths = names.map((name) => ({
5251
+ name,
5252
+ path: sessionFilePath(name, profile, cwd)
5253
+ }));
5254
+ const loaded = [];
5255
+ const broken = [];
5256
+ for (const p of paths) try {
5257
+ loaded.push(await loadStorageState(p.path));
5258
+ } catch {
5259
+ broken.push(p);
5260
+ }
5261
+ if (broken.length > 0) {
5262
+ const list = broken.map((b) => b.name).join(", ");
5263
+ const profileFlag = profile ? ` --profile ${profile}` : "";
5264
+ return {
5265
+ ok: false,
5266
+ error: `session not usable: ${list} (looked under ${dirname(broken[0].path)})`,
5267
+ hint: `create it with: ${broken.map((b) => `ccqa session bootstrap ${b.name}${profileFlag}`).join(" · ")}`
5268
+ };
5269
+ }
5270
+ if (paths.length === 1) return {
5271
+ ok: true,
5272
+ statePath: paths[0].path
5273
+ };
5274
+ return {
5275
+ ok: true,
5276
+ statePath: await writeMergedTempState(mergeStorageStates(loaded))
5277
+ };
5278
+ }
5177
5279
  async function runOneSpec(args) {
5178
5280
  const { featureName, specName, opts, userPromptSuffix, cwd } = args;
5179
5281
  const specDir = getSpecDir(featureName, specName, cwd);
@@ -5198,21 +5300,20 @@ async function runOneSpec(args) {
5198
5300
  const sessionName = generateLiveSessionName();
5199
5301
  meta("session", sessionName);
5200
5302
  let statePath = null;
5201
- if (spec.statePath) {
5202
- statePath = isAbsolute(spec.statePath) ? spec.statePath : resolve(cwd, spec.statePath);
5203
- try {
5204
- await access(statePath);
5205
- } catch {
5206
- const msg = `spec.statePath points to a missing file: ${statePath}`;
5207
- error(msg);
5303
+ if (spec.session && spec.session.length > 0) {
5304
+ const resolution = await resolveSessionState(spec.session, opts.profile, cwd);
5305
+ if (!resolution.ok) {
5306
+ error(resolution.error);
5307
+ hint(resolution.hint);
5208
5308
  return {
5209
5309
  kind: "error",
5210
5310
  featureName,
5211
5311
  specName,
5212
- error: msg
5312
+ error: resolution.error
5213
5313
  };
5214
5314
  }
5215
- meta("state", statePath);
5315
+ statePath = resolution.statePath;
5316
+ meta("state", spec.session.join(", "));
5216
5317
  }
5217
5318
  const runId = buildRunId();
5218
5319
  const runDir = opts.out ?? join(specDir, "runs", runId);
@@ -5576,6 +5677,7 @@ async function runDispatcher(targets, opts) {
5576
5677
  ...reportDir ? { reportDir } : {},
5577
5678
  ...typeof opts.retry === "number" ? { retry: opts.retry } : {},
5578
5679
  concurrency: opts.concurrency ?? 1,
5680
+ ...opts.profile ? { profile: opts.profile } : {},
5579
5681
  ...reportDir && opts.driftAudit !== false ? { driftAudit: true } : {},
5580
5682
  ...reportDir && opts.failureAnalysis === false ? { failureAnalysis: false } : {}
5581
5683
  });
@@ -9669,6 +9771,13 @@ Write stable, hand-maintained context here for the trace phase of 'ccqa record'.
9669
9771
  content: `# Agent learnings for ccqa record
9670
9772
 
9671
9773
  This file is updated by 'ccqa record --update-agent-prompt'. Same convention as live.agent.md — stable rules go in record.user.md.
9774
+ `
9775
+ },
9776
+ {
9777
+ relPath: ".ccqa/sessions/.gitignore",
9778
+ content: `# Saved browser sessions contain live auth cookies. Never commit them.
9779
+ *
9780
+ !.gitignore
9672
9781
  `
9673
9782
  }
9674
9783
  ];
@@ -9676,6 +9785,7 @@ const initCommand = new Command("init").description("Create .ccqa/prompts/{live,
9676
9785
  const cwd = resolveCwd(opts.cwd);
9677
9786
  header("init", cwd);
9678
9787
  await mkdir(join(cwd, ".ccqa", "prompts"), { recursive: true });
9788
+ await mkdir(join(cwd, ".ccqa", "sessions"), { recursive: true });
9679
9789
  const created = [];
9680
9790
  const skipped = [];
9681
9791
  for (const t of TEMPLATES) if (await writeTemplate(join(cwd, t.relPath), t.content, opts.force ?? false)) created.push(t.relPath);
@@ -10183,6 +10293,89 @@ function mdCell(value) {
10183
10293
  return value.replace(/\|/g, "\\|").replace(/\n/g, " ");
10184
10294
  }
10185
10295
  //#endregion
10296
+ //#region src/cli/session.ts
10297
+ const AB = createRequire(import.meta.url).resolve("agent-browser/bin/agent-browser.js");
10298
+ /**
10299
+ * Run agent-browser attached to the user's terminal (no timeout, inherited
10300
+ * stdio) so a human can complete an interactive login during `bootstrap`.
10301
+ * Distinct from runtime/spawn-ab.ts, which pipes stdio and hard-times-out for
10302
+ * non-interactive automation.
10303
+ */
10304
+ function runAbInteractive(args) {
10305
+ return spawnSync(AB, args, { stdio: "inherit" }).status ?? 1;
10306
+ }
10307
+ function validateName(name) {
10308
+ const parsed = SessionNameSchema.safeParse(name);
10309
+ if (!parsed.success) {
10310
+ error(`invalid session name "${name}": ${parsed.error.issues[0]?.message ?? "bad name"}`);
10311
+ process.exit(2);
10312
+ }
10313
+ return parsed.data;
10314
+ }
10315
+ const profileOption = ["--profile <name>", "Sessions bucket to read/write (.ccqa/sessions/<profile>/). Defaults to 'default'."];
10316
+ const bootstrapCommand = new Command("bootstrap").description("Open a headed browser so you can log in by hand, then save the resulting session (cookies + localStorage) for `session:` specs to restore. The saved file holds live auth cookies — keep .ccqa/sessions/ gitignored.").argument("<name>", "Session name to save (a slug; resolves to .ccqa/sessions/<profile>/<name>.json)").option("--url <url>", "URL to open first (e.g. the login page). Omit to start with a blank tab.").option(...profileOption).option("--cwd <path>", "Project root containing .ccqa/ (defaults to the current directory).").action(async (rawName, opts) => {
10317
+ const name = validateName(rawName);
10318
+ const cwd = resolveCwd(opts.cwd);
10319
+ const dest = sessionFilePath(name, opts.profile, cwd);
10320
+ header("session bootstrap", name);
10321
+ meta("profile", opts.profile ?? "default");
10322
+ meta("save to", dest);
10323
+ blank();
10324
+ const openArgs = [
10325
+ "--headed",
10326
+ "open",
10327
+ ...opts.url ? [opts.url] : ["about:blank"]
10328
+ ];
10329
+ info("opening a browser — log in by hand, then return here.");
10330
+ const openStatus = runAbInteractive(openArgs);
10331
+ if (openStatus !== 0) {
10332
+ error(`agent-browser open exited ${openStatus}`);
10333
+ process.exit(1);
10334
+ }
10335
+ const rl = createInterface$1({
10336
+ input: process.stdin,
10337
+ output: process.stdout
10338
+ });
10339
+ await rl.question("\nPress Enter once you are fully logged in to save the session… ");
10340
+ rl.close();
10341
+ await mkdir(dirname(dest), { recursive: true });
10342
+ const saveStatus = runAbInteractive([
10343
+ "state",
10344
+ "save",
10345
+ dest
10346
+ ]);
10347
+ runAbInteractive(["close"]);
10348
+ if (saveStatus !== 0) {
10349
+ error(`agent-browser state save exited ${saveStatus}`);
10350
+ process.exit(1);
10351
+ }
10352
+ blank();
10353
+ info(`saved session "${name}" → ${dest}`);
10354
+ hint("reference it from a spec with: session: " + name);
10355
+ });
10356
+ const lsCommand = new Command("ls").description("List saved sessions for a profile (names + last-saved times). No secret values are shown.").option(...profileOption).option("--cwd <path>", "Project root containing .ccqa/ (defaults to the current directory).").action(async (opts) => {
10357
+ const cwd = resolveCwd(opts.cwd);
10358
+ const profile = opts.profile ?? "default";
10359
+ const dir = sessionsDir(profile, cwd);
10360
+ header("sessions", profile);
10361
+ let entries;
10362
+ try {
10363
+ entries = (await readdir(dir)).filter((f) => f.endsWith(".json"));
10364
+ } catch {
10365
+ entries = [];
10366
+ }
10367
+ if (entries.length === 0) {
10368
+ info(`no saved sessions in ${dir}`);
10369
+ hint("create one with: ccqa session bootstrap <name>");
10370
+ return;
10371
+ }
10372
+ for (const file of entries.sort()) {
10373
+ const info = await stat(join(dir, file));
10374
+ meta(file.replace(/\.json$/, ""), `saved ${info.mtime.toISOString()}`);
10375
+ }
10376
+ });
10377
+ const sessionCommand = new Command("session").description("Manage saved browser sessions (cookies + localStorage) for `session:` specs.").addCommand(bootstrapCommand).addCommand(lsCommand);
10378
+ //#endregion
10186
10379
  //#region src/cli/index.ts
10187
10380
  function resolvePackageJson() {
10188
10381
  const distCandidate = fileURLToPath(new URL("../package.json", import.meta.url));
@@ -10203,6 +10396,7 @@ program.addCommand(perspectivesCommand);
10203
10396
  program.addCommand(recordCommand);
10204
10397
  program.addCommand(runCommand);
10205
10398
  program.addCommand(driftCommand);
10399
+ program.addCommand(sessionCommand);
10206
10400
  program.parse();
10207
10401
  //#endregion
10208
10402
  export {};
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {