@anyslate/cli 0.2.0 → 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
@@ -17,7 +17,7 @@ Most days you'll never type `anyslate` yourself. Here's what actually happens:
17
17
  | Trigger | Who calls the CLI | What lands |
18
18
  |---|---|---|
19
19
  | You open a Claude Code session | Claude Code's `SessionStart` hook | Session-start activity; the AnySlate session is created on first sight |
20
- | Claude edits `routes/auth.ts` | Claude Code's `PostToolUse` hook | "Edited routes/auth.ts" in the activity ledger (**awaits your approval** - see below) |
20
+ | Claude edits `routes/auth.ts` | Claude Code's `PostToolUse` hook | "Edited routes/auth.ts" in the activity ledger |
21
21
  | Claude runs `npm test` | Claude Code's `PostToolUse` hook | Command + output in the activity ledger |
22
22
  | You `git commit` | Git's `post-commit` hook | Commit metadata + diff stats captured |
23
23
  | Teammate merges your PR | GitHub webhook (no CLI needed) | PR-merged event captured |
@@ -39,11 +39,13 @@ If you want a decision recorded, type it:
39
39
  anyslate checkpoint --note "Decided: Redis for session cache, not memcached"
40
40
  ```
41
41
 
42
- ### Approval: file edits wait, shell commands don't
42
+ ### Approval: hook captures auto-promote
43
43
 
44
- The classifier treats `Edit` / `Write` / `MultiEdit` as **high-risk** - those rows stay pending in **AI Memory Activity** until you approve them. `Bash` and session lifecycle events classify **low-risk** and auto-promote on their own.
44
+ Every lifecycle hook - `Edit` / `Write` / `MultiEdit`, `Bash`, and the session start/stop markers - classifies **low-risk** and auto-promotes on its own, 30-90 seconds after capture. An edit-heavy session does **not** build an approval queue.
45
45
 
46
- This is deliberate, and it means a Claude Code session that edits a lot of files produces a queue you have to work through. Budget a few seconds a day for it, or approve in bulk.
46
+ Rows still wait for approval in **AI Memory Activity** when they are genuinely riskier: a capture aimed at a different session than the one that produced it, a decision that supersedes an earlier one, a submission from an untrusted source, or one carrying `confidence < 0.8`.
47
+
48
+ File edits used to map to `artifact_produced`, which is high-risk by classification. That was wrong on two counts: it forced a manual approval on the single most common event in a session, and `artifact_produced` is a contract meaning "I stored an artifact, here is its id" - which a hook can never satisfy, so the server rejected it. Edits now map to `task_completed` and carry the touched paths in the ledger.
47
49
 
48
50
  ## What you get back
49
51
 
@@ -75,22 +77,56 @@ Requires Node ≥ 20.
75
77
 
76
78
  ## Authenticate
77
79
 
78
- Mint an MCP token in the AnySlate app: **Avatar (top-right) → API Tokens → Tokens tab → Create Token**. This is the same dialog on the desktop app and on the cloud app at `https://cloud.anyslate.io`; the dialog is titled *MCP Tokens*. MCP token minting is **available on every plan** - Free, Pro and Unlimited - with no token quota.
80
+ ```bash
81
+ anyslate login
82
+ ```
83
+
84
+ That's it. `login` opens your browser, you approve the CLI on the AnySlate consent screen, and the credentials land in `~/.anyslate/cli.json` (mode `0600`). Nothing to copy, nothing to paste.
85
+
86
+ What happens under the hood, in case you're the kind of person who wants to know before you run it: OAuth 2.1 authorization code with PKCE. The CLI reads every endpoint from the server's discovery documents (`/.well-known/oauth-authorization-server` and `/.well-known/oauth-protected-resource`) rather than assuming any path, registers itself once via Dynamic Client Registration and caches the resulting client id, binds a short-lived listener on `127.0.0.1` for the callback, and verifies the resulting token with one live request before writing anything.
87
+
88
+ The access token lasts **one hour**; a refresh token lasts **30 days**. The CLI refreshes automatically — five minutes ahead of expiry, and again if a call comes back `401` — so your hooks keep capturing without you thinking about it. Refresh tokens are single-use and rotate on every refresh; the new one is written to disk immediately.
89
+
90
+ Then confirm the whole install:
91
+
92
+ ```bash
93
+ anyslate doctor
94
+ ```
79
95
 
