@anyslate/cli 0.1.0 → 0.2.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.
@@ -1,30 +1,58 @@
1
- // `anyslate login` write `~/.anyslate/cli.json` with the user's MCP token.
1
+ // `anyslate login` - validate credentials, then write `~/.anyslate/cli.json`.
2
2
  //
3
- // Mint the token in the desktop app at SettingsAI Memory Connect → Mint
4
- // MCP Token (Professional tier only). Then:
3
+ // Mint the token in the desktop app at Avatar (top-right) API Tokens
4
+ // Create Token (available on every current tier). Then:
5
5
  //
6
6
  // anyslate login --token <BEARER>
7
7
  // anyslate login --token <BEARER> --handle <HANDLE_ID>
8
8
  // anyslate login --token <BEARER> --api-url https://anyslate-mcp-service-development.<workers-dev-url>
9
9
  //
10
- // The file is written with mode 0600 only the current user can read it.
10
+ // The file is written with mode 0600 - only the current user can read it.
11
+ //
12
+ // W3: `login` used to perform ZERO network I/O — its imports were three
13
+ // node: builtins. It accepted a token that is not a token and a hostname that
14
+ // cannot resolve, printing success and exiting 0 for both. It now:
15
+ // 1. checks token format,
16
+ // 2. resolves URL shape (MUST precede any token verdict — a wrong URL 404s
17
+ // before auth middleware and masks the real answer),
18
+ // 3. runs one live GET {root}/mcp/auth/verify,
19
+ // 4. warns on a missing memory:write scope,
20
+ // 5. writes the file ONLY on success.
21
+ // `--force` writes anyway; `--no-verify` skips the probe (air-gapped setup).
11
22
 
12
23
  import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
13
- import { homedir } from 'node:os';
14
24
  import { join } from 'node:path';
25
+ import { DEFAULT_API_URL, anyslateDir, isCaptureDisabled, normalizeApiRoot } from '../config.mjs';
26
+ import { checkUrlShape, isValidTokenFormat, probeVerify, scopeWarning, tokenPreview } from '../verify.mjs';
27
+ import { makeIo } from '../io.mjs';
15
28
 
16
29
  /**
17
30
  * @param {string[]} argv arguments after `login`
31
+ * @param {{fetchImpl?: typeof fetch, env?: NodeJS.ProcessEnv}} [deps]
18
32
  * @returns {Promise<number>}
19
33
  */
