@valescoagency/runway 0.2.0 → 0.4.0

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
@@ -14,7 +14,7 @@ zero-secrets-at-rest, and the `gh` CLI for PR creation.
14
14
  |---|---|
15
15
  | `runway doctor` | Read-only preflight diagnostic: host tooling, env vars, repo state, and the agent docker image. Use when something stopped working and you want a sanity report. `--json` for CI / scripted health checks. |
16
16
  | `runway init` | Scaffold the cwd repo for runway: write `.sandcastle/Dockerfile` + (tier 2) `.env.schema` with op:// references. Run **once per target repo**. |
17
- | `runway run` | Drain a Linear queue. For each `Todo` issue: branch, agent works, sub-agent reviews, PR opens (or `needs-human` label). Run **whenever you want a batch of work done**. |
17
+ | `runway run` | Drain a Linear queue. For each `Todo` issue: branch, agent works, sub-agent reviews, PR opens (or `ready-for-human` label). Run **whenever you want a batch of work done**. |
18
18
  | `runway upgrade` | Update the runway CLI itself: `git pull` the local clone, `pnpm install`, typecheck. `--check` for a dry-run, `--force` to override dirty/branch refusals. |
19
19
  | `runway upgrade-repo` | Re-render the cwd repo's runway scaffold against the current vendored templates. Use after a runway version bump that changed the Dockerfile or template shape — `init` writes them, `upgrade-repo` keeps them current without re-prompting for op:// values. |
20
20
 
@@ -50,7 +50,7 @@ runway (this CLI, on your Mac, run from inside the target repo)
50
50
  │ → REVIEW: APPROVED | REVIEW: REJECTED — <reason>
51
51
 
52
52
  ├── approved → git push → gh pr create → Linear "In Review"
53
- └── rejected → Linear label "needs-human", comment with reason
53
+ └── rejected → Linear label "ready-for-human", comment with reason
54
54
  ↓ next issue
55
55
  ```
56
56
 
@@ -61,8 +61,12 @@ runway (this CLI, on your Mac, run from inside the target repo)
61
61
  - Node 22+
62
62
  - `gh` CLI authenticated against the org that hosts your target repo
63
63
  - Linear API key with read+write on the team you're targeting
64
- - Anthropic API key (set in the **target repo's** `.sandcastle/.env`,
65
- not in runway's env Sandcastle reads it)
64
+ - A Claude Code credential **either** an Anthropic API key
65
+ (`sk-ant-api03-…`, pay-per-token) **or** a Pro/Max OAuth token
66
+ (`sk-ant-oat01-…`, generated via `claude setup-token`). The two are
67
+ not interchangeable — see "Claude Code auth modes" below. Stored in
68
+ the **target repo's** `.sandcastle/.env` (tier 1) or 1Password
69
+ (tier 2); never in runway's own env.
66
70
 
67
71
  ## One-time setup per target repo
68
72
 
@@ -71,7 +75,8 @@ cd /path/to/your/repo
71
75
  runway init \
72
76
  --op-vault=runway \
73
77
  --anthropic-item=anthropic-api-key \
74
- --gh-token-item=gh-token
78
+ --gh-token-item=gh-token \
79
+ --auth-mode=api-key # or --auth-mode=oauth for Pro/Max tokens
75
80
  ```
76
81
 
