@leeguoo/wrangler-accounts 1.5.0 → 1.6.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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "Claude Code plugins for practical developer workflows from the wrangler-accounts author.",
8
- "version": "1.5.0"
8
+ "version": "1.5.1"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "wrangler-accounts",
13
13
  "source": "./plugins/wrangler-accounts",
14
14
  "description": "Cloudflare Wrangler multi-account skill plus a Bash hook that blocks raw wrangler calls when local profiles are configured.",
15
- "version": "1.5.0",
15
+ "version": "1.5.1",
16
16
  "author": {
17
17
  "name": "Lee Guo"
18
18
  },
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "wrangler-accounts",
3
+ "description": "Cloudflare Wrangler multi-account helper with a bundled skill and Bash guard hook for Claude Code.",
4
+ "version": "1.5.1",
5
+ "author": {
6
+ "name": "Lee Guo"
7
+ },
8
+ "homepage": "https://github.com/leeguooooo/wrangler-accounts#readme",
9
+ "repository": "https://github.com/leeguooooo/wrangler-accounts",
10
+ "license": "MIT"
11
+ }
package/README.md CHANGED
@@ -49,6 +49,17 @@ If you explicitly want raw `wrangler`, bypass the hook for that command:
49
49
  NOWRANGLER_ACCOUNTS_GUARD=1 wrangler deploy
50
50
  ```
51
51
 
52
+ ### Upgrading from the `skills.sh` install
53
+
54
+ If you previously installed this skill via `npx skills add leeguooooo/wrangler-accounts`, remove that standalone copy after installing the plugin — otherwise Claude Code's slash command picker shows two identical `/wrangler-accounts` entries (one from the skills.sh directory, one from the plugin):
55
+
56
+ ```bash
57
+ npx skills remove wrangler-accounts
58
+ # or, if the CLI is missing: rm -rf ~/.agents/skills/wrangler-accounts
59
+ ```
60
+
61
+ Then run `/reload-plugins` and confirm only one entry remains.
62
+
52
63
  ## What it does
53
64
 
54
65
  Every execution runs `wrangler` inside a per-invocation **shadow HOME** — a temporary directory that mirrors most of your real home, except `.wrangler/config/default.toml` is a symlink pointing at the saved profile's config. Token refreshes flow back to the profile automatically. Nothing touches your real `~/.wrangler`. Two parallel invocations get two independent shadow HOMEs.
@@ -100,7 +111,8 @@ WRANGLER_PROFILE=<name> wrangler-accounts <wrangler-args...>
100
111
  wrangler-accounts exec <name> # interactive subshell
101
112
  wrangler-accounts exec <name> -- <cmd> [args] # one command
102
113
 
103
- wrangler-accounts login <name> # isolated OAuth login
114
+ wrangler-accounts login <name> # isolated OAuth login (browser)
115
+ wrangler-accounts token-add <name> <api-token> <account-id> [--force] # API token profile (no browser)
104
116
  wrangler-accounts default [name | --unset] # manage persistent default
105
117
  wrangler-accounts whoami [--profile <name>] # show resolved identity
106
118
  wrangler-accounts list # fast table (name/status/expires/identity)
@@ -154,12 +166,32 @@ When you run `wrangler-accounts <wrangler-args>`, the active profile is resolved
154
166
  4. `profilesDir/default` (set via `wrangler-accounts default <name>`)
155
167
  5. Hard error with actionable hint
156
168
 
157
- ## When to use `wrangler-accounts` vs. native env vars
169
+ ## API token profiles (no browser required)
170
+
171
+ Since 1.6.0 you can save a Cloudflare API token + account ID as a named profile — no OAuth browser flow:
172
+
173
+ ```bash
174
+ # Get your API token: Cloudflare dashboard → My Profile → API Tokens
175
+ wrangler-accounts token-add work CF_TOKEN_HERE ACCOUNT_ID_HERE
158
176
 