20
- export async function runLogin(argv) {
34
+ export async function runLogin(argv, deps = {}) {
35
+ const env = deps.env ?? process.env;
36
+ const fetchImpl = deps.fetchImpl ?? fetch;
37
+ const { out, err } = makeIo(deps);
21
38
  const flags = parseFlags(argv);
39
+
22
40
  if (!flags.token) {
23
- process.stderr.write('usage: anyslate login --token <BEARER> [--handle <ID>] [--api-url <URL>]\n');
41
+ err.write(
42
+ 'usage: anyslate login --token <BEARER> [--handle <ID>] [--api-url <URL>] [--force] [--no-verify]\n',
43
+ );
24
44
  return 2;
25
45
  }
26
46
 
27
- const dir = join(homedir(), '.anyslate');
47
+ if (isCaptureDisabled(env)) {
48
+ // login is setup, not capture — it still runs, but say so, because the
49
+ // shell it was run in will capture nothing afterwards.
50
+ err.write(
51
+ 'anyslate: note — ANYSLATE_DISABLE is set, so capture is off in this shell. `login` still writes your config.\n',
52
+ );
53
+ }
54
+
55
+ const dir = anyslateDir(env);
28
56
  const path = join(dir, 'cli.json');
29
57
  let existing = {};
30
58
  try {
@@ -33,35 +61,99 @@ export async function runLogin(argv) {
33
61
  existing = {};
34
62
  }
35
63
 
64
+ // --- 1. Token format ----------------------------------------------------
65
+ if (!isValidTokenFormat(flags.token)) {
66
+ const msg =
67
+ `anyslate: that doesn't look like an AnySlate token (expected as_mcp_… or as_oauth_…, got "${tokenPreview(flags.token)}").\n` +
68
+ 'anyslate: mint one in the desktop app at Avatar (top-right) → API Tokens → Create Token.\n';
69
+ if (!flags.force) {
70
+ err.write(msg);
71
+ return 1;
72
+ }
73
+ err.write(msg);
74
+ err.write('anyslate: --force given — writing anyway.\n');
75
+ }
76
+
77
+ // --- 2. URL shape (MUST precede any token verdict) ----------------------
78
+ // Preserve the merge semantics — `login --token <new>` alone keeps the
79
+ // stored apiUrl — but re-normalize the merged value so a previously-bad
80
+ // apiUrl cannot survive a re-login.
81
+ const mergedUrl = flags.apiUrl ?? existing.apiUrl ?? existing.api_url ?? DEFAULT_API_URL;
82
+ const shape = checkUrlShape(mergedUrl);
83
+ if (!shape.ok) {
84
+ err.write(`${shape.message}\n`);
85
+ if (!flags.force) return 1;
86
+ err.write('anyslate: --force given — writing anyway.\n');
87
+ }
88
+ const root = shape.ok ? shape.root : normalizeApiRoot(mergedUrl);
89
+
90
+ if (shape.ok && shape.normalized) {
91
+ out.write(
92
+ `anyslate: apiUrl "${shape.original}" ends in /mcp — the CLI wants the service ROOT and appends /mcp itself.\n` +
93
+ `anyslate: using "${root}". Run \`anyslate login --api-url ${root}\` to persist.\n`,
94
+ );
95
+ }
96
+
97
+ // --- 3. Live probe ------------------------------------------------------
98
+ let verified = null;
99
+ if (flags.noVerify) {
100
+ err.write('anyslate: --no-verify given — skipping the live connection check.\n');
101
+ } else {
102
+ verified = await probeVerify({ root, token: flags.token, fetchImpl });
103
+ if (!verified.ok) {
104
+ err.write(`${verified.message}\n`);
105
+ if (!flags.force) {
106
+ err.write(
107
+ 'anyslate: nothing was written. Re-run with a working --token/--api-url, or `--force` to write anyway, or `--no-verify` to skip the check.\n',
108
+ );
109
+ return 1;
110
+ }
111
+ err.write('anyslate: --force given — writing anyway.\n');
112
+ } else {
113
+ out.write(`${verified.message}\n`);
114
+ // --- 4. Scope check (warn, never block) -----------------------------
115
+ const warning = scopeWarning(verified.scopes);
116
+ if (warning) err.write(`${warning}\n`);
117
+ }
118
+ }
119
+
120
+ // --- 5. Write only on success (or --force / --no-verify) ----------------
36
121
  const next = {
37
122
  ...existing,
38
123
  mcp_token: flags.token,
39
124
  handle: flags.handle ?? existing.handle ?? null,
40
- apiUrl: flags.apiUrl ?? existing.apiUrl ?? 'https://mcp.anyslate.io',
125
+ apiUrl: root,
41
126
  };
127
+ delete next.api_url; // collapse the legacy alias so only one key can drift
42
128
 
43
129
  try {
44
130
  mkdirSync(dir, { recursive: true, mode: 0o700 });
45
131
  writeFileSync(path, JSON.stringify(next, null, 2), { mode: 0o600 });
46
132
  } catch (e) {
47
- process.stderr.write(`anyslate login: write failed (${e?.message ?? e})\n`);
133
+ err.write(`anyslate login: write failed (${e?.message ?? e})\n`);
48
134
  return 1;
49
135
  }
50
136
 
51
- process.stdout.write(`anyslate: wrote ${path}\n`);
52
- process.stdout.write(` apiUrl: ${next.apiUrl}\n`);
53
- process.stdout.write(` handle: ${next.handle ?? '(none — bearer token only)'}\n`);
137
+ out.write(`anyslate: wrote ${path}\n`);
138
+ out.write(` apiUrl: ${next.apiUrl}\n`);
139
+ out.write(` handle: ${next.handle ?? '(none — bearer token only)'}\n`);
140
+ if (verified?.ok && verified.defaultHandleId) {
141
+ out.write(` token is bound to handle: ${verified.defaultHandleId} (server-side scope)\n`);
142
+ }
143
+ out.write('anyslate: run `anyslate doctor` to verify the full setup.\n');
54
144
  return 0;
55
145
  }
56
146
 
57
147
  /** @param {string[]} argv */
58
148
  function parseFlags(argv) {
59
- const out = {};
149
+ const out = { force: false, noVerify: false };
60
150
  for (let i = 0; i < argv.length; i += 1) {
61
151
  const a = argv[i];
62
152
  if (a === '--token' && argv[i + 1]) out.token = argv[++i];
63
153
  else if (a === '--handle' && argv[i + 1]) out.handle = argv[++i];
64
154
  else if ((a === '--api-url' || a === '--api_url') && argv[i + 1]) out.apiUrl = argv[++i];
155
+ else if (a === '--force') out.force = true;
156
+ else if (a === '--no-verify' || a === '--skip-verify') out.noVerify = true;
65
157
  }
66
158
  return out;
67
159
  }
@@ -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,120 @@
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 { callTool, formatCallFailure } from '../mcp-client.mjs';
20
+ import { readStdin, stdinTimeoutFromEnv } from '../stdin.mjs';
21
+ import { recordRun } from '../runlog.mjs';
22
+ import { VERSION } from '../version.mjs';
23
+ import { makeIo } from '../io.mjs';
15
24
 
16
25
  const ALLOWED_KINDS = new Set([
17
26
  'code_block', 'file_path', 'error_message', 'shell_command',
18
27
  'config_snippet', 'url_reference', 'fenced_quote',
19
28
  ]);
20
29
 
21
- const MAX_CONTENT_BYTES = 5_000_000;
30
+ export const MAX_CONTENT_BYTES = 5_000_000;
31
+
32
+ /** Uploading over a pipe may legitimately be slow; give stdin more idle room. */
33
+ const STDIN_IDLE_TIMEOUT_MS = 60_000;
34
+
35
+ /**
36
+ * A Buffer round-trips through utf8 iff it *is* valid utf8. Node replaces
37
+ * invalid sequences with U+FFFD, so re-encoding produces different bytes.
38
+ *
39
+ * @param {Buffer} buf
40
+ * @returns {boolean}
41
+ */
42
+ export function isValidUtf8(buf) {
43
+ return Buffer.compare(Buffer.from(buf.toString('utf8'), 'utf8'), buf) === 0;
44
+ }
22
45
 
23
46
  /**
24
47
  * @param {string[]} argv arguments after `upload-artifact`
48
+ * @param {{env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch, stdin?: NodeJS.ReadableStream}} [deps]
25
49
  * @returns {Promise<number>}
26
50
  */
27
- export async function runUploadArtifact(argv) {
51
+ export async function runUploadArtifact(argv, deps = {}) {
52
+ const env = deps.env ?? process.env;
53
+ const { out, err } = makeIo(deps);
28
54
  const flags = parseFlags(argv);
29
55
  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`);
56
+ err.write('usage: anyslate upload-artifact --session <id> --kind <kind> [--file <path> | <stdin>] [--language <lang>] [--path-hint <name>]\n');
57
+ err.write(` kinds: ${[...ALLOWED_KINDS].join(', ')}\n`);
32
58
  return 2;
33
59
  }
34
60
  if (!ALLOWED_KINDS.has(flags.kind)) {
35
- process.stderr.write(`anyslate upload-artifact: unsupported kind: ${flags.kind}\n`);
61
+ err.write(`anyslate upload-artifact: unsupported kind: ${flags.kind}\n`);
36
62
  return 2;
37
63
  }
38
64
 
39
- const cfg = loadConfig();
65
+ const cfg = loadConfig(env);
66
+ if (cfg.disabled) {
67
+ err.write(`${DISABLED_NOTICE}\n`);
68
+ return 0;
69
+ }
70
+
71
+ const notice = apiUrlNormalizationNotice(cfg);
72
+ if (notice) err.write(notice);
73
+
40
74
  const tokenCheck = requireToken(cfg);
41
75
  if (!tokenCheck.ok) {
42
- process.stderr.write(`anyslate upload-artifact: ${tokenCheck.error}\n`);
76
+ err.write(`anyslate upload-artifact: ${tokenCheck.error}\n`);
77
+ recordRun({ command: 'upload-artifact', ok: false, apiUrl: cfg.apiUrl, error: tokenCheck.error, version: VERSION, exitCode: 1 }, env);
43
78
  return 1;
44
79
  }
45
80
 
46
81
  let content;
47
82
  let pathHint = flags.pathHint;
48
83
  if (flags.file) {
84
+ let buf;
49
85
  try {
50
- content = readFileSync(flags.file, 'utf8');
51
- if (!pathHint) pathHint = basename(flags.file);
86
+ buf = readFileSync(flags.file);
52
87
  } catch (e) {
53
- process.stderr.write(`anyslate upload-artifact: cannot read ${flags.file} (${e?.message ?? e})\n`);
88
+ err.write(`anyslate upload-artifact: cannot read ${flags.file} (${e?.message ?? e})\n`);
89
+ return 1;
90
+ }
91
+ if (buf.byteLength > MAX_CONTENT_BYTES) {
92
+ err.write(`anyslate upload-artifact: content exceeds ${MAX_CONTENT_BYTES} byte cap\n`);
54
93
  return 1;
55
94
  }
95
+ if (!isValidUtf8(buf)) {
96
+ err.write(
97
+ `anyslate: ${flags.file} is not valid UTF-8. Binary artifacts are not supported by upload-artifact.\n`,
98
+ );
99
+ return 1;
100
+ }
101
+ content = buf.toString('utf8');
102
+ if (!pathHint) pathHint = basename(flags.file);
56
103
  } else {
57
- content = await readStdin();
104
+ // The 5 MB cap MUST be passed explicitly — readStdin's default is 1 MB and
105
+ // silently truncates.
106
+ try {
107
+ content = await readStdin(deps.stdin ?? process.stdin, {
108
+ maxBytes: MAX_CONTENT_BYTES,
109
+ timeoutMs: stdinTimeoutFromEnv(env, STDIN_IDLE_TIMEOUT_MS),
110
+ });
111
+ } catch (e) {
112
+ err.write(`anyslate upload-artifact: stdin read failed (${e?.message ?? e})\n`);
113
+ return 1;
114
+ }
58
115
  }
59
116
 
60
117
  if (!content || !content.length) {
61
- process.stderr.write('anyslate upload-artifact: empty content (provide --file or pipe data on stdin)\n');
118
+ err.write('anyslate upload-artifact: empty content (provide --file or pipe data on stdin)\n');
62
119
  return 1;
63
120
  }
64
121
  if (Buffer.byteLength(content, 'utf8') > MAX_CONTENT_BYTES) {
65
- process.stderr.write(`anyslate upload-artifact: content exceeds ${MAX_CONTENT_BYTES} byte cap\n`);
122
+ err.write(`anyslate upload-artifact: content exceeds ${MAX_CONTENT_BYTES} byte cap\n`);
66
123
  return 1;
67
124
  }
68
125
 
@@ -81,20 +138,69 @@ export async function runUploadArtifact(argv) {
81
138
  token: cfg.mcpToken,
82
139
  toolName: 'upload_artifact',
83
140
  args,
141
+ fetchImpl: deps.fetchImpl,
84
142
  });
85
143
  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`);
144
+ const message = formatCallFailure('anyslate upload-artifact', res);
145
+ err.write(message);
146
+ recordRun(
147
+ {
148
+ command: 'upload-artifact',
149
+ ok: false,
150
+ apiUrl: cfg.apiUrl,
151
+ status: res.status,
152
+ isError: !!res.isError,
153
+ networkError: !!res.networkError,
154
+ error: message.trim(),
155
+ version: VERSION,
156
+ exitCode: 1,
157
+ },
158
+ env,
159
+ );
88
160
  return 1;
89
161
  }
90
- process.stdout.write(`${JSON.stringify(res.data)}\n`);
162
+ recordRun({ command: 'upload-artifact', ok: true, apiUrl: cfg.apiUrl, status: res.status, version: VERSION, exitCode: 0 }, env);
163
+ out.write(`${formatUploadArtifactOutput(res.data)}\n`);
91
164
  return 0;
92
165
  } catch (e) {
93
- process.stderr.write(`anyslate upload-artifact: request failed (${e?.message ?? e})\n`);
166
+ const message = `anyslate upload-artifact: request failed (${e?.message ?? e})\n`;
167
+ err.write(message);
168
+ recordRun({ command: 'upload-artifact', ok: false, apiUrl: cfg.apiUrl, error: message.trim(), version: VERSION, exitCode: 1 }, env);
94
169
  return 1;
95
170
  }
96
171
  }
