skillrepo 4.2.0 → 4.4.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,420 @@
1
+ /**
2
+ * Unit / integration tests for `runPublish` and `runUnpublish` —
3
+ * Epic #1444 R3 #1456.
4
+ *
5
+ * Tests both verbs in one file because they share the
6
+ * `setSkillVisibility` HTTP helper and have symmetric outcome
7
+ * mappings (only the response shape and 403 code differ). Each
8
+ * test exercises a distinct outcome of the discriminated
9
+ * `VisibilityResult` union returned by the HTTP layer.
10
+ */
11
+
12
+ import { describe, it, beforeEach, afterEach } from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { tmpdir } from "node:os";
17
+
18
+ import { runPublish } from "../../commands/publish.mjs";
19
+ import { runUnpublish } from "../../commands/unpublish.mjs";
20
+ import {
21
+ CliError,
22
+ EXIT_VALIDATION,
23
+ EXIT_AUTH,
24
+ EXIT_SCOPE,
25
+ } from "../../lib/errors.mjs";
26
+ import { createMockServer } from "../e2e/mock-server.mjs";
27
+ import { createCaptureStream } from "../helpers/capture-stream.mjs";
28
+ import {
29
+ captureHome,
30
+ setSandboxHome,
31
+ restoreHome,
32
+ } from "../helpers/sandbox-home.mjs";
33
+
34
+ let sandbox;
35
+ let server;
36
+ let serverUrl;
37
+ let originalCwd;
38
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
39
+ let originalHomeEnv;
40
+ let stdout;
41
+ const VALID_KEY = "sk_live_test";
42
+
43
+ async function setup() {
44
+ sandbox = mkdtempSync(join(tmpdir(), "cli-cmd-pub-"));
45
+ mkdirSync(join(sandbox, "project"), { recursive: true });
46
+ mkdirSync(join(sandbox, "home"), { recursive: true });
47
+ originalCwd = process.cwd();
48
+ originalHomeEnv = captureHome();
49
+ process.chdir(join(sandbox, "project"));
50
+ setSandboxHome(join(sandbox, "home"));
51
+ delete process.env.SKILLREPO_ACCESS_KEY;
52
+
53
+ server = createMockServer({});
54
+ const port = await server.start();
55
+ serverUrl = `http://127.0.0.1:${port}`;
56
+
57
+ stdout = createCaptureStream();
58
+ }
59
+
60
+ async function teardown() {
61
+ if (server) await server.stop();
62
+ process.chdir(originalCwd);
63
+ restoreHome(originalHomeEnv);
64
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
65
+ server = null;
66
+ }
67
+
68
+ // argv used by both verbs — identifier first, then auth + endpoint
69
+ // flags. Caller can append extra flags (e.g. `--json`) at the end.
70
+ const baseArgv = (...extra) => [
71
+ `@alice/my-skill`,
72
+ "--key",
73
+ VALID_KEY,
74
+ "--url",
75
+ serverUrl,
76
+ ...extra,
77
+ ];
78
+
79
+ // ── runPublish ──────────────────────────────────────────────────────────
80
+
81
+ describe("runPublish — happy path", () => {
82
+ beforeEach(setup);
83
+ afterEach(teardown);
84
+
85
+ it("200 published → exit 0, prints '✓ Published @owner/name'", async () => {
86
+ // Default mock-server response is 200 published with synthesised
87
+ // SyncSkill — no slot configuration needed.
88
+ await runPublish(baseArgv(), { stdout });
89
+ const out = stdout.text();
90
+ // Matches the established `✓ <verb>` shape used by add/remove/push.
91
+ assert.match(out, /✓ Published @alice\/my-skill/);
92
+ assert.match(out, /now in the public catalog/);
93
+ });
94
+
95
+ it("200 unchanged → exit 0, prints '✓ Already published'", async () => {
96
+ server.setPublishResponse("alice", "my-skill", {
97
+ status: 200,
98
+ body: {
99
+ action: "unchanged",
100
+ skill: { owner: "alice", name: "my-skill" },
101
+ },
102
+ });
103
+ await runPublish(baseArgv(), { stdout });
104
+ assert.match(stdout.text(), /✓ Already published/);
105
+ });
106
+
107
+ it("--json emits a structured success payload (no `ok` field — matches add/remove/push convention)", async () => {
108
+ await runPublish(baseArgv("--json"), { stdout });
109
+ const parsed = JSON.parse(stdout.text());
110
+ // `action` is the success discriminator. NO `ok` field — the
111
+ // CLI exits non-zero on error, so the presence of stdout JSON
112
+ // already implies success. Matches add.mjs / remove.mjs /
113
+ // push.mjs shapes.
114
+ assert.equal(parsed.ok, undefined);
115
+ assert.equal(parsed.action, "published");
116
+ assert.equal(parsed.owner, "alice");
117
+ assert.equal(parsed.name, "my-skill");
118
+ });
119
+ });
120
+
121
+ describe("runPublish — error paths", () => {
122
+ beforeEach(setup);
123
+ afterEach(teardown);
124
+
125
+ it("missing identifier → CliError EXIT_VALIDATION", async () => {
126
+ await assert.rejects(
127
+ runPublish(["--key", VALID_KEY, "--url", serverUrl], { stdout }),
128
+ (err) =>
129
+ err instanceof CliError &&
130
+ err.exitCode === EXIT_VALIDATION &&
131
+ /Missing skill identifier/.test(err.message),
132
+ );
133
+ });
134
+
135
+ it("404 not-found → CliError EXIT_VALIDATION with helpful hint", async () => {
136
+ server.setPublishResponse("alice", "my-skill", {
137
+ status: 404,
138
+ body: { error: "Skill not found", code: "skill_not_found" },
139
+ });
140
+ await assert.rejects(
141
+ runPublish(baseArgv(), { stdout }),
142
+ (err) =>
143
+ err instanceof CliError &&
144
+ err.exitCode === EXIT_VALIDATION &&
145
+ /not found in your account/.test(err.message),
146
+ );
147
+ });
148
+
149
+ it("403 publish_not_permitted → CliError EXIT_SCOPE with canPublish hint", async () => {
150
+ server.setPublishResponse("alice", "my-skill", {
151
+ status: 403,
152
+ body: {
153
+ error: "You do not have permission to publish this skill.",
154
+ code: "publish_not_permitted",
155
+ },
156
+ });
157
+ await assert.rejects(
158
+ runPublish(baseArgv(), { stdout }),
159
+ (err) =>
160
+ err instanceof CliError &&
161
+ err.exitCode === EXIT_SCOPE &&
162
+ /canPublish/.test(err.hint ?? ""),
163
+ );
164
+ });
165
+
166
+ it("403 hint does NOT duplicate the server's error reason text", async () => {
167
+ // Regression guard: pre-PR-F-review the CLI hint repeated the
168
+ // entitlement explanation that the server already includes in
169
+ // its `error` field. Result was the same sentence twice in
170
+ // terminal output. The hint must stay short and action-only.
171
+ server.setPublishResponse("alice", "my-skill", {
172
+ status: 403,
173
+ body: {
174
+ error:
175
+ "You do not have permission to publish this skill. Admins, or members with the `canPublish` entitlement, can publish.",
176
+ code: "publish_not_permitted",
177
+ },
178
+ });
179
+ await assert.rejects(runPublish(baseArgv(), { stdout }), (err) => {
180
+ assert.ok(err instanceof CliError);
181
+ // The server's reason text contains "Admins, or members with
182
+ // the `canPublish` entitlement, can publish." That sentence
183
+ // must NOT appear in the hint too.
184
+ assert.doesNotMatch(
185
+ err.hint ?? "",
186
+ /Admins, or members with the `canPublish` entitlement, can publish\./,
187
+ );
188
+ // But the hint should still be actionable.
189
+ assert.match(err.hint ?? "", /Ask an account admin/);
190
+ return true;
191
+ });
192
+ });
193
+
194
+ it("403 plan_limit → CliError EXIT_VALIDATION (NOT authError), billing hint surfaces", async () => {
195
+ // Real-world regression scenario: an account over its skill
196
+ // quota tries to publish. Server returns 403 with code
197
+ // `plan_limit`. The CLI MUST classify this as exit 5
198
+ // validation with the billing hint, NOT exit 2 authError
199
+ // ("contact support") — a pre-review version of the code
200
+ // consumed the 403 body and let mapErrorResponse re-parse, but
201
+ // the body was empty by then so plan_limit fell through to
202
+ // authError.
203
+ server.setPublishResponse("alice", "my-skill", {
204
+ status: 403,
205
+ body: {
206
+ error: "You are over your library skill quota.",
207
+ code: "plan_limit",
208
+ },
209
+ });
210
+ await assert.rejects(runPublish(baseArgv(), { stdout }), (err) => {
211
+ assert.ok(err instanceof CliError);
212
+ assert.equal(err.exitCode, EXIT_VALIDATION);
213
+ assert.match(err.hint ?? "", /Upgrade your plan|billing/);
214
+ return true;
215
+ });
216
+ });
217
+
218
+ it("422 namespace_unset → CliError EXIT_VALIDATION, reason text passes through", async () => {
219
+ server.setPublishResponse("alice", "my-skill", {
220
+ status: 422,
221
+ body: {
222
+ error: "Set up your account namespace before publishing.",
223
+ code: "namespace_unset",
224
+ },
225
+ });
226
+ await assert.rejects(
227
+ runPublish(baseArgv(), { stdout }),
228
+ (err) =>
229
+ err instanceof CliError &&
230
+ err.exitCode === EXIT_VALIDATION &&
231
+ /account namespace/.test(err.message),
232
+ );
233
+ });
234
+
235
+ it("422 analysis_pending → CliError EXIT_VALIDATION, server reason wins", async () => {
236
+ server.setPublishResponse("alice", "my-skill", {
237
+ status: 422,
238
+ body: {
239
+ error: "This skill must be analyzed before publishing.",
240
+ code: "analysis_pending",
241
+ },
242
+ });
243
+ await assert.rejects(
244
+ runPublish(baseArgv(), { stdout }),
245
+ (err) =>
246
+ err instanceof CliError &&
247
+ err.exitCode === EXIT_VALIDATION &&
248
+ /must be analyzed/.test(err.message),
249
+ );
250
+ });
251
+
252
+ it("422 safety_grade_too_low → CliError EXIT_VALIDATION, server reason wins", async () => {
253
+ server.setPublishResponse("alice", "my-skill", {
254
+ status: 422,
255
+ body: {
256
+ error: "Skills with a safety grade of F cannot be published.",
257
+ code: "safety_grade_too_low",
258
+ },
259
+ });
260
+ await assert.rejects(
261
+ runPublish(baseArgv(), { stdout }),
262
+ (err) =>
263
+ err instanceof CliError &&
264
+ err.exitCode === EXIT_VALIDATION &&
265
+ /safety grade of F cannot be published/.test(err.message),
266
+ );
267
+ });
268
+
269
+ it("401 unauthorized → CliError EXIT_AUTH", async () => {
270
+ server.setPublishResponse("alice", "my-skill", {
271
+ status: 401,
272
+ body: { error: "Invalid access key" },
273
+ });
274
+ await assert.rejects(
275
+ runPublish(baseArgv(), { stdout }),
276
+ (err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
277
+ );
278
+ });
279
+ });
280
+
281
+ // ── runUnpublish ────────────────────────────────────────────────────────
282
+
283
+ describe("runUnpublish — happy path", () => {
284
+ beforeEach(setup);
285
+ afterEach(teardown);
286
+
287
+ it("200 unpublished with N=0 subscribers → exit 0, no-notifications message", async () => {
288
+ // Default mock-server response is 200 unpublished, count=0.
289
+ await runUnpublish(baseArgv(), { stdout });
290
+ assert.match(
291
+ stdout.text(),
292
+ /✓ Unpublished @alice\/my-skill — no other accounts had it in their library/,
293
+ );
294
+ });
295
+
296
+ it("200 unpublished with N=1 → singular 'notified 1 subscriber'", async () => {
297
+ server.setUnpublishResponse("alice", "my-skill", {
298
+ status: 200,
299
+ body: {
300
+ action: "unpublished",
301
+ notifiedSubscriberCount: 1,
302
+ skill: { owner: "alice", name: "my-skill" },
303
+ },
304
+ });
305
+ await runUnpublish(baseArgv(), { stdout });
306
+ const out = stdout.text();
307
+ assert.match(out, /notified 1 subscriber\b/);
308
+ assert.doesNotMatch(out, /1 subscribers/);
309
+ });
310
+
311
+ it("200 unpublished with N=5 → plural 'notified 5 subscribers'", async () => {
312
+ server.setUnpublishResponse("alice", "my-skill", {
313
+ status: 200,
314
+ body: {
315
+ action: "unpublished",
316
+ notifiedSubscriberCount: 5,
317
+ skill: { owner: "alice", name: "my-skill" },
318
+ },
319
+ });
320
+ await runUnpublish(baseArgv(), { stdout });
321
+ const out = stdout.text();
322
+ assert.match(out, /notified 5 subscribers/);
323
+ assert.match(out, /they keep their current copy/);
324
+ });
325
+
326
+ it("200 unchanged → exit 0, prints '✓ Already private'", async () => {
327
+ server.setUnpublishResponse("alice", "my-skill", {
328
+ status: 200,
329
+ body: {
330
+ action: "unchanged",
331
+ skill: { owner: "alice", name: "my-skill" },
332
+ },
333
+ });
334
+ await runUnpublish(baseArgv(), { stdout });
335
+ assert.match(stdout.text(), /✓ Already private/);
336
+ });
337
+
338
+ it("--json emits a structured payload with notifiedSubscriberCount (no `ok` field)", async () => {
339
+ server.setUnpublishResponse("alice", "my-skill", {
340
+ status: 200,
341
+ body: {
342
+ action: "unpublished",
343
+ notifiedSubscriberCount: 3,
344
+ skill: { owner: "alice", name: "my-skill" },
345
+ },
346
+ });
347
+ await runUnpublish(baseArgv("--json"), { stdout });
348
+ const parsed = JSON.parse(stdout.text());
349
+ assert.equal(parsed.ok, undefined);
350
+ assert.equal(parsed.action, "unpublished");
351
+ assert.equal(parsed.notifiedSubscriberCount, 3);
352
+ });
353
+
354
+ it("--json on unchanged emits notifiedSubscriberCount=0 (no `ok` field)", async () => {
355
+ server.setUnpublishResponse("alice", "my-skill", {
356
+ status: 200,
357
+ body: {
358
+ action: "unchanged",
359
+ skill: { owner: "alice", name: "my-skill" },
360
+ },
361
+ });
362
+ await runUnpublish(baseArgv("--json"), { stdout });
363
+ const parsed = JSON.parse(stdout.text());
364
+ assert.equal(parsed.ok, undefined);
365
+ assert.equal(parsed.action, "unchanged");
366
+ assert.equal(parsed.notifiedSubscriberCount, 0);
367
+ });
368
+ });
369
+
370
+ describe("runUnpublish — error paths", () => {
371
+ beforeEach(setup);
372
+ afterEach(teardown);
373
+
374
+ it("404 not-found → CliError EXIT_VALIDATION", async () => {
375
+ server.setUnpublishResponse("alice", "my-skill", {
376
+ status: 404,
377
+ body: { error: "Skill not found", code: "skill_not_found" },
378
+ });
379
+ await assert.rejects(
380
+ runUnpublish(baseArgv(), { stdout }),
381
+ (err) =>
382
+ err instanceof CliError &&
383
+ err.exitCode === EXIT_VALIDATION &&
384
+ /not found in your account/.test(err.message),
385
+ );
386
+ });
387
+
388
+ it("403 unpublish_not_permitted → CliError EXIT_SCOPE with action-only hint", async () => {
389
+ server.setUnpublishResponse("alice", "my-skill", {
390
+ status: 403,
391
+ body: {
392
+ error:
393
+ "You do not have permission to unpublish this skill. Admins, or members with the `canPublish` entitlement, can unpublish.",
394
+ code: "unpublish_not_permitted",
395
+ },
396
+ });
397
+ await assert.rejects(runUnpublish(baseArgv(), { stdout }), (err) => {
398
+ assert.ok(err instanceof CliError);
399
+ assert.equal(err.exitCode, EXIT_SCOPE);
400
+ // Same regression guard as the publish path — hint must NOT
401
+ // duplicate the server's entitlement explanation.
402
+ assert.doesNotMatch(
403
+ err.hint ?? "",
404
+ /Admins, or members with the `canPublish` entitlement, can unpublish\./,
405
+ );
406
+ assert.match(err.hint ?? "", /Ask an account admin/);
407
+ return true;
408
+ });
409
+ });
410
+
411
+ it("missing identifier → CliError EXIT_VALIDATION", async () => {
412
+ await assert.rejects(
413
+ runUnpublish(["--key", VALID_KEY, "--url", serverUrl], { stdout }),
414
+ (err) =>
415
+ err instanceof CliError &&
416
+ err.exitCode === EXIT_VALIDATION &&
417
+ /Missing skill identifier/.test(err.message),
418
+ );
419
+ });
420
+ });
@@ -9,6 +9,8 @@
9
9
  * POST /api/v1/library — multipart file-push (#1452)
10
10
  * POST /api/v1/library/refs — add catalog skill to library (#1451)
11
11
  * DELETE /api/v1/library/[owner]/[name] — remove from library (PR3a)
12
+ * POST /api/v1/library/[owner]/[name]/publish — make global (#1449)
13
+ * POST /api/v1/library/[owner]/[name]/unpublish — make private (#1449)
12
14
  * GET /api/v1/skills/[owner]/[name] — single skill fetch (PR2)
13
15
  * GET /api/v1/skills/search — keyword search (PR2)
14
16
  *
@@ -30,6 +32,10 @@
30
32
  * setPushResponse(resp) — response for POST /library multipart push (#1452)
31
33
  * getLastPostBody() — inspect the most recent POST body (JSON-parsed)
32
34
  *
35
+ * (Epic #1444 R3 #1449 additions)
36
+ * setPublishResponse(owner, name, resp) — per-skill response for POST /library/<owner>/<name>/publish
37
+ * setUnpublishResponse(owner, name, resp) — per-skill response for POST /library/<owner>/<name>/unpublish
38
+ *
33
39
  * The slot APIs let unit/integration tests configure each scenario
34
40
  * without spinning up multiple servers per test. Default behavior
35
41
  * for an unconfigured slot is a sensible empty payload.
@@ -86,6 +92,10 @@ export function createMockServer(initialPayload, options = {}) {
86
92
  // matches any owner/name when no exact match is registered.
87
93
  let addResponses = new Map(); // key: "owner/name" or "*" → { status, body }
88
94
  let removeResponses = new Map(); // key: "owner/name" or "*" → { status, body }
95
+ // Epic #1444 R3 #1456 — POST /api/v1/library/[owner]/[name]/publish
96
+ // and POST /api/v1/library/[owner]/[name]/unpublish (#1449).
97
+ let publishResponses = new Map(); // key: "owner/name" or "*" → { status, body }
98
+ let unpublishResponses = new Map(); // key: "owner/name" or "*" → { status, body }
89
99
 
90
100
  // #1452 — POST /api/v1/library (multipart file-push) response slot.
91
101
  // Tests set this with `setPushResponse({ status, body })`; when null,
@@ -307,6 +317,86 @@ export function createMockServer(initialPayload, options = {}) {
307
317
  return;
308
318
  }
309
319
 
320
+ // ── #1449: POST /api/v1/library/[owner]/[name]/publish ─────────
321
+ {
322
+ const m = url.pathname.match(
323
+ /^\/api\/v1\/library\/([^\/]+)\/([^\/]+)\/publish$/,
324
+ );
325
+ if (m && req.method === "POST") {
326
+ if (!checkAuth(req, res)) return;
327
+ const owner = decodeURIComponent(m[1]);
328
+ const name = decodeURIComponent(m[2]);
329
+ const configured =
330
+ publishResponses.get(`${owner}/${name}`) ?? publishResponses.get("*");
331
+ if (configured) {
332
+ res.writeHead(configured.status, { "Content-Type": "application/json" });
333
+ res.end(JSON.stringify(configured.body));
334
+ return;
335
+ }
336
+ // Default: 200 published with synthesized SyncSkill.
337
+ res.writeHead(200, { "Content-Type": "application/json" });
338
+ res.end(
339
+ JSON.stringify({
340
+ action: "published",
341
+ skill: {
342
+ owner,
343
+ name,
344
+ version: "1.0.0",
345
+ description: "mock",
346
+ keywords: [],
347
+ updatedAt: new Date().toISOString(),
348
+ etag: `"${owner}/${name}@0"`,
349
+ contextSignals: null,
350
+ files: [],
351
+ filesIncomplete: false,
352
+ },
353
+ }),
354
+ );
355
+ return;
356
+ }
357
+ }
358
+
359
+ // ── #1449: POST /api/v1/library/[owner]/[name]/unpublish ───────
360
+ {
361
+ const m = url.pathname.match(
362
+ /^\/api\/v1\/library\/([^\/]+)\/([^\/]+)\/unpublish$/,
363
+ );
364
+ if (m && req.method === "POST") {
365
+ if (!checkAuth(req, res)) return;
366
+ const owner = decodeURIComponent(m[1]);
367
+ const name = decodeURIComponent(m[2]);
368
+ const configured =
369
+ unpublishResponses.get(`${owner}/${name}`) ??
370
+ unpublishResponses.get("*");
371
+ if (configured) {
372
+ res.writeHead(configured.status, { "Content-Type": "application/json" });
373
+ res.end(JSON.stringify(configured.body));
374
+ return;
375
+ }
376
+ // Default: 200 unpublished, zero subscribers notified.
377
+ res.writeHead(200, { "Content-Type": "application/json" });
378
+ res.end(
379
+ JSON.stringify({
380
+ action: "unpublished",
381
+ notifiedSubscriberCount: 0,
382
+ skill: {
383
+ owner,
384
+ name,
385
+ version: "1.0.0",
386
+ description: "mock",
387
+ keywords: [],
388
+ updatedAt: new Date().toISOString(),
389
+ etag: `"${owner}/${name}@0"`,
390
+ contextSignals: null,
391
+ files: [],
392
+ filesIncomplete: false,
393
+ },
394
+ }),
395
+ );
396
+ return;
397
+ }
398
+ }
399
+
310
400
  // ── PR3a: DELETE /api/v1/library/[owner]/[name] (remove) ────────
311
401
  {
312
402
  const m = url.pathname.match(/^\/api\/v1\/library\/([^\/]+)\/([^\/]+)$/);
@@ -563,6 +653,26 @@ export function createMockServer(initialPayload, options = {}) {
563
653
  removeResponses = new Map();
564
654
  },
565
655
 
656
+ // Epic #1444 R3 #1456 — publish / unpublish slot setters.
657
+ setPublishResponse(owner, name, response) {
658
+ publishResponses.set(`${owner}/${name}`, response);
659
+ },
660
+ setPublishResponseForAny(response) {
661
+ publishResponses.set("*", response);
662
+ },
663
+ clearPublishResponses() {
664
+ publishResponses = new Map();
665
+ },
666
+ setUnpublishResponse(owner, name, response) {
667
+ unpublishResponses.set(`${owner}/${name}`, response);
668
+ },
669
+ setUnpublishResponseForAny(response) {
670
+ unpublishResponses.set("*", response);
671
+ },
672
+ clearUnpublishResponses() {
673
+ unpublishResponses = new Map();
674
+ },
675
+
566
676
  /**
567
677
  * #1452 — register the next POST /api/v1/library (multipart file-push)
568
678
  * response. Pass `null` to restore the default 201 LibraryPushResponse.
@@ -206,6 +206,39 @@ describe("writeConfig", () => {
206
206
  assert.equal(mode, 0o600, `Expected 0o600, got ${mode.toString(8)}`);
207
207
  });
208
208
 
209
+ // Telemetry opt-in/out flag persistence (#1539). The field is only
210
+ // written when the caller explicitly sets it (boolean) — omitting
211
+ // means "no preference recorded" and the telemetry module treats
212
+ // that as the opt-out-friendly default of enabled.
213
+ it("persists telemetry=true when explicitly set", () => {
214
+ writeConfig({
215
+ apiKey: "sk_live_abc",
216
+ serverUrl: "https://example.com",
217
+ telemetry: true,
218
+ });
219
+ const raw = JSON.parse(readFileSync(globalConfigPath(), "utf-8"));
220
+ assert.equal(raw.telemetry, true);
221
+ });
222
+
223
+ it("persists telemetry=false when explicitly set", () => {
224
+ writeConfig({
225
+ apiKey: "sk_live_abc",
226
+ serverUrl: "https://example.com",
227
+ telemetry: false,
228
+ });
229
+ const raw = JSON.parse(readFileSync(globalConfigPath(), "utf-8"));
230
+ assert.equal(raw.telemetry, false);
231
+ });
232
+
233
+ it("does NOT write a telemetry field when omitted (preserves no-preference state)", () => {
234
+ writeConfig({
235
+ apiKey: "sk_live_abc",
236
+ serverUrl: "https://example.com",
237
+ });
238
+ const raw = JSON.parse(readFileSync(globalConfigPath(), "utf-8"));
239
+ assert.equal("telemetry" in raw, false);
240
+ });
241
+
209
242
  it("throws validationError on missing apiKey", () => {
210
243
  assert.throws(
211
244
  () => writeConfig({ serverUrl: "https://example.com" }),