77
82
  (No `--op-account` — runway uses 1Password service-account auth
@@ -92,6 +97,37 @@ and no varlock (faster but secrets land on disk).
92
97
 
93
98
  Architecture walkthrough: [`docs/secrets-with-varlock.md`](docs/secrets-with-varlock.md).
94
99
 
100
+ ## Claude Code auth modes
101
+
102
+ Claude Code accepts two distinct credentials, and they are **not
103
+ interchangeable** — passing one as the other yields a generic
104
+ `Invalid API key` inside the container with no useful diagnostic.
105
+
106
+ | Mode | Env var | Token shape | Source |
107
+ |---|---|---|---|
108
+ | `api-key` (default) | `ANTHROPIC_API_KEY` | `sk-ant-api03-…` | [Anthropic console](https://console.anthropic.com), pay-per-token |
109
+ | `oauth` | `CLAUDE_CODE_OAUTH_TOKEN` | `sk-ant-oat01-…` | `claude setup-token` on your Pro/Max account |
110
+
111
+ Pick whichever matches what's stored in your 1Password item:
112
+
113
+ ```bash
114
+ runway init --tier=2 --op-vault=runway \
115
+ --anthropic-item=claude-pro-oauth-token \
116
+ --gh-token-item=gh-token \
117
+ --auth-mode=oauth
118
+ ```
119
+
120
+ The `--anthropic-item` flag is the 1Password item name regardless of
121
+ mode; only the env var written into `.env.schema` changes. `runway
122
+ doctor` surfaces the resolved mode under Environment (`claude auth
123
+ mode: oauth (…)`), and fails fast if `.env.schema` ends up with both
124
+ env vars at once.
125
+
126
+ If you switch modes later, run `runway upgrade-repo` — it extracts
127
+ the existing op:// references, re-renders the template with the new
128
+ mode (detected automatically from the schema), and writes back. You
129
+ do not need to re-pass the op:// flags.
130
+
95
131
  ## Secrets — recommended: varlock + 1Password
96
132
 
97
133
  If you don't want any secret sitting at rest in any `.env` file,
@@ -121,13 +157,23 @@ export LINEAR_API_KEY=lin_api_...
121
157
  # Optional overrides:
122
158
  # export RUNWAY_LINEAR_TEAM=VA
123
159
  # export RUNWAY_LINEAR_PROJECT=<project-id-or-slug> # optional, scopes queue to one project
160
+ # export RUNWAY_BASE_BRANCH=master # optional, overrides auto-detected default branch
124
161
  # export RUNWAY_READY_STATUS="Todo"
125
162
  # export RUNWAY_IN_PROGRESS_STATUS="In Progress"
126
163
  # export RUNWAY_IN_REVIEW_STATUS="In Review"
127
- # export RUNWAY_HITL_LABEL="needs-human"
164
+ # export RUNWAY_HITL_LABEL="ready-for-human"
128
165
  # export RUNWAY_MAX_ITERATIONS=5
129
166
  ```
130
167
 
168
+ `RUNWAY_HITL_LABEL` defaults to `ready-for-human`, matching the
169
+ [Flightplan](https://github.com/valescoagency/flightplan) canonical
170
+ state-label vocabulary (`needs-triage`, `needs-info`,
171
+ `ready-for-agent`, `ready-for-human`, `wontfix`) that Bedrock and
172
+ other Valesco repos use. Override the env var if your workspace uses
173
+ a different label. `runway doctor` validates that the configured
174
+ team, workflow states, and HITL label all exist before any agent run
175
+ — misconfiguration surfaces immediately instead of mid-drain.
176
+
131
177
  ### From source (development)
132
178
 
133
179
  ```bash
@@ -140,19 +186,58 @@ pnpm link --global # so `runway` is on your $PATH
140
186
 
141
187
  `pnpm dev -- <args>` runs the TypeScript source via `tsx` without building, useful while iterating on runway itself.
142
188
 
189
+ #### Tests
190
+
191
+ ```bash
192
+ pnpm test # one-shot run, used by CI
193
+ pnpm test:watch # watch mode for local iteration
194
+ ```
195
+
196
+ Vitest is the harness; tests live colocated with the source as
197
+ `*.test.ts` files (e.g. `src/git.test.ts` next to `src/git.ts`). CI
198
+ runs `pnpm typecheck && pnpm test` on every PR via
199
+ `.github/workflows/ci.yml`.
200
+
201
+ When adding logic that has a sharp pass/fail signal, add a test next
202
+ to it. The seed suite covers `parseRunArgs`, `detectBaseBranch`, the
203
+ `parseOpRefs` regex extraction, and the `drainQueue` error-handler
204
+ branches — copy any of those as a shape for new tests.
205
+
206
+ #### Git hooks (lefthook + commitlint)
207
+
208
+ Hooks install automatically on `pnpm install` via the `prepare`
209
+ script. What runs and when:
210
+
211
+ | Hook | Runs | Why |
212
+ |---|---|---|
213
+ | `pre-commit` | `pnpm typecheck` | Catch TS errors before they land on a branch. |
214
+ | `commit-msg` | `pnpm exec commitlint --edit` | Reject non-conventional commit messages (CLAUDE.md convention). |
215
+ | `pre-push` | `pnpm test` | Block pushing red. |
216
+
217
+ Skip a single hook invocation with `LEFTHOOK=0 git commit …` (or
218
+ `… git push …`). To re-install after editing `lefthook.yml`, run
219
+ `pnpm exec lefthook install -f`.
220
+
143
221
  ## Usage
144
222
 
145
223
  ```bash
146
224
  cd /path/to/the/repo/you/want/agents/working/on
147
225
  runway run # drain the entire ready queue
148
- runway run --max 3 # process at most 3 issues then exit
226
+ runway run --max 3 # attempt at most 3 issues then exit
149
227
  runway --help
150
228
  ```
151
229
 
152
230
  `runway` (no subcommand) is an alias for `runway run` for back-compat.
153
231
 
232
+ `--max N` bounds **attempts**, not successes. Every issue picked up
233
+ counts as one attempt, whether it ends in a PR, a `needs-human` label,
234
+ or a revert-to-`Todo` after an infrastructure failure. An issue
235
+ reverted in this invocation will not be re-picked in the same
236
+ invocation — re-run runway after fixing the underlying config to retry
237
+ it.
238
+
154
239
  The CLI exits with 0 even if some issues hit HITL or errored — those
155
- are normal outcomes. Check Linear for the `needs-human` label and the
240
+ are normal outcomes. Check Linear for the `ready-for-human` label and the
156
241
  per-issue comments for what happened.
157
242
 
158
243
  ## Linear conventions
@@ -170,13 +255,28 @@ It transitions them through:
170
255
  agent has committed to its branch — startup failures before any
171
256
  commits revert the issue back to `Todo` rather than stranding it)
172
257
  - `In Review` when the PR opens
173
- - (label `needs-human`) if the agent or reviewer can't finish *after*
258
+ - (label `ready-for-human`) if the agent or reviewer can't finish *after*
174
259
  the agent has committed real work
175
260
 
176
261
  These names are configurable per env var; the queries match by name so
177
262
  your Linear workspace's actual state names need to line up with what
178
263
  you set.
179
264
 
265
+ ## Base branch
266
+
267
+ Runway auto-detects the repo's default branch at the start of every
268
+ `runway run` by reading `origin/HEAD` (with `git remote show origin`
269
+ as a fallback for fresh clones). That branch is used for diffing the
270
+ agent's work, counting commits when deciding whether a startup
271
+ failure should revert to `Todo`, and as the `--base` for the PR.
272
+
273
+ Set `RUNWAY_BASE_BRANCH=<name>` to override detection — useful when
274
+ you want runway to target a release branch instead of the default, or
275
+ when `origin/HEAD` isn't set and you don't want to run
276
+ `git remote set-head origin --auto`. `runway doctor` surfaces the
277
+ resolved base branch (detected or overridden) in its Environment
278
+ section.
279
+
180
280
  ## Sub-agent review
181
281
 
182
282
  Every implementation run is followed by a fresh Sandcastle run with
@@ -1,6 +1,10 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { execa } from "execa";
4
+ import { detectBaseBranch } from "../git.js";
5
+ import { loadPolicy } from "../policy.js";
6
+ import { loadConfig } from "../config.js";
7
+ import { validateLinearConfig } from "../linear.js";
4
8
  // ---------------------------------------------------------------------------
5
9
  // Usage
6
10
  // ---------------------------------------------------------------------------
@@ -83,15 +87,17 @@ export async function doctorCommand(argv) {
83
87
  const sections = [];
84
88
  sections.push(await checkHostTooling(tierForToolingChecks));
85
89
  if (initialised || opts.tierOverride !== undefined) {
86
- sections.push(checkEnvironment(tierForToolingChecks));
90
+ sections.push(await checkEnvironment(tierForToolingChecks, cwd, repo));
87
91
  sections.push(await checkRepoState(cwd, repo));
88
92
  sections.push(await checkDockerImage(cwd));
93
+ sections.push(await checkLinearConfig());
89
94
  }
90
95
  else {
91
96
  // Push placeholder skipped sections so JSON output stays well-shaped.
92
97
  sections.push(skippedSection("Environment"));
93
98
  sections.push(skippedSection("Repo state"));
94
99
  sections.push(skippedSection("Docker image"));
100
+ sections.push(skippedSection("Linear configuration"));
95
101
  }
96
102
  // Render
97
103
  if (opts.json) {
@@ -101,8 +107,14 @@ export async function doctorCommand(argv) {
101
107
  renderText(sections, tier, initialised, opts.detailed);
102
108
  }
103
109
  // Exit code: required-check failures = 1.
104
- // Sections 1, 2, 4 are "required"; section 3 (repo state) is informational.
105
- const requiredSections = [sections[0], sections[1], sections[3]];
110
+ // Required: 0 host tooling, 1 environment, 3 docker image, 4 Linear
111
+ // config. Section 2 (repo state) is informational.
112
+ const requiredSections = [
113
+ sections[0],
114
+ sections[1],
115
+ sections[3],
116
+ sections[4],
117
+ ];
106
118
  const failed = requiredSections.some((s) => s?.ran && [...s.checks.values()].some((c) => c.status === "fail"));
107
119
  process.exit(failed ? 1 : 0);
108
120
  }
@@ -113,11 +125,24 @@ function detectRepoState(cwd) {
113
125
  const hasDockerfile = existsSync(join(cwd, ".sandcastle", "Dockerfile"));
114
126
  const hasSchema = existsSync(join(cwd, ".env.schema"));
115
127
  let tier = null;
128
+ let authMode = null;
129
+ let hasConflictingAuthVars = false;
116
130
  if (hasSchema) {
117
131
  try {
118
132
  const schema = readFileSync(join(cwd, ".env.schema"), "utf8");
119
- if (/ANTHROPIC_API_KEY\s*=\s*exec\(/.test(schema)) {
133
+ const hasApiKey = /ANTHROPIC_API_KEY\s*=\s*exec\(/.test(schema);
134
+ const hasOauth = /CLAUDE_CODE_OAUTH_TOKEN\s*=\s*exec\(/.test(schema);
135
+ if (hasApiKey && hasOauth) {
136
+ tier = 2;
137
+ hasConflictingAuthVars = true;
138
+ }
139
+ else if (hasApiKey) {
120
140
  tier = 2;
141
+ authMode = "api-key";
142
+ }
143
+ else if (hasOauth) {
144
+ tier = 2;
145
+ authMode = "oauth";
121
146
  }
122
147
  else if (hasDockerfile) {
123
148
  tier = 1;
@@ -130,7 +155,7 @@ function detectRepoState(cwd) {
130
155
  else if (hasDockerfile) {
131
156
  tier = 1;
132
157
  }
133
- return { tier, hasDockerfile, hasSchema };
158
+ return { tier, hasDockerfile, hasSchema, authMode, hasConflictingAuthVars };
134
159
  }
135
160
  // ---------------------------------------------------------------------------
136
161
  // Section: Host tooling
@@ -229,7 +254,7 @@ async function checkGhAuth() {
229
254
  // ---------------------------------------------------------------------------
230
255
  // Section: Environment
231
256
  // ---------------------------------------------------------------------------
232
- function checkEnvironment(tier) {
257
+ async function checkEnvironment(tier, cwd, repo) {
233
258
  const checks = new Map();
234
259
  checks.set("LINEAR_API_KEY", envSet("LINEAR_API_KEY", "fail"));
235
260
  // Informational: which Linear scope a `runway run` would use.
@@ -242,9 +267,87 @@ function checkEnvironment(tier) {
242
267
  ? `team ${team} / project ${project}`
243
268
  : `team ${team} (team-wide — RUNWAY_LINEAR_PROJECT unset)`,
244
269
  });
270
+ // Informational: which base branch a `runway run` would diff against
271
+ // and target with PRs. Detection failure here is a real problem —
272
+ // surface it as a fail so the user knows up front.
273
+ const override = process.env.RUNWAY_BASE_BRANCH?.trim();
274
+ if (override) {
275
+ checks.set("base_branch", {
276
+ status: "ok",
277
+ label: "base branch",
278
+ detail: `${override} (RUNWAY_BASE_BRANCH override)`,
279
+ });
280
+ }
281
+ else {
282
+ try {
283
+ const detected = await detectBaseBranch(cwd);
284
+ checks.set("base_branch", {
285
+ status: "ok",
286
+ label: "base branch",
287
+ detail: `${detected} (detected from origin/HEAD)`,
288
+ });
289
+ }
290
+ catch (err) {
291
+ checks.set("base_branch", {
292
+ status: "fail",
293
+ label: "base branch",
294
+ detail: errMsg(err),
295
+ });
296
+ }
297
+ }
245
298
  if (tier === 2) {
246
299
  // Tier 2: needed by varlock to resolve op:// refs in the container.
247
300
  checks.set("OP_SERVICE_ACCOUNT_TOKEN", envSet("OP_SERVICE_ACCOUNT_TOKEN", "fail"));
301
+ // Surface which Claude Code auth env var the .env.schema declares.
302
+ // ANTHROPIC_API_KEY and CLAUDE_CODE_OAUTH_TOKEN aren't
303
+ // interchangeable; a mismatch between this and what's stored in
304
+ // 1Password yields a generic "Invalid API key" inside the
305
+ // container with no useful diagnostic.
306
+ if (repo.hasConflictingAuthVars) {
307
+ checks.set("auth_mode", {
308
+ status: "fail",
309
+ label: "claude auth mode",
310
+ detail: ".env.schema declares both ANTHROPIC_API_KEY and CLAUDE_CODE_OAUTH_TOKEN — pick one (they are not interchangeable)",
311
+ });
312
+ }
313
+ else if (repo.authMode === "oauth") {
314
+ checks.set("auth_mode", {
315
+ status: "ok",
316
+ label: "claude auth mode",
317
+ detail: "oauth (CLAUDE_CODE_OAUTH_TOKEN — Pro/Max subscription)",
318
+ });
319
+ }
320
+ else if (repo.authMode === "api-key") {
321
+ checks.set("auth_mode", {
322
+ status: "ok",
323
+ label: "claude auth mode",
324
+ detail: "api-key (ANTHROPIC_API_KEY — pay-per-token)",
325
+ });
326
+ }
327
+ else {
328
+ checks.set("auth_mode", {
329
+ status: "fail",
330
+ label: "claude auth mode",
331
+ detail: ".env.schema declares neither ANTHROPIC_API_KEY nor CLAUDE_CODE_OAUTH_TOKEN",
332
+ });
333
+ }
334
+ }
335
+ // VA-352: surface the active impl-pass write-path policy so the
336
+ // operator can see whether an agent run can touch CI workflows, etc.
337
+ try {
338
+ const policy = loadPolicy(cwd);
339
+ checks.set("policy", {
340
+ status: "ok",
341
+ label: "impl policy",
342
+ detail: `${policy.source} (${policy.forbiddenPaths.length} forbidden path${policy.forbiddenPaths.length === 1 ? "" : "s"})`,
343
+ });
344
+ }
345
+ catch (err) {
346
+ checks.set("policy", {
347
+ status: "fail",
348
+ label: "impl policy",
349
+ detail: errMsg(err),
350
+ });
248
351
  }
249
352
  return { title: "Environment", checks, ran: true };
250
353
  }
@@ -370,6 +473,51 @@ async function checkDockerImage(cwd) {
370
473
  detail: imageUser ? `User=${imageUser}` : `User unset (root); host=${expected}`,
371
474
  });
372
475
  }
476
+ // VA-351: container readiness — pnpm on PATH + HOME/cache env
477
+ // baked in. Cheap one-shot run; fails fast if the image is stale.
478
+ try {
479
+ const probe = await execa("docker", [
480
+ "run",
481
+ "--rm",
482
+ imageName,
483
+ "bash",
484
+ "-lc",
485
+ 'set -e; which pnpm >/dev/null && printf "HOME=%s\\nXDG_CACHE_HOME=%s\\nTURBO_CACHE_DIR=%s\\n" "$HOME" "$XDG_CACHE_HOME" "$TURBO_CACHE_DIR"',
486
+ ], { reject: false });
487
+ const out = probe.stdout ?? "";
488
+ const missing = [];
489
+ if (probe.exitCode !== 0)
490
+ missing.push("pnpm");
491
+ if (!/^HOME=\/home\/agent\s*$/m.test(out))
492
+ missing.push("HOME");
493
+ if (!/^XDG_CACHE_HOME=\/home\/agent\/.cache\s*$/m.test(out)) {
494
+ missing.push("XDG_CACHE_HOME");
495
+ }
496
+ if (!/^TURBO_CACHE_DIR=\/tmp\/turbo-cache\s*$/m.test(out)) {
497
+ missing.push("TURBO_CACHE_DIR");
498
+ }
499
+ if (missing.length === 0) {
500
+ checks.set("container_ready", {
501
+ status: "ok",
502
+ label: "container readiness",
503
+ detail: "pnpm on PATH; HOME, XDG_CACHE_HOME, TURBO_CACHE_DIR set",
504
+ });
505
+ }
506
+ else {
507
+ checks.set("container_ready", {
508
+ status: "warn",
509
+ label: "container readiness",
510
+ detail: `missing or wrong inside container: ${missing.join(", ")} — rebuild via \`runway upgrade-repo && docker build .sandcastle -t ${imageName}\``,
511
+ });
512
+ }
513
+ }
514
+ catch (err) {
515
+ checks.set("container_ready", {
516
+ status: "warn",
517
+ label: "container readiness",
518
+ detail: `probe failed: ${errMsg(err)}`,
519
+ });
520
+ }
373
521
  }
374
522
  catch (err) {
375
523
  checks.set("image_present", {
@@ -380,6 +528,134 @@ async function checkDockerImage(cwd) {
380
528
  }
381
529
  return { title: "Docker image", checks, ran: true };
382
530
  }
531
+ // ---------------------------------------------------------------------------
532
+ // Section: Linear configuration (VA-354)
533
+ // ---------------------------------------------------------------------------
534
+ /**
535
+ * Validate that the team, workflow states, and HITL label `runway run`
536
+ * would use actually exist on the Linear workspace. Without this,
537
+ * misconfiguration only surfaces deep inside a long agent run — too
538
+ * late to fix without losing the work.
539
+ */
540
+ async function checkLinearConfig() {
541
+ const checks = new Map();
542
+ // The config loader's only hard requirement is LINEAR_API_KEY; the
543
+ // rest defaults. If the key is missing, the Environment section
544
+ // already fails — surface a skip here rather than re-failing.
545
+ if (!process.env.LINEAR_API_KEY) {
546
+ checks.set("linear_config", {
547
+ status: "skip",
548
+ label: "Linear config",
549
+ detail: "LINEAR_API_KEY unset — skipped",
550
+ });
551
+ return { title: "Linear configuration", checks, ran: true };
552
+ }
553
+ let config;
554
+ try {
555
+ config = loadConfig();
556
+ }
557
+ catch (err) {
558
+ checks.set("linear_config", {
559
+ status: "fail",
560
+ label: "Linear config",
561
+ detail: `failed to load runway config: ${errMsg(err)}`,
562
+ });
563
+ return { title: "Linear configuration", checks, ran: true };
564
+ }
565
+ let result;
566
+ try {
567
+ result = await validateLinearConfig(config);
568
+ }
569
+ catch (err) {
570
+ checks.set("linear_api", {
571
+ status: "fail",
572
+ label: "Linear API",
573
+ detail: `validation request failed: ${errMsg(err)}`,
574
+ });
575
+ return { title: "Linear configuration", checks, ran: true };
576
+ }
577
+ if (result.team.kind === "missing") {
578
+ checks.set("team", {
579
+ status: "fail",
580
+ label: `team ${config.linearTeam}`,
581
+ detail: `Linear team key "${result.team.key}" not found — set RUNWAY_LINEAR_TEAM`,
582
+ });
583
+ // States/labels are skipped when the team missing; surface
584
+ // explicitly so the user knows they weren't checked.
585
+ checks.set("states", {
586
+ status: "skip",
587
+ label: "workflow states",
588
+ detail: "skipped (team missing)",
589
+ });
590
+ checks.set("hitl_label", {
591
+ status: "skip",
592
+ label: "HITL label",
593
+ detail: "skipped (team missing)",
594
+ });
595
+ return { title: "Linear configuration", checks, ran: true };
596
+ }
597
+ checks.set("team", {
598
+ status: "ok",
599
+ label: `team ${config.linearTeam}`,
600
+ detail: `id=${result.team.id}`,
601
+ });
602
+ for (const [key, configured, state] of [
603
+ ["ready_state", config.readyStatus, result.readyStatus],
604
+ ["in_progress_state", config.inProgressStatus, result.inProgressStatus],
605
+ ["in_review_state", config.inReviewStatus, result.inReviewStatus],
606
+ ]) {
607
+ if (state.kind === "ok") {
608
+ checks.set(key, {
609
+ status: "ok",
610
+ label: `workflow state "${configured}"`,
611
+ detail: "present",
612
+ });
613
+ }
614
+ else if (state.kind === "skipped") {
615
+ checks.set(key, {
616
+ status: "skip",
617
+ label: `workflow state "${configured}"`,
618
+ detail: state.reason,
619
+ });
620
+ }
621
+ else {
622
+ checks.set(key, {
623
+ status: "fail",
624
+ label: `workflow state "${configured}"`,
625
+ detail: `not found on team; available: ${formatList(state.available)}`,
626
+ });
627
+ }
628
+ }
629
+ if (result.hitlLabel.kind === "ok") {
630
+ checks.set("hitl_label", {
631
+ status: "ok",
632
+ label: `HITL label "${config.hitlLabel}"`,
633
+ detail: "present",
634
+ });
635
+ }
636
+ else if (result.hitlLabel.kind === "skipped") {
637
+ checks.set("hitl_label", {
638
+ status: "skip",
639
+ label: `HITL label "${config.hitlLabel}"`,
640
+ detail: result.hitlLabel.reason,
641
+ });
642
+ }
643
+ else {
644
+ checks.set("hitl_label", {
645
+ status: "fail",
646
+ label: `HITL label "${config.hitlLabel}"`,
647
+ detail: `not found on team — set RUNWAY_HITL_LABEL or create the label. Available: ${formatList(result.hitlLabel.available)}`,
648
+ });
649
+ }
650
+ return { title: "Linear configuration", checks, ran: true };
651
+ }
652
+ function formatList(items) {
653
+ if (items.length === 0)
654
+ return "(none)";
655
+ if (items.length <= 8)
656
+ return items.join(", ");
657
+ return `${items.slice(0, 8).join(", ")}, …(+${items.length - 8} more)`;
658
+ }
383
659
  /**
384
660
  * Sanitize the cwd's basename the same way sandcastle's `defaultImageName`
385
661
  * does: lowercase, replace any char outside `[a-z0-9_.-]` with `-`, fall
@@ -5,6 +5,10 @@ import { execa } from "execa";
5
5
  const __dirname = dirname(fileURLToPath(import.meta.url));
6
6
  // runway/src/commands/init.ts → runway/templates/
7
7
  const TEMPLATES_DIR = join(__dirname, "..", "..", "templates");
8
+ const AUTH_MODE_ENV_VAR = {
9
+ "api-key": "ANTHROPIC_API_KEY",
10
+ oauth: "CLAUDE_CODE_OAUTH_TOKEN",
11
+ };
8
12
  export function printInitUsage() {
9
13
  console.log(`runway init — scaffold a target repo for runway consumption
10
14
 
@@ -23,8 +27,18 @@ OPTIONS
23
27
  --tier=2 DEFAULT. Adds varlock + 1Password CLI inside the
24
28
  container. Zero secrets at rest.
25
29
  --op-vault=NAME 1Password vault name (e.g. "runway"). Required for tier 2.
26
- --anthropic-item=N Item name in the vault that holds ANTHROPIC_API_KEY. Required for tier 2.
30
+ --anthropic-item=N Item name in the vault that holds the Claude Code
31
+ credential (ANTHROPIC_API_KEY or
32
+ CLAUDE_CODE_OAUTH_TOKEN — see --auth-mode).
33
+ Required for tier 2.
27
34
  --gh-token-item=N Item name in the vault that holds GH_TOKEN. Required for tier 2.
35
+ --auth-mode=MODE How Claude Code authenticates inside the
36
+ container. \`api-key\` (default) writes the
37
+ ANTHROPIC_API_KEY env var for pay-per-token API
38
+ keys (sk-ant-api03-…). \`oauth\` writes
39
+ CLAUDE_CODE_OAUTH_TOKEN for Pro/Max
40
+ subscription tokens from \`claude setup-token\`
41
+ (sk-ant-oat01-…). They are NOT interchangeable.
28
42
  --allow-dirty Skip the "working tree clean" preflight check.
29
43
  --force Overwrite an existing .sandcastle/Dockerfile.
30
44
  --skip-build Don't \`docker build\` the agent image. Faster init,
@@ -63,6 +77,7 @@ function parseInitArgs(argv) {
63
77
  let opVault;
64
78
  let anthropicItem;
65
79
  let ghTokenItem;
80
+ let authMode = "api-key";
66
81
  let allowDirty = false;
67
82
  let force = false;
68
83
  let skipBuild = false;
@@ -95,6 +110,13 @@ function parseInitArgs(argv) {
95
110
  else if (arg.startsWith("--gh-token-item=")) {
96
111
  ghTokenItem = arg.slice("--gh-token-item=".length);
97
112
  }
113
+ else if (arg.startsWith("--auth-mode=")) {
114
+ const v = arg.slice("--auth-mode=".length);
115
+ if (v !== "api-key" && v !== "oauth") {
116
+ throw new Error(`--auth-mode must be "api-key" or "oauth", got "${v}"`);
117
+ }
118
+ authMode = v;
119
+ }
98
120
  else {
99
121
  throw new Error(`unknown argument: ${arg}`);
100
122
  }
@@ -116,6 +138,7 @@ function parseInitArgs(argv) {
116
138
  opVault,
117
139
  anthropicItem,
118
140
  ghTokenItem,
141
+ authMode,
119
142
  allowDirty,
120
143
  force,
121
144
  skipBuild,
@@ -277,12 +300,14 @@ export async function applyVarlockLayer(cwd, opts) {
277
300
  writeFileSync(`${schemaPath}.bak`, readFileSync(schemaPath, "utf8"));
278
301
  }
279
302
  const schemaTemplate = readFileSync(join(TEMPLATES_DIR, ".env.schema.target-repo"), "utf8");
303
+ const anthropicEnvVar = AUTH_MODE_ENV_VAR[opts.authMode];
280
304
  const rendered = schemaTemplate
281
305
  .replaceAll("{{OP_VAULT}}", opts.opVault)
282
306
  .replaceAll("{{ANTHROPIC_ITEM}}", opts.anthropicItem)
283
- .replaceAll("{{GH_TOKEN_ITEM}}", opts.ghTokenItem);
307
+ .replaceAll("{{GH_TOKEN_ITEM}}", opts.ghTokenItem)
308
+ .replaceAll("{{ANTHROPIC_ENV_VAR}}", anthropicEnvVar);
284
309
  writeFileSync(schemaPath, rendered);
285
- console.log(` ✓ wrote .env.schema (op://${opts.opVault}/...)`);
310
+ console.log(` ✓ wrote .env.schema (auth-mode=${opts.authMode}, ${anthropicEnvVar}, op://${opts.opVault}/...)`);
286
311
  // 2. Patch Dockerfile.
287
312
  const dockerfilePath = join(cwd, ".sandcastle", "Dockerfile");
288
313
  if (!existsSync(dockerfilePath)) {
@@ -361,11 +386,12 @@ export async function verify(cwd, opts) {
361
386
  if (!existsSync(schemaPath))
362
387
  fail(".env.schema missing at repo root (tier 2 requires it)");
363
388
  const schema = readFileSync(schemaPath, "utf8");
364
- if (!schema.includes("ANTHROPIC_API_KEY="))
365
- fail(".env.schema missing ANTHROPIC_API_KEY");
389
+ const anthropicEnvVar = AUTH_MODE_ENV_VAR[opts.authMode];
390
+ if (!schema.includes(`${anthropicEnvVar}=`))
391
+ fail(`.env.schema missing ${anthropicEnvVar} (auth-mode=${opts.authMode})`);
366
392
  if (!schema.includes("GH_TOKEN="))
367
393
  fail(".env.schema missing GH_TOKEN");
368
- ok(".env.schema declares ANTHROPIC_API_KEY + GH_TOKEN");
394
+ ok(`.env.schema declares ${anthropicEnvVar} + GH_TOKEN`);
369
395
  // Inline secret shape check.
370
396
  const secretRe = /(sk-ant-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9]{20,}|lin_api_[A-Za-z0-9]{20,})/;
371
397
  if (secretRe.test(schema)) {