159
- `wrangler-accounts` is a **local developer convenience** for juggling multiple OAuth sessions on your workstation. It is not the right primitive for CI.
177
+ # Use exactly like an OAuth profile
178
+ wrangler-accounts --profile work deploy
179
+ wrangler-accounts work r2 list
180
+ ```
181
+
182
+ Token profiles appear in `list` with `[token]` type and `STATUS: token`. There's no expiration; they're always ready.
183
+
184
+ **Env-var pass-through:** if `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` are already in the environment, no profile selection is needed at all:
185
+
186
+ ```bash
187
+ CLOUDFLARE_API_TOKEN=xxx CLOUDFLARE_ACCOUNT_ID=yyy wrangler-accounts deploy
188
+ ```
189
+
190
+ ## When to use `wrangler-accounts` vs. native env vars
160
191
 
161
- - **Local dev, multiple accounts** → `wrangler-accounts`
162
- - **CI / deploy pipelines** → **`CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` with plain `wrangler`**. Wrangler is designed to read those env vars natively. Create an API token in the Cloudflare dashboard with the scopes your pipeline needs, set the two env vars in your CI secrets, and call `wrangler deploy` directly — no `wrangler-accounts` involved.
192
+ - **Local dev, multiple OAuth accounts** → `wrangler-accounts login <name>` (the classic use case)
193
+ - **Local dev, API token accounts** → `wrangler-accounts token-add <name> <token> <account-id>` (1.6.0+)
194
+ - **CI / deploy pipelines** → either `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` with plain `wrangler`, or the env-var pass-through mode above
163
195
  - **Shared scripts** that run locally or in CI → parameterize on `WRANGLER_PROFILE` so devs can run them with `WRANGLER_PROFILE=work wrangler-accounts ./deploy.sh`.
164
196
 
165
197
  ## Options
@@ -18,9 +18,11 @@ const {
18
18
  isValidName,
19
19
  isBackupName,
20
20
  listProfiles,
21
+ getProfileType,
21
22
  fileHash,
22
23
  readExpirationTime,
23
24
  readSessionState,
25
+ readTokenSessionState,
24
26
  filesEqual,
25
27
  writeMeta,
26
28
  readMeta,
@@ -33,6 +35,8 @@ const {
33
35
  backupCurrentConfig,
34
36
  findMatchingProfile,
35
37
  saveProfile: saveProfileImpl,
38
+ saveTokenProfile: saveTokenProfileImpl,
39
+ readTokenCredentials,
36
40
  removeProfile: removeProfileImpl,
37
41
  } = require("../lib/profile-store");
38
42
  const {
@@ -62,6 +66,7 @@ const MANAGEMENT_SUBCOMMANDS = new Set([
62
66
  "login",
63
67
  "remove",
64
68
  "default",
69
+ "token-add",
65
70
  "whoami",
66
71
  "gc",
67
72
  "use",
@@ -129,6 +134,10 @@ function removeProfile(...args) {
129
134
  try { return removeProfileImpl(...args); }
130
135
  catch (err) { die(err.message); }
131
136
  }
137
+ function saveTokenProfile(...args) {
138
+ try { return saveTokenProfileImpl(...args); }
139
+ catch (err) { die(err.message); }
140
+ }
132
141
 
133
142
  let outputJson = false;
134
143
 
@@ -141,6 +150,156 @@ function die(message, exitCode = 1) {
141
150
  process.exit(exitCode);
142
151
  }
143
152
 
153
+ function profileTypeForName(profilesDir, name) {
154
+ return getProfileType(path.join(profilesDir, name));
155
+ }
156
+
157
+ function tokenProfileExists(profilesDir, name) {
158
+ return profileTypeForName(profilesDir, name) !== null;
159
+ }
160
+
161
+ function noProfileMessage() {
162
+ return [
163
+ 'No profile specified. Options:',
164
+ ' - wrangler-accounts --profile <name> ...',
165
+ ' - WRANGLER_PROFILE=<name> wrangler-accounts ...',
166
+ ' - wrangler-accounts default <name> (set a persistent default)',
167
+ ].join('\n');
168
+ }
169
+
170
+ function resolveProfileAny({
171
+ cliProfile,
172
+ positional,
173
+ env,
174
+ profilesDir,
175
+ managementSubcommands,
176
+ }) {
177
+ try {
178
+ return resolveProfile({
179
+ cliProfile,
180
+ positional,
181
+ env,
182
+ profilesDir,
183
+ managementSubcommands,
184
+ });
185
+ } catch (err) {
186
+ if (!(err instanceof ResolveError)) throw err;
187
+ if (err.code === "INVALID_NAME") throw err;
188
+ }
189
+
190
+ if (cliProfile) {
191
+ if (!isValidName(cliProfile)) {
192
+ throw new ResolveError(`Invalid profile name: ${cliProfile}`, "INVALID_NAME");
193
+ }
194
+ if (tokenProfileExists(profilesDir, cliProfile)) {
195
+ return { name: cliProfile, source: "cli" };
196
+ }
197
+ throw new ResolveError(`Profile not found: ${cliProfile}`, "PROFILE_NOT_FOUND");
198
+ }
199
+
200
+ if (positional && !managementSubcommands.has(positional)) {
201
+ if (isValidName(positional) && tokenProfileExists(profilesDir, positional)) {
202
+ return { name: positional, source: "positional" };
203
+ }
204
+ }
205
+
206
+ const envProfile = env && env.WRANGLER_PROFILE;
207
+ if (envProfile && envProfile.length) {
208
+ if (!isValidName(envProfile)) {
209
+ throw new ResolveError(`Invalid profile name: ${envProfile}`, "INVALID_NAME");
210
+ }
211
+ if (tokenProfileExists(profilesDir, envProfile)) {
212
+ return { name: envProfile, source: "env" };
213
+ }
214
+ throw new ResolveError(`Profile not found: ${envProfile}`, "PROFILE_NOT_FOUND");
215
+ }
216
+
217
+ const def = getDefaultProfile(profilesDir);
218
+ if (def) {
219
+ if (!isValidName(def)) {
220
+ throw new ResolveError(`Invalid profile name: ${def}`, "INVALID_NAME");
221
+ }
222
+ if (tokenProfileExists(profilesDir, def)) {
223
+ return { name: def, source: "default" };
224
+ }
225
+ throw new ResolveError(`Profile not found: ${def}`, "PROFILE_NOT_FOUND");
226
+ }
227
+
228
+ throw new ResolveError(noProfileMessage(), "NO_PROFILE");
229
+ }
230
+
231
+ function runAnonymousTokenMode({
232
+ command,
233
+ args,
234
+ captureStdout = false,
235
+ }) {
236
+ return runIsolated({
237
+ profile: "token-env",
238
+ profileCfg: null,
239
+ profileDir: null,
240
+ realHome: os.homedir(),
241
+ command,
242
+ args,
243
+ apiToken: process.env.CLOUDFLARE_API_TOKEN || null,
244
+ accountId: process.env.CLOUDFLARE_ACCOUNT_ID || null,
245
+ baseEnv: process.env,
246
+ captureStdout,
247
+ cloudflaredPath: findCloudflared(),
248
+ });
249
+ }
250
+
251
+ function runResolvedProfileCommand({
252
+ resolved,
253
+ profilesDir,
254
+ command,
255
+ args,
256
+ captureStdout = false,
257
+ }) {
258
+ const profileDir = path.join(profilesDir, resolved.name);
259
+ const profileType = getProfileType(profileDir);
260
+
261
+ if (profileType === "token") {
262
+ const creds = readTokenCredentials(profileDir);
263
+ if (!creds || !creds.apiToken) {
264
+ die(`Token profile '${resolved.name}' is missing token.json credentials.`);
265
+ }
266
+ return runIsolated({
267
+ profile: resolved.name,
268
+ profileCfg: null,
269
+ profileDir,
270
+ realHome: os.homedir(),
271
+ command,
272
+ args,
273
+ apiToken: creds.apiToken,
274
+ accountId: creds.accountId || null,
275
+ baseEnv: process.env,
276
+ captureStdout,
277
+ cloudflaredPath: findCloudflared(),
278
+ });
279
+ }
280
+
281
+ const profileCfg = path.join(profileDir, "config.toml");
282
+ const session = readSessionState(profileCfg);
283
+ if (session.effective === 'expired') {
284
+ die(
285
+ `Profile '${resolved.name}' has expired Wrangler OAuth credentials and no refresh_token to renew them (expiration_time: ${session.expirationTime}). Run 'wrangler-accounts login ${resolved.name}' to re-authenticate.`,
286
+ 3
287
+ );
288
+ }
289
+
290
+ return runIsolated({
291
+ profile: resolved.name,
292
+ profileCfg,
293
+ profileDir,
294
+ realHome: os.homedir(),
295
+ command,
296
+ args,
297
+ baseEnv: process.env,
298
+ captureStdout,
299
+ cloudflaredPath: findCloudflared(),
300
+ });
301
+ }
302
+
144
303
  function printHelp(exitCode = 0) {
145
304
  const text = `wrangler-accounts - manage multiple Wrangler login profiles
146
305
 
@@ -152,6 +311,7 @@ Commands:
152
311
  status
153
312
  login <name>
154
313
  save <name>
314
+ token-add <name> <api-token> <account-id>
155
315
  sync <name>
156
316
  sync-active
157
317
  use <name>
@@ -293,9 +453,9 @@ function useProfile(name, configPath, profilesDir, backup) {
293
453
  }
294
454
 
295
455
  const session = readSessionState(profileConfig);
