@valescoagency/runway 0.1.2 → 0.3.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
@@ -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,13 +75,16 @@ 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
78
83
  (`OP_SERVICE_ACCOUNT_TOKEN`) exclusively, and the token already
79
84
  encodes the tenant. `op://` URIs runway writes are
80
- `op://<vault>/<item>`, not `op://<account>/<vault>/<item>`.)
85
+ `op://<vault>/<item>/credential`, not `op://<account>/<vault>/<item>`.
86
+ The `/credential` field selector is required for `API_CREDENTIAL`
87
+ items, which is the canonical 1Password category for API keys.)
81
88
 
82
89
  This runs `npx sandcastle init`, patches the generated `.sandcastle/Dockerfile`
83
90
  to bake in `varlock` + the 1Password CLI + a `claude` shim, scaffolds
@@ -90,6 +97,37 @@ and no varlock (faster but secrets land on disk).
90
97
 
91
98
  Architecture walkthrough: [`docs/secrets-with-varlock.md`](docs/secrets-with-varlock.md).
92
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
+
93
131
  ## Secrets — recommended: varlock + 1Password
94
132
 
95
133
  If you don't want any secret sitting at rest in any `.env` file,
@@ -118,6 +156,8 @@ Export runway's own env (in your shell rc, or wherever you keep API keys):
118
156
  export LINEAR_API_KEY=lin_api_...
119
157
  # Optional overrides:
120
158
  # export RUNWAY_LINEAR_TEAM=VA
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
121
161
  # export RUNWAY_READY_STATUS="Todo"
122
162
  # export RUNWAY_IN_PROGRESS_STATUS="In Progress"
123
163
  # export RUNWAY_IN_REVIEW_STATUS="In Review"
@@ -137,6 +177,38 @@ pnpm link --global # so `runway` is on your $PATH
137
177
 
138
178
  `pnpm dev -- <args>` runs the TypeScript source via `tsx` without building, useful while iterating on runway itself.
139
179
 
180
+ #### Tests
181
+
182
+ ```bash
183
+ pnpm test # one-shot run, used by CI
184
+ pnpm test:watch # watch mode for local iteration
185
+ ```
186
+
187
+ Vitest is the harness; tests live colocated with the source as
188
+ `*.test.ts` files (e.g. `src/git.test.ts` next to `src/git.ts`). CI
189
+ runs `pnpm typecheck && pnpm test` on every PR via
190
+ `.github/workflows/ci.yml`.
191
+
192
+ When adding logic that has a sharp pass/fail signal, add a test next
193
+ to it. The seed suite covers `parseRunArgs`, `detectBaseBranch`, the
194
+ `parseOpRefs` regex extraction, and the `drainQueue` error-handler
195
+ branches — copy any of those as a shape for new tests.
196
+
197
+ #### Git hooks (lefthook + commitlint)
198
+
199
+ Hooks install automatically on `pnpm install` via the `prepare`
200
+ script. What runs and when:
201
+
202
+ | Hook | Runs | Why |
203
+ |---|---|---|
204
+ | `pre-commit` | `pnpm typecheck` | Catch TS errors before they land on a branch. |
205
+ | `commit-msg` | `pnpm exec commitlint --edit` | Reject non-conventional commit messages (CLAUDE.md convention). |
206
+ | `pre-push` | `pnpm test` | Block pushing red. |
207
+
208
+ Skip a single hook invocation with `LEFTHOOK=0 git commit …` (or
209
+ `… git push …`). To re-install after editing `lefthook.yml`, run
210
+ `pnpm exec lefthook install -f`.
211
+
140
212
  ## Usage
141
213
 