80
- Copy the token (it starts with `as_mcp_`; you only see it once), then:
96
+ ### Signing in to a non-production environment
97
+
98
+ ```bash
99
+ anyslate login --api-url https://anyslate-mcp-service-development.example.workers.dev
100
+ ```
101
+
102
+ `--api-url` takes the service **root** (no `/mcp` suffix — see [the two URL conventions](#the-two-url-conventions---the-one-thing-people-get-wrong)). Every OAuth endpoint, and the OAuth `resource` identifier, is then read from *that* root's discovery documents. Dev and production issue different client ids, so the CLI caches one per root and reuses it.
103
+
104
+ If discovery fails, `login` says which host it tried and stops. It will not fall back to guessed endpoint paths.
105
+
106
+ ### Static tokens, for CI
107
+
108
+ Browsers are in short supply on a build agent. The token path is unchanged and stays supported:
81
109
 
82
110
  ```bash
83
111
  anyslate login --token as_mcp_your_token_here
112
+ # or, with no config file at all:
113
+ export ANYSLATE_MCP_TOKEN=as_mcp_your_token_here
84
114
  ```
85
115
 
86
- `login` verifies the token against the server before writing anything. If the host is unreachable, the URL isn't an AnySlate MCP service, or the token is unrecognised/revoked/expired, it prints the reason, **writes nothing, and exits non-zero**. See [`anyslate login`](#anyslate-login) for the `--force` / `--no-verify` escapes.
116
+ Mint the token in the AnySlate app: **Avatar (top-right) API Tokens → Tokens tab → Create Token**. This is the same dialog on the desktop app and on the cloud app at `https://cloud.anyslate.io`; the dialog is titled *MCP Tokens*. MCP token minting is **available on every plan** - Free, Pro and Unlimited - with no token quota. Copy the token (it starts with `as_mcp_`; you only see it once).
87
117
 
88
- Then confirm the whole install:
118
+ `login --token` verifies the token against the server before writing anything. If the host is unreachable, the URL isn't an AnySlate MCP service, or the token is unrecognised/revoked/expired, it prints the reason, **writes nothing, and exits non-zero**. See [`anyslate login`](#anyslate-login) for the `--force` / `--no-verify` escapes.
119
+
120
+ Static tokens never expire and are never refreshed. That is the trade: convenient for CI, and the reason the browser flow is the default everywhere else.
121
+
122
+ ### Signing out
89
123
 
90
124
  ```bash
91
- anyslate doctor
125
+ anyslate logout
92
126
  ```
93
127
 
128
+ Revokes the session server-side, then removes the credentials from `~/.anyslate/cli.json`. Your `apiUrl`, `handle` and cached client registration are kept, so `anyslate login` afterwards is a one-liner.
129
+
94
130
  ### Environment variables
95
131
 
96
132
  ```bash
@@ -104,6 +140,14 @@ export ANYSLATE_STDIN_TIMEOUT_MS=10000 # optional, stdin idle timeout
104
140
 
105
141
  Env vars override `~/.anyslate/cli.json`.
106
142
 
143
+ The bearer the CLI actually sends is resolved in this order, first hit wins:
144
+
145
+ 1. `ANYSLATE_MCP_TOKEN` — a static token from the environment. Never refreshed.
146
+ 2. The OAuth access token from `cli.json`, refreshed if it is expired or close to it.
147
+ 3. The static `mcp_token` in `cli.json`.
148
+
149
+ `anyslate doctor` prints which of the three won, so an `ANYSLATE_MCP_TOKEN` you exported weeks ago and forgot cannot silently shadow a browser sign-in.
150
+
107
151
  `ANYSLATE_DISABLE=1` stops `hook` / `checkpoint` / `upload-artifact` from making any network call (they exit 0). `doctor` and `login` still run, so you can diagnose and set up while capture is off. Any value counts as "on" except empty, `0`, `false`, `no`, `off`.
108
152
 
109
153
  Capability handles are `mh_` + a 32-character id (e.g. `mh_V1StGXR8Z5jdHi6BmyT0aQx3nKpL7cWe`), minted alongside a scoped token in the **Handles** tab of the same dialog. **A handle id that isn't yours or doesn't exist is rejected server-side** - since 0.2.0 the CLI reports that as a failure instead of exiting 0 silently.
@@ -134,8 +178,10 @@ anyslate: using "https://mcp.anyslate.io". Run `anyslate login --api-url https:/
134
178
  anyslate hook <session-start|post-tool-use|stop> [--strict] [--session <id>] [--note <text>] [--host <hint>]
135
179
  anyslate checkpoint --note "..." [--kind milestone] [--session <id>] [--host <hint>] [--source <source>]
136
180
  anyslate upload-artifact --session <id> --kind <kind> [--file <path>] [--language <lang>] [--path-hint <path>]
181
+ anyslate login [--api-url <URL>] [--no-browser] [--timeout <seconds>] [--handle <ID>]
137
182
  anyslate login --token <BEARER> [--handle <ID>] [--api-url <URL>] [--force] [--no-verify]
138
- anyslate doctor [--deep]
183
+ anyslate logout [--local]
184
+ anyslate doctor [--deep] [--refresh]
139
185
  anyslate version | help
140
186
  ```
141
187
 
@@ -143,7 +189,11 @@ anyslate version | help
143
189
 
144
190
  The one command whose job is to fail loudly. Run it after setup and any time capture seems dead. It exits non-zero if any check FAILs.
145
191
 
146
- It checks, in order: which config layer won (env var vs `cli.json`) per key; that a token is present; that the token *looks* like a token; that the URL parses and has the right shape (**before** any token verdict, because a wrong URL 404s ahead of auth and would otherwise be misread as a bad token); that the host is reachable and healthy; that the token is valid (and, if not, whether it is unrecognised, revoked or expired); that the token carries `memory:write`; the effective handle scope; that `anyslate` is on the PATH a hook subprocess would get; and whether Claude Code's hooks are actually wired in `~/.claude/settings.json`.
192
+ It checks, in order: which config layer won (env var vs `cli.json`) per key; that a credential is present; **which auth mode is in use** — OAuth or static token — and for OAuth how long the access token has left and whether a refresh token is stored; that the token *looks* like a token; that the URL parses and has the right shape (**before** any token verdict, because a wrong URL 404s ahead of auth and would otherwise be misread as a bad token); that the host is reachable and healthy; **whether refresh works**; that the token is valid (and, if not, whether it is unrecognised, revoked or expired); that the token carries `memory:write`; the effective handle scope; that `anyslate` is on the PATH a hook subprocess would get; and whether Claude Code's hooks are actually wired in `~/.claude/settings.json`.
193
+
194
+ The refresh check runs **after** reachability on purpose: a refresh against a host that is down fails for a reason that has nothing to do with your credentials, and reporting that as "refresh is broken" sends you hunting for the wrong bug. If the access token is still comfortably valid, `doctor` reports the refresh token as present but does not spend it — pass `--refresh` to force a real refresh round trip (which rotates the token).
195
+
196
+ An expired OAuth access token is a **WARN**, not a FAIL: access tokens are supposed to expire hourly. The FAIL you care about is `oauth-refresh`, which means renewal itself is broken and you need to run `anyslate login` again.
147
197
 
148
198
  Reading the output:
149
199
 
@@ -151,7 +201,7 @@ Reading the output:
151
201
  - **WARN** - works, but something is narrower or more fragile than you probably intend (e.g. a project-scoped token, or a config key coming from an env var you forgot you exported).
152
202
  - **FAIL** - this is why capture isn't working. Each FAIL prints the specific remedy.
153
203
 
154
- `doctor` is **not** side-effect-free: verifying the token updates its `last_used_at`, consumes rate-limit budget, and writes an audit-log row. `--deep` additionally submits a real `doctor_probe` activity, which is visible in your Activity feed - that's why it's opt-in.
204
+ `doctor` is **not** side-effect-free: verifying the token updates its `last_used_at`, consumes rate-limit budget, and writes an audit-log row. `--deep` additionally submits a real `doctor_probe` activity, which is visible in your Activity feed - that's why it's opt-in. `--refresh` forces an OAuth refresh, which rotates your refresh token - also why it's opt-in.
155
205
 
156
206
  ### `anyslate hook ...`
157
207
 
@@ -185,14 +235,39 @@ Content is read as UTF-8. Binary files are not supported and are refused rather
185
235
 
186
236
  ### `anyslate login`
187
237
 
188
- Verifies, then writes `~/.anyslate/cli.json` with mode `0600`. Idempotent - preserves the apiUrl/handle if you only update the token (the preserved apiUrl is re-normalized and re-verified).
238
+ Two paths, one config file. `--token` selects the static path; its absence selects the browser flow.
239
+
240
+ Both end the same way: one live request to `/mcp/auth/verify` that proves reachability, URL shape, token validity and scopes in a single round trip, then a `0600` write to `~/.anyslate/cli.json`. **On verification failure it writes nothing and exits non-zero.** Both are idempotent — they preserve the `apiUrl` and `handle` you already had (and re-normalize a stored `apiUrl` that carries a stale `/mcp` suffix).
241
+
242
+ A token missing the `memory:write` scope is a **warning**, not a block - but `anyslate hook` needs it, so heed it.
243
+
244
+ **Browser flow flags**
245
+
246
+ - `--api-url <root>` - which environment to sign in to. Defaults to production. Every OAuth endpoint comes from that root's discovery documents.
247
+ - `--no-browser` - print the authorization URL instead of launching anything. For SSH sessions and containers. The URL is printed either way, so a browser that fails to appear never leaves you stuck.
248
+ - `--timeout <seconds>` - how long to wait for the callback. Default `180`.
249
+ - `--handle <ID>` - store a capability handle alongside the credentials.
189
250
 
190
- Verification is a single request that proves reachability, URL shape, token existence, revocation, expiry and scopes in one round trip. **On failure it writes nothing and exits non-zero.** Two escapes:
251
+ **Static-token flags**
191
252
 
192
253
  - `--force` - write anyway, with a warning. For when you know the server is temporarily down.
193
254
  - `--no-verify` - skip the probe entirely. For air-gapped or offline setup.
194
255
 
195
- A token missing the `memory:write` scope is a **warning**, not a block - but `anyslate hook` needs it, so heed it.
256
+ **What gets stored.** The browser flow writes an `oauth` block: the client id, the access token, the refresh token, the absolute expiry, and the token endpoint and `resource` it discovered (cached so a background refresh costs one request instead of three, and bound to the root so switching environments re-discovers rather than reusing the wrong one). The static flow writes `mcp_token`, exactly as before. Neither path touches the other's keys.
257
+
258
+ **Refresh.** Handled automatically by whichever command needs it — `hook`, `checkpoint`, `upload-artifact`, `doctor`. It happens five minutes before expiry, and once more on a `401`. Since hooks can fire in parallel, refreshes are serialized with a lock file and the config is replaced atomically, so two concurrent hooks cannot lose each other's rotated token. A hook **never** opens a browser: if refresh fails there, it logs the reason and exits 0 like any other failure.
259
+
260
+ ### `anyslate logout`
261
+
262
+ Revokes the OAuth session at the server's revocation endpoint (refresh token first — it's the 30-day one), then removes `oauth` and `mcp_token` from `~/.anyslate/cli.json`.
263
+
264
+ Revocation is best effort. If the host is unreachable the credentials are still cleared and you get a warning, because a `logout` that refuses to run offline is a `logout` you cannot use when you most need one.
265
+
266
+ `apiUrl`, `handle` and the cached client registration survive. The registration is not a credential, and re-registering costs one of the ten Dynamic Client Registrations allowed per hour.
267
+
268
+ - `--local` - skip revocation and only clear the local file. The server-side session then stays live until it expires.
269
+
270
+ If `ANYSLATE_MCP_TOKEN` is set in your environment, `logout` says so: it overrides the config, so capture keeps working until you unset it.
196
271
 
197
272
  ## Wiring into Claude Code
198
273
 
@@ -258,7 +333,16 @@ Set `ANYSLATE_DISABLE=1` in the shell where you're running the AI tool. The CLI
258
333
  Setting `ANYSLATE_MCP_TOKEN=` (empty) does **not** disable capture - an empty string falls through to the token in `~/.anyslate/cli.json`, so capture keeps running. Earlier versions of this README claimed otherwise; that claim was wrong. Use `ANYSLATE_DISABLE=1`.
259
334
 
260
335
  **Where does the data live?**
261
- In your AnySlate workspace. The CLI is a stateless client; it sends events to the AnySlate MCP service over HTTPS and stores nothing locally beyond your token in `~/.anyslate/cli.json` and the local run logs described under `anyslate hook` (all mode 0600).
336
+ In your AnySlate workspace. The CLI is a stateless client; it sends events to the AnySlate MCP service over HTTPS and stores nothing locally beyond your credentials in `~/.anyslate/cli.json` and the local run logs described under `anyslate hook` (all mode 0600). The run logs redact anything token-shaped before writing.
337
+
338
+ **Do I need to log in again every hour?**
339
+ No. The one-hour lifetime is the access token's; the CLI renews it in the background from a 30-day refresh token, including inside hooks running unattended. You'll re-authenticate roughly monthly, or whenever you run `anyslate logout`.
340
+
341
+ **I'm on a headless box / over SSH. Can I still use the browser flow?**
342
+ Yes - `anyslate login --no-browser` prints the URL, you open it on a machine that has a browser, and it redirects back to `127.0.0.1` on the port the CLI is listening on. That only works if the browser can reach that loopback address, so from a remote box you'll want an SSH tunnel - or just use `--token`, which is what it's there for.
343
+
344
+ **Is the CLI adding dependencies to do OAuth?**
345
+ No. It has no runtime dependencies. PKCE, the loopback listener and the browser launch are all `node:` builtins, and there's a test that fails the build if an import ever points outside them.
262
346
 
263
347
  **Can I see what was captured?**
264
348
  Yes - **AI Memory → Activity** shows every captured row with its status (pending / merged / rejected / failed), and failures under the **Failed** tab carry the reason. High-risk items wait for approval; low-risk items promote on their own in 30-90 seconds.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anyslate/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "AnySlate CLI - lifecycle hooks, git/CI capture, and manual checkpoints for AI memory. Validates its connection at login, diagnoses itself with `anyslate doctor`, and fails open without failing silent.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/auth.mjs ADDED
@@ -0,0 +1,310 @@
1
+ // Bearer resolution and refresh for every command that talks to the server.
2
+ //
3
+ // THIS IS THE LOAD-BEARING PART OF THE OAUTH WORK. A static `as_mcp_` token
4
+ // never expires, so `loadConfig().mcpToken` was the whole auth story. An OAuth
5
+ // access token lives 3600 seconds, and hooks fire unattended in
6
+ // non-interactive shells — so a token WILL expire mid-session, in a process
7
+ // with no terminal, no browser, and a hard "must exit 0" contract.
8
+ //
9
+ // The rules that fall out of that:
10
+ //
11
+ // * Refresh PROACTIVELY, 5 minutes ahead of expiry, before the call. Waiting
12
+ // for the 401 spends a round trip and, on the hook path, risks the 15s
13
+ // budget.
14
+ // * On a 401, refresh EXACTLY ONCE and retry EXACTLY ONCE. A loop here is a
15
+ // self-inflicted rate limit against an endpoint that is already refusing us.
16
+ // * Persist the rotated refresh token immediately (rotation is on server-side).
17
+ // * NEVER open a browser. This module is imported by `hook`; a browser launch
18
+ // from a lifecycle hook would spray tabs across the user's screen and hang
19
+ // a process that must not hang.
20
+ // * Never throw. Callers are fail-open paths; a rejected promise here would
21
+ // become an uncaught exception in a hook and a non-zero exit.
22
+
23
+ import { readConfigFile, updateConfigFile, withRefreshLock } from './credentials.mjs';
24
+ import { callTool } from './mcp-client.mjs';
25
+ import { discover, refreshAccessToken, REFRESH_SKEW_MS } from './oauth.mjs';
26
+
27
+ export { REFRESH_SKEW_MS };
28
+
29
+ /** Everything a hook/checkpoint/upload path is allowed to say about auth. */
30
+ export const RELOGIN_HINT = 'Run `anyslate login` to sign in again.';
31
+
32
+ /**
33
+ * @param {unknown} expiresAt ISO 8601
34
+ * @returns {number|null} epoch ms, or null when unparseable
35
+ */
36
+ export function expiryMs(expiresAt) {
37
+ if (typeof expiresAt !== 'string' || !expiresAt) return null;
38
+ const t = Date.parse(expiresAt);
39
+ return Number.isFinite(t) ? t : null;
40
+ }
41
+
42
+ /**
43
+ * Is this credential inside the refresh window (or already dead)?
44
+ *
45
+ * An UNPARSEABLE or ABSENT expiry counts as expiring. Treating "I do not know"
46
+ * as "still valid" is how a config written by a future version, or hand-edited,
47
+ * turns into a silent 401 loop.
48
+ *
49
+ * @param {{expires_at?: string}|null|undefined} oauth
50
+ * @param {number} [skewMs]
51
+ * @param {number} [now]
52
+ */
53
+ export function isExpiring(oauth, skewMs = REFRESH_SKEW_MS, now = Date.now()) {
54
+ if (!oauth?.access_token) return true;
55
+ const t = expiryMs(oauth.expires_at);
56
+ if (t == null) return true;
57
+ return t - now <= skewMs;
58
+ }
59
+
60
+ /** Hard expiry, no skew — used for reporting, not for refresh decisions. */
61
+ export function isExpired(oauth, now = Date.now()) {
62
+ return isExpiring(oauth, 0, now);
63
+ }
64
+
65
+ /** Minutes until expiry, for human output. Negative means already expired. */
66
+ export function minutesUntilExpiry(oauth, now = Date.now()) {
67
+ const t = expiryMs(oauth?.expires_at);
68
+ if (t == null) return null;
69
+ return Math.round((t - now) / 60_000);
70
+ }
71
+
72
+ /** Does this config hold OAuth credentials worth using or refreshing? */
73
+ export function hasOauthCredentials(oauth) {
74
+ return !!(oauth && (oauth.access_token || oauth.refresh_token));
75
+ }
76
+
77
+ /**
78
+ * Resolve the endpoints used for a refresh.
79
+ *
80
+ * `login` caches `token_endpoint`/`resource`/`root` alongside the tokens, so
81
+ * the common refresh costs ONE request rather than three. The cache is only
82
+ * trusted when it was recorded for the root we are actually calling — a
83
+ * `--api-url` switch from prod to dev must re-discover, because the two issue
84
+ * different client ids and different `resource` values.
85
+ *
86
+ * @returns {Promise<{ok: true, tokenEndpoint: string, resource: string}
87
+ * | {ok: false, code: string, message: string}>}
88
+ */
89
+ async function refreshEndpoints({ oauth, root, fetchImpl }) {
90
+ if (oauth?.root === root && oauth?.token_endpoint && oauth?.resource) {
91
+ return { ok: true, tokenEndpoint: oauth.token_endpoint, resource: oauth.resource };
92
+ }
93
+ const found = await discover({ root, fetchImpl });
94
+ if (!found.ok) return found;
95
+ return { ok: true, tokenEndpoint: found.tokenEndpoint, resource: found.resource };
96
+ }
97
+
98
+ /**
99
+ * Perform one refresh, under the lock, persisting the rotated token.
100
+ *
101
+ * `staleToken` is the access token that just failed. It exists because expiry
102
+ * alone cannot decide whether a refresh is still needed: a token the server
103
+ * rejected with a 401 may have an `expires_at` an hour in the future (revoked
104
+ * early, clock skew, a config restored from a backup). Without it, the
105
+ * "somebody else already refreshed" short-circuit hands the caller back the
106
+ * very token that just 401'd, and the retry fails identically.
107
+ *
108
+ * @param {{env: NodeJS.ProcessEnv, root: string, fetchImpl?: typeof fetch, now?: number,
109
+ * staleToken?: string|null}} opts
110
+ * @returns {Promise<{ok: true, token: string, rotated: boolean, oauth: object}
111
+ * | {ok: false, code: string, message: string}>}
112
+ */
113
+ export async function refreshCredentials({ env, root, fetchImpl = fetch, now, staleToken = null }) {
114
+ return withRefreshLock(
115
+ async () => {
116
+ // Re-read INSIDE the lock. While we queued, a sibling hook may have done
117
+ // the whole refresh already — redeeming our (now-replaced) refresh token
118
+ // would fail and, worse, would overwrite theirs.
119
+ const disk = readConfigFile(env);
120
+ const oauth = disk.oauth && typeof disk.oauth === 'object' ? disk.oauth : null;
121
+
122
+ if (!hasOauthCredentials(oauth)) {
123
+ return { ok: false, code: 'no_oauth_credentials', message: 'anyslate: no OAuth credentials to refresh.' };
124
+ }
125
+
126
+ // Skip only when the on-disk token is BOTH unexpired AND different from
127
+ // the one we are replacing — i.e. genuinely somebody else's fresh token.
128
+ const supersededByPeer = staleToken ? oauth.access_token && oauth.access_token !== staleToken : true;
129
+ if (supersededByPeer && !isExpiring(oauth, REFRESH_SKEW_MS, now)) {
130
+ return { ok: true, token: oauth.access_token, rotated: false, oauth };
131
+ }
132
+ if (!oauth.refresh_token) {
133
+ return {
134
+ ok: false,
135
+ code: 'no_refresh_token',
136
+ message: `anyslate: the stored OAuth session has no refresh token and its access token has expired. ${RELOGIN_HINT}`,
137
+ };
138
+ }
139
+
140
+ const endpoints = await refreshEndpoints({ oauth, root, fetchImpl });
141
+ if (!endpoints.ok) return endpoints;
142
+
143
+ const res = await refreshAccessToken({
144
+ tokenEndpoint: endpoints.tokenEndpoint,
145
+ refreshToken: oauth.refresh_token,
146
+ clientId: oauth.client_id,
147
+ resource: endpoints.resource,
148
+ fetchImpl,
149
+ now,
150
+ });
151
+ if (!res.ok) {
152
+ return {
153
+ ok: false,
154
+ code: res.code || 'refresh_failed',
155
+ message: `${res.message} ${RELOGIN_HINT}`,
156
+ };
157
+ }
158
+
159
+ // Persist BEFORE returning. Rotation means the old refresh token is dead
160
+ // the instant the server answered; a crash between here and the next
161
+ // write would lose the only usable credential.
162
+ const next = {
163
+ ...oauth,
164
+ access_token: res.tokens.access_token,
165
+ // A response that omits refresh_token means "keep the one you have".
166
+ refresh_token: res.tokens.refresh_token || oauth.refresh_token,
167
+ expires_at: res.tokens.expires_at,
168
+ token_endpoint: endpoints.tokenEndpoint,
169
+ resource: endpoints.resource,
170
+ root,
171
+ };
172
+ try {
173
+ updateConfigFile((current) => ({ ...current, oauth: { ...(current.oauth ?? {}), ...next } }), env);
174
+ } catch (e) {
175
+ return {
176
+ ok: false,
177
+ code: 'persist_failed',
178
+ message: `anyslate: refreshed the OAuth session but could not write the config (${e?.message ?? e}).`,
179
+ };
180
+ }
181
+ return { ok: true, token: next.access_token, rotated: true, oauth: next };
182
+ },
183
+ { env },
184
+ );
185
+ }
186
+
187
+ /**
188
+ * The bearer for the next call, refreshing if needed.
189
+ *
190
+ * Precedence mirrors loadConfig: ANYSLATE_MCP_TOKEN → OAuth → static
191
+ * `mcp_token` → nothing. The one deliberate divergence: loadConfig cannot
192
+ * refresh (it is synchronous), so an EXPIRED OAuth credential falls through
193
+ * there while here it is refreshed and preferred. If the refresh fails and a
194
+ * static token also exists, we fall back to it rather than failing the call —
195
+ * with `warning` set, so the caller can say so.
196
+ *
197
+ * @param {{cfg: object, env: NodeJS.ProcessEnv, fetchImpl?: typeof fetch, force?: boolean, now?: number}} opts
198
+ * @returns {Promise<{ok: true, token: string, mode: 'env'|'oauth'|'static', refreshed: boolean, warning?: string}
199
+ * | {ok: false, code: string, message: string, mode: string}>}
200
+ */
201
+ export async function resolveBearer({ cfg, env, fetchImpl = fetch, force = false, now }) {
202
+ if (cfg?.sources?.mcpToken === 'env' && cfg.mcpToken) {
203
+ return { ok: true, token: cfg.mcpToken, mode: 'env', refreshed: false };
204
+ }
205
+
206
+ const oauth = cfg?.oauth ?? null;
207
+ if (hasOauthCredentials(oauth)) {
208
+ if (!force && !isExpiring(oauth, REFRESH_SKEW_MS, now)) {
209
+ return { ok: true, token: oauth.access_token, mode: 'oauth', refreshed: false };
210
+ }
211
+ // `force` means "the token we just used was rejected" — name it, so the
212
+ // lock's peer-refresh check cannot hand the same rejected token back.
213
+ const refreshed = await refreshCredentials({
214
+ env,
215
+ root: cfg.apiUrl,
216
+ fetchImpl,
217
+ now,
218
+ staleToken: force ? (oauth.access_token ?? null) : null,
219
+ });
220
+ if (refreshed.ok) {
221
+ return { ok: true, token: refreshed.token, mode: 'oauth', refreshed: refreshed.rotated };
222
+ }
223
+ if (cfg.staticToken) {
224
+ return {
225
+ ok: true,
226
+ token: cfg.staticToken,
227
+ mode: 'static',
228
+ refreshed: false,
229
+ warning: `anyslate: OAuth refresh failed (${refreshed.code}) — falling back to the static token in your config.`,
230
+ };
231
+ }
232
+ return { ...refreshed, mode: 'oauth' };
233
+ }
234
+
235
+ if (cfg?.mcpToken) {
236
+ return { ok: true, token: cfg.mcpToken, mode: 'static', refreshed: false };
237
+ }
238
+
239
+ return {
240
+ ok: false,
241
+ mode: 'none',
242
+ code: 'no_credentials',
243
+ message:
244
+ 'no credentials configured. Run `anyslate login` to sign in with your browser, or `anyslate login --token <BEARER>` for CI. Mint a token in the AnySlate desktop app at Avatar (top-right) → API Tokens.',
245
+ };
246
+ }
247
+
248
+ /**
249
+ * `callTool` with the auth lifecycle wrapped around it: proactive refresh, then
250
+ * at most one refresh-and-retry on a 401.
251
+ *
252
+ * The retry is gated on `mode === 'oauth'`: a static token that 401s is revoked
253
+ * or mistyped, and re-sending it cannot help.
254
+ *
255
+ * @param {{cfg: object, env: NodeJS.ProcessEnv, toolName: string, args: object,
256
+ * fetchImpl?: typeof fetch, timeoutMs?: number}} opts
257
+ * @returns {Promise<object>} the callTool result, plus `authMode` / `authWarning`,
258
+ * or an auth failure shaped like a callTool failure (`authError: true`).
259
+ */
260
+ export async function callToolWithAuth({ cfg, env, toolName, args, fetchImpl, timeoutMs }) {
261
+ const first = await resolveBearer({ cfg, env, fetchImpl });
262
+ if (!first.ok) {
263
+ return { ok: false, status: 0, data: first.message, raw: null, authError: true, authMode: first.mode };
264
+ }
265
+
266
+ const invoke = (token) =>
267
+ callTool({ apiUrl: cfg.apiUrl, token, toolName, args, fetchImpl, timeoutMs });
268
+
269
+ let res = await invoke(first.token);
270
+ res.authMode = first.mode;
271
+ if (first.warning) res.authWarning = first.warning;
272
+
273
+ const retryable = !res.ok && res.status === 401 && first.mode === 'oauth' && !first.refreshed;
274
+ if (!retryable) return res;
275
+
276
+ const second = await resolveBearer({ cfg, env, fetchImpl, force: true });
277
+ if (!second.ok) {
278
+ res.authError = true;
279
+ res.authRefreshFailed = second.message;
280
+ return res;
281
+ }
282
+ const retried = await invoke(second.token);
283
+ retried.authMode = second.mode;
284
+ retried.authRetried = true;
285
+ if (second.warning) retried.authWarning = second.warning;
286
+ return retried;
287
+ }
288
+
289
+ /**
290
+ * The failure line for a result that failed on AUTH rather than on transport.
291
+ *
292
+ * `formatCallFailure` renders `server ${status} — ${data}`, which for an auth
293
+ * failure that never reached the server would print `server 0` — an HTTP status
294
+ * that did not happen. Returns null when this is an ordinary failure and the
295
+ * existing formatter should be used instead.
296
+ *
297
+ * @param {string} prefix
298
+ * @param {object} res
299
+ * @returns {string|null}
300
+ */
301
+ export function formatAuthFailure(prefix, res) {
302
+ if (!res?.authError) return null;
303
+ if (res.authRefreshFailed) {
304
+ return `${prefix}: server ${res.status} and the OAuth refresh failed — ${res.authRefreshFailed}\n`;
305
+ }
306
+ if (res.status === 0 && !res.networkError) {
307
+ return `${prefix}: ${typeof res.data === 'string' ? res.data : JSON.stringify(res.data)}\n`;
308
+ }
309
+ return null;
310
+ }
@@ -12,7 +12,8 @@
12
12
  // stdout carries results only. Every error goes to stderr.
13
13
 
14
14
  import { loadConfig, requireToken, apiUrlNormalizationNotice, DISABLED_NOTICE } from '../config.mjs';
15
- import { callTool, formatCallFailure } from '../mcp-client.mjs';
15
+ import { formatCallFailure } from '../mcp-client.mjs';
16
+ import { callToolWithAuth, formatAuthFailure } from '../auth.mjs';
16
17
  import { recordRun } from '../runlog.mjs';
17
18
  import { VERSION } from '../version.mjs';
18
19
  import { makeIo } from '../io.mjs';
@@ -51,10 +52,11 @@ export async function runCheckpoint(argv, deps = {}) {
51
52
  const notice = apiUrlNormalizationNotice(cfg);
52
53
  if (notice) err.write(notice);
53
54
 
54
- const tokenCheck = requireToken(cfg);
55
- if (!tokenCheck.ok) {
56
- err.write(`anyslate checkpoint: ${tokenCheck.error}\n`);
57
- recordRun({ command: 'checkpoint', ok: false, apiUrl: cfg.apiUrl, error: tokenCheck.error, version: VERSION, exitCode: 1 }, env);
55
+ // Presence only — an expired OAuth token is refreshed by callToolWithAuth.
56
+ if (cfg.authMode === 'none') {
57
+ const error = requireToken({ mcpToken: null }).error;
58
+ err.write(`anyslate checkpoint: ${error}\n`);
59
+ recordRun({ command: 'checkpoint', ok: false, apiUrl: cfg.apiUrl, error, version: VERSION, exitCode: 1 }, env);
58
60
  return 1;
59
61
  }
60
62
 
@@ -74,15 +76,16 @@ export async function runCheckpoint(argv, deps = {}) {
74
76
  if (flags.session) args.session_id_hint = flags.session;
75
77
 
76
78
  try {
77
- const res = await callTool({
78
- apiUrl: cfg.apiUrl,
79
- token: cfg.mcpToken,
79
+ const res = await callToolWithAuth({
80
+ cfg,
81
+ env,
80
82
  toolName: 'activity_submit',
81
83
  args,
82
84
  fetchImpl: deps.fetchImpl,
83
85
  });
86
+ if (res.authWarning) err.write(`${res.authWarning}\n`);
84
87
  if (!res.ok) {
85
- const message = formatCallFailure('anyslate checkpoint', res);
88
+ const message = formatAuthFailure('anyslate checkpoint', res) ?? formatCallFailure('anyslate checkpoint', res);
86
89
  err.write(message);
87
90
  recordRun(
88
91
  {