@anyslate/cli 0.1.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.
@@ -0,0 +1,131 @@
1
+ // `anyslate logout` - revoke the OAuth session, then clear local credentials.
2
+ //
3
+ // This was genuinely missing. Before OAuth the only way to "log out" was to
4
+ // hand-edit or delete `~/.anyslate/cli.json`, which also threw away the apiUrl
5
+ // and handle — so the documented recovery was "delete the file and set
6
+ // everything up again". With refresh tokens that gap becomes a security
7
+ // problem, not just an annoyance: a 30-day refresh token stays live on disk
8
+ // with no supported way to invalidate it.
9
+ //
10
+ // ORDER MATTERS, AND SO DOES FAILING SOFT. Revoke first (best effort), clear
11
+ // second, ALWAYS. If revocation is skipped on a network error the user is left
12
+ // holding a credential they believe is dead. If clearing is skipped because
13
+ // revocation failed, `logout` becomes unusable exactly when it is most needed —
14
+ // offline, or against a server that is down. So a failed revoke is reported and
15
+ // the local credential is removed regardless.
16
+ //
17
+ // What is deliberately NOT removed: `apiUrl`, `handle`, and the cached
18
+ // `oauth_clients` map. The client registration is not a credential (it grants
19
+ // nothing on its own), and dropping it would burn one of the 10-per-hour
20
+ // Dynamic Client Registrations on the next login.
21
+
22
+ import { join } from 'node:path';
23
+ import { anyslateDir, loadConfig } from '../config.mjs';
24
+ import { readConfigFile, writeConfigFile } from '../credentials.mjs';
25
+ import { discover, revocationEndpointFor, revokeToken } from '../oauth.mjs';
26
+ import { makeIo } from '../io.mjs';
27
+
28
+ /**
29
+ * @param {string[]} argv arguments after `logout`
30
+ * @param {{env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch}} [deps]
31
+ * @returns {Promise<number>}
32
+ */
33
+ export async function runLogout(argv = [], deps = {}) {
34
+ const env = deps.env ?? process.env;
35
+ const fetchImpl = deps.fetchImpl ?? fetch;
36
+ const { out, err } = makeIo(deps);
37
+ const flags = parseFlags(argv);
38
+
39
+ const cfg = loadConfig(env);
40
+ const current = readConfigFile(env);
41
+ const oauth = current.oauth && typeof current.oauth === 'object' ? current.oauth : null;
42
+ const hadOauth = !!(oauth && (oauth.access_token || oauth.refresh_token));
43
+ const hadStatic = !!(current.mcp_token || current.token);
44
+
45
+ if (!hadOauth && !hadStatic) {
46
+ out.write('anyslate: no stored credentials — nothing to log out of.\n');
47
+ if (env.ANYSLATE_MCP_TOKEN) {
48
+ out.write('anyslate: note — ANYSLATE_MCP_TOKEN is set in your environment; unset it to fully sign out.\n');
49
+ }
50
+ return 0;
51
+ }
52
+
53
+ // --- 1. Revoke (best effort) --------------------------------------------
54
+ if (hadOauth && !flags.local) {
55
+ const root = oauth.root || cfg.apiUrl;
56
+ const discovery = await discover({ root, fetchImpl });
57
+ const endpoint = revocationEndpointFor(discovery.ok ? discovery : null, root);
58
+
59
+ // Revoke the refresh token first: it is the long-lived one (30 days), and
60
+ // it is what an attacker with a stale config file would actually use.
61
+ const targets = [
62
+ oauth.refresh_token ? { token: oauth.refresh_token, hint: 'refresh_token' } : null,
63
+ oauth.access_token ? { token: oauth.access_token, hint: 'access_token' } : null,
64
+ ].filter(Boolean);
65
+
66
+ let revoked = 0;
67
+ let failed = null;
68
+ for (const t of targets) {
69
+ const res = await revokeToken({
70
+ revocationEndpoint: endpoint,
71
+ token: t.token,
72
+ clientId: oauth.client_id,
73
+ tokenTypeHint: t.hint,
74
+ fetchImpl,
75
+ });
76
+ if (res.ok) revoked += 1;
77
+ else failed = res.detail ?? 'unknown error';
78
+ }
79
+
80
+ if (revoked === targets.length) {
81
+ out.write(`anyslate: revoked the OAuth session at ${hostOf(endpoint)}.\n`);
82
+ } else {
83
+ err.write(
84
+ `anyslate: warning — could not revoke the OAuth session at ${hostOf(endpoint)} (${failed}). ` +
85
+ 'Clearing the local credentials anyway; revoke the token in the app if that matters.\n',
86
+ );
87
+ }
88
+ } else if (hadOauth && flags.local) {
89
+ err.write('anyslate: --local given — skipping revocation; the server-side session stays live until it expires.\n');
90
+ }
91
+
92
+ // --- 2. Clear (always) ---------------------------------------------------
93
+ const path = join(anyslateDir(env), 'cli.json');
94
+ try {
95
+ const disk = readConfigFile(env);
96
+ delete disk.oauth;
97
+ delete disk.mcp_token;
98
+ delete disk.token;
99
+ writeConfigFile(disk, env);
100
+ } catch (e) {
101
+ err.write(`anyslate logout: could not update ${path} (${e?.message ?? e})\n`);
102
+ return 1;
103
+ }
104
+
105
+ const cleared = [hadOauth && 'OAuth session', hadStatic && 'static token'].filter(Boolean).join(' and ');
106
+ out.write(`anyslate: cleared the ${cleared} from ${path}.\n`);
107
+ out.write('anyslate: apiUrl and handle were kept. Run `anyslate login` to sign in again.\n');
108
+ if (env.ANYSLATE_MCP_TOKEN) {
109
+ err.write(
110
+ 'anyslate: warning — ANYSLATE_MCP_TOKEN is still set in your environment and overrides the config, so capture keeps working. Unset it to fully sign out.\n',
111
+ );
112
+ }
113
+ return 0;
114
+ }
115
+
116
+ function hostOf(url) {
117
+ try {
118
+ return new URL(url).host;
119
+ } catch {
120
+ return String(url);
121
+ }
122
+ }
123
+
124
+ /** @param {string[]} argv */
125
+ function parseFlags(argv) {
126
+ const out = { local: false };
127
+ for (const a of argv) {
128
+ if (a === '--local' || a === '--no-revoke') out.local = true;
129
+ }
130
+ return out;
131
+ }
@@ -1,4 +1,4 @@
1
- // `anyslate upload-artifact` upload a file (or stdin) as an MCP artifact.
1
+ // `anyslate upload-artifact` - upload a file (or stdin) as an MCP artifact.
2
2
  //