97
172
 
173
+ /**
174
+ * @param {unknown} data
175
+ * @returns {string}
176
+ */
177
+ export function formatUploadArtifactOutput(data) {
178
+ const uri = extractArtifactUri(data);
179
+ return uri || JSON.stringify(data);
180
+ }
181
+
182
+ /**
183
+ * @param {unknown} data
184
+ * @returns {string|null}
185
+ */
186
+ function extractArtifactUri(data) {
187
+ if (typeof data === 'string') {
188
+ return data.startsWith('cloud://artifact/') ? data : null;
189
+ }
190
+ if (!data || typeof data !== 'object') return null;
191
+ for (const key of ['cloud_uri', 'cloudUri', 'uri', 'artifact_uri', 'artifactUri']) {
192
+ const v = data[key];
193
+ if (typeof v === 'string' && v.startsWith('cloud://artifact/')) return v;
194
+ }
195
+ const artifact = data.artifact;
196
+ if (artifact && typeof artifact === 'object') {
197
+ const nestedUri = extractArtifactUri(artifact);
198
+ if (nestedUri) return nestedUri;
199
+ }
200
+ const id = data.artifact_id || data.artifactId || data.id;
201
+ return typeof id === 'string' && id.length > 0 ? `cloud://artifact/${id}` : null;
202
+ }
203
+
98
204
  /** @param {string[]} argv */