142
214
  ```bash
@@ -157,18 +229,38 @@ per-issue comments for what happened.
157
229
  Runway picks up issues that are:
158
230
 
159
231
  - in team `RUNWAY_LINEAR_TEAM` (default `VA`)
232
+ - (optionally) in project `RUNWAY_LINEAR_PROJECT` (override per-run
233
+ with `runway run --project=<id-or-slug-or-name>`; unset = team-wide)
160
234
  - in workflow state `RUNWAY_READY_STATUS` (default `Todo`)
161
235
 
162
236
  It transitions them through:
163
237
 
164
- - `In Progress` while the agent is running
238
+ - `In Progress` while the agent is running (specifically: once the
239
+ agent has committed to its branch — startup failures before any
240
+ commits revert the issue back to `Todo` rather than stranding it)
165
241
  - `In Review` when the PR opens
166
- - (label `needs-human`) if the agent or reviewer can't finish
242
+ - (label `needs-human`) if the agent or reviewer can't finish *after*
243
+ the agent has committed real work
167
244
 
168
245
  These names are configurable per env var; the queries match by name so
169
246
  your Linear workspace's actual state names need to line up with what
170
247
  you set.
171
248
 
249
+ ## Base branch
250
+
251
+ Runway auto-detects the repo's default branch at the start of every
252
+ `runway run` by reading `origin/HEAD` (with `git remote show origin`
253
+ as a fallback for fresh clones). That branch is used for diffing the
254
+ agent's work, counting commits when deciding whether a startup
255
+ failure should revert to `Todo`, and as the `--base` for the PR.
256
+
257
+ Set `RUNWAY_BASE_BRANCH=<name>` to override detection — useful when
258
+ you want runway to target a release branch instead of the default, or
259
+ when `origin/HEAD` isn't set and you don't want to run
260
+ `git remote set-head origin --auto`. `runway doctor` surfaces the
261
+ resolved base branch (detected or overridden) in its Environment
262
+ section.
263
+
172
264
  ## Sub-agent review
173
265
 
174
266
  Every implementation run is followed by a fresh Sandcastle run with
@@ -1,6 +1,7 @@
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";
4
5
  // ---------------------------------------------------------------------------
5
6
  // Usage
6
7
  // ---------------------------------------------------------------------------
@@ -83,7 +84,7 @@ export async function doctorCommand(argv) {
83
84
  const sections = [];
84
85
  sections.push(await checkHostTooling(tierForToolingChecks));
85
86
  if (initialised || opts.tierOverride !== undefined) {
86
- sections.push(checkEnvironment(tierForToolingChecks));
87
+ sections.push(await checkEnvironment(tierForToolingChecks, cwd, repo));
87
88
  sections.push(await checkRepoState(cwd, repo));
88
89
  sections.push(await checkDockerImage(cwd));
89
90
  }
@@ -113,11 +114,24 @@ function detectRepoState(cwd) {
113
114
  const hasDockerfile = existsSync(join(cwd, ".sandcastle", "Dockerfile"));
114
115
  const hasSchema = existsSync(join(cwd, ".env.schema"));
115
116
  let tier = null;
117
+ let authMode = null;
118
+ let hasConflictingAuthVars = false;
116
119
  if (hasSchema) {
117
120
  try {
118
121
  const schema = readFileSync(join(cwd, ".env.schema"), "utf8");
119
- if (/ANTHROPIC_API_KEY\s*=\s*exec\(/.test(schema)) {
122
+ const hasApiKey = /ANTHROPIC_API_KEY\s*=\s*exec\(/.test(schema);
123
+ const hasOauth = /CLAUDE_CODE_OAUTH_TOKEN\s*=\s*exec\(/.test(schema);
124
+ if (hasApiKey && hasOauth) {
120
125
  tier = 2;
126
+ hasConflictingAuthVars = true;
127
+ }
128
+ else if (hasApiKey) {
129
+ tier = 2;
130
+ authMode = "api-key";
131
+ }
132
+ else if (hasOauth) {
133
+ tier = 2;
134
+ authMode = "oauth";
121
135
  }
122
136
  else if (hasDockerfile) {
123
137
  tier = 1;
@@ -130,7 +144,7 @@ function detectRepoState(cwd) {
130
144
  else if (hasDockerfile) {
131
145
  tier = 1;
132
146
  }
133
- return { tier, hasDockerfile, hasSchema };
147
+ return { tier, hasDockerfile, hasSchema, authMode, hasConflictingAuthVars };
134
148
  }
135
149
  // ---------------------------------------------------------------------------
136
150
  // Section: Host tooling
@@ -229,12 +243,83 @@ async function checkGhAuth() {
229
243
  // ---------------------------------------------------------------------------
230
244
  // Section: Environment
231
245
  // ---------------------------------------------------------------------------
232
- function checkEnvironment(tier) {
246
+ async function checkEnvironment(tier, cwd, repo) {
233
247
  const checks = new Map();
234
248
  checks.set("LINEAR_API_KEY", envSet("LINEAR_API_KEY", "fail"));
249
+ // Informational: which Linear scope a `runway run` would use.
250
+ const team = process.env.RUNWAY_LINEAR_TEAM?.trim() || "VA";
251
+ const project = process.env.RUNWAY_LINEAR_PROJECT?.trim();
252
+ checks.set("linear_scope", {
253
+ status: "ok",
254
+ label: "linear scope",
255
+ detail: project
256
+ ? `team ${team} / project ${project}`
257
+ : `team ${team} (team-wide — RUNWAY_LINEAR_PROJECT unset)`,
258
+ });
259
+ // Informational: which base branch a `runway run` would diff against
260
+ // and target with PRs. Detection failure here is a real problem —
261
+ // surface it as a fail so the user knows up front.
262
+ const override = process.env.RUNWAY_BASE_BRANCH?.trim();
263
+ if (override) {
264
+ checks.set("base_branch", {
265
+ status: "ok",
266
+ label: "base branch",
267
+ detail: `${override} (RUNWAY_BASE_BRANCH override)`,
268
+ });
269
+ }
270
+ else {
271
+ try {
272
+ const detected = await detectBaseBranch(cwd);
273
+ checks.set("base_branch", {
274
+ status: "ok",
275
+ label: "base branch",
276
+ detail: `${detected} (detected from origin/HEAD)`,
277
+ });
278
+ }
279
+ catch (err) {
280
+ checks.set("base_branch", {
281
+ status: "fail",
282
+ label: "base branch",
283
+ detail: errMsg(err),
284
+ });
285
+ }
286
+ }
235
287
  if (tier === 2) {
236
288
  // Tier 2: needed by varlock to resolve op:// refs in the container.
237
289
  checks.set("OP_SERVICE_ACCOUNT_TOKEN", envSet("OP_SERVICE_ACCOUNT_TOKEN", "fail"));
290
+ // Surface which Claude Code auth env var the .env.schema declares.
291
+ // ANTHROPIC_API_KEY and CLAUDE_CODE_OAUTH_TOKEN aren't
292
+ // interchangeable; a mismatch between this and what's stored in
293
+ // 1Password yields a generic "Invalid API key" inside the
294
+ // container with no useful diagnostic.
295
+ if (repo.hasConflictingAuthVars) {
296
+ checks.set("auth_mode", {
297
+ status: "fail",
298
+ label: "claude auth mode",
299
+ detail: ".env.schema declares both ANTHROPIC_API_KEY and CLAUDE_CODE_OAUTH_TOKEN — pick one (they are not interchangeable)",
300
+ });
301
+ }
302
+ else if (repo.authMode === "oauth") {
303
+ checks.set("auth_mode", {
304
+ status: "ok",
305
+ label: "claude auth mode",
306
+ detail: "oauth (CLAUDE_CODE_OAUTH_TOKEN — Pro/Max subscription)",
307
+ });
308
+ }
309
+ else if (repo.authMode === "api-key") {
310
+ checks.set("auth_mode", {
311
+ status: "ok",
312
+ label: "claude auth mode",
313
+ detail: "api-key (ANTHROPIC_API_KEY — pay-per-token)",
314
+ });
315
+ }
316
+ else {
317
+ checks.set("auth_mode", {
318
+ status: "fail",
319
+ label: "claude auth mode",
320
+ detail: ".env.schema declares neither ANTHROPIC_API_KEY nor CLAUDE_CODE_OAUTH_TOKEN",
321
+ });
322
+ }
238
323
  }
239
324
  return { title: "Environment", checks, ran: true };
240
325
  }
@@ -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,
@@ -35,8 +49,10 @@ NOTE
35
49
  No --op-account flag — runway uses 1Password service-account auth
36
50
  exclusively (OP_SERVICE_ACCOUNT_TOKEN). The token already encodes
37
51
  which 1Password tenant to talk to, so the op:// URI omits the
38
- account segment: \`op://<vault>/<item>\` rather than
39
- \`op://<account>/<vault>/<item>\`.
52
+ account segment: \`op://<vault>/<item>/<field>\` rather than
53
+ \`op://<account>/<vault>/<item>/<field>\`. Runway hard-codes the
54
+ \`credential\` field, which is the canonical field name on
55
+ 1Password API_CREDENTIAL items.
40
56
 
41
57
  WHAT THIS COMMAND DOES
42
58
  1. Preflight: docker, gh, node, (tier 2) varlock + op CLI, git state.
@@ -61,6 +77,7 @@ function parseInitArgs(argv) {
61
77
  let opVault;
62
78
  let anthropicItem;
63
79
  let ghTokenItem;
80
+ let authMode = "api-key";
64
81
  let allowDirty = false;
65
82
  let force = false;
66
83
  let skipBuild = false;
@@ -93,6 +110,13 @@ function parseInitArgs(argv) {
93
110
  else if (arg.startsWith("--gh-token-item=")) {
94
111
  ghTokenItem = arg.slice("--gh-token-item=".length);
95
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
+ }
96
120
  else {
97
121
  throw new Error(`unknown argument: ${arg}`);
98
122
  }
@@ -114,6 +138,7 @@ function parseInitArgs(argv) {
114
138
  opVault,
115
139
  anthropicItem,
116
140
  ghTokenItem,
141
+ authMode,
117
142
  allowDirty,
118
143
  force,
119
144
  skipBuild,
@@ -275,12 +300,14 @@ export async function applyVarlockLayer(cwd, opts) {
275
300
  writeFileSync(`${schemaPath}.bak`, readFileSync(schemaPath, "utf8"));
276
301
  }
277
302
  const schemaTemplate = readFileSync(join(TEMPLATES_DIR, ".env.schema.target-repo"), "utf8");
303
+ const anthropicEnvVar = AUTH_MODE_ENV_VAR[opts.authMode];
278
304
  const rendered = schemaTemplate
279
305
  .replaceAll("{{OP_VAULT}}", opts.opVault)
280
306
  .replaceAll("{{ANTHROPIC_ITEM}}", opts.anthropicItem)
281
- .replaceAll("{{GH_TOKEN_ITEM}}", opts.ghTokenItem);
307
+ .replaceAll("{{GH_TOKEN_ITEM}}", opts.ghTokenItem)
308
+ .replaceAll("{{ANTHROPIC_ENV_VAR}}", anthropicEnvVar);
282
309
  writeFileSync(schemaPath, rendered);
283
- console.log(` ✓ wrote .env.schema (op://${opts.opVault}/...)`);
310
+ console.log(` ✓ wrote .env.schema (auth-mode=${opts.authMode}, ${anthropicEnvVar}, op://${opts.opVault}/...)`);
284
311
  // 2. Patch Dockerfile.
285
312
  const dockerfilePath = join(cwd, ".sandcastle", "Dockerfile");
286
313
  if (!existsSync(dockerfilePath)) {
@@ -359,11 +386,12 @@ export async function verify(cwd, opts) {
359
386
  if (!existsSync(schemaPath))
360
387
  fail(".env.schema missing at repo root (tier 2 requires it)");
361
388
  const schema = readFileSync(schemaPath, "utf8");
362
- if (!schema.includes("ANTHROPIC_API_KEY="))
363
- 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})`);
364
392
  if (!schema.includes("GH_TOKEN="))
365
393
  fail(".env.schema missing GH_TOKEN");
366
- ok(".env.schema declares ANTHROPIC_API_KEY + GH_TOKEN");
394
+ ok(`.env.schema declares ${anthropicEnvVar} + GH_TOKEN`);
367
395
  // Inline secret shape check.
368
396
  const secretRe = /(sk-ant-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9]{20,}|lin_api_[A-Za-z0-9]{20,})/;
369
397
  if (secretRe.test(schema)) {
@@ -2,7 +2,7 @@ import { loadConfig } from "../config.js";
2
2
  import { createLinearGateway } from "../linear.js";
3
3
  import { createGithubGateway } from "../github.js";
4
4
  import { assertSandcastleInitialised, drainQueue, } from "../orchestrator.js";
5
- function parseRunArgs(argv) {
5
+ export function parseRunArgs(argv) {
6
6
  const opts = {};
7
7
  for (let i = 0; i < argv.length; i += 1) {
8
8
  const a = argv[i];
@@ -17,6 +17,16 @@ function parseRunArgs(argv) {
17
17
  opts.max = n;
18
18
  i += 1;
19
19
  }
20
+ else if (a === "--project") {
21
+ const v = argv[i + 1];
22
+ if (!v)
23
+ throw new Error("--project requires a value");
24
+ opts.project = v;
25
+ i += 1;
26
+ }
27
+ else if (a?.startsWith("--project=")) {
28
+ opts.project = a.slice("--project=".length);
29
+ }
20
30
  else if (a === "--help" || a === "-h") {
21
31
  printRunUsage();
22
32
  process.exit(0);
@@ -36,11 +46,19 @@ USAGE
36
46
 
37
47
  OPTIONS
38
48
  --max, -n N Process at most N issues then exit. Default: drain queue.
49
+ --project ID Scope the queue to a single Linear project under the
50
+ team. Accepts project UUID, slug, or name. Overrides
51
+ RUNWAY_LINEAR_PROJECT. Default: team-wide.
39
52
  --help, -h Show this help.
40
53
 
41
54
  ENVIRONMENT
42
55
  LINEAR_API_KEY required
43
56
  RUNWAY_LINEAR_TEAM default "VA"
57
+ RUNWAY_LINEAR_PROJECT optional — scope to one project
58
+ RUNWAY_BASE_BRANCH optional — override the auto-detected base
59
+ branch (the branch runway diffs against
60
+ and targets with PRs). Detected from
61
+ origin/HEAD when unset.
44
62
  RUNWAY_READY_STATUS default "Todo"
45
63
  RUNWAY_IN_PROGRESS_STATUS default "In Progress"
46
64
  RUNWAY_IN_REVIEW_STATUS default "In Review"
@@ -52,10 +70,16 @@ export async function runCommand(argv) {
52
70
  const opts = parseRunArgs(argv);
53
71
  const cwd = process.cwd();
54
72
  assertSandcastleInitialised(cwd);
55
- const config = loadConfig();
73
+ const baseConfig = loadConfig();
74
+ const config = opts.project
75
+ ? { ...baseConfig, linearProject: opts.project }
76
+ : baseConfig;
56
77
  const linear = createLinearGateway(config);
57
78
  const github = createGithubGateway();
58
- console.log(`[runway] draining queue from team ${config.linearTeam} (status="${config.readyStatus}") against ${cwd}`);
79
+ const scope = config.linearProject
80
+ ? `team ${config.linearTeam} / project ${config.linearProject}`
81
+ : `team ${config.linearTeam}`;
82
+ console.log(`[runway] draining queue from ${scope} (status="${config.readyStatus}") against ${cwd}`);
59
83
  const result = await drainQueue({ config, linear, github, cwd }, { max: opts.max });
60
84
  console.log(`[runway] done — processed=${result.processed} opened=${result.opened} hitl=${result.hitl} errored=${result.errored}`);
61
85
  }
@@ -27,7 +27,9 @@ OPTIONS
27
27
  lines outside the runway templates (manual edits).
28
28
  --op-vault=NAME Override the 1Password vault. By default, upgrade-repo
29
29
  extracts this from the existing .env.schema.
30
- --anthropic-item=N Override the ANTHROPIC_API_KEY item name.
30
+ --anthropic-item=N Override the Claude Code credential item name
31
+ (i.e. the ANTHROPIC_API_KEY or
32
+ CLAUDE_CODE_OAUTH_TOKEN 1Password item).
31
33
  --gh-token-item=N Override the GH_TOKEN item name.
32
34
  --help, -h Show this help.
33
35
 
@@ -118,6 +120,7 @@ export async function upgradeRepoCommand(argv) {
118
120
  opVault: "placeholder",
119
121
  anthropicItem: "placeholder",
120
122
  ghTokenItem: "placeholder",
123
+ authMode: "api-key",
121
124
  };
122
125
  await preflight(cwd, preflightOpts);
123
126
  // Render new file contents in memory.
@@ -165,6 +168,7 @@ export async function upgradeRepoCommand(argv) {
165
168
  opVault: resolved?.opVault ?? "placeholder",
166
169
  anthropicItem: resolved?.anthropicItem ?? "placeholder",
167
170
  ghTokenItem: resolved?.ghTokenItem ?? "placeholder",
171
+ authMode: resolved?.authMode ?? "api-key",
168
172
  };
169
173
  await verify(cwd, verifyOpts);
170
174
  console.log(`[runway upgrade-repo] done — tier ${tier} scaffold refreshed`);
@@ -180,8 +184,10 @@ function detectTier(cwd) {
180
184
  }
181
185
  if (existsSync(schemaPath)) {
182
186
  const schema = readFileSync(schemaPath, "utf8");
183
- // Tier-2 marker: ANTHROPIC_API_KEY uses the varlock shell-call form.
184
- const tier2Marker = new RegExp(`ANTHROPIC_API_KEY\\s*=\\s*${execName()}\\(`);
187
+ // Tier-2 marker: the Claude Code credential (whichever env var
188
+ // name the user picked at init time) uses the varlock shell-call
189
+ // form.
190
+ const tier2Marker = new RegExp(`(ANTHROPIC_API_KEY|CLAUDE_CODE_OAUTH_TOKEN)\\s*=\\s*${execName()}\\(`);
185
191
  if (tier2Marker.test(schema)) {
186
192
  return 2;
187
193
  }
@@ -197,17 +203,29 @@ function execName() {
197
203
  // ---------------------------------------------------------------------------
198
204
  // op:// extraction
199
205
  // ---------------------------------------------------------------------------
200
- const ANTHROPIC_RE = new RegExp(`^\\s*ANTHROPIC_API_KEY\\s*=\\s*${execName()}\\(\\s*['"]op read "op://([^/"]+)/([^"]+)"['"]\\s*\\)\\s*$`, "m");
206
+ // Either ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN capture which
207
+ // it is so upgrade-repo can re-render in the same auth mode.
208
+ const ANTHROPIC_RE = new RegExp(`^\\s*(ANTHROPIC_API_KEY|CLAUDE_CODE_OAUTH_TOKEN)\\s*=\\s*${execName()}\\(\\s*['"]op read "op://([^/"]+)/([^"]+)"['"]\\s*\\)\\s*$`, "m");
201
209
  const GH_TOKEN_RE = new RegExp(`^\\s*GH_TOKEN\\s*=\\s*${execName()}\\(\\s*['"]op read "op://([^/"]+)/([^"]+)"['"]\\s*\\)\\s*$`, "m");
202
210
  function resolveOpRefs(cwd, opts) {
203
211
  const schemaPath = join(cwd, ".env.schema");
204
212
  const schema = readFileSync(schemaPath, "utf8");
213
+ return parseOpRefs(schema, opts);
214
+ }
215
+ /**
216
+ * Pure schema-string → ResolvedOpRefs parser. Split out from
217
+ * `resolveOpRefs` so it can be unit-tested without touching the
218
+ * filesystem. The regex captures + override / vault-mismatch logic
219
+ * live here; the disk read lives in `resolveOpRefs`.
220
+ */
221
+ export function parseOpRefs(schema, opts) {
205
222
  const anthropicMatch = schema.match(ANTHROPIC_RE);
206
223
  const ghTokenMatch = schema.match(GH_TOKEN_RE);
207
224
  // Per-field override > extracted > error.
208
- const opVault = opts.opVault ?? anthropicMatch?.[1] ?? ghTokenMatch?.[1] ?? null;
209
- const anthropicItem = opts.anthropicItem ?? anthropicMatch?.[2] ?? null;
225
+ const opVault = opts.opVault ?? anthropicMatch?.[2] ?? ghTokenMatch?.[1] ?? null;
226
+ const anthropicItem = opts.anthropicItem ?? anthropicMatch?.[3] ?? null;
210
227
  const ghTokenItem = opts.ghTokenItem ?? ghTokenMatch?.[2] ?? null;
228
+ const authMode = anthropicMatch?.[1] === "CLAUDE_CODE_OAUTH_TOKEN" ? "oauth" : "api-key";
211
229
  if (!opVault || !anthropicItem || !ghTokenItem) {
212
230
  throw new Error("could not parse existing .env.schema; pass --op-vault, --anthropic-item, --gh-token-item explicitly to override.");
213
231
  }
@@ -217,10 +235,10 @@ function resolveOpRefs(cwd, opts) {
217
235
  if (!opts.opVault &&
218
236
  anthropicMatch &&
219
237
  ghTokenMatch &&
220
- anthropicMatch[1] !== ghTokenMatch[1]) {
221
- throw new Error(`vault mismatch in .env.schema: ANTHROPIC_API_KEY uses "${anthropicMatch[1]}", GH_TOKEN uses "${ghTokenMatch[1]}". Pass --op-vault to disambiguate.`);
238
+ anthropicMatch[2] !== ghTokenMatch[1]) {
239
+ throw new Error(`vault mismatch in .env.schema: ${anthropicMatch[1]} uses "${anthropicMatch[2]}", GH_TOKEN uses "${ghTokenMatch[1]}". Pass --op-vault to disambiguate.`);
222
240
  }
223
- return { opVault, anthropicItem, ghTokenItem };
241
+ return { opVault, anthropicItem, ghTokenItem, authMode };
224
242
  }
225
243
  // ---------------------------------------------------------------------------
226
244
  // Render: Dockerfile
@@ -270,15 +288,25 @@ function renderEnvSchema(cwd, resolved) {
270
288
  const schemaPath = join(cwd, ".env.schema");
271
289
  const before = readFileSync(schemaPath, "utf8");
272
290
  const tmpl = readFileSync(join(TEMPLATES_DIR, ".env.schema.target-repo"), "utf8");
291
+ const anthropicEnvVar = resolved.authMode === "oauth" ? "CLAUDE_CODE_OAUTH_TOKEN" : "ANTHROPIC_API_KEY";
273
292
  let body = tmpl
274
293
  .replaceAll("{{OP_VAULT}}", resolved.opVault)
275
294
  .replaceAll("{{ANTHROPIC_ITEM}}", resolved.anthropicItem)
276
- .replaceAll("{{GH_TOKEN_ITEM}}", resolved.ghTokenItem);
277
- // Preserve user-added `KEY=<call>(...)` lines for keys other than the two
278
- // we own. Match any `KEY = <execName>(` line in the existing schema and
279
- // append the whole line if its key isn't ANTHROPIC_API_KEY or GH_TOKEN.
295
+ .replaceAll("{{GH_TOKEN_ITEM}}", resolved.ghTokenItem)
296
+ .replaceAll("{{ANTHROPIC_ENV_VAR}}", anthropicEnvVar);
297
+ // Preserve user-added `KEY=<call>(...)` lines for keys other than the
298
+ // ones we own. Match any `KEY = <execName>(` line in the existing
299
+ // schema and append the whole line if its key isn't the active
300
+ // Claude Code auth var or GH_TOKEN.
280
301
  const userExecRe = new RegExp(`^([A-Z_][A-Z0-9_]*)\\s*=\\s*${execName()}\\(`, "gm");
281
- const ownedKeys = new Set(["ANTHROPIC_API_KEY", "GH_TOKEN"]);
302
+ // Both possible auth-mode vars are reserved so a user who switches
303
+ // modes doesn't end up with the dead one duplicated as a "preserved"
304
+ // line.
305
+ const ownedKeys = new Set([
306
+ "ANTHROPIC_API_KEY",
307
+ "CLAUDE_CODE_OAUTH_TOKEN",
308
+ "GH_TOKEN",
309
+ ]);
282
310
  const preservedLines = [];
283
311
  for (const match of before.matchAll(userExecRe)) {
284
312
  const key = match[1];
package/dist/config.js CHANGED
@@ -25,6 +25,24 @@ const ConfigSchema = z.object({
25
25
  */
26
26
  opServiceAccountToken: z.string().optional(),
27
27
  linearTeam: z.string().default("VA"),
28
+ /**
29
+ * Optional. Scopes the `runway run` queue to a single project under
30
+ * `linearTeam`. Resolved by Linear project ID, slug, or name. When
31
+ * unset, runway drains every `Todo` issue on the team (legacy
32
+ * behavior). Source: `RUNWAY_LINEAR_PROJECT` env var or
33
+ * `--project` CLI flag on `runway run`.
34
+ */
35
+ linearProject: z.string().optional(),
36
+ /**
37
+ * Optional. Override the auto-detected base branch — the branch
38
+ * runway diffs against, opens PRs against, and uses to count
39
+ * agent-branch commits. Source: `RUNWAY_BASE_BRANCH` env var. When
40
+ * unset, runway resolves the default branch from `origin/HEAD` at
41
+ * orchestrator startup. Set this when the repo's default branch is
42
+ * not on the origin (rare) or when you want to target a release
43
+ * branch instead.
44
+ */
45
+ baseBranch: z.string().optional(),
28
46
  readyStatus: z.string().default("Todo"),
29
47
  inProgressStatus: z.string().default("In Progress"),
30
48
  inReviewStatus: z.string().default("In Review"),
@@ -36,6 +54,8 @@ export function loadConfig() {
36
54
  linearApiKey: process.env.LINEAR_API_KEY,
37
55
  opServiceAccountToken: process.env.OP_SERVICE_ACCOUNT_TOKEN,
38
56
  linearTeam: process.env.RUNWAY_LINEAR_TEAM,
57
+ linearProject: process.env.RUNWAY_LINEAR_PROJECT,
58
+ baseBranch: process.env.RUNWAY_BASE_BRANCH,
39
59
  readyStatus: process.env.RUNWAY_READY_STATUS,
40
60
  inProgressStatus: process.env.RUNWAY_IN_PROGRESS_STATUS,
41
61
  inReviewStatus: process.env.RUNWAY_IN_REVIEW_STATUS,
package/dist/git.js ADDED
@@ -0,0 +1,41 @@
1
+ import { execa } from "execa";
2
+ /**
3
+ * Resolve the default branch name of the cwd repo. Tries
4
+ * `git symbolic-ref` against `origin/HEAD` first (fast, works on any
5
+ * clone where the symbolic ref has been set), then falls back to
6
+ * `git remote show origin` (slower, hits the network but works on
7
+ * fresh clones that never had `origin/HEAD` set locally).
8
+ *
9
+ * Throws if neither path resolves a branch name — better to fail
10
+ * fast at orchestrator startup than to crash mid-diff with a stale
11
+ * "ambiguous argument" git error.
12
+ */
13
+ export async function detectBaseBranch(repoPath) {
14
+ // Fast path: local symbolic ref. Returns e.g. `origin/main` or `origin/master`.
15
+ try {
16
+ const { stdout, exitCode } = await execa("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd: repoPath, reject: false });
17
+ if (exitCode === 0) {
18
+ const name = stdout.trim().replace(/^origin\//, "");
19
+ if (name)
20
+ return name;
21
+ }
22
+ }
23
+ catch {
24
+ // fall through to remote-show fallback
25
+ }
26
+ // Slow path: ask the remote. Output line looks like ` HEAD branch: master`.
27
+ try {
28
+ const { stdout } = await execa("git", ["remote", "show", "origin"], {
29
+ cwd: repoPath,
30
+ });
31
+ const match = stdout.match(/^\s*HEAD branch:\s*(\S+)\s*$/m);
32
+ if (match?.[1])
33
+ return match[1];
34
+ }
35
+ catch {
36
+ // fall through to error
37
+ }
38
+ throw new Error(`Could not detect the default branch of ${repoPath}. ` +
39
+ `Set RUNWAY_BASE_BRANCH explicitly, or run ` +
40
+ `\`git remote set-head origin --auto\` to populate origin/HEAD.`);
41
+ }
package/dist/github.js CHANGED
@@ -12,13 +12,13 @@ export function createGithubGateway() {
12
12
  stdio: "inherit",
13
13
  });
14
14
  },
15
- async openPullRequest({ repoPath, branch, issue, body }) {
15
+ async openPullRequest({ repoPath, branch, base, issue, body }) {
16
16
  const title = `${issue.identifier}: ${issue.title}`;
17
17
  const { stdout } = await execa("gh", [
18
18
  "pr",
19
19
  "create",
20
20
  "--base",
21
- "main",
21
+ base,
22
22
  "--head",
23
23
  branch,
24
24
  "--title",
package/dist/linear.js CHANGED
@@ -25,14 +25,39 @@ export function createLinearGateway(config) {
25
25
  }
26
26
  return team.id;
27
27
  }
28
+ /**
29
+ * Resolve a project identifier (UUID, slug, or name) to its Linear
30
+ * project ID. Tries each shape in order so user-facing flags like
31
+ * `--project=bedrock` work without forcing users to copy the UUID.
32
+ */
33
+ async function findProjectId(identifier) {
34
+ const projects = await client.projects({
35
+ filter: {
36
+ or: [
37
+ { id: { eq: identifier } },
38
+ { slugId: { eq: identifier } },
39
+ { name: { eq: identifier } },
40
+ ],
41
+ },
42
+ });
43
+ const project = projects.nodes[0];
44
+ if (!project) {
45
+ throw new Error(`Linear project "${identifier}" not found`);
46
+ }
47
+ return project.id;
48
+ }
28
49
  return {
29
50
  async fetchReady() {
30
51
  const teamId = await findTeamId();
31
52
  const readyStateId = await findStateId(teamId, config.readyStatus);
53
+ const projectId = config.linearProject
54
+ ? await findProjectId(config.linearProject)
55
+ : null;
32
56
  const issues = await client.issues({
33
57
  filter: {
34
58
  team: { id: { eq: teamId } },
35
59
  state: { id: { eq: readyStateId } },
60
+ ...(projectId ? { project: { id: { eq: projectId } } } : {}),
36
61
  },
37
62
  // Stable order: oldest first so the queue drains FIFO.
38
63
  orderBy: "createdAt",
@@ -4,6 +4,7 @@ import { run, claudeCode } from "@ai-hero/sandcastle";
4
4
  import { docker } from "@ai-hero/sandcastle/sandboxes/docker";
5
5
  import { execa } from "execa";
6
6
  import { implementVars, loadImplementPrompt, loadReviewPrompt, renderPrompt, reviewVars, } from "./prompts.js";
7
+ import { detectBaseBranch } from "./git.js";
7
8
  const REVIEW_VERDICT_RE = /^REVIEW:\s*(APPROVED|REJECTED)(?:\s+—\s+(.*))?$/m;
8
9
  /**
9
10
  * Confirms the cwd looks like a sandcastle-initialised repo. If not,
@@ -27,13 +28,19 @@ export async function drainQueue(deps, opts = {}) {
27
28
  let opened = 0;
28
29
  let hitl = 0;
29
30
  let errored = 0;
31
+ // Resolve the base branch once at startup so every issue in the
32
+ // drain sees the same answer (and so a misconfigured repo fails
33
+ // fast, before we touch any Linear state).
34
+ const baseBranch = config.baseBranch ?? (await detectBaseBranch(deps.cwd));
35
+ console.log(`[runway] base branch resolved to "${baseBranch}"`);
36
+ const runDeps = { ...deps, baseBranch };
30
37
  while (processed < max) {
31
38
  const queue = await linear.fetchReady();
32
39
  if (queue.length === 0)
33
40
  break;
34
41
  const issue = queue[0];
35
42
  try {
36
- const verdict = await processIssue(issue, deps);
43
+ const verdict = await processIssue(issue, runDeps);
37
44
  processed += 1;
38
45
  if (verdict === "opened")
39
46
  opened += 1;
@@ -43,18 +50,36 @@ export async function drainQueue(deps, opts = {}) {
43
50
  catch (err) {
44
51
  errored += 1;
45
52
  console.error(`[runway] error on ${issue.identifier}:`, err);
46
- await linear
47
- .applyLabel(issue.id, config.hitlLabel)
48
- .catch(() => undefined);
49
- await linear
50
- .comment(issue.id, `Runway hit an unrecoverable error and flagged for human review:\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\``)
51
- .catch(() => undefined);
53
+ // If the agent crashed before producing any commits (missing
54
+ // image, varlock validation, container failed to boot, etc.),
55
+ // it's an infrastructure failure — not a HITL. Revert the issue
56
+ // to `Todo` and skip the `needs-human` label so the next run
57
+ // can pick it up cleanly. `In Progress` is reserved for "agent
58
+ // has committed to the branch".
59
+ const branch = `agent/${issue.identifier.toLowerCase()}`;
60
+ const startedRealWork = await hasCommits(deps.cwd, baseBranch, branch);
61
+ if (!startedRealWork) {
62
+ await linear
63
+ .transition(issue.id, config.readyStatus)
64
+ .catch(() => undefined);
65
+ await linear
66
+ .comment(issue.id, `Runway hit a startup failure before the agent produced any commits — reverting to \`${config.readyStatus}\` for retry:\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\``)
67
+ .catch(() => undefined);
68
+ }
69
+ else {
70
+ await linear
71
+ .applyLabel(issue.id, config.hitlLabel)
72
+ .catch(() => undefined);
73
+ await linear
74
+ .comment(issue.id, `Runway hit an unrecoverable error and flagged for human review:\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\``)
75
+ .catch(() => undefined);
76
+ }
52
77
  }
53
78
  }
54
79
  return { processed, opened, hitl, errored };
55
80
  }
56
81
  async function processIssue(issue, deps) {
57
- const { config, linear, github, cwd } = deps;
82
+ const { config, linear, github, cwd, baseBranch } = deps;
58
83
  const branch = `agent/${issue.identifier.toLowerCase()}`;
59
84
  await linear.transition(issue.id, config.inProgressStatus);
60
85
  await linear.comment(issue.id, `Runway picked up this issue. Branch: \`${branch}\`.`);
@@ -76,8 +101,8 @@ async function processIssue(issue, deps) {
76
101
  return "hitl";
77
102
  }
78
103
  // 2. Review pass — read-only-ish, just looking at the diff.
79
- const diff = await captureDiff(cwd, branch);
80
- const commitLog = await captureCommitLog(cwd, branch);
104
+ const diff = await captureDiff(cwd, baseBranch, branch);
105
+ const commitLog = await captureCommitLog(cwd, baseBranch, branch);
81
106
  const reviewPrompt = renderPrompt(await loadReviewPrompt(), reviewVars({ issue, diff, commits: commitLog }));
82
107
  const reviewResult = await run({
83
108
  agent: claudeCode("claude-opus-4-6"),
@@ -101,6 +126,7 @@ async function processIssue(issue, deps) {
101
126
  const prUrl = await github.openPullRequest({
102
127
  repoPath: cwd,
103
128
  branch,
129
+ base: baseBranch,
104
130
  issue,
105
131
  body: prBody,
106
132
  });
@@ -113,15 +139,31 @@ async function flagHitl(issue, deps, reason) {
113
139
  await linear.applyLabel(issue.id, config.hitlLabel);
114
140
  await linear.comment(issue.id, `Runway flagged for human review: ${reason}`);
115
141
  }
116
- async function captureDiff(repoPath, branch) {
117
- const { stdout } = await execa("git", ["diff", `main...${branch}`], {
142
+ /**
143
+ * Whether the agent branch has any commits beyond `base`. Used by the
144
+ * drain loop to distinguish "agent crashed mid-run, after producing
145
+ * real work" (→ HITL) from "agent crashed during startup, no work
146
+ * done" (→ revert to Todo). If the branch doesn't exist or git fails,
147
+ * treat as "no commits" so we revert rather than strand the issue.
148
+ */
149
+ async function hasCommits(repoPath, base, branch) {
150
+ try {
151
+ const { stdout } = await execa("git", ["rev-list", "--count", `${base}..${branch}`], { cwd: repoPath, reject: false });
152
+ return Number.parseInt(stdout.trim(), 10) > 0;
153
+ }
154
+ catch {
155
+ return false;
156
+ }
157
+ }
158
+ async function captureDiff(repoPath, base, branch) {
159
+ const { stdout } = await execa("git", ["diff", `${base}...${branch}`], {
118
160
  cwd: repoPath,
119
161
  });
120
162
  // Truncate to keep the review prompt under the model's context budget.
121
163
  return stdout.length > 60_000 ? `${stdout.slice(0, 60_000)}\n…(truncated)` : stdout;
122
164
  }
123
- async function captureCommitLog(repoPath, branch) {
124
- const { stdout } = await execa("git", ["log", "--oneline", `main..${branch}`], { cwd: repoPath });
165
+ async function captureCommitLog(repoPath, base, branch) {
166
+ const { stdout } = await execa("git", ["log", "--oneline", `${base}..${branch}`], { cwd: repoPath });
125
167
  return stdout;
126
168
  }
127
169
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valescoagency/runway",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "Linear-driven orchestrator + scaffolder for coding agents on Sandcastle. `runway init` scaffolds a target repo (sandcastle + varlock + 1Password); `runway run` drains a Linear queue against it; `runway doctor`, `runway upgrade`, `runway upgrade-repo` round out the lifecycle.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -45,9 +45,13 @@
45
45
  "zod": "^3.23.8"
46
46
  },
47
47
  "devDependencies": {
48
+ "@commitlint/cli": "^21.0.0",
49
+ "@commitlint/config-conventional": "^21.0.0",
48
50
  "@types/node": "^22.10.0",
51
+ "lefthook": "^2.1.6",
49
52
  "tsx": "^4.19.2",
50
- "typescript": "^5.7.2"
53
+ "typescript": "^5.7.2",
54
+ "vitest": "^4.1.5"
51
55
  },
52
56
  "engines": {
53
57
  "node": ">=22"
@@ -56,9 +60,11 @@
56
60
  "access": "public"
57
61
  },
58
62
  "scripts": {
59
- "build": "tsc && chmod +x dist/cli.js",
63
+ "build": "tsc -p tsconfig.build.json && chmod +x dist/cli.js",
60
64
  "typecheck": "tsc --noEmit",
61
65
  "dev": "tsx src/cli.ts",
66
+ "test": "vitest run",
67
+ "test:watch": "vitest",
62
68
  "lint": "echo 'lint not configured yet'"
63
69
  }
64
70
  }
@@ -14,19 +14,24 @@
14
14
  #
15
15
  # Note on the op:// shape: with service-account auth (the only mode
16
16
  # runway uses), the token already encodes the 1Password tenant, so the
17
- # URI omits the account segment — `op://<vault>/<item>`, not
18
- # `op://<account>/<vault>/<item>`.
19
-
20
- # @sensitive @required
21
- ANTHROPIC_API_KEY=exec('op read "op://{{OP_VAULT}}/{{ANTHROPIC_ITEM}}"')
17
+ # URI omits the account segment — `op://<vault>/<item>/<field>`, not
18
+ # `op://<account>/<vault>/<item>/<field>`. For API_CREDENTIAL items
19
+ # (the natural category for API keys), the field is `credential`.
20
+ #
21
+ # Note on Claude Code auth: ANTHROPIC_API_KEY is a pay-per-token API
22
+ # key (sk-ant-api03-…). CLAUDE_CODE_OAUTH_TOKEN is a Pro/Max
23
+ # subscription token from `claude setup-token` (sk-ant-oat01-…). They
24
+ # are NOT interchangeable. Runway init writes whichever the user
25
+ # selected with --auth-mode; see runway's README "Claude Code auth"
26
+ # section for details.
27
+ #
28
+ # To add another secret, copy one of the two live entries below. Do
29
+ # NOT leave a commented-out example block here: varlock parses any
30
+ # `# @decorator` line as a real decorator, and a decorator with no
31
+ # attached config line fails validation ("detached comment block").
22
32
 
23
33
  # @sensitive @required
24
- GH_TOKEN=exec('op read "op://{{OP_VAULT}}/{{GH_TOKEN_ITEM}}"')
34
+ {{ANTHROPIC_ENV_VAR}}=exec('op read "op://{{OP_VAULT}}/{{ANTHROPIC_ITEM}}/credential"')
25
35
 
26
- # Add other secrets the agent needs at runtime here. Examples:
27
- #
28
- # @sensitive @required
29
- # OPENAI_API_KEY=exec('op read "op://{{OP_VAULT}}/openai-api-key"')
30
- #
31
36
  # @sensitive @required
32
- # DATABASE_URL=exec('op read "op://{{OP_VAULT}}/database-url"')
37
+ GH_TOKEN=exec('op read "op://{{OP_VAULT}}/{{GH_TOKEN_ITEM}}/credential"')