3
3
  // Usage:
4
4
  // anyslate upload-artifact --session <id> --kind code_block --file ./diff.patch
@@ -6,63 +6,122 @@
6
6
  //
7
7
  // Returns the cloud://artifact/<id> URI on stdout (one per line) so callers
8
8
  // can pipe it into a subsequent `anyslate checkpoint --note ...` if desired.
9
+ //
10
+ // W11: `--file` used to `readFileSync(path, 'utf8')`, which silently mangles
11
+ // binaries (measured: 64 raw bytes in → 124 bytes uploaded, exit 0). The
12
+ // `upload_artifact` MCP tool is a TEXT channel, so we detect and refuse rather
13
+ // than pretend. Both `--file` and stdin honour the same 5 MB cap; the stdin
14
+ // path must never fall back to readStdin's 1 MB default.
9
15
 
10
16
  import { readFileSync } from 'node:fs';
11
17
  import { basename } from 'node:path';
12
- import { loadConfig, requireToken } from '../config.mjs';
13
- import { callTool } from '../mcp-client.mjs';
14
- import { readStdin } from '../stdin.mjs';
18
+ import { loadConfig, requireToken, apiUrlNormalizationNotice, DISABLED_NOTICE } from '../config.mjs';
19
+ import { formatCallFailure } from '../mcp-client.mjs';
20
+ import { callToolWithAuth, formatAuthFailure } from '../auth.mjs';
21
+ import { readStdin, stdinTimeoutFromEnv } from '../stdin.mjs';
22
+ import { recordRun } from '../runlog.mjs';
23
+ import { VERSION } from '../version.mjs';
24
+ import { makeIo } from '../io.mjs';
15
25
 
16
26
  const ALLOWED_KINDS = new Set([
17
27
  'code_block', 'file_path', 'error_message', 'shell_command',
18
28
  'config_snippet', 'url_reference', 'fenced_quote',
19
29
  ]);
20
30
 