296
- if (session.expired) {
456
+ if (session.effective === 'expired') {
297
457
  die(
298
- `Profile '${name}' has expired Wrangler OAuth credentials (expiration_time: ${session.expirationTime}). Run 'wrangler-accounts login ${name}' to refresh it.`
458
+ `Profile '${name}' has expired Wrangler OAuth credentials and no refresh_token to renew them (expiration_time: ${session.expirationTime}). Run 'wrangler-accounts login ${name}' to re-authenticate.`
299
459
  );
300
460
  }
301
461
 
@@ -391,7 +551,7 @@ function main() {
391
551
 
392
552
  let resolved;
393
553
  try {
394
- resolved = resolveProfile({
554
+ resolved = resolveProfileAny({
395
555
  cliProfile: profileArg,
396
556
  positional,
397
557
  env: process.env,
@@ -400,6 +560,13 @@ function main() {
400
560
  });
401
561
  } catch (err) {
402
562
  if (err instanceof ResolveError) {
563
+ if (err.code === "NO_PROFILE" && process.env.CLOUDFLARE_API_TOKEN) {
564
+ const result = runAnonymousTokenMode({
565
+ command: "wrangler",
566
+ args: rest,
567
+ });
568
+ process.exit(result.exitCode);
569
+ }
403
570
  const exitCode =
404
571
  err.code === "NO_PROFILE" || err.code === "PROFILE_NOT_FOUND" ? 2 : 1;
405
572
  die(err.message, exitCode);
@@ -407,26 +574,14 @@ function main() {
407
574
  throw err;
408
575
  }
409
576
 
410
- const profileCfg = path.join(profilesDir, resolved.name, "config.toml");
411
- const session = readSessionState(profileCfg);
412
- if (session.expired) {
413
- die(
414
- `Profile '${resolved.name}' has expired Wrangler OAuth credentials (expiration_time: ${session.expirationTime}). Run 'wrangler-accounts login ${resolved.name}' to refresh it.`,
415
- 3
416
- );
417
- }
418
-
419
577
  // If positional was consumed as profile, drop it from wrangler argv
420
578
  const wranglerArgs = resolved.source === "positional" ? rest.slice(1) : rest;
421
579
 
422
- const result = runIsolated({
423
- profile: resolved.name,
424
- profileCfg,
425
- realHome: os.homedir(),
580
+ const result = runResolvedProfileCommand({
581
+ resolved,
582
+ profilesDir,
426
583
  command: "wrangler",
427
584
  args: wranglerArgs,
428
- baseEnv: process.env,
429
- cloudflaredPath: findCloudflared(),
430
585
  });
431
586
  process.exit(result.exitCode);
432
587
  }
@@ -438,12 +593,15 @@ function main() {
438
593
 
439
594
  const entries = profiles.map((name) => {
440
595
  const profileDir = path.join(profilesDir, name);
596
+ const type = getProfileType(profileDir) || "oauth";
441
597
  const cfgPath = path.join(profileDir, "config.toml");
442
- const session = readSessionState(cfgPath);
598
+ const session =
599
+ type === "token" ? readTokenSessionState() : readSessionState(cfgPath);
443
600
  const meta = readMeta(profileDir);
444
601
  const identity = getMetaIdentity(meta);
445
602
  return {
446
603
  name,
604
+ type,
447
605
  isDefault: name === defaultName,
448
606
  isActive: name === activeName,
449
607
  status: session.effective, // 'valid' | 'refreshable' | 'expired' | 'unknown'
@@ -468,17 +626,14 @@ function main() {
468
626
  }
469
627
  const cloudflaredPath = findCloudflared();
470
628
  for (const e of entries) {
471
- const profileCfg = path.join(profilesDir, e.name, "config.toml");
472
629
  try {
473
- const r = runIsolated({
474
- profile: e.name,
475
- profileCfg,
476
- realHome: os.homedir(),
630
+ const resolved = { name: e.name, source: "deep" };
631
+ const r = runResolvedProfileCommand({
632
+ resolved,
633
+ profilesDir,
477
634
  command: "wrangler",
478
635
  args: ["whoami"],
479
- baseEnv: process.env,
480
636
  captureStdout: true,
481
- cloudflaredPath,
482
637
  });
483
638
  const output = `${r.stdout || ""}\n${r.stderr || ""}`;
484
639
  if (r.exitCode === 0) {
@@ -522,7 +677,7 @@ function main() {
522
677
  if (defaultName) console.log(`Default: ${defaultName}\n`);
523
678
  const rows = entries.map((e) => ({
524
679
  marker: e.isDefault ? "*" : " ",
525
- name: e.name,
680
+ name: `${e.name} [${e.type}]`,
526
681
  status:
527
682
  e.status === "expired" ? "EXPIRED"
528
683
  : e.status === "refreshable" ? "valid*"
@@ -594,12 +749,15 @@ function main() {
594
749
  const matchType = exactMatch ? "hash" : identityMatches.length === 1 ? "identity" : null;
595
750
  const profileStates = Object.fromEntries(
596
751
  profiles.map((name) => {
597
- const profileConfig = path.join(profilesDir, name, "config.toml");
598
- const meta = readMeta(path.join(profilesDir, name));
752
+ const profileDir = path.join(profilesDir, name);
753
+ const type = getProfileType(profileDir) || "oauth";
754
+ const profileConfig = path.join(profileDir, "config.toml");
755
+ const meta = readMeta(profileDir);
599
756
  return [
600
757
  name,
601
758
  {
602
- ...readSessionState(profileConfig),
759
+ ...(type === "token" ? readTokenSessionState() : readSessionState(profileConfig)),
760
+ type,
603
761
  identity: getMetaIdentity(meta),
604
762
  },
605
763
  ];
@@ -651,10 +809,13 @@ function main() {
651
809
  }
652
810
  for (const name of profiles) {
653
811
  const profileSession = profileStates[name];
654
- if (!profileSession.expirationTime) continue;
655
- const state = profileSession.expired ? "expired" : "valid";
812
+ const state =
813
+ profileSession.effective === "token"
814
+ ? "token"
815
+ : profileSession.expired ? "expired" : "valid";
656
816
  const suffix = profileSession.identity ? `, ${describeIdentity(profileSession.identity)}` : "";
657
- console.log(`- ${name}: ${profileSession.expirationTime} (${state}${suffix ? suffix : ""})`);
817
+ const expiry = profileSession.expirationTime || "(n/a)";
818
+ console.log(`- ${name} [${profileSession.type}]: ${expiry} (${state}${suffix ? suffix : ""})`);
658
819
  }
659
820
  }
660
821
  return;
@@ -689,6 +850,19 @@ function main() {
689
850
  return;
690
851
  }
691
852
 
853
+ if (command === "token-add") {
854
+ const name = rest[1];
855
+ const apiToken = rest[2];
856
+ const accountId = rest[3];
857
+ if (!name) die("Missing profile name for token-add");
858
+ if (!apiToken) die("Missing API token for token-add");
859
+ if (!accountId) die("Missing account ID for token-add");
860
+ ensureDir(profilesDir);
861
+ saveTokenProfile(name, apiToken, accountId, profilesDir, opts.force);
862
+ console.log(`Saved token profile '${name}'`);
863
+ return;
864
+ }
865
+
692
866
  if (command === "login") {
693
867
  const name = rest[1];
694
868
  if (!name) die("Missing profile name for login");
@@ -983,7 +1157,7 @@ function main() {
983
1157
  const profileArg = opts.profile || rest[1] || null;
984
1158
  let resolved;
985
1159
  try {
986
- resolved = resolveProfile({
1160
+ resolved = resolveProfileAny({
987
1161
  cliProfile: profileArg,
988
1162
  positional: null,
989
1163
  env: process.env,
@@ -991,10 +1165,29 @@ function main() {
991
1165
  managementSubcommands: MANAGEMENT_SUBCOMMANDS,
992
1166
  });
993
1167
  } catch (err) {
994
- if (err instanceof ResolveError) die(err.message, 2);
1168
+ if (err instanceof ResolveError) {
1169
+ if (err.code === "NO_PROFILE" && process.env.CLOUDFLARE_API_TOKEN) {
1170
+ const result = runAnonymousTokenMode({
1171
+ command: "wrangler",
1172
+ args: ["whoami"],
1173
+ });
1174
+ process.exit(result.exitCode);
1175
+ }
1176
+ die(err.message, 2);
1177
+ }
995
1178
  throw err;
996
1179
  }
997
1180
  const profileDir = path.join(profilesDir, resolved.name);
1181
+ const profileType = getProfileType(profileDir) || "oauth";
1182
+ if (profileType === "token") {
1183
+ const result = runResolvedProfileCommand({
1184
+ resolved,
1185
+ profilesDir,
1186
+ command: "wrangler",
1187
+ args: ["whoami"],
1188
+ });
1189
+ process.exit(result.exitCode);
1190
+ }
998
1191
  const meta = readMeta(profileDir);
999
1192
  const identity = getMetaIdentity(meta);
1000
1193
  if (opts.json) {
@@ -1004,6 +1197,7 @@ function main() {
1004
1197
  command: "whoami",
1005
1198
  profile: resolved.name,
1006
1199
  source: resolved.source,
1200
+ type: profileType,
1007
1201
  identity,
1008
1202
  },
1009
1203
  null,
@@ -1061,7 +1255,7 @@ function main() {
1061
1255
 
1062
1256
  let resolved;
1063
1257
  try {
1064
- resolved = resolveProfile({
1258
+ resolved = resolveProfileAny({
1065
1259
  cliProfile: profileName,
1066
1260
  positional: null,
1067
1261
  env: process.env,
@@ -1073,15 +1267,6 @@ function main() {
1073
1267
  throw err;
1074
1268
  }
1075
1269
 
1076
- const profileCfg = path.join(profilesDir, resolved.name, "config.toml");
1077
- const session = readSessionState(profileCfg);
1078
- if (session.expired) {
1079
- die(
1080
- `Profile '${resolved.name}' has expired Wrangler OAuth credentials. Run 'wrangler-accounts login ${resolved.name}' to refresh it.`,
1081
- 3
1082
- );
1083
- }
1084
-
1085
1270
  // Everything after `--` is the user command. Without `--`, launch $SHELL -i.
1086
1271
  const dashDashIdx = rest.indexOf("--", 2);
1087
1272
  let cmd;
@@ -1095,14 +1280,11 @@ function main() {
1095
1280
  cmdArgs = ["-i"];
1096
1281
  }
1097
1282
 
1098
- const result = runIsolated({
1099
- profile: resolved.name,
1100
- profileCfg,
1101
- realHome: os.homedir(),
1283
+ const result = runResolvedProfileCommand({
1284
+ resolved,
1285
+ profilesDir,
1102
1286
  command: cmd,
1103
1287
  args: cmdArgs,
1104
- baseEnv: process.env,
1105
- cloudflaredPath: findCloudflared(),
1106
1288
  });
1107
1289
  process.exit(result.exitCode);
1108
1290
  }
@@ -1138,8 +1320,7 @@ function main() {
1138
1320
  }
1139
1321
  // Set the default profile
1140
1322
  if (!isValidName(name)) die(`Invalid profile name: ${name}`);
1141
- const cfg = path.join(profilesDir, name, "config.toml");
1142
- if (!fs.existsSync(cfg)) die(`Profile not found: ${name}`, 2);
1323
+ if (!tokenProfileExists(profilesDir, name)) die(`Profile not found: ${name}`, 2);
1143
1324
  setDefaultProfile(profilesDir, name);
1144
1325
  if (opts.json) {
1145
1326
  console.log(JSON.stringify({ command: "default", name }, null, 2));
package/lib/isolation.js CHANGED
@@ -21,15 +21,15 @@ const { spawnSync } = require('node:child_process');
21
21
  *
22
22
  * @param {object} args
23
23
  * @param {string} args.realHome
24
- * @param {string} args.profileCfg - path to the profile's config.toml file
24
+ * @param {string|null} [args.profileCfg] - path to the profile's config.toml file
25
25
  * @param {string} [args.label] - optional label for the tmpdir name
26
26
  * @returns {string} path to the shadow HOME
27
27
  */
28
- function createShadowHome({ realHome, profileCfg, label = 'wa' }) {
28
+ function createShadowHome({ realHome, profileCfg = null, label = 'wa' }) {
29
29
  if (!realHome || !fs.existsSync(realHome)) {
30
30
  throw new Error(`real HOME does not exist: ${realHome}`);
31
31
  }
32
- if (!profileCfg || !fs.existsSync(profileCfg)) {
32
+ if (profileCfg && !fs.existsSync(profileCfg)) {
33
33
  throw new Error(`profile config does not exist: ${profileCfg}`);
34
34
  }
35
35
 
@@ -59,10 +59,12 @@ function createShadowHome({ realHome, profileCfg, label = 'wa' }) {
59
59
  // saved profile automatically.
60
60
  const shadowWranglerConfig = path.join(shadow, '.wrangler', 'config');
61
61
  fs.mkdirSync(shadowWranglerConfig, { recursive: true });
62
- fs.symlinkSync(
63
- profileCfg,
64
- path.join(shadowWranglerConfig, 'default.toml'),
65
- );
62
+ const defaultConfigPath = path.join(shadowWranglerConfig, 'default.toml');
63
+ if (profileCfg) {
64
+ fs.symlinkSync(profileCfg, defaultConfigPath);
65
+ } else {
66
+ fs.writeFileSync(defaultConfigPath, '');
67
+ }
66
68
 
67
69
  return shadow;
68
70
  }
@@ -94,6 +96,9 @@ function buildIsolatedEnv({
94
96
  realHome,
95
97
  profile,
96
98
  profileCfg = null,
99
+ profileDir = null,
100
+ apiToken = null,
101
+ accountId = null,
97
102
  baseEnv = process.env,
98
103
  cloudflaredPath = null,
99
104
  }) {
@@ -105,6 +110,9 @@ function buildIsolatedEnv({
105
110
  env.WRANGLER_REGISTRY_PATH = path.join(realHome, '.wrangler', 'registry');
106
111
  env.WRANGLER_LOG_PATH = path.join(realHome, '.wrangler', 'logs');
107
112
  env.WRANGLER_SEND_METRICS = 'false';
113
+ if (!env.WA_TEST_OUT) {
114
+ env.WA_TEST_OUT = path.join(shadow, '.wrangler', 'wa-test-out.json');
115
+ }
108
116
 
109
117
  // CRITICAL: Wrangler caches the user's selected Cloudflare account ID
110
118
  // in `wrangler-account.json` inside `getCacheFolder()`. If multiple
@@ -124,9 +132,9 @@ function buildIsolatedEnv({
124
132
  // (CLOUDFLARED_PATH / node_modules); WRANGLER_CACHE_DIR is only for
125
133
  // config-cache files like wrangler-account.json and
126
134
  // pages-config-cache.json.
127
- if (profileCfg) {
128
- const profileDir = path.dirname(profileCfg);
129
- const cacheDir = path.join(profileDir, 'cache');
135
+ const cacheRoot = profileDir || (profileCfg ? path.dirname(profileCfg) : null);
136
+ if (cacheRoot) {
137
+ const cacheDir = path.join(cacheRoot, 'cache');
130
138
  try {
131
139
  fs.mkdirSync(cacheDir, { recursive: true });
132
140
  } catch (err) {
@@ -137,6 +145,14 @@ function buildIsolatedEnv({
137
145
  env.WRANGLER_CACHE_DIR = cacheDir;
138
146
  }
139
147
 
148
+ if (apiToken) {
149
+ env.CLOUDFLARE_API_TOKEN = apiToken;
150
+ }
151
+
152
+ if (accountId) {
153
+ env.CLOUDFLARE_ACCOUNT_ID = accountId;
154
+ }
155
+
140
156
  if (cloudflaredPath) {
141
157
  env.CLOUDFLARED_PATH = cloudflaredPath;
142
158
  }
@@ -162,9 +178,12 @@ function buildIsolatedEnv({
162
178
  function runIsolated({
163
179
  profile,
164
180
  profileCfg,
181
+ profileDir = null,
165
182
  realHome,
166
183
  command,
167
184
  args,
185
+ apiToken = null,
186
+ accountId = null,
168
187
  baseEnv = process.env,
169
188
  captureStdout = false,
170
189
  cloudflaredPath = null,
@@ -179,6 +198,9 @@ function runIsolated({
179
198
  realHome,
180
199
  profile,
181
200
  profileCfg,
201
+ profileDir,
202
+ apiToken,
203
+ accountId,
182
204
  baseEnv,
183
205
  cloudflaredPath,
184
206
  });
@@ -16,6 +16,13 @@ function isBackupName(name) {
16
16
  return name.startsWith('__backup-');
17
17
  }
18
18
 
19
+ function getProfileType(profileDir) {
20
+ if (!profileDir || !fs.existsSync(profileDir)) return null;
21
+ if (fs.existsSync(path.join(profileDir, 'config.toml'))) return 'oauth';
22
+ if (fs.existsSync(path.join(profileDir, 'token.json'))) return 'token';
23
+ return null;
24
+ }
25
+
19
26
  function listProfiles(profilesDir, { includeBackups = false } = {}) {
20
27
  if (!fs.existsSync(profilesDir)) return [];
21
28
  const entries = fs.readdirSync(profilesDir, { withFileTypes: true });
@@ -23,7 +30,7 @@ function listProfiles(profilesDir, { includeBackups = false } = {}) {
23
30
  .filter((entry) => entry.isDirectory())
24
31
  .map((entry) => entry.name)
25
32
  .filter((name) => includeBackups || !isBackupName(name))
26
- .filter((name) => fs.existsSync(path.join(profilesDir, name, 'config.toml')))
33
+ .filter((name) => getProfileType(path.join(profilesDir, name)) !== null)
27
34
  .sort();
28
35
  }
29
36
 
@@ -199,7 +206,9 @@ function findMatchingProfile(profilesDir, configPath, { includeBackups = false }
199
206
  const configHash = fileHash(configPath);
200
207
  const profiles = listProfiles(profilesDir, { includeBackups });
201
208
  for (const name of profiles) {
202
- const profileConfig = path.join(profilesDir, name, 'config.toml');
209
+ const profileDir = path.join(profilesDir, name);
210
+ if (getProfileType(profileDir) !== 'oauth') continue;
211
+ const profileConfig = path.join(profileDir, 'config.toml');
203
212
  if (fileHash(profileConfig) === configHash) return name;
204
213
  }
205
214
  return null;
@@ -223,6 +232,59 @@ function saveProfile(name, configPath, profilesDir, force, identity = null) {
223
232
  writeMeta(profileDir, name, configPath, identity);
224
233
  }
225
234
 
235
+ function saveTokenProfile(name, apiToken, accountId, profilesDir, force) {
236
+ if (!isValidName(name)) {
237
+ throw new Error(`Invalid profile name: ${name}`);
238
+ }
239
+
240
+ const profileDir = path.join(profilesDir, name);
241
+ if (fs.existsSync(profileDir) && !force) {
242
+ throw new Error(`Profile exists: ${name} (use --force to overwrite)`);
243
+ }
244
+
245
+ ensureDir(profileDir);
246
+
247
+ const tokenPath = path.join(profileDir, 'token.json');
248
+ fs.writeFileSync(
249
+ tokenPath,
250
+ JSON.stringify({ apiToken, accountId }, null, 2),
251
+ );
252
+ fs.chmodSync(tokenPath, 0o600);
253
+
254
+ fs.writeFileSync(
255
+ path.join(profileDir, 'meta.json'),
256
+ JSON.stringify(
257
+ {
258
+ name,
259
+ savedAt: new Date().toISOString(),
260
+ type: 'token',
261
+ accountId,
262
+ },
263
+ null,
264
+ 2,
265
+ ),
266
+ );
267
+ }
268
+
269
+ function readTokenCredentials(profileDir) {
270
+ const tokenPath = path.join(profileDir, 'token.json');
271
+ if (!fs.existsSync(tokenPath)) return null;
272
+ try {
273
+ return JSON.parse(fs.readFileSync(tokenPath, 'utf8'));
274
+ } catch {
275
+ return null;
276
+ }
277
+ }
278
+
279
+ function readTokenSessionState() {
280
+ return {
281
+ expirationTime: null,
282
+ expired: null,
283
+ hasRefreshToken: false,
284
+ effective: 'token',
285
+ };
286
+ }
287
+
226
288
  function removeProfile(name, profilesDir) {
227
289
  if (!isValidName(name)) {
228
290
  throw new Error(`Invalid profile name: ${name}`);
@@ -245,10 +307,12 @@ module.exports = {
245
307
  ensureDir,
246
308
  isValidName,
247
309
  isBackupName,
310
+ getProfileType,
248
311
  listProfiles,
249
312
  fileHash,
250
313
  readExpirationTime,
251
314
  readSessionState,
315
+ readTokenSessionState,
252
316
  filesEqual,
253
317
  writeMeta,
254
318
  readMeta,
@@ -261,5 +325,7 @@ module.exports = {
261
325
  backupCurrentConfig,
262
326
  findMatchingProfile,
263
327
  saveProfile,
328
+ saveTokenProfile,
329
+ readTokenCredentials,
264
330
  removeProfile,
265
331
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leeguoo/wrangler-accounts",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Cloudflare Wrangler multi-account manager — save, switch, and run wrangler against multiple Cloudflare Workers accounts with AWS-style --profile and per-invocation shadow HOME isolation.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "wrangler-accounts",
3
3
  "description": "Cloudflare Wrangler multi-account helper with a bundled skill and Bash guard hook for Claude Code.",
4
- "version": "1.5.0",
4
+ "version": "1.5.1",
5
5
  "author": {
6
6
  "name": "Lee Guo"
7
7
  },
@@ -30,6 +30,13 @@ Non-Claude-Code users can keep using the `skills.sh` distribution path for this
30
30
  npx skills add leeguooooo/wrangler-accounts -g -y
31
31
  ```
32
32
 
33
+ If a Claude Code user previously installed via `skills.sh`, removing the standalone copy avoids a duplicate `/wrangler-accounts` entry in the command picker:
34
+
35
+ ```bash
36
+ npx skills remove wrangler-accounts
37
+ # or: rm -rf ~/.agents/skills/wrangler-accounts
38
+ ```
39
+
33
40
  ## Prerequisites (check before running any recipe below)
34
41
 
35
42
  This skill is only documentation — the actual `wrangler-accounts` binary must also be installed on the user's `PATH`. Before running any command below, verify:
@@ -59,6 +66,7 @@ If `wrangler-accounts --version` is below any of these, **upgrade first** before
59
66
  | **≥ 1.2.2** | per-profile `WRANGLER_CACHE_DIR`, fixes account-id leak across profiles | `d1`/`r2 object` commands return `7403 not authorized` even though OAuth is valid; deploys silently land in the wrong account |
60
67
  | **≥ 1.3.0** | STATUS column distinguishes `valid` / `valid*` / `EXPIRED` (refresh-token-aware) | `list` shows `EXPIRED` for healthy profiles, scaring you into running `login` for no reason |
61
68
  | **≥ 1.4.0** | `login` refuses non-TTY contexts and accidental overwrites | `login <name>` hangs forever in non-interactive contexts; reflexive `login` overwrites a healthy profile |
69
+ | **≥ 1.6.0** | API token profiles (`token-add`) + anonymous env-var pass-through | only OAuth profiles existed; `CLOUDFLARE_API_TOKEN` in env still required a named profile to be selected |
62
70
 
63
71
  ```bash
64
72
  npm i -g @leeguoo/wrangler-accounts@latest # always-safe upgrade
@@ -101,6 +109,7 @@ How to read `list --deep` output:
101
109
  ## Quick Start
102
110
 
103
111
  - `wrangler-accounts login <name>` — interactive OAuth login into a new profile (never touches real `~/.wrangler`)
112
+ - `wrangler-accounts token-add <name> <api-token> <account-id>` — save an API token profile (no browser login needed)
104
113
  - `wrangler-accounts default <name>` — set the persistent default profile
105
114
  - `wrangler-accounts deploy` — run `wrangler deploy` under the default profile
106
115
  - `wrangler-accounts --profile personal deploy` — one-shot override
@@ -162,6 +171,7 @@ Use `--json` for structured output.
162
171
  | `valid*` / `refreshable` | access_token past expiry **BUT** refresh_token present; wrangler will auto-refresh on next use | **none** — this is fine, don't scare the user |
163
172
  | `EXPIRED` / `expired` | access_token expired **AND** no refresh_token saved; profile is genuinely broken | `wrangler-accounts login <name>` |
164
173
  | `unknown` | profile file has no `expiration_time` field | run `list --deep` to verify live |
174
+ | `token` | API token profile (1.6.0+) — no expiration concept, always ready | none |
165
175
 
166
176
  **Cloudflare OAuth lifecycle reference:** access tokens are short-lived (~1 hour) by design. Every profile with `offline_access` in its scopes also has a long-lived refresh_token (~30 days, silently extended on use). Wrangler refreshes access tokens automatically whenever it runs a command and the current one is past expiry. **Do not tell the user to re-login just because `list` shows an expired access token** — check `hasRefreshToken` first. If the profile's STATUS is `valid*` / `refreshable`, nothing is wrong.
167
177
 
@@ -169,13 +179,36 @@ The only time a user actually needs `wrangler-accounts login <name>` again is:
169
179
  1. STATUS is `EXPIRED` (no refresh_token at all — profile was saved without `offline_access` scope)
170
180
  2. OR `list --deep` returns `✗` with "Not logged in" / "refresh token may be revoked" (refresh token itself got invalidated)
171
181
 
182
+ ### Save an API token profile (no browser required)
183
+
184
+ `wrangler-accounts token-add <name> <api-token> <account-id> [--force]`
185
+
186
+ Saves a Cloudflare API token + account ID as a named profile. No OAuth browser flow needed. The credentials are stored in `token.json` (mode 0600) inside the profile directory.
187
+
188
+ ```bash
189
+ # Get your API token from: Cloudflare dashboard → My Profile → API Tokens
190
+ wrangler-accounts token-add work CF_TOKEN_HERE ACCOUNT_ID_HERE
191
+
192
+ # Use identically to OAuth profiles
193
+ wrangler-accounts --profile work deploy
194
+ wrangler-accounts work r2 list
195
+ ```
196
+
197
+ Token profiles appear in `list` with a `[token]` type indicator and `STATUS: token` — there is no expiration concept, so they are always ready to use. `remove` works the same as for OAuth profiles.
198
+
199
+ **Env-var pass-through (1.6.0+):** when `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` are already set in the environment and no profile is specified, `wrangler-accounts` runs in anonymous-token mode (no named profile needed). Useful for CI jobs that inject credentials via secrets:
200
+
201
+ ```bash
202
+ CLOUDFLARE_API_TOKEN=xxx CLOUDFLARE_ACCOUNT_ID=yyy wrangler-accounts deploy
203
+ ```
204
+
172
205
  ### Save, sync, login, remove
173
206
 
174
207
  - `wrangler-accounts save <name>` — snapshot current Wrangler config as a profile
175
208
  - `wrangler-accounts sync <name>` — refresh a specific profile from the current login
176
209
  - `wrangler-accounts sync-default` — refresh the default profile
177
210
  - `wrangler-accounts login <name>` — fresh isolated OAuth login
178
- - `wrangler-accounts remove <name>` — delete a profile
211
+ - `wrangler-accounts remove <name>` — delete a profile (works for both OAuth and token profiles)
179
212
 
180
213
  ### Clean up stale shadow HOMEs
181
214
 
@@ -303,9 +336,9 @@ wrangler deploy
303
336
 
304
337
  ## Troubleshooting
305
338
 
306
- ### "Profile 'X' has expired Wrangler OAuth credentials"
339
+ ### "Profile 'X' has expired Wrangler OAuth credentials and no refresh_token to renew them"
307
340
 
308
- The saved OAuth access token is past its `expiration_time` and the refresh didn't happen (or Wrangler is older than 4.x). Refresh it interactively:
341
+ The saved OAuth access_token is past its `expiration_time` AND there is no `refresh_token` in the profile config (no `offline_access` scope at login time, or the token was revoked). The profile is genuinely broken; only re-authenticating fixes it:
309
342
 
310
343
  ```bash
311
344
  wrangler-accounts login <name>
@@ -313,16 +346,20 @@ wrangler-accounts login <name>
313
346
 
314
347
  This overwrites the existing profile with a fresh OAuth session. Any saved metadata (identity, etc.) is re-verified.
315
348
 
349
+ **Note:** If you see this error in 1.5.0 or earlier, you may be hitting a known regression: any profile whose access_token had passed expiration was blocked even when a refresh_token was present. Upgrade to 1.5.1+ — `wrangler` itself silently refreshes those tokens on the next call, so wrangler-accounts no longer pre-flights against `expiration_time` alone.
350
+
316
351
  ### "No profile specified. Options: ..."
317
352
 
318
353
  The user ran `wrangler-accounts <wrangler-args>` without a resolvable profile. Fix one of:
319
354
 
320
355
  ```bash
321
- wrangler-accounts --profile <name> <args> # one-shot
356
+ wrangler-accounts --profile <name> <args> # one-shot
322
357
  WRANGLER_PROFILE=<name> wrangler-accounts <args> # env var
323
- wrangler-accounts default <name> # persistent default
358
+ wrangler-accounts default <name> # persistent default
324
359
  ```
325
360
 
361
+ **1.6.0+**: if `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` are both present in the environment, this error no longer fires — the tool runs in anonymous-token mode automatically.
362
+
326
363
  ### "Profile not found: X"
327
364
 
328
365
  The profile name doesn't exist in `profilesDir`. Check what's saved:
@@ -428,7 +465,32 @@ If a user is hitting a "wrong account" symptom and the credentials look right, t
428
465
 
429
466
  ## CI guidance
430
467
 
431
- For CI and deploy pipelines, **use `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` with plain `wrangler`**, not saved OAuth profiles. `wrangler-accounts` is a local developer convenience for juggling OAuth sessions on your workstation, not a CI primitive.
468
+ For CI and deploy pipelines you have two options:
469
+
470
+ **Option 1 — plain `wrangler` with env vars (simplest for CI):**
471
+
472
+ ```bash
473
+ CLOUDFLARE_API_TOKEN=<token>
474
+ CLOUDFLARE_ACCOUNT_ID=<account-id>
475
+ wrangler deploy
476
+ ```
477
+
478
+ **Option 2 — `wrangler-accounts` with a token profile (useful when you want the same CLI both locally and in CI):**
479
+
480
+ ```bash
481
+ # Save once (locally or during CI bootstrap):
482
+ wrangler-accounts token-add work "$CF_TOKEN" "$CF_ACCOUNT_ID"
483
+ # Use the same commands locally and in CI:
484
+ wrangler-accounts --profile work deploy
485
+ ```
486
+
487
+ Or use the anonymous pass-through (no profile needed if env vars are present):
488
+
489
+ ```bash
490
+ CLOUDFLARE_API_TOKEN="$CF_TOKEN" CLOUDFLARE_ACCOUNT_ID="$CF_ACCOUNT_ID" wrangler-accounts deploy
491
+ ```
492
+
493
+ `wrangler-accounts` is primarily a local developer convenience for juggling OAuth sessions on your workstation, but since 1.6.0 it also supports API token profiles for teams that want a consistent CLI interface across local and CI environments.
432
494
 
433
495
  ## Paths and environment
434
496
 
@@ -444,7 +506,7 @@ Use `--json` when another tool needs to parse results. All v1.0 commands that pr
444
506
 
445
507
  ## Naming rules
446
508
 
447
- Profile names: letters, numbers, dot, underscore, dash only. Names matching management subcommand names (`exec`, `default`, `whoami`, `gc`, `login`, `list`, `status`, `save`, `sync`, `sync-default`, `remove`, `use`, `sync-active`) cannot be reached via positional shorthand — use `--profile <name>` for those.
509
+ Profile names: letters, numbers, dot, underscore, dash only. Names matching management subcommand names (`exec`, `default`, `whoami`, `gc`, `login`, `token-add`, `list`, `status`, `save`, `sync`, `sync-default`, `remove`, `use`, `sync-active`) cannot be reached via positional shorthand — use `--profile <name>` for those.
448
510
 
449
511
  ## Deprecated
450
512
 
@@ -30,6 +30,13 @@ Non-Claude-Code users can keep using the `skills.sh` distribution path for this
30
30
  npx skills add leeguooooo/wrangler-accounts -g -y
31
31
  ```
32
32
 
33
+ If a Claude Code user previously installed via `skills.sh`, removing the standalone copy avoids a duplicate `/wrangler-accounts` entry in the command picker:
34
+
35
+ ```bash
36
+ npx skills remove wrangler-accounts
37
+ # or: rm -rf ~/.agents/skills/wrangler-accounts
38
+ ```
39
+
33
40
  ## Prerequisites (check before running any recipe below)
34
41
 
35
42
  This skill is only documentation — the actual `wrangler-accounts` binary must also be installed on the user's `PATH`. Before running any command below, verify:
@@ -59,6 +66,7 @@ If `wrangler-accounts --version` is below any of these, **upgrade first** before
59
66
  | **≥ 1.2.2** | per-profile `WRANGLER_CACHE_DIR`, fixes account-id leak across profiles | `d1`/`r2 object` commands return `7403 not authorized` even though OAuth is valid; deploys silently land in the wrong account |
60
67
  | **≥ 1.3.0** | STATUS column distinguishes `valid` / `valid*` / `EXPIRED` (refresh-token-aware) | `list` shows `EXPIRED` for healthy profiles, scaring you into running `login` for no reason |
61
68
  | **≥ 1.4.0** | `login` refuses non-TTY contexts and accidental overwrites | `login <name>` hangs forever in non-interactive contexts; reflexive `login` overwrites a healthy profile |
69
+ | **≥ 1.6.0** | API token profiles (`token-add`) + anonymous env-var pass-through | only OAuth profiles existed; `CLOUDFLARE_API_TOKEN` in env still required a named profile to be selected |
62
70
 
63
71
  ```bash
64
72
  npm i -g @leeguoo/wrangler-accounts@latest # always-safe upgrade
@@ -101,6 +109,7 @@ How to read `list --deep` output:
101
109
  ## Quick Start
102
110
 
103
111
  - `wrangler-accounts login <name>` — interactive OAuth login into a new profile (never touches real `~/.wrangler`)
112
+ - `wrangler-accounts token-add <name> <api-token> <account-id>` — save an API token profile (no browser login needed)
104
113
  - `wrangler-accounts default <name>` — set the persistent default profile
105
114
  - `wrangler-accounts deploy` — run `wrangler deploy` under the default profile
106
115
  - `wrangler-accounts --profile personal deploy` — one-shot override
@@ -162,6 +171,7 @@ Use `--json` for structured output.
162
171
  | `valid*` / `refreshable` | access_token past expiry **BUT** refresh_token present; wrangler will auto-refresh on next use | **none** — this is fine, don't scare the user |
163
172
  | `EXPIRED` / `expired` | access_token expired **AND** no refresh_token saved; profile is genuinely broken | `wrangler-accounts login <name>` |
164
173
  | `unknown` | profile file has no `expiration_time` field | run `list --deep` to verify live |
174
+ | `token` | API token profile (1.6.0+) — no expiration concept, always ready | none |
165
175
 
166
176
  **Cloudflare OAuth lifecycle reference:** access tokens are short-lived (~1 hour) by design. Every profile with `offline_access` in its scopes also has a long-lived refresh_token (~30 days, silently extended on use). Wrangler refreshes access tokens automatically whenever it runs a command and the current one is past expiry. **Do not tell the user to re-login just because `list` shows an expired access token** — check `hasRefreshToken` first. If the profile's STATUS is `valid*` / `refreshable`, nothing is wrong.
167
177
 
@@ -169,13 +179,36 @@ The only time a user actually needs `wrangler-accounts login <name>` again is:
169
179
  1. STATUS is `EXPIRED` (no refresh_token at all — profile was saved without `offline_access` scope)
170
180
  2. OR `list --deep` returns `✗` with "Not logged in" / "refresh token may be revoked" (refresh token itself got invalidated)
171
181
 
182
+ ### Save an API token profile (no browser required)
183
+
184
+ `wrangler-accounts token-add <name> <api-token> <account-id> [--force]`
185
+
186
+ Saves a Cloudflare API token + account ID as a named profile. No OAuth browser flow needed. The credentials are stored in `token.json` (mode 0600) inside the profile directory.
187
+
188
+ ```bash
189
+ # Get your API token from: Cloudflare dashboard → My Profile → API Tokens
190
+ wrangler-accounts token-add work CF_TOKEN_HERE ACCOUNT_ID_HERE
191
+
192
+ # Use identically to OAuth profiles
193
+ wrangler-accounts --profile work deploy
194
+ wrangler-accounts work r2 list
195
+ ```
196
+
197
+ Token profiles appear in `list` with a `[token]` type indicator and `STATUS: token` — there is no expiration concept, so they are always ready to use. `remove` works the same as for OAuth profiles.
198
+
199
+ **Env-var pass-through (1.6.0+):** when `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` are already set in the environment and no profile is specified, `wrangler-accounts` runs in anonymous-token mode (no named profile needed). Useful for CI jobs that inject credentials via secrets:
200
+
201
+ ```bash
202
+ CLOUDFLARE_API_TOKEN=xxx CLOUDFLARE_ACCOUNT_ID=yyy wrangler-accounts deploy
203
+ ```
204
+
172
205
  ### Save, sync, login, remove
173
206
 
174
207
  - `wrangler-accounts save <name>` — snapshot current Wrangler config as a profile
175
208
  - `wrangler-accounts sync <name>` — refresh a specific profile from the current login
176
209
  - `wrangler-accounts sync-default` — refresh the default profile
177
210
  - `wrangler-accounts login <name>` — fresh isolated OAuth login
178
- - `wrangler-accounts remove <name>` — delete a profile
211
+ - `wrangler-accounts remove <name>` — delete a profile (works for both OAuth and token profiles)
179
212
 
180
213
  ### Clean up stale shadow HOMEs
181
214
 
@@ -303,9 +336,9 @@ wrangler deploy
303
336
 
304
337
  ## Troubleshooting
305
338
 
306
- ### "Profile 'X' has expired Wrangler OAuth credentials"
339
+ ### "Profile 'X' has expired Wrangler OAuth credentials and no refresh_token to renew them"
307
340
 
308
- The saved OAuth access token is past its `expiration_time` and the refresh didn't happen (or Wrangler is older than 4.x). Refresh it interactively:
341
+ The saved OAuth access_token is past its `expiration_time` AND there is no `refresh_token` in the profile config (no `offline_access` scope at login time, or the token was revoked). The profile is genuinely broken; only re-authenticating fixes it:
309
342
 
310
343
  ```bash
311
344
  wrangler-accounts login <name>
@@ -313,16 +346,20 @@ wrangler-accounts login <name>
313
346
 
314
347
  This overwrites the existing profile with a fresh OAuth session. Any saved metadata (identity, etc.) is re-verified.
315
348
 
349
+ **Note:** If you see this error in 1.5.0 or earlier, you may be hitting a known regression: any profile whose access_token had passed expiration was blocked even when a refresh_token was present. Upgrade to 1.5.1+ — `wrangler` itself silently refreshes those tokens on the next call, so wrangler-accounts no longer pre-flights against `expiration_time` alone.
350
+
316
351
  ### "No profile specified. Options: ..."
317
352
 
318
353
  The user ran `wrangler-accounts <wrangler-args>` without a resolvable profile. Fix one of:
319
354
 
320
355
  ```bash
321
- wrangler-accounts --profile <name> <args> # one-shot
356
+ wrangler-accounts --profile <name> <args> # one-shot
322
357
  WRANGLER_PROFILE=<name> wrangler-accounts <args> # env var
323
- wrangler-accounts default <name> # persistent default
358
+ wrangler-accounts default <name> # persistent default
324
359
  ```
325
360
 
361
+ **1.6.0+**: if `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` are both present in the environment, this error no longer fires — the tool runs in anonymous-token mode automatically.
362
+
326
363
  ### "Profile not found: X"
327
364
 
328
365
  The profile name doesn't exist in `profilesDir`. Check what's saved:
@@ -428,7 +465,32 @@ If a user is hitting a "wrong account" symptom and the credentials look right, t
428
465
 
429
466
  ## CI guidance
430
467
 
431
- For CI and deploy pipelines, **use `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` with plain `wrangler`**, not saved OAuth profiles. `wrangler-accounts` is a local developer convenience for juggling OAuth sessions on your workstation, not a CI primitive.
468
+ For CI and deploy pipelines you have two options:
469
+
470
+ **Option 1 — plain `wrangler` with env vars (simplest for CI):**
471
+
472
+ ```bash
473
+ CLOUDFLARE_API_TOKEN=<token>
474
+ CLOUDFLARE_ACCOUNT_ID=<account-id>
475
+ wrangler deploy
476
+ ```
477
+
478
+ **Option 2 — `wrangler-accounts` with a token profile (useful when you want the same CLI both locally and in CI):**
479
+
480
+ ```bash
481
+ # Save once (locally or during CI bootstrap):
482
+ wrangler-accounts token-add work "$CF_TOKEN" "$CF_ACCOUNT_ID"
483
+ # Use the same commands locally and in CI:
484
+ wrangler-accounts --profile work deploy
485
+ ```
486
+
487
+ Or use the anonymous pass-through (no profile needed if env vars are present):
488
+
489
+ ```bash
490
+ CLOUDFLARE_API_TOKEN="$CF_TOKEN" CLOUDFLARE_ACCOUNT_ID="$CF_ACCOUNT_ID" wrangler-accounts deploy
491
+ ```
492
+
493
+ `wrangler-accounts` is primarily a local developer convenience for juggling OAuth sessions on your workstation, but since 1.6.0 it also supports API token profiles for teams that want a consistent CLI interface across local and CI environments.
432
494
 
433
495
  ## Paths and environment
434
496
 
@@ -444,7 +506,7 @@ Use `--json` when another tool needs to parse results. All v1.0 commands that pr
444
506
 
445
507
  ## Naming rules
446
508
 
447
- Profile names: letters, numbers, dot, underscore, dash only. Names matching management subcommand names (`exec`, `default`, `whoami`, `gc`, `login`, `list`, `status`, `save`, `sync`, `sync-default`, `remove`, `use`, `sync-active`) cannot be reached via positional shorthand — use `--profile <name>` for those.
509
+ Profile names: letters, numbers, dot, underscore, dash only. Names matching management subcommand names (`exec`, `default`, `whoami`, `gc`, `login`, `token-add`, `list`, `status`, `save`, `sync`, `sync-default`, `remove`, `use`, `sync-active`) cannot be reached via positional shorthand — use `--profile <name>` for those.
448
510
 
449
511
  ## Deprecated
450
512