99
205
  function parseFlags(argv) {
100
206
  const out = {};
package/src/config.mjs CHANGED
@@ -10,7 +10,14 @@
10
10
  //
11
11
  // All file reads tolerate missing / unreadable files; the CLI fails open so
12
12
  // `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.
13
+ // a missing config - it just no-ops with a warning to stderr.
14
+ //
15
+ // URL normalization (W1): the CLI wants the service ROOT and appends `/mcp`
16
+ // itself. A config whose apiUrl already ends in `/mcp` produced `/mcp/mcp` and
17
+ // a permanent 404 that masked every token verdict (the 404 fires at
18
+ // app.notFound() BEFORE auth middleware). We therefore normalize on the READ
19
+ // path, not only in `login`, so already-broken configs self-heal without a
20
+ // re-login.
14
21
 
15
22
  import { readFileSync } from 'node:fs';
16
23
  import { homedir } from 'node:os';
@@ -18,22 +25,70 @@ import { join } from 'node:path';
18
25
 
19
26
  export const DEFAULT_API_URL = 'https://mcp.anyslate.io';
20
27
 
21
- const CONFIG_PATHS = [
22
- () => join(homedir(), '.anyslate', 'cli.json'),
23
- () => join(homedir(), '.anyslate', 'session.json'),
24
- ];
28
+ /** Where the desktop app / CLI keep their state. `ANYSLATE_HOME` overrides. */
29
+ export function anyslateDir(env = process.env) {
30
+ if (env.ANYSLATE_HOME && env.ANYSLATE_HOME.trim()) return env.ANYSLATE_HOME.trim();
31
+ return join(homedir(), '.anyslate');
32
+ }
33
+
34
+ export function configPaths(env = process.env) {
35
+ const dir = anyslateDir(env);
36
+ return [join(dir, 'cli.json'), join(dir, 'session.json')];
37
+ }
38
+
39
+ /**
40
+ * Strip trailing slashes AND every trailing `/mcp` segment.
41
+ *
42
+ * `(\/mcp)+$` — not `\/mcp$`. The single-segment form leaves an already
43
+ * double-suffixed `…/mcp/mcp` broken (defect #45).
44
+ *
45
+ * @param {unknown} u
46
+ * @returns {string}
47
+ */
48
+ export function normalizeApiRoot(u) {
49
+ return String(u ?? '')
50
+ .trim()
51
+ .replace(/\/+$/, '')
52
+ .replace(/(\/mcp)+$/i, '');
53
+ }
54
+
55
+ /**
56
+ * The dedicated capture kill switch (Q5). Deliberately NOT implemented by
57
+ * changing the falsy-coalescing of ANYSLATE_MCP_TOKEN — an empty string there
58
+ * still falls through to the file token, and scripts that clear variables by
59
+ * emptying them must keep working.
60
+ *
61
+ * @param {NodeJS.ProcessEnv} env
62
+ * @returns {boolean}
63
+ */
64
+ export function isCaptureDisabled(env = process.env) {
65
+ const v = env.ANYSLATE_DISABLE;
66
+ if (v === undefined || v === null) return false;
67
+ const s = String(v).trim().toLowerCase();
68
+ if (s === '' || s === '0' || s === 'false' || s === 'no' || s === 'off') return false;
69
+ return true;
70
+ }
71
+
72
+ export const DISABLED_NOTICE =
73
+ 'anyslate: ANYSLATE_DISABLE is set — capture disabled for this shell (no network call made).';
25
74
 
26
75
  /**
27
76
  * @param {NodeJS.ProcessEnv} env
28
77
  * @param {() => string[]} [paths] override for tests
29
- * @returns {{apiUrl: string, mcpToken: string|null, handle: string|null, source: string}}
78
+ * @returns {{
79
+ * apiUrl: string, apiUrlRaw: string, apiUrlNormalized: boolean,
80
+ * mcpToken: string|null, handle: string|null,
81
+ * source: string, sources: {apiUrl: string, mcpToken: string, handle: string},
82
+ * disabled: boolean
83
+ * }}
30
84
  */
31
85
  export function loadConfig(env = process.env, paths) {
32
- const candidates = paths ? paths() : CONFIG_PATHS.map((fn) => fn());
86
+ const candidates = paths ? paths() : configPaths(env);
33
87
 
34
- /** @type {{apiUrl?: string, mcp_token?: string, handle?: string}} */
88
+ /** @type {{apiUrl?: string, api_url?: string, mcp_token?: string, token?: string, handle?: string}} */
35
89
  let fileConfig = {};
36
90
  let source = 'env';
91
+ let filePath = null;
37
92
  for (const p of candidates) {
38
93
  try {
39
94
  const raw = readFileSync(p, 'utf8');
@@ -41,18 +96,61 @@ export function loadConfig(env = process.env, paths) {
41
96
  if (parsed && typeof parsed === 'object') {
42
97
  fileConfig = parsed;
43
98
  source = p;
99
+ filePath = p;
44
100
  break;
45
101
  }
46
102
  } catch {
47
- // ignore fall through to next candidate
103
+ // ignore - fall through to next candidate
48
104
  }
49
105
  }
50
106
 
51
- const apiUrl = (env.ANYSLATE_API_URL || fileConfig.apiUrl || fileConfig.api_url || DEFAULT_API_URL).replace(/\/+$/, '');
107
+ // Per-key provenance. `source` alone is misleading: it names the file that
108
+ // parsed, while an env var may still be winning for an individual key
109
+ // (defect #38 / W4 check #1).
110
+ const layerOf = (envValue, fileValue) => {
111
+ if (envValue) return 'env';
112
+ if (fileValue) return filePath ?? 'default';
113
+ return 'default';
114
+ };
115
+
116
+ const apiUrlRaw = String(
117
+ env.ANYSLATE_API_URL || fileConfig.apiUrl || fileConfig.api_url || DEFAULT_API_URL,
118
+ ).trim();
119
+ const apiUrl = normalizeApiRoot(apiUrlRaw);
120
+ const apiUrlNormalized = apiUrl !== apiUrlRaw.replace(/\/+$/, '');
121
+
52
122
  const mcpToken = env.ANYSLATE_MCP_TOKEN || fileConfig.mcp_token || fileConfig.token || null;
53
123
  const handle = env.ANYSLATE_HANDLE || fileConfig.handle || null;
54
124
 
55
- return { apiUrl, mcpToken, handle, source };
125
+ return {
126
+ apiUrl,
127
+ apiUrlRaw,
128
+ apiUrlNormalized,
129
+ mcpToken,
130
+ handle,
131
+ source,
132
+ sources: {
133
+ apiUrl: layerOf(env.ANYSLATE_API_URL, fileConfig.apiUrl || fileConfig.api_url),
134
+ mcpToken: layerOf(env.ANYSLATE_MCP_TOKEN, fileConfig.mcp_token || fileConfig.token),
135
+ handle: layerOf(env.ANYSLATE_HANDLE, fileConfig.handle),
136
+ },
137
+ disabled: isCaptureDisabled(env),
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Exact user-facing notice for a rewritten apiUrl. Silent correction teaches
143
+ * the user nothing, so every command that resolves a rewritten URL says so.
144
+ *
145
+ * @param {{apiUrlRaw: string, apiUrl: string, apiUrlNormalized: boolean}} cfg
146
+ * @returns {string|null}
147
+ */
148
+ export function apiUrlNormalizationNotice(cfg) {
149
+ if (!cfg?.apiUrlNormalized) return null;
150
+ return (
151
+ `anyslate: apiUrl "${cfg.apiUrlRaw}" ends in /mcp — the CLI wants the service ROOT and appends /mcp itself.\n` +
152
+ `anyslate: using "${cfg.apiUrl}". Run \`anyslate login --api-url ${cfg.apiUrl}\` to persist.\n`
153
+ );
56
154
  }
57
155
 
58
156
  /**
@@ -64,7 +162,7 @@ export function requireToken(cfg) {
64
162
  return {
65
163
  ok: false,
66
164
  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.',
165
+ 'no MCP token configured. Run `anyslate login --token <BEARER>` or set ANYSLATE_MCP_TOKEN. Mint a token in the AnySlate desktop app at Avatar (top-right) API Tokens.',
68
166
  };
69
167
  }
70
168
  return { ok: true };