21
- const MAX_CONTENT_BYTES = 5_000_000;
31
+ export const MAX_CONTENT_BYTES = 5_000_000;
32
+
33
+ /** Uploading over a pipe may legitimately be slow; give stdin more idle room. */
34
+ const STDIN_IDLE_TIMEOUT_MS = 60_000;
35
+
36
+ /**
37
+ * A Buffer round-trips through utf8 iff it *is* valid utf8. Node replaces
38
+ * invalid sequences with U+FFFD, so re-encoding produces different bytes.
39
+ *
40
+ * @param {Buffer} buf
41
+ * @returns {boolean}
42
+ */
43
+ export function isValidUtf8(buf) {
44
+ return Buffer.compare(Buffer.from(buf.toString('utf8'), 'utf8'), buf) === 0;
45
+ }
22
46
 
23
47
  /**
24
48
  * @param {string[]} argv arguments after `upload-artifact`
49
+ * @param {{env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch, stdin?: NodeJS.ReadableStream}} [deps]
25
50
  * @returns {Promise<number>}
26
51
  */
27
- export async function runUploadArtifact(argv) {
52
+ export async function runUploadArtifact(argv, deps = {}) {
53
+ const env = deps.env ?? process.env;
54
+ const { out, err } = makeIo(deps);
28
55
  const flags = parseFlags(argv);
29
56
  if (!flags.session || !flags.kind) {
30
- process.stderr.write('usage: anyslate upload-artifact --session <id> --kind <kind> [--file <path> | <stdin>] [--language <lang>] [--path-hint <name>]\n');
31
- process.stderr.write(` kinds: ${[...ALLOWED_KINDS].join(', ')}\n`);
57
+ err.write('usage: anyslate upload-artifact --session <id> --kind <kind> [--file <path> | <stdin>] [--language <lang>] [--path-hint <name>]\n');
58
+ err.write(` kinds: ${[...ALLOWED_KINDS].join(', ')}\n`);
32
59
  return 2;
33
60
  }
34
61
  if (!ALLOWED_KINDS.has(flags.kind)) {
35
- process.stderr.write(`anyslate upload-artifact: unsupported kind: ${flags.kind}\n`);
62
+ err.write(`anyslate upload-artifact: unsupported kind: ${flags.kind}\n`);
36
63
  return 2;
37
64
  }
38
65
 
39
- const cfg = loadConfig();
40
- const tokenCheck = requireToken(cfg);
41
- if (!tokenCheck.ok) {
42
- process.stderr.write(`anyslate upload-artifact: ${tokenCheck.error}\n`);
66
+ const cfg = loadConfig(env);
67
+ if (cfg.disabled) {
68
+ err.write(`${DISABLED_NOTICE}\n`);
69
+ return 0;
70
+ }
71
+
72
+ const notice = apiUrlNormalizationNotice(cfg);
73
+ if (notice) err.write(notice);
74
+
75
+ // Presence only — an expired OAuth token is refreshed by callToolWithAuth.
76
+ if (cfg.authMode === 'none') {
77
+ const error = requireToken({ mcpToken: null }).error;
78
+ err.write(`anyslate upload-artifact: ${error}\n`);
79
+ recordRun({ command: 'upload-artifact', ok: false, apiUrl: cfg.apiUrl, error, version: VERSION, exitCode: 1 }, env);
43
80
  return 1;
44
81
  }
45
82
 
46
83
  let content;
47
84
  let pathHint = flags.pathHint;
48
85
  if (flags.file) {
86
+ let buf;
49
87
  try {
50
- content = readFileSync(flags.file, 'utf8');
51
- if (!pathHint) pathHint = basename(flags.file);
88
+ buf = readFileSync(flags.file);
52
89
  } catch (e) {
53
- process.stderr.write(`anyslate upload-artifact: cannot read ${flags.file} (${e?.message ?? e})\n`);
90
+ err.write(`anyslate upload-artifact: cannot read ${flags.file} (${e?.message ?? e})\n`);
91
+ return 1;
92
+ }
93
+ if (buf.byteLength > MAX_CONTENT_BYTES) {
94
+ err.write(`anyslate upload-artifact: content exceeds ${MAX_CONTENT_BYTES} byte cap\n`);
54
95
  return 1;
55
96
  }
97
+ if (!isValidUtf8(buf)) {
98
+ err.write(
99
+ `anyslate: ${flags.file} is not valid UTF-8. Binary artifacts are not supported by upload-artifact.\n`,
100
+ );
101
+ return 1;
102
+ }
103
+ content = buf.toString('utf8');
104
+ if (!pathHint) pathHint = basename(flags.file);
56
105
  } else {
57
- content = await readStdin();
106
+ // The 5 MB cap MUST be passed explicitly — readStdin's default is 1 MB and
107
+ // silently truncates.
108
+ try {
109
+ content = await readStdin(deps.stdin ?? process.stdin, {
110
+ maxBytes: MAX_CONTENT_BYTES,
111
+ timeoutMs: stdinTimeoutFromEnv(env, STDIN_IDLE_TIMEOUT_MS),
112
+ });
113
+ } catch (e) {
114
+ err.write(`anyslate upload-artifact: stdin read failed (${e?.message ?? e})\n`);
115
+ return 1;
116
+ }
58
117
  }
59
118
 
60
119
  if (!content || !content.length) {
61
- process.stderr.write('anyslate upload-artifact: empty content (provide --file or pipe data on stdin)\n');
120
+ err.write('anyslate upload-artifact: empty content (provide --file or pipe data on stdin)\n');
62
121
  return 1;
63
122
  }
64
123
  if (Buffer.byteLength(content, 'utf8') > MAX_CONTENT_BYTES) {
65
- process.stderr.write(`anyslate upload-artifact: content exceeds ${MAX_CONTENT_BYTES} byte cap\n`);
124
+ err.write(`anyslate upload-artifact: content exceeds ${MAX_CONTENT_BYTES} byte cap\n`);
66
125
  return 1;
67
126
  }
68
127
 
@@ -76,25 +135,76 @@ export async function runUploadArtifact(argv) {
76
135
  if (pathHint) args.path_hint = pathHint;
77
136
 
78
137
  try {
79
- const res = await callTool({
80
- apiUrl: cfg.apiUrl,
81
- token: cfg.mcpToken,
138
+ const res = await callToolWithAuth({
139
+ cfg,
140
+ env,
82
141
  toolName: 'upload_artifact',
83
142
  args,
143
+ fetchImpl: deps.fetchImpl,
84
144
  });
145
+ if (res.authWarning) err.write(`${res.authWarning}\n`);
85
146
  if (!res.ok) {
86
- const msg = typeof res.data === 'string' ? res.data : JSON.stringify(res.data);
87
- process.stderr.write(`anyslate upload-artifact: server ${res.status} ${msg}\n`);
147
+ const message =
148
+ formatAuthFailure('anyslate upload-artifact', res) ?? formatCallFailure('anyslate upload-artifact', res);
149
+ err.write(message);
150
+ recordRun(
151
+ {
152
+ command: 'upload-artifact',
153
+ ok: false,
154
+ apiUrl: cfg.apiUrl,
155
+ status: res.status,
156
+ isError: !!res.isError,
157
+ networkError: !!res.networkError,
158
+ error: message.trim(),
159
+ version: VERSION,
160
+ exitCode: 1,
161
+ },
162
+ env,
163
+ );
88
164
  return 1;
89
165
  }
90
- process.stdout.write(`${JSON.stringify(res.data)}\n`);
166
+ recordRun({ command: 'upload-artifact', ok: true, apiUrl: cfg.apiUrl, status: res.status, version: VERSION, exitCode: 0 }, env);
167
+ out.write(`${formatUploadArtifactOutput(res.data)}\n`);
91
168
  return 0;
92
169
  } catch (e) {
93
- process.stderr.write(`anyslate upload-artifact: request failed (${e?.message ?? e})\n`);
170
+ const message = `anyslate upload-artifact: request failed (${e?.message ?? e})\n`;
171
+ err.write(message);
172
+ recordRun({ command: 'upload-artifact', ok: false, apiUrl: cfg.apiUrl, error: message.trim(), version: VERSION, exitCode: 1 }, env);
94
173
  return 1;
95
174
  }
96
175
  }
97
176
 
177
+ /**
178
+ * @param {unknown} data
179
+ * @returns {string}
180
+ */
181
+ export function formatUploadArtifactOutput(data) {
182
+ const uri = extractArtifactUri(data);
183
+ return uri || JSON.stringify(data);
184
+ }
185
+
186
+ /**
187
+ * @param {unknown} data
188
+ * @returns {string|null}
189
+ */
190
+ function extractArtifactUri(data) {
191
+ if (typeof data === 'string') {
192
+ return data.startsWith('cloud://artifact/') ? data : null;
193
+ }
194
+ if (!data || typeof data !== 'object') return null;
195
+ for (const key of ['cloud_uri', 'cloudUri', 'uri', 'artifact_uri', 'artifactUri']) {
196
+ const v = data[key];
197
+ if (typeof v === 'string' && v.startsWith('cloud://artifact/')) return v;
198
+ }
199
+ const artifact = data.artifact;
200
+ if (artifact && typeof artifact === 'object') {
201
+ const nestedUri = extractArtifactUri(artifact);
202
+ if (nestedUri) return nestedUri;
203
+ }
204
+ const id = data.artifact_id || data.artifactId || data.id;
205
+ return typeof id === 'string' && id.length > 0 ? `cloud://artifact/${id}` : null;
206
+ }
207
+
98
208
  /** @param {string[]} argv */
99
209
  function parseFlags(argv) {
100
210
  const out = {};
package/src/config.mjs CHANGED
@@ -5,12 +5,28 @@
5
5
  // 2. ~/.anyslate/cli.json (preferred CLI config)
6
6
  // 3. ~/.anyslate/session.json (legacy, only the `mcp_token` / `handle` keys)
7
7
  //
8
+ // BEARER precedence specifically (`mcpToken`), narrower than the above:
9
+ // ANYSLATE_MCP_TOKEN → oauth.access_token (present AND unexpired) → mcp_token
10
+ //
11
+ // `loadConfig` is synchronous, so it cannot refresh — an OAuth credential past
12
+ // its expiry falls through here as if absent. That is deliberate: the raw
13
+ // `oauth` block is returned alongside, and src/auth.mjs (async) is what decides
14
+ // to refresh it. Making this function "smart" would mean every config read
15
+ // could block on the network, including inside `doctor`'s own diagnostics.
16
+ //
8
17
  // Defaults:
9
18
  // - apiUrl → https://mcp.anyslate.io
10
19
  //
11
20
  // All file reads tolerate missing / unreadable files; the CLI fails open so
12
21
  // `anyslate hook session-start` never breaks a Claude Code session because of
13
- // a missing config it just no-ops with a warning to stderr.
22
+ // a missing config - it just no-ops with a warning to stderr.
23
+ //
24
+ // URL normalization (W1): the CLI wants the service ROOT and appends `/mcp`
25
+ // itself. A config whose apiUrl already ends in `/mcp` produced `/mcp/mcp` and
26
+ // a permanent 404 that masked every token verdict (the 404 fires at
27
+ // app.notFound() BEFORE auth middleware). We therefore normalize on the READ
28
+ // path, not only in `login`, so already-broken configs self-heal without a
29
+ // re-login.
14
30
 
15
31
  import { readFileSync } from 'node:fs';
16
32
  import { homedir } from 'node:os';
@@ -18,22 +34,89 @@ import { join } from 'node:path';
18
34
 
19
35
  export const DEFAULT_API_URL = 'https://mcp.anyslate.io';
20
36
 
21
- const CONFIG_PATHS = [
22
- () => join(homedir(), '.anyslate', 'cli.json'),
23
- () => join(homedir(), '.anyslate', 'session.json'),
24
- ];
37
+ /** Where the desktop app / CLI keep their state. `ANYSLATE_HOME` overrides. */
38
+ export function anyslateDir(env = process.env) {
39
+ if (env.ANYSLATE_HOME && env.ANYSLATE_HOME.trim()) return env.ANYSLATE_HOME.trim();
40
+ return join(homedir(), '.anyslate');
41
+ }
42
+
43
+ export function configPaths(env = process.env) {
44
+ const dir = anyslateDir(env);
45
+ return [join(dir, 'cli.json'), join(dir, 'session.json')];
46
+ }
47
+
48
+ /**
49
+ * Strip trailing slashes AND every trailing `/mcp` segment.
50
+ *
51
+ * `(\/mcp)+$` — not `\/mcp$`. The single-segment form leaves an already
52
+ * double-suffixed `…/mcp/mcp` broken (defect #45).
53
+ *
54
+ * @param {unknown} u
55
+ * @returns {string}
56
+ */
57
+ export function normalizeApiRoot(u) {
58
+ return String(u ?? '')
59
+ .trim()
60
+ .replace(/\/+$/, '')
61
+ .replace(/(\/mcp)+$/i, '');
62
+ }
63
+
64
+ /**
65
+ * The dedicated capture kill switch (Q5). Deliberately NOT implemented by
66
+ * changing the falsy-coalescing of ANYSLATE_MCP_TOKEN — an empty string there
67
+ * still falls through to the file token, and scripts that clear variables by
68
+ * emptying them must keep working.
69
+ *
70
+ * @param {NodeJS.ProcessEnv} env
71
+ * @returns {boolean}
72
+ */
73
+ export function isCaptureDisabled(env = process.env) {
74
+ const v = env.ANYSLATE_DISABLE;
75
+ if (v === undefined || v === null) return false;
76
+ const s = String(v).trim().toLowerCase();
77
+ if (s === '' || s === '0' || s === 'false' || s === 'no' || s === 'off') return false;
78
+ return true;
79
+ }
80
+
81
+ export const DISABLED_NOTICE =
82
+ 'anyslate: ANYSLATE_DISABLE is set — capture disabled for this shell (no network call made).';
83
+
84
+ /**
85
+ * Is this stored OAuth credential still usable without a refresh?
86
+ * An absent or unparseable `expires_at` counts as expired — "I cannot tell"
87
+ * must never resolve to "still good".
88
+ *
89
+ * @param {{access_token?: string, expires_at?: string}|null|undefined} oauth
90
+ * @param {number} [now]
91
+ * @returns {boolean}
92
+ */
93
+ export function oauthTokenUsable(oauth, now = Date.now()) {
94
+ if (!oauth || typeof oauth !== 'object') return false;
95
+ if (typeof oauth.access_token !== 'string' || !oauth.access_token) return false;
96
+ const t = Date.parse(String(oauth.expires_at ?? ''));
97
+ if (!Number.isFinite(t)) return false;
98
+ return t > now;
99
+ }
25
100
 
26
101
  /**
27
102
  * @param {NodeJS.ProcessEnv} env
28
103
  * @param {() => string[]} [paths] override for tests
29
- * @returns {{apiUrl: string, mcpToken: string|null, handle: string|null, source: string}}
104
+ * @returns {{
105
+ * apiUrl: string, apiUrlRaw: string, apiUrlNormalized: boolean,
106
+ * mcpToken: string|null, handle: string|null,
107
+ * oauth: object|null, staticToken: string|null, authMode: 'env'|'oauth'|'static'|'none',
108
+ * oauthClients: Record<string, string>,
109
+ * source: string, sources: {apiUrl: string, mcpToken: string, handle: string},
110
+ * disabled: boolean
111
+ * }}
30
112
  */
31
113
  export function loadConfig(env = process.env, paths) {
32
- const candidates = paths ? paths() : CONFIG_PATHS.map((fn) => fn());
114
+ const candidates = paths ? paths() : configPaths(env);
33
115
 
34
- /** @type {{apiUrl?: string, mcp_token?: string, handle?: string}} */
116
+ /** @type {{apiUrl?: string, api_url?: string, mcp_token?: string, token?: string, handle?: string}} */
35
117
  let fileConfig = {};
36
118
  let source = 'env';
119
+ let filePath = null;
37
120
  for (const p of candidates) {
38
121
  try {
39
122
  const raw = readFileSync(p, 'utf8');
@@ -41,18 +124,84 @@ export function loadConfig(env = process.env, paths) {
41
124
  if (parsed && typeof parsed === 'object') {
42
125
  fileConfig = parsed;
43
126
  source = p;
127
+ filePath = p;
44
128
  break;
45
129
  }
46
130
  } catch {
47
- // ignore fall through to next candidate
131
+ // ignore - fall through to next candidate
48
132
  }
49
133
  }
50
134
 
51
- const apiUrl = (env.ANYSLATE_API_URL || fileConfig.apiUrl || fileConfig.api_url || DEFAULT_API_URL).replace(/\/+$/, '');
52
- const mcpToken = env.ANYSLATE_MCP_TOKEN || fileConfig.mcp_token || fileConfig.token || null;
135
+ // Per-key provenance. `source` alone is misleading: it names the file that
136
+ // parsed, while an env var may still be winning for an individual key
137
+ // (defect #38 / W4 check #1).
138
+ const layerOf = (envValue, fileValue) => {
139
+ if (envValue) return 'env';
140
+ if (fileValue) return filePath ?? 'default';
141
+ return 'default';
142
+ };
143
+
144
+ const apiUrlRaw = String(
145
+ env.ANYSLATE_API_URL || fileConfig.apiUrl || fileConfig.api_url || DEFAULT_API_URL,
146
+ ).trim();
147
+ const apiUrl = normalizeApiRoot(apiUrlRaw);
148
+ const apiUrlNormalized = apiUrl !== apiUrlRaw.replace(/\/+$/, '');
149
+
150
+ const oauth = fileConfig.oauth && typeof fileConfig.oauth === 'object' ? fileConfig.oauth : null;
151
+ const oauthClients =
152
+ fileConfig.oauth_clients && typeof fileConfig.oauth_clients === 'object' ? fileConfig.oauth_clients : {};
153
+ const staticToken = fileConfig.mcp_token || fileConfig.token || null;
154
+ const liveOauthToken = oauthTokenUsable(oauth) ? oauth.access_token : null;
155
+
156
+ const mcpToken = env.ANYSLATE_MCP_TOKEN || liveOauthToken || staticToken || null;
53
157
  const handle = env.ANYSLATE_HANDLE || fileConfig.handle || null;
54
158
 
55
- return { apiUrl, mcpToken, handle, source };
159
+ /** Which layer supplied the bearer — what `doctor` renders as the auth mode. */
160
+ const authMode = env.ANYSLATE_MCP_TOKEN
161
+ ? 'env'
162
+ : liveOauthToken
163
+ ? 'oauth'
164
+ : staticToken
165
+ ? 'static'
166
+ : oauth
167
+ ? 'oauth' // credentials exist but need a refresh; auth.mjs handles it
168
+ : 'none';
169
+
170
+ return {
171
+ apiUrl,
172
+ apiUrlRaw,
173
+ apiUrlNormalized,
174
+ mcpToken,
175
+ handle,
176
+ oauth,
177
+ oauthClients,
178
+ staticToken,
179
+ authMode,
180
+ source,
181
+ sources: {
182
+ apiUrl: layerOf(env.ANYSLATE_API_URL, fileConfig.apiUrl || fileConfig.api_url),
183
+ // The bearer's provenance follows the same three-way precedence as the
184
+ // value itself, so `doctor` cannot report "from file" for an env token.
185
+ mcpToken: layerOf(env.ANYSLATE_MCP_TOKEN, liveOauthToken || staticToken),
186
+ handle: layerOf(env.ANYSLATE_HANDLE, fileConfig.handle),
187
+ },
188
+ disabled: isCaptureDisabled(env),
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Exact user-facing notice for a rewritten apiUrl. Silent correction teaches
194
+ * the user nothing, so every command that resolves a rewritten URL says so.
195
+ *
196
+ * @param {{apiUrlRaw: string, apiUrl: string, apiUrlNormalized: boolean}} cfg
197
+ * @returns {string|null}
198
+ */
199
+ export function apiUrlNormalizationNotice(cfg) {
200
+ if (!cfg?.apiUrlNormalized) return null;
201
+ return (
202
+ `anyslate: apiUrl "${cfg.apiUrlRaw}" ends in /mcp — the CLI wants the service ROOT and appends /mcp itself.\n` +
203
+ `anyslate: using "${cfg.apiUrl}". Run \`anyslate login --api-url ${cfg.apiUrl}\` to persist.\n`
204
+ );
56
205
  }
57
206
 
58
207
  /**
@@ -64,7 +213,7 @@ export function requireToken(cfg) {
64
213
  return {
65
214
  ok: false,
66
215
  error:
67
- 'no MCP token configured. Run `anyslate login --token <BEARER>` or set ANYSLATE_MCP_TOKEN. Mint a token at Settings AI Memory Connect in the AnySlate desktop app.',
216
+ 'no MCP token configured. Run `anyslate login` to sign in with your browser, or `anyslate login --token <BEARER>` / set ANYSLATE_MCP_TOKEN for CI. Mint a token in the AnySlate desktop app at Avatar (top-right) API Tokens.',
68
217
  };
69
218
  }
70
219
  return { ok: true };