happyskills 0.53.0 → 1.0.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/package.json +1 -1
  3. package/src/api/auth.js +18 -2
  4. package/src/api/client.js +29 -3
  5. package/src/api/feedback.js +14 -5
  6. package/src/api/repos.js +28 -10
  7. package/src/api/translate.js +90 -0
  8. package/src/commands/delete.js +15 -1
  9. package/src/commands/feedback.js +2 -2
  10. package/src/commands/init.js +5 -1
  11. package/src/commands/install.js +58 -32
  12. package/src/commands/postlex.js +53 -35
  13. package/src/commands/postlex.test.js +48 -18
  14. package/src/commands/pull.js +5 -1
  15. package/src/commands/reconcile.js +52 -4
  16. package/src/commands/release.js +45 -15
  17. package/src/commands/schema.js +179 -0
  18. package/src/commands/search.js +34 -22
  19. package/src/commands/search.test.js +59 -33
  20. package/src/commands/uninstall.js +20 -11
  21. package/src/commands/validate.js +33 -11
  22. package/src/constants/error_codes.js +197 -0
  23. package/src/constants/exit_codes.js +54 -0
  24. package/src/constants/next_step_actions.js +133 -0
  25. package/src/constants/next_step_by_error_code.js +249 -0
  26. package/src/constants.js +2 -1
  27. package/src/index.js +51 -7
  28. package/src/integration/api_envelope.test.js +499 -0
  29. package/src/integration/bump.test.js +13 -4
  30. package/src/integration/cli.test.js +169 -147
  31. package/src/integration/drift.test.js +16 -4
  32. package/src/integration/install_fresh.test.js +37 -29
  33. package/src/integration/reconcile.test.js +77 -56
  34. package/src/integration/release.test.js +48 -31
  35. package/src/integration/schema.test.js +167 -0
  36. package/src/schema/envelope.schema.json +73 -0
  37. package/src/schema/envelope_test_helpers.js +94 -0
  38. package/src/schema/envelope_validator.js +239 -0
  39. package/src/schema/envelope_validator.test.js +333 -0
  40. package/src/ui/envelope.js +171 -0
  41. package/src/ui/output.js +66 -2
  42. package/src/utils/errors.js +116 -47
  43. package/src/utils/intent.js +22 -1
@@ -0,0 +1,499 @@
1
+ 'use strict'
2
+ /**
3
+ * End-to-end integration tests: CLI ↔ stubbed API speaking the new
4
+ * canonical six-key envelope (spec 260525-cli-default-json § 4).
5
+ *
6
+ * We spin up a tiny `http.createServer` per test that emits the EXACT
7
+ * envelope the production API now returns (post-Session 3), point the
8
+ * CLI binary at it via HAPPYSKILLS_API_URL, and assert that:
9
+ * 1. The CLI parses the response without losing fields (search.mode,
10
+ * rerank context, feedback's next_step.context payload, the device
11
+ * flow's AUTHORIZATION_PENDING polling loop).
12
+ * 2. The CLI's own --json output is itself a valid envelope.
13
+ *
14
+ * These tests catch the field-read drift between CLI command code and
15
+ * API envelope locations — the gap Session 3 introduced when /repos:search
16
+ * moved metadata into data, /feedback hoisted next_step to the root, and
17
+ * /auth/device/token started speaking the canonical shape on HTTP 202.
18
+ */
19
+ const { describe, it, before, after } = require('node:test')
20
+ const assert = require('node:assert/strict')
21
+ const { spawn } = require('child_process')
22
+ const http = require('http')
23
+ const fs = require('fs')
24
+ const os = require('os')
25
+ const path = require('path')
26
+ const {
27
+ parse_envelope,
28
+ assert_success_envelope,
29
+ } = require('../schema/envelope_test_helpers')
30
+
31
+ const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
32
+ const NODE = process.execPath
33
+
34
+ // Minimal stub-server harness. Each test installs a route table that maps
35
+ // `${METHOD} ${PATH}` → handler({ url, body }) returning { status, body }.
36
+ // The body is always JSON-encoded with the new six-key envelope shape.
37
+ //
38
+ // IMPORTANT: every response carries `Connection: close`. Without this,
39
+ // undici's connection pool keeps the socket warm and prevents the CLI
40
+ // child process from exiting (the request-and-response cycle finishes but
41
+ // the idle keep-alive socket keeps the event loop alive, so spawnSync
42
+ // hangs until its timeout fires). Closing per response makes the child
43
+ // exit naturally as soon as the run() promise resolves.
44
+ const make_stub = () => {
45
+ const routes = new Map()
46
+ const server = http.createServer((req, res) => {
47
+ let body = ''
48
+ req.on('data', c => { body += c })
49
+ req.on('end', () => {
50
+ const key = `${req.method} ${req.url.split('?')[0]}`
51
+ const handler = routes.get(key)
52
+ const close_headers = { 'Content-Type': 'application/json', 'Connection': 'close' }
53
+ if (!handler) {
54
+ res.writeHead(404, close_headers)
55
+ res.end(JSON.stringify({
56
+ ok: false,
57
+ data: {},
58
+ error: { code: 'NOT_FOUND', message: `No stub for ${key}` },
59
+ next_step: {},
60
+ warnings: [],
61
+ meta: { command: key, exit_code: 1, envelope_schema_version: '1.0.0', api_version: 'stub' },
62
+ }))
63
+ return
64
+ }
65
+ let parsed_body = null
66
+ try { parsed_body = body ? JSON.parse(body) : null } catch (_) { /* leave null */ }
67
+ const out = handler({ url: req.url, body: parsed_body, headers: req.headers })
68
+ res.writeHead(out.status || 200, close_headers)
69
+ res.end(JSON.stringify(out.body))
70
+ })
71
+ })
72
+ // Disable keep-alive at the server level too — belt-and-braces against
73
+ // any client that ignores the Connection: close header.
74
+ server.keepAliveTimeout = 1
75
+ return {
76
+ server,
77
+ route: (key, handler) => { routes.set(key, handler) },
78
+ start: () => new Promise(resolve => server.listen(0, '127.0.0.1', () => resolve(server.address().port))),
79
+ close: () => new Promise(resolve => server.close(() => resolve())),
80
+ }
81
+ }
82
+
83
+ // Streaming spawn instead of spawnSync. The CLI's success path returns
84
+ // without calling process.exit so the Node event loop holds the process
85
+ // open until undici's keep-alive sockets idle out (~30s). spawnSync only
86
+ // captures output AFTER the child exits, so it would hang. Streaming
87
+ // captures stdout incrementally and we kill the child as soon as we've
88
+ // seen a complete JSON envelope (or stderr indicates an error path).
89
+ const run_cli = (args, env_override = {}) => new Promise((resolve) => {
90
+ const has_mode_flag = args.includes('--json') || args.includes('--text')
91
+ const final_args = has_mode_flag ? args : [...args, '--text']
92
+ const child = spawn(NODE, [CLI, ...final_args], {
93
+ env: { ...process.env, NO_COLOR: '1', CI: '', ...env_override },
94
+ })
95
+ let stdout = ''
96
+ let stderr = ''
97
+ let settled = false
98
+ const settle = (code) => {
99
+ if (settled) return
100
+ settled = true
101
+ try { child.kill('SIGTERM') } catch (_) { /* ignore */ }
102
+ resolve({ stdout, stderr, code })
103
+ }
104
+ child.stdout.on('data', (d) => {
105
+ stdout += d.toString()
106
+ // As soon as we've seen what looks like a complete JSON document on
107
+ // stdout, the command has emitted its envelope. Settle with code 0
108
+ // (the visible code; non-zero error paths exit explicitly and we
109
+ // catch those via the `exit` handler below).
110
+ if (stdout.trim().endsWith('}')) {
111
+ try {
112
+ JSON.parse(stdout)
113
+ settle(0)
114
+ } catch (_) { /* not yet a full envelope, keep buffering */ }
115
+ }
116
+ })
117
+ child.stderr.on('data', (d) => { stderr += d.toString() })
118
+ child.on('exit', (code) => settle(code))
119
+ // Hard ceiling — should never fire under normal conditions.
120
+ setTimeout(() => settle(null), 15000)
121
+ })
122
+
123
+ const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-api-stub-'))
124
+
125
+ // Build a canonical success envelope for the stub.
126
+ const success_envelope = (data, opts = {}) => ({
127
+ ok: true,
128
+ data,
129
+ error: {},
130
+ next_step: opts.next_step || {},
131
+ warnings: opts.warnings || [],
132
+ meta: {
133
+ command: opts.command || 'stub',
134
+ exit_code: 0,
135
+ envelope_schema_version: '1.0.0',
136
+ api_version: 'stub-5.0.0',
137
+ },
138
+ })
139
+
140
+ // ─── search ────────────────────────────────────────────────────────────────
141
+
142
+ describe('CLI ↔ API: /repos:search envelope (§ 15.3.1)', () => {
143
+ let stub, port
144
+
145
+ before(async () => {
146
+ stub = make_stub()
147
+ port = await stub.start()
148
+ })
149
+ after(async () => { await stub.close() })
150
+
151
+ it('renders results when the API places metadata inside data (post-§ 15.3.1)', async () => {
152
+ stub.route('POST /repos:search', () => ({
153
+ status: 200,
154
+ body: success_envelope({
155
+ query: 'deploy aws',
156
+ mode: 'semantic',
157
+ results: [{
158
+ name: 'deploy-aws',
159
+ workspace_slug: 'acme',
160
+ full_name: 'acme/deploy-aws',
161
+ description: 'Ship a service to AWS Lambda',
162
+ match_quality: 'strong',
163
+ quality_score: 80,
164
+ star_count: 12,
165
+ }],
166
+ count: 1,
167
+ match_notice: null,
168
+ workspace_match: null,
169
+ intent_id: 'int-abc-123',
170
+ }, { command: 'POST /repos:search' }),
171
+ }))
172
+ const { code, stdout } = await run_cli(
173
+ ['search', 'deploy aws', '--json', '--limit', '10'],
174
+ { HAPPYSKILLS_API_URL: `http://127.0.0.1:${port}` }
175
+ )
176
+ assert.strictEqual(code, 0, `expected exit 0, got ${code}; stdout:\n${stdout}`)
177
+ const env = parse_envelope(stdout, 'search semantic')
178
+ assert_success_envelope(env)
179
+ assert.strictEqual(env.data.mode, 'semantic')
180
+ assert.strictEqual(env.data.count, 1)
181
+ assert.strictEqual(env.data.results[0].name, 'deploy-aws')
182
+ })
183
+
184
+ it('forwards the rerank protocol through next_step.context.* (post-§ 15.3.1)', async () => {
185
+ stub.route('POST /repos:search', () => ({
186
+ status: 200,
187
+ body: success_envelope({
188
+ query: 'deploy aws',
189
+ mode: 'semantic',
190
+ results: [
191
+ { name: 'deploy-aws', workspace_slug: 'acme', full_name: 'acme/deploy-aws', match_quality: 'good', quality_score: 70, star_count: 5 },
192
+ { name: 'aws-lambda', workspace_slug: 'acme', full_name: 'acme/aws-lambda', match_quality: 'good', quality_score: 65, star_count: 3 },
193
+ ],
194
+ count: 2,
195
+ match_notice: null,
196
+ }, {
197
+ command: 'POST /repos:search',
198
+ next_step: {
199
+ kind: 'continuation',
200
+ action: 'rank_digests_inline',
201
+ instructions: 'Rank these per the system prompt.',
202
+ context: {
203
+ rerank_digests: [
204
+ { candidate_id: 1, digest: 'deploy aws lambda' },
205
+ { candidate_id: 2, digest: 'aws lambda functions' },
206
+ ],
207
+ rerank_system_prompt: 'You are a ranker.',
208
+ rerank_prompt_version: 'v3',
209
+ rerank_response_schema: { type: 'object', properties: { ranking: { type: 'array' } } },
210
+ },
211
+ },
212
+ }),
213
+ }))
214
+ const { code, stdout } = await run_cli(
215
+ ['search', 'deploy aws', '--with-rerank', '--json', '--limit', '50'],
216
+ { HAPPYSKILLS_API_URL: `http://127.0.0.1:${port}` }
217
+ )
218
+ assert.strictEqual(code, 0, `expected exit 0, got ${code}; stdout:\n${stdout}`)
219
+ const env = parse_envelope(stdout, 'search rerank')
220
+ assert_success_envelope(env)
221
+ // Rerank payload reached the CLI emit (the CLI flattens into data.rerank_*).
222
+ assert.ok(Array.isArray(env.data.rerank_digests))
223
+ assert.strictEqual(env.data.rerank_digests.length, 2)
224
+ assert.strictEqual(env.data.rerank_system_prompt, 'You are a ranker.')
225
+ assert.strictEqual(env.data.rerank_prompt_version, 'v3')
226
+ // CLI emits its own next_step pointing at rank_digests_inline.
227
+ assert.strictEqual(env.next_step.action, 'rank_digests_inline')
228
+ assert.strictEqual(env.next_step.kind, 'continuation')
229
+ })
230
+
231
+ it('surfaces match_notice into the emitted envelope', async () => {
232
+ stub.route('POST /repos:search', () => ({
233
+ status: 200,
234
+ body: success_envelope({
235
+ query: 'deploy',
236
+ mode: 'semantic',
237
+ results: [],
238
+ count: 0,
239
+ match_notice: 'Ambiguous — multiple meanings detected',
240
+ }, { command: 'POST /repos:search' }),
241
+ }))
242
+ const { code, stdout } = await run_cli(
243
+ ['search', 'deploy', '--json', '--limit', '10'],
244
+ { HAPPYSKILLS_API_URL: `http://127.0.0.1:${port}` }
245
+ )
246
+ assert.strictEqual(code, 0)
247
+ const env = parse_envelope(stdout, 'search match_notice')
248
+ assert_success_envelope(env)
249
+ assert.match(env.data.match_notice, /ambiguous/i)
250
+ })
251
+ })
252
+
253
+ // ─── feedback ──────────────────────────────────────────────────────────────
254
+
255
+ describe('CLI ↔ API: POST /feedback envelope (§ 15.3.4)', () => {
256
+ let stub, port, tmp_xdg
257
+
258
+ before(async () => {
259
+ stub = make_stub()
260
+ port = await stub.start()
261
+ tmp_xdg = make_tmp()
262
+ // Stub credentials so the CLI sends auth.
263
+ const creds_dir = path.join(tmp_xdg, 'happyskills')
264
+ fs.mkdirSync(creds_dir, { recursive: true })
265
+ const payload = { email: 'test@example.com', 'cognito:username': 'testuser', sub: 'test-sub-123' }
266
+ const fake_jwt = `eyJhbGciOiJSUzI1NiJ9.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.fakesig`
267
+ fs.writeFileSync(path.join(creds_dir, 'credentials.json'), JSON.stringify({
268
+ id_token: fake_jwt,
269
+ access_token: 'fake-access',
270
+ refresh_token: 'fake-refresh',
271
+ expires_in: 3600,
272
+ stored_at: new Date().toISOString(),
273
+ }, null, '\t'))
274
+ })
275
+ after(async () => {
276
+ await stub.close()
277
+ fs.rmSync(tmp_xdg, { recursive: true, force: true })
278
+ })
279
+
280
+ it('reads next_step.context.{feedback_id, attachments_supported, max_attachments} from the hoisted envelope', async () => {
281
+ stub.route('POST /feedback', ({ body }) => ({
282
+ status: 201,
283
+ body: {
284
+ ok: true,
285
+ data: {
286
+ feedback: {
287
+ id: 'fb-001',
288
+ subject: body?.subject || 'A bug',
289
+ body: body?.body || 'It broke.',
290
+ category: body?.category || 'bug',
291
+ created_at: '2026-05-28T00:00:00Z',
292
+ },
293
+ },
294
+ error: {},
295
+ next_step: {
296
+ kind: 'continuation',
297
+ action: 'attach_screenshot',
298
+ instructions: 'Thanks — feedback recorded. The principal may optionally attach a screenshot.',
299
+ context: {
300
+ feedback_id: 'fb-001',
301
+ attachments_supported: true,
302
+ max_attachments: 5,
303
+ message: 'Want to attach a screenshot?',
304
+ },
305
+ },
306
+ warnings: [],
307
+ meta: { command: 'POST /feedback', exit_code: 0, envelope_schema_version: '1.0.0', api_version: 'stub-5.0.0' },
308
+ },
309
+ }))
310
+ const { code, stdout } = await run_cli(
311
+ ['feedback', 'bug', 'It broke.', '--subject', 'A bug', '--json'],
312
+ { HAPPYSKILLS_API_URL: `http://127.0.0.1:${port}`, XDG_CONFIG_HOME: tmp_xdg }
313
+ )
314
+ assert.strictEqual(code, 0, `expected exit 0, got ${code}; stdout:\n${stdout}`)
315
+ const env = parse_envelope(stdout, 'feedback create')
316
+ assert_success_envelope(env)
317
+ assert.strictEqual(env.data.feedback.id, 'fb-001')
318
+ // The CLI's emitted next_step must carry the attach_screenshot action.
319
+ assert.strictEqual(env.next_step.action, 'attach_screenshot')
320
+ assert.strictEqual(env.next_step.context.feedback_id, 'fb-001')
321
+ assert.strictEqual(env.next_step.context.attachments_supported, true)
322
+ assert.strictEqual(env.next_step.context.max_attachments, 5)
323
+ })
324
+ })
325
+
326
+ // ─── device flow polling ───────────────────────────────────────────────────
327
+
328
+ describe('CLI ↔ API: /auth/device/token envelope (§ 15.3.2)', () => {
329
+ let stub, port, tmp_xdg
330
+
331
+ before(async () => {
332
+ stub = make_stub()
333
+ port = await stub.start()
334
+ tmp_xdg = make_tmp()
335
+ })
336
+ after(async () => {
337
+ await stub.close()
338
+ fs.rmSync(tmp_xdg, { recursive: true, force: true })
339
+ })
340
+
341
+ it('completes a login that AUTHORIZATION_PENDING-polls once then succeeds', () => {
342
+ // First a /device/start, then /device/token returns PENDING once,
343
+ // then succeeds with a token. The login command opens a browser by
344
+ // default; we use --device flag... actually login doesn't have a
345
+ // browser-skip flag for tests. Instead we exercise the polling
346
+ // envelope directly by spawning the binary with the device URL set
347
+ // and a special flag combination. For this test we verify just the
348
+ // envelope-parsing piece — that the new shape works when the API
349
+ // emits AUTHORIZATION_PENDING via the canonical envelope.
350
+ //
351
+ // We test the device_token client module in isolation to keep this
352
+ // test deterministic (the full browser flow spawns `open`).
353
+ const { device_token } = require('../api/auth')
354
+ let call_count = 0
355
+ stub.route('POST /auth/device/token', () => {
356
+ call_count += 1
357
+ if (call_count === 1) {
358
+ return {
359
+ status: 202,
360
+ body: {
361
+ ok: false,
362
+ data: {},
363
+ error: { code: 'AUTHORIZATION_PENDING', message: 'Waiting for user authorization' },
364
+ next_step: {
365
+ kind: 'recovery',
366
+ action: 'retry',
367
+ instructions: 'Authorization is still pending. Poll again after the suggested interval.',
368
+ context: { retry_after_seconds: 2 },
369
+ },
370
+ warnings: [],
371
+ meta: { command: 'POST /auth/device/token', exit_code: 0, envelope_schema_version: '1.0.0', api_version: 'stub-5.0.0' },
372
+ },
373
+ }
374
+ }
375
+ return {
376
+ status: 200,
377
+ body: {
378
+ ok: true,
379
+ data: {
380
+ id_token: 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhYmMifQ.sig',
381
+ access_token: 'access-xyz',
382
+ refresh_token: 'refresh-xyz',
383
+ expires_in: 3600,
384
+ },
385
+ error: {},
386
+ next_step: {},
387
+ warnings: [],
388
+ meta: { command: 'POST /auth/device/token', exit_code: 0, envelope_schema_version: '1.0.0', api_version: 'stub-5.0.0' },
389
+ },
390
+ }
391
+ })
392
+ // Set the API URL so the auth client targets the stub.
393
+ const prev = process.env.HAPPYSKILLS_API_URL
394
+ process.env.HAPPYSKILLS_API_URL = `http://127.0.0.1:${port}`
395
+ // Re-require the client module since it caches API_URL via constants.
396
+ // In practice this only matters if the test runs in a worker that has
397
+ // already cached the client — we accept that and use the env var
398
+ // directly via the request.
399
+ return (async () => {
400
+ try {
401
+ const [pending_err, pending] = await device_token('test-device-code')
402
+ assert.equal(pending_err, null, `unexpected error on pending poll: ${pending_err && pending_err.map(e => e.message).join(', ')}`)
403
+ assert.ok(pending && pending.error && pending.error.code === 'AUTHORIZATION_PENDING',
404
+ `expected AUTHORIZATION_PENDING surface, got ${JSON.stringify(pending)}`)
405
+ const [success_err, success] = await device_token('test-device-code')
406
+ assert.equal(success_err, null, `unexpected error on success poll: ${success_err && success_err.map(e => e.message).join(', ')}`)
407
+ assert.ok(success.id_token, 'expected id_token in success response')
408
+ assert.strictEqual(success.access_token, 'access-xyz')
409
+ assert.strictEqual(success.refresh_token, 'refresh-xyz')
410
+ } finally {
411
+ if (prev === undefined) delete process.env.HAPPYSKILLS_API_URL
412
+ else process.env.HAPPYSKILLS_API_URL = prev
413
+ }
414
+ })()
415
+ })
416
+ })
417
+
418
+ // ─── array-returning endpoints — sole-results unwrap ───────────────────────
419
+ //
420
+ // Spec § 4.3 mandates `data` is always an object — arrays are wrapped as
421
+ // `{ results: [...] }`. The CLI API client strips that wrapper when it's
422
+ // the sole payload (see cli/src/api/client.js) so legacy CLI consumers
423
+ // (`people list`, `groups list`, `access list`, etc.) keep seeing the
424
+ // bare array they always did. This test exercises the round-trip for the
425
+ // `people search` command which iterates the returned array directly.
426
+
427
+ describe('CLI ↔ API: array-returning endpoints unwrap sole `results`', () => {
428
+ let stub, port, tmp_xdg
429
+
430
+ before(async () => {
431
+ stub = make_stub()
432
+ port = await stub.start()
433
+ tmp_xdg = make_tmp()
434
+ const creds_dir = path.join(tmp_xdg, 'happyskills')
435
+ fs.mkdirSync(creds_dir, { recursive: true })
436
+ const payload = { email: 'test@example.com', 'cognito:username': 'testuser', sub: 'test-sub-123' }
437
+ const fake_jwt = `eyJhbGciOiJSUzI1NiJ9.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.fakesig`
438
+ fs.writeFileSync(path.join(creds_dir, 'credentials.json'), JSON.stringify({
439
+ id_token: fake_jwt,
440
+ access_token: 'fake-access',
441
+ refresh_token: 'fake-refresh',
442
+ expires_in: 3600,
443
+ stored_at: new Date().toISOString(),
444
+ }, null, '\t'))
445
+ })
446
+ after(async () => {
447
+ await stub.close()
448
+ fs.rmSync(tmp_xdg, { recursive: true, force: true })
449
+ })
450
+
451
+ it('iterates `/users/search` rows that arrive wrapped as data.results', async () => {
452
+ stub.route('GET /users/search', () => ({
453
+ status: 200,
454
+ body: success_envelope({
455
+ results: [
456
+ { username: 'alice', email: 'a@x.com', display_name: 'Alice' },
457
+ { username: 'bob', email: 'b@x.com', display_name: 'Bob' },
458
+ ],
459
+ }, { command: 'GET /users/search' }),
460
+ }))
461
+ const { code, stdout } = await run_cli(
462
+ ['people', 'search', 'al', '--json'],
463
+ { HAPPYSKILLS_API_URL: `http://127.0.0.1:${port}`, XDG_CONFIG_HOME: tmp_xdg }
464
+ )
465
+ assert.strictEqual(code, 0, `expected exit 0, got ${code}; stdout:\n${stdout}`)
466
+ const env = parse_envelope(stdout, 'people search')
467
+ assert_success_envelope(env)
468
+ // The CLI re-emits the rows under data.results (canonical array key).
469
+ const rows = Array.isArray(env.data) ? env.data : env.data.results
470
+ assert.ok(Array.isArray(rows), `expected an array of users; got ${JSON.stringify(env.data)}`)
471
+ assert.strictEqual(rows.length, 2)
472
+ assert.strictEqual(rows[0].username, 'alice')
473
+ })
474
+
475
+ it('text mode renders the rows without crashing on .map/.length', async () => {
476
+ // Pre-fix: client returned `{ results: [...] }`, so `users.map` was
477
+ // undefined and the text path threw TypeError. This test pins down
478
+ // the regression by spawning the command WITHOUT --json (so the
479
+ // run_cli helper appends --text per its default), and asserting
480
+ // the process exits 0 with both usernames visible on stdout.
481
+ stub.route('GET /users/search', () => ({
482
+ status: 200,
483
+ body: success_envelope({
484
+ results: [
485
+ { username: 'alice', email: 'a@x.com', display_name: 'Alice' },
486
+ { username: 'bob', email: 'b@x.com', display_name: 'Bob' },
487
+ ],
488
+ }, { command: 'GET /users/search' }),
489
+ }))
490
+ const { code, stdout, stderr } = await run_cli(
491
+ ['people', 'search', 'al'],
492
+ { HAPPYSKILLS_API_URL: `http://127.0.0.1:${port}`, XDG_CONFIG_HOME: tmp_xdg }
493
+ )
494
+ assert.strictEqual(code, 0, `expected exit 0; stderr:\n${stderr}\nstdout:\n${stdout}`)
495
+ assert.doesNotMatch(stderr, /TypeError|is not a function|Cannot read/i, `text mode threw:\n${stderr}`)
496
+ assert.match(stdout, /alice/)
497
+ assert.match(stdout, /bob/)
498
+ })
499
+ })
@@ -15,17 +15,24 @@ const { spawnSync } = require('child_process')
15
15
  const fs = require('fs')
16
16
  const os = require('os')
17
17
  const path = require('path')
18
+ const {
19
+ parse_envelope,
20
+ assert_success_envelope,
21
+ } = require('../schema/envelope_test_helpers')
18
22
 
19
23
  const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
20
24
  const NODE = process.execPath
21
25
 
22
26
  const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-bump-test-'))
23
27
  const run = (args, opts) => {
24
- const result = spawnSync(NODE, [CLI, ...args], {
28
+ const has_mode_flag = args.includes('--json') || args.includes('--text')
29
+ const final_args = has_mode_flag ? args : [...args, '--text']
30
+ const result = spawnSync(NODE, [CLI, ...final_args], {
25
31
  env: {
26
32
  ...process.env,
27
- NO_COLOR: '1',
33
+ NO_COLOR: '1', HAPPYSKILLS_ENVELOPE_STRICT: '1',
28
34
  HAPPYSKILLS_API_URL: 'http://localhost:0',
35
+ CI: '',
29
36
  ...(opts?.env || {})
30
37
  },
31
38
  encoding: 'utf-8',
@@ -34,9 +41,11 @@ const run = (args, opts) => {
34
41
  })
35
42
  return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
36
43
  }
44
+ // Parse + envelope-validate. Bump/list emit success envelopes here.
37
45
  const parse_json = (stdout, label) => {
38
- try { return JSON.parse(stdout) }
39
- catch { assert.fail(`${label}: stdout is not valid JSON:\n${stdout}`) }
46
+ const env = parse_envelope(stdout, label)
47
+ assert_success_envelope(env, label)
48
+ return env
40
49
  }
41
50
 
42
51
  // Scaffold a project with one locked-and-installed skill.