happyskills 0.54.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.
- package/CHANGELOG.md +24 -0
- package/package.json +1 -1
- package/src/api/auth.js +18 -2
- package/src/api/client.js +29 -3
- package/src/api/feedback.js +14 -5
- package/src/api/repos.js +28 -10
- package/src/api/translate.js +90 -0
- package/src/commands/delete.js +15 -1
- package/src/commands/feedback.js +2 -2
- package/src/commands/init.js +5 -1
- package/src/commands/install.js +58 -32
- package/src/commands/postlex.js +46 -34
- package/src/commands/postlex.test.js +48 -18
- package/src/commands/pull.js +5 -1
- package/src/commands/reconcile.js +52 -4
- package/src/commands/release.js +45 -15
- package/src/commands/schema.js +179 -0
- package/src/commands/search.js +26 -22
- package/src/commands/search.test.js +59 -33
- package/src/commands/uninstall.js +20 -11
- package/src/commands/validate.js +33 -11
- package/src/constants/error_codes.js +197 -0
- package/src/constants/exit_codes.js +54 -0
- package/src/constants/next_step_actions.js +133 -0
- package/src/constants/next_step_by_error_code.js +249 -0
- package/src/constants.js +2 -1
- package/src/index.js +51 -7
- package/src/integration/api_envelope.test.js +499 -0
- package/src/integration/bump.test.js +13 -4
- package/src/integration/cli.test.js +169 -147
- package/src/integration/drift.test.js +16 -4
- package/src/integration/install_fresh.test.js +37 -29
- package/src/integration/reconcile.test.js +77 -56
- package/src/integration/release.test.js +48 -31
- package/src/integration/schema.test.js +167 -0
- package/src/schema/envelope.schema.json +73 -0
- package/src/schema/envelope_test_helpers.js +94 -0
- package/src/schema/envelope_validator.js +239 -0
- package/src/schema/envelope_validator.test.js +333 -0
- package/src/ui/envelope.js +171 -0
- package/src/ui/output.js +66 -2
- package/src/utils/errors.js +116 -47
|
@@ -172,25 +172,32 @@ describe('determine_next_step', () => {
|
|
|
172
172
|
rationale: '',
|
|
173
173
|
}))
|
|
174
174
|
|
|
175
|
-
|
|
175
|
+
// Per spec 260525-cli-default-json § 7.1 the closed action names are
|
|
176
|
+
// "clarify_query" (kind=clarification) and "present_to_user"
|
|
177
|
+
// (kind=continuation). Tests below use those — the older bare "clarify"
|
|
178
|
+
// label is deprecated.
|
|
179
|
+
|
|
180
|
+
it('emits present_to_user (kind=continuation) when top 3 are all strong/good', () => {
|
|
176
181
|
const ns = determine_next_step(fo(['strong', 'good', 'strong']), 'q', 0)
|
|
177
182
|
assert.equal(ns.action, 'present_to_user')
|
|
183
|
+
assert.equal(ns.kind, 'continuation')
|
|
178
184
|
})
|
|
179
185
|
|
|
180
|
-
it('emits
|
|
186
|
+
it('emits clarify_query (kind=clarification) when top 3 include a partial AND budget remains', () => {
|
|
181
187
|
const ns = determine_next_step(fo(['strong', 'partial', 'good']), 'q', 0)
|
|
182
|
-
assert.equal(ns.action, '
|
|
183
|
-
assert.equal(ns.
|
|
188
|
+
assert.equal(ns.action, 'clarify_query')
|
|
189
|
+
assert.equal(ns.kind, 'clarification')
|
|
190
|
+
// max_turns_remaining + suggested_questions live inside context (spec § 4.5).
|
|
191
|
+
assert.equal(ns.context.max_turns_remaining, 2)
|
|
184
192
|
assert.equal(ns.context.clarification_turns_used, 0)
|
|
185
|
-
|
|
186
|
-
const last = ns.suggested_questions[0].options[ns.suggested_questions[0].options.length - 1]
|
|
193
|
+
const last = ns.context.suggested_questions[0].options[ns.context.suggested_questions[0].options.length - 1]
|
|
187
194
|
assert.match(last.label, /search anyway/i)
|
|
188
195
|
})
|
|
189
196
|
|
|
190
|
-
it('emits
|
|
197
|
+
it('emits clarify_query when top 3 include a weak/null AND budget remains', () => {
|
|
191
198
|
const ns = determine_next_step(fo(['weak', 'good', null]), 'q', 1)
|
|
192
|
-
assert.equal(ns.action, '
|
|
193
|
-
assert.equal(ns.max_turns_remaining, 1)
|
|
199
|
+
assert.equal(ns.action, 'clarify_query')
|
|
200
|
+
assert.equal(ns.context.max_turns_remaining, 1)
|
|
194
201
|
})
|
|
195
202
|
|
|
196
203
|
it('emits present_to_user with budget-spent note when budget exhausted', () => {
|
|
@@ -201,7 +208,7 @@ describe('determine_next_step', () => {
|
|
|
201
208
|
|
|
202
209
|
it('clamps clarification_turns_used to [0, 2]', () => {
|
|
203
210
|
const ns_neg = determine_next_step(fo(['partial', 'partial', 'partial']), 'q', -1)
|
|
204
|
-
assert.equal(ns_neg.max_turns_remaining, 2)
|
|
211
|
+
assert.equal(ns_neg.context.max_turns_remaining, 2)
|
|
205
212
|
const ns_big = determine_next_step(fo(['partial', 'partial', 'partial']), 'q', 99)
|
|
206
213
|
assert.equal(ns_big.action, 'present_to_user')
|
|
207
214
|
})
|
|
@@ -218,16 +225,28 @@ describe('determine_next_step', () => {
|
|
|
218
225
|
})
|
|
219
226
|
|
|
220
227
|
// ─── build_retry_envelope ─────────────────────────────────────────────────
|
|
228
|
+
// Envelope contract: six-key { ok, data, error, next_step, warnings, meta }
|
|
229
|
+
// with data={}, error.code=RANKING_SCHEMA_MISMATCH (closed enum), kind=recovery,
|
|
230
|
+
// action=retry_rank, exit_code mirrored on meta (NOT on error).
|
|
231
|
+
|
|
232
|
+
const { validate_envelope } = require('../schema/envelope_validator')
|
|
221
233
|
|
|
222
234
|
describe('build_retry_envelope', () => {
|
|
223
|
-
it('produces a retry_rank envelope
|
|
235
|
+
it('produces a retry_rank envelope conforming to the closed schema', () => {
|
|
224
236
|
const env = build_retry_envelope('the query', 'ranking missing field rationale', 1, 0)
|
|
225
|
-
|
|
226
|
-
assert.equal(
|
|
227
|
-
assert.equal(env.
|
|
237
|
+
const { ok, errors } = validate_envelope(env)
|
|
238
|
+
assert.equal(ok, true, `envelope failed validation: ${JSON.stringify(errors)}`)
|
|
239
|
+
assert.equal(env.ok, false, 'retry envelope is a failure (ok=false)')
|
|
240
|
+
assert.deepEqual(env.data, {}, 'data must be the empty object, never null')
|
|
241
|
+
assert.equal(env.error.code, 'RANKING_SCHEMA_MISMATCH')
|
|
242
|
+
assert.equal(typeof env.error.message, 'string')
|
|
243
|
+
assert.equal('exit_code' in env.error, false, 'exit_code lives on meta, not error')
|
|
244
|
+
assert.equal(env.next_step.kind, 'recovery')
|
|
228
245
|
assert.equal(env.next_step.action, 'retry_rank')
|
|
229
246
|
assert.equal(env.next_step.context.clarification_turns_used, 1)
|
|
230
247
|
assert.equal(env.next_step.context.retry_count, 1)
|
|
248
|
+
assert.equal(env.meta.command, 'postlex')
|
|
249
|
+
assert.ok(env.meta.exit_code > 0, 'retry envelope must mirror a non-zero exit code')
|
|
231
250
|
})
|
|
232
251
|
|
|
233
252
|
it('increments retry_count on each call', () => {
|
|
@@ -344,8 +363,16 @@ describe('normalize_data_rows', () => {
|
|
|
344
363
|
// ─── extract_data_array_from_search_output (v0.48.0) ──────────────────────
|
|
345
364
|
|
|
346
365
|
describe('extract_data_array_from_search_output', () => {
|
|
347
|
-
it('extracts data.results from the canonical envelope shape', () => {
|
|
348
|
-
|
|
366
|
+
it('extracts data.results from the canonical (spec 260525) envelope shape', () => {
|
|
367
|
+
// Canonical envelope: every key always an object, never null.
|
|
368
|
+
const env = {
|
|
369
|
+
ok: true,
|
|
370
|
+
data: { query: 'q', mode: 'semantic', results: [{ name: 'foo' }] },
|
|
371
|
+
error: {},
|
|
372
|
+
next_step: {},
|
|
373
|
+
warnings: [],
|
|
374
|
+
meta: { command: 'search', exit_code: 0, envelope_schema_version: '1.0.0' },
|
|
375
|
+
}
|
|
349
376
|
const r = extract_data_array_from_search_output(env)
|
|
350
377
|
assert.equal(r.length, 1)
|
|
351
378
|
assert.equal(r[0].name, 'foo')
|
|
@@ -380,9 +407,12 @@ describe('parse_input with --search-output', () => {
|
|
|
380
407
|
it('extracts data.results from a full search envelope passed as the third argument', () => {
|
|
381
408
|
const ranking = JSON.stringify({ ranking: [{ rank: 1, candidate_id: 1, rationale: 'top' }] })
|
|
382
409
|
const search_out = JSON.stringify({
|
|
410
|
+
ok: true,
|
|
383
411
|
data: { query: 'q', mode: 'semantic', results: [{ name: 'deploy-aws', workspace_slug: 'acme' }] },
|
|
384
|
-
error:
|
|
385
|
-
next_step:
|
|
412
|
+
error: {},
|
|
413
|
+
next_step: {},
|
|
414
|
+
warnings: [],
|
|
415
|
+
meta: { command: 'search', exit_code: 0, envelope_schema_version: '1.0.0' },
|
|
386
416
|
})
|
|
387
417
|
const r = parse_input(ranking, null, search_out)
|
|
388
418
|
assert.equal(r.parse_error, null)
|
package/src/commands/pull.js
CHANGED
|
@@ -125,7 +125,11 @@ const run = (args) => catch_errors('Pull failed', async () => {
|
|
|
125
125
|
next_step: result.next_step || null,
|
|
126
126
|
error: result.error || null
|
|
127
127
|
})
|
|
128
|
-
|
|
128
|
+
// Spec § 9.1 — exit code reflects whether the OPERATION succeeded,
|
|
129
|
+
// not whether a next_step is present. Success-with-followup (data
|
|
130
|
+
// populated, next_step set, error empty) is exit 0. A populated
|
|
131
|
+
// error is the signal for non-zero exit.
|
|
132
|
+
if (result.error && result.error.code) process.exit(EXIT_CODES.ERROR)
|
|
129
133
|
return
|
|
130
134
|
}
|
|
131
135
|
if (result.error) {
|
|
@@ -58,6 +58,52 @@ const resolve_lock_entry = (raw, project_root, is_global) => catch_errors('Faile
|
|
|
58
58
|
return { full, lock_entry, lock_data }
|
|
59
59
|
})
|
|
60
60
|
|
|
61
|
+
// Expand the internal { action, context: {...} } reconcile result into the
|
|
62
|
+
// closed-enum six-key next_step shape (spec § 4.5 / § 7). Returns {} for
|
|
63
|
+
// the no-followup case.
|
|
64
|
+
const NEXT_STEP_INSTRUCTIONS = {
|
|
65
|
+
resolve_regression: 'Disk version is older than lock. Pick one of the listed options and re-run with --apply <option>.',
|
|
66
|
+
resolve_missing_skill_json: 'skill.json is missing. Pick one of the listed restore options and re-run with --apply <option>.',
|
|
67
|
+
resolve_missing_dir: 'The skill directory is missing. Pick one of the listed options and re-run with --apply <option>.',
|
|
68
|
+
install_first: 'Skill is not in the lock file. Install it first, then re-run reconcile.',
|
|
69
|
+
}
|
|
70
|
+
const NEXT_STEP_KINDS_MAP = {
|
|
71
|
+
resolve_regression: 'decision',
|
|
72
|
+
resolve_missing_skill_json: 'decision',
|
|
73
|
+
resolve_missing_dir: 'decision',
|
|
74
|
+
install_first: 'routing',
|
|
75
|
+
}
|
|
76
|
+
const enrich_reconcile_next_step = (ns, full) => {
|
|
77
|
+
if (!ns || typeof ns !== 'object') return {}
|
|
78
|
+
const action = ns.action
|
|
79
|
+
if (!action) return {}
|
|
80
|
+
const kind = NEXT_STEP_KINDS_MAP[action] || 'decision'
|
|
81
|
+
if (action === 'install_first') {
|
|
82
|
+
return {
|
|
83
|
+
kind,
|
|
84
|
+
action,
|
|
85
|
+
instructions: NEXT_STEP_INSTRUCTIONS.install_first,
|
|
86
|
+
context: { commands: [`npx happyskills install ${full} --json`] },
|
|
87
|
+
route_to_skill: 'happyskills',
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const opts = ns.context?.options || []
|
|
91
|
+
return {
|
|
92
|
+
kind,
|
|
93
|
+
action,
|
|
94
|
+
instructions: NEXT_STEP_INSTRUCTIONS[action] || 'Pick one of the listed options and re-run.',
|
|
95
|
+
context: {
|
|
96
|
+
options: opts,
|
|
97
|
+
...(ns.context?.drift ? { drift: ns.context.drift } : {}),
|
|
98
|
+
commands: opts.length > 0
|
|
99
|
+
? [`npx happyskills reconcile ${full} --apply ${opts[0]} --json`]
|
|
100
|
+
: undefined,
|
|
101
|
+
},
|
|
102
|
+
principal_authorization_required: true,
|
|
103
|
+
route_to_skill: 'happyskills-sync',
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
61
107
|
const apply_restore_from_lock_version = ({ skill_dir, lock_entry }) => catch_errors('Failed to restore from lock', async () => {
|
|
62
108
|
const [read_err, manifest] = await read_manifest(skill_dir)
|
|
63
109
|
if (read_err) throw e('Failed to read skill.json', read_err)
|
|
@@ -197,10 +243,12 @@ const run = (args) => catch_errors('Reconcile failed', async () => {
|
|
|
197
243
|
if (recon_err) throw e('Reconcile failed', recon_err)
|
|
198
244
|
|
|
199
245
|
if (args.flags.json) {
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
246
|
+
const { emit_envelope } = require('../ui/envelope')
|
|
247
|
+
// Inline the human-readable hint into data so the envelope's
|
|
248
|
+
// next_step slot remains reserved for actionable protocol followups.
|
|
249
|
+
const data = result.hint !== undefined ? { ...result.data, hint: result.hint } : result.data
|
|
250
|
+
const next_step = enrich_reconcile_next_step(result.next_step, full)
|
|
251
|
+
emit_envelope({ data, next_step })
|
|
204
252
|
return
|
|
205
253
|
}
|
|
206
254
|
|
package/src/commands/release.js
CHANGED
|
@@ -217,11 +217,14 @@ const orchestrate = (args) => catch_errors('Release failed', async () => {
|
|
|
217
217
|
drift: target_info.drift
|
|
218
218
|
}),
|
|
219
219
|
next_step: {
|
|
220
|
-
|
|
220
|
+
kind: 'recovery',
|
|
221
|
+
action: 'reconcile_first',
|
|
222
|
+
instructions: 'Drift must be resolved before release. Run reconcile, follow its next_step, then retry release.',
|
|
221
223
|
context: {
|
|
222
|
-
|
|
223
|
-
skill:
|
|
224
|
-
}
|
|
224
|
+
commands: [`npx happyskills reconcile ${lock_key} --json`],
|
|
225
|
+
skill: lock_key,
|
|
226
|
+
},
|
|
227
|
+
route_to_skill: 'happyskills-sync',
|
|
225
228
|
}
|
|
226
229
|
})
|
|
227
230
|
return { code: EXIT_CODES.ERROR, envelope: restored }
|
|
@@ -229,14 +232,26 @@ const orchestrate = (args) => catch_errors('Release failed', async () => {
|
|
|
229
232
|
if (target_info.status === 'missing_version') {
|
|
230
233
|
const restored = await restore_and({
|
|
231
234
|
...envelope_error('MISSING_VERSION', '--no-bump was passed but disk is not ahead of lock; nothing to publish.'),
|
|
232
|
-
next_step: {
|
|
235
|
+
next_step: {
|
|
236
|
+
kind: 'decision',
|
|
237
|
+
action: 'specify_bump_type',
|
|
238
|
+
instructions: 'No target version determined. Pick a bump type and re-run.',
|
|
239
|
+
context: { options: ['patch', 'minor', 'major'], current_version: manifest.version },
|
|
240
|
+
principal_authorization_required: true,
|
|
241
|
+
}
|
|
233
242
|
})
|
|
234
243
|
return { code: EXIT_CODES.USAGE, envelope: restored }
|
|
235
244
|
}
|
|
236
245
|
if (target_info.status === 'invalid_bump') {
|
|
237
246
|
const restored = await restore_and({
|
|
238
247
|
...envelope_error('INVALID_BUMP', `--bump "${target_info.bump}" is not patch/minor/major or a valid semver.`),
|
|
239
|
-
next_step: {
|
|
248
|
+
next_step: {
|
|
249
|
+
kind: 'decision',
|
|
250
|
+
action: 'specify_bump_type',
|
|
251
|
+
instructions: 'The bump value is not valid. Pick a bump type and re-run.',
|
|
252
|
+
context: { options: ['patch', 'minor', 'major'], current_version: manifest.version, got: target_info.bump },
|
|
253
|
+
principal_authorization_required: true,
|
|
254
|
+
}
|
|
240
255
|
})
|
|
241
256
|
return { code: EXIT_CODES.USAGE, envelope: restored }
|
|
242
257
|
}
|
|
@@ -244,8 +259,11 @@ const orchestrate = (args) => catch_errors('Release failed', async () => {
|
|
|
244
259
|
const restored = await restore_and({
|
|
245
260
|
...envelope_error('MISSING_VERSION', 'No --bump provided and no CHANGELOG entry indicates an intended next version.'),
|
|
246
261
|
next_step: {
|
|
247
|
-
|
|
248
|
-
|
|
262
|
+
kind: 'decision',
|
|
263
|
+
action: 'specify_bump_type',
|
|
264
|
+
instructions: 'No target version provided. Pick a bump type and re-run.',
|
|
265
|
+
context: { options: ['patch', 'minor', 'major'], current_version: manifest.version },
|
|
266
|
+
principal_authorization_required: true,
|
|
249
267
|
}
|
|
250
268
|
})
|
|
251
269
|
return { code: EXIT_CODES.USAGE, envelope: restored }
|
|
@@ -254,12 +272,15 @@ const orchestrate = (args) => catch_errors('Release failed', async () => {
|
|
|
254
272
|
const restored = await restore_and({
|
|
255
273
|
...envelope_error('BUMP_DISAGREEMENT', `--bump ${target_info.requested_bump} disagrees with the disk version ${target_info.disk_version}.`),
|
|
256
274
|
next_step: {
|
|
257
|
-
|
|
275
|
+
kind: 'decision',
|
|
276
|
+
action: 'resolve_bump_disagreement',
|
|
277
|
+
instructions: 'The --bump value disagrees with the disk version. Pick one and re-run.',
|
|
258
278
|
context: {
|
|
259
|
-
disk_version:
|
|
279
|
+
disk_version: target_info.disk_version,
|
|
260
280
|
requested_bump: target_info.requested_bump,
|
|
261
|
-
lock_version:
|
|
262
|
-
}
|
|
281
|
+
lock_version: target_info.lock_version,
|
|
282
|
+
},
|
|
283
|
+
principal_authorization_required: true,
|
|
263
284
|
}
|
|
264
285
|
})
|
|
265
286
|
return { code: EXIT_CODES.USAGE, envelope: restored }
|
|
@@ -299,7 +320,13 @@ const orchestrate = (args) => catch_errors('Release failed', async () => {
|
|
|
299
320
|
if (cf_err || !cf_content) {
|
|
300
321
|
const restored = await restore_and({
|
|
301
322
|
...envelope_error('CHANGELOG_SOURCE_UNREADABLE', `Could not read --changelog-from "${changelog_from}".`),
|
|
302
|
-
next_step: {
|
|
323
|
+
next_step: {
|
|
324
|
+
kind: 'recovery',
|
|
325
|
+
action: 'provide_changelog',
|
|
326
|
+
instructions: 'The --changelog-from file could not be read. Provide a readable file and re-run.',
|
|
327
|
+
context: {},
|
|
328
|
+
principal_authorization_required: true,
|
|
329
|
+
}
|
|
303
330
|
})
|
|
304
331
|
return { code: EXIT_CODES.USAGE, envelope: restored }
|
|
305
332
|
}
|
|
@@ -311,8 +338,11 @@ const orchestrate = (args) => catch_errors('Release failed', async () => {
|
|
|
311
338
|
const restored = await restore_and({
|
|
312
339
|
...envelope_error('MISSING_CHANGELOG_ENTRY', `CHANGELOG.md does not contain a ## [${target_version}] entry.`),
|
|
313
340
|
next_step: {
|
|
314
|
-
|
|
315
|
-
|
|
341
|
+
kind: 'recovery',
|
|
342
|
+
action: 'provide_changelog',
|
|
343
|
+
instructions: 'CHANGELOG.md is missing an entry for the target version. Add the entry and re-run release.',
|
|
344
|
+
context: { target_version, current_top_entry: cl_top },
|
|
345
|
+
principal_authorization_required: true,
|
|
316
346
|
}
|
|
317
347
|
})
|
|
318
348
|
return { code: EXIT_CODES.USAGE, envelope: restored }
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// happyskills schema — machine-readable description of the CLI surface.
|
|
3
|
+
// Spec 260525-cli-default-json § 10. A generic agent without HappySkills
|
|
4
|
+
// skills installed can learn every command, every error code, and every
|
|
5
|
+
// next_step.action in one call.
|
|
6
|
+
//
|
|
7
|
+
// Generation strategy: each command module MAY export a `schema` object
|
|
8
|
+
// alongside `run`. When that's missing we fall back to a minimal stub so
|
|
9
|
+
// the surface is always discoverable even if a command hasn't been audited
|
|
10
|
+
// yet (defensive, additive). The schema lives next to the implementation
|
|
11
|
+
// so there's no drift.
|
|
12
|
+
|
|
13
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
14
|
+
const { COMMANDS, COMMAND_ALIASES, CLI_VERSION } = require('../constants')
|
|
15
|
+
const { ERROR_CODE_LIST } = require('../constants/error_codes')
|
|
16
|
+
const {
|
|
17
|
+
NEXT_STEP_ACTION_LIST,
|
|
18
|
+
NEXT_STEP_KINDS,
|
|
19
|
+
ACTION_KIND,
|
|
20
|
+
} = require('../constants/next_step_actions')
|
|
21
|
+
const { ENVELOPE_SCHEMA_VERSION } = require('../schema/envelope_validator')
|
|
22
|
+
const { print_help } = require('../ui/output')
|
|
23
|
+
const { emit_envelope } = require('../ui/envelope')
|
|
24
|
+
const { exit_with_error } = require('../utils/errors')
|
|
25
|
+
const { EXIT_CODES } = require('../constants')
|
|
26
|
+
|
|
27
|
+
const HELP_TEXT = `Usage: happyskills schema [options]
|
|
28
|
+
|
|
29
|
+
Emit a machine-readable description of the CLI surface — every command,
|
|
30
|
+
its input contract, output envelope shape, error codes, and next_step
|
|
31
|
+
actions. Designed for agentic discovery.
|
|
32
|
+
|
|
33
|
+
Options:
|
|
34
|
+
--json Output as JSON envelope (default for non-TTY callers)
|
|
35
|
+
--text Output as a human-readable listing
|
|
36
|
+
|
|
37
|
+
Examples:
|
|
38
|
+
happyskills schema --json
|
|
39
|
+
happyskills schema --text`
|
|
40
|
+
|
|
41
|
+
// Compute aliases by walking COMMAND_ALIASES in reverse — each command
|
|
42
|
+
// gets the list of aliases that resolve to it.
|
|
43
|
+
const aliases_for = (name) => {
|
|
44
|
+
const out = []
|
|
45
|
+
for (const [alias, canonical] of Object.entries(COMMAND_ALIASES)) {
|
|
46
|
+
if (canonical === name) out.push(alias)
|
|
47
|
+
}
|
|
48
|
+
return out
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Audience inference fallback when a command module doesn't export schema.
|
|
52
|
+
// Mapping aligns with docs/cli-commands-*.md taxonomy.
|
|
53
|
+
const AUDIENCE_HINT = {
|
|
54
|
+
// consumer
|
|
55
|
+
init: 'consumer', install: 'consumer', uninstall: 'consumer',
|
|
56
|
+
list: 'consumer', check: 'consumer', status: 'consumer',
|
|
57
|
+
update: 'consumer', search: 'consumer', postlex: 'consumer',
|
|
58
|
+
diff: 'consumer', pull: 'consumer', snapshot: 'consumer',
|
|
59
|
+
reconcile: 'consumer', enable: 'consumer', disable: 'consumer',
|
|
60
|
+
versions: 'consumer', changelog: 'consumer', bump: 'consumer',
|
|
61
|
+
// author
|
|
62
|
+
publish: 'author', convert: 'author', fork: 'author',
|
|
63
|
+
validate: 'author', delete: 'author', visibility: 'author',
|
|
64
|
+
release: 'author',
|
|
65
|
+
// account
|
|
66
|
+
login: 'account', logout: 'account', whoami: 'account',
|
|
67
|
+
setup: 'account', config: 'account', people: 'account',
|
|
68
|
+
groups: 'account', access: 'account', agents: 'account',
|
|
69
|
+
feedback: 'account', 'self-update': 'account',
|
|
70
|
+
// system
|
|
71
|
+
schema: 'system',
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const default_schema = (name) => ({
|
|
75
|
+
name,
|
|
76
|
+
aliases: aliases_for(name),
|
|
77
|
+
audience: AUDIENCE_HINT[name] || 'consumer',
|
|
78
|
+
purpose: `(no curated purpose for "${name}" yet)`,
|
|
79
|
+
mutation: ![
|
|
80
|
+
'list', 'check', 'status', 'search', 'postlex', 'diff', 'versions',
|
|
81
|
+
'changelog', 'whoami', 'schema',
|
|
82
|
+
].includes(name),
|
|
83
|
+
interactive_in_text_mode: false,
|
|
84
|
+
input: { positional: [], flags: [] },
|
|
85
|
+
output: { data_shape: {} },
|
|
86
|
+
errors: [],
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// Load the per-command schema export. Misses fall back to default_schema.
|
|
90
|
+
// We deliberately swallow errors inside the require so a broken module
|
|
91
|
+
// doesn't crash `schema --json`.
|
|
92
|
+
const load_command_schema = (name) => {
|
|
93
|
+
try {
|
|
94
|
+
const mod = require(`./${name}`)
|
|
95
|
+
if (mod && mod.schema && typeof mod.schema === 'object') {
|
|
96
|
+
return { ...default_schema(name), ...mod.schema, aliases: mod.schema.aliases || aliases_for(name) }
|
|
97
|
+
}
|
|
98
|
+
} catch (_) {
|
|
99
|
+
// ignored — fall through to default
|
|
100
|
+
}
|
|
101
|
+
return default_schema(name)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const build_command_registry = () => {
|
|
105
|
+
const all = [...COMMANDS]
|
|
106
|
+
if (!all.includes('schema')) all.push('schema')
|
|
107
|
+
return all.map(load_command_schema)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const build_error_code_list = () => ERROR_CODE_LIST.map(code => ({ code }))
|
|
111
|
+
|
|
112
|
+
const build_next_step_action_list = () =>
|
|
113
|
+
NEXT_STEP_ACTION_LIST.map(action => ({ action, kind: ACTION_KIND[action] || null }))
|
|
114
|
+
|
|
115
|
+
const build_schema_payload = () => ({
|
|
116
|
+
envelope_schema_version: ENVELOPE_SCHEMA_VERSION,
|
|
117
|
+
envelope_schema_uri: 'https://schemas.happyskills.dev/envelope/v1.json',
|
|
118
|
+
cli_version: CLI_VERSION,
|
|
119
|
+
commands: build_command_registry(),
|
|
120
|
+
error_codes: build_error_code_list(),
|
|
121
|
+
next_step_actions: build_next_step_action_list(),
|
|
122
|
+
next_step_kinds: [...NEXT_STEP_KINDS],
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const print_text_listing = (payload) => {
|
|
126
|
+
const groups = { consumer: [], author: [], account: [], system: [] }
|
|
127
|
+
for (const cmd of payload.commands) {
|
|
128
|
+
const bucket = groups[cmd.audience] || groups.consumer
|
|
129
|
+
bucket.push(cmd)
|
|
130
|
+
}
|
|
131
|
+
console.log(`happyskills v${payload.cli_version} — CLI surface (envelope schema v${payload.envelope_schema_version})`)
|
|
132
|
+
console.log('')
|
|
133
|
+
for (const [audience, list] of Object.entries(groups)) {
|
|
134
|
+
if (list.length === 0) continue
|
|
135
|
+
console.log(audience.toUpperCase())
|
|
136
|
+
for (const cmd of list.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
137
|
+
const aliases = cmd.aliases && cmd.aliases.length ? ` (${cmd.aliases.join(', ')})` : ''
|
|
138
|
+
console.log(` ${cmd.name.padEnd(14)} ${cmd.purpose}${aliases}`)
|
|
139
|
+
}
|
|
140
|
+
console.log('')
|
|
141
|
+
}
|
|
142
|
+
console.log(`For the full machine-readable contract: happyskills schema --json`)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const run = (args) => catch_errors('Schema command failed', async () => {
|
|
146
|
+
if (args.flags._show_help) {
|
|
147
|
+
print_help(HELP_TEXT)
|
|
148
|
+
return process.exit(EXIT_CODES.SUCCESS)
|
|
149
|
+
}
|
|
150
|
+
const payload = build_schema_payload()
|
|
151
|
+
if (args.flags.json) {
|
|
152
|
+
emit_envelope({ data: payload })
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
print_text_listing(payload)
|
|
156
|
+
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
157
|
+
|
|
158
|
+
// Schema for `schema` itself — recursive but harmless. § 10.
|
|
159
|
+
const schema = {
|
|
160
|
+
name: 'schema',
|
|
161
|
+
aliases: [],
|
|
162
|
+
audience: 'system',
|
|
163
|
+
purpose: 'Emit a machine-readable description of the CLI surface.',
|
|
164
|
+
mutation: false,
|
|
165
|
+
interactive_in_text_mode: false,
|
|
166
|
+
input: { positional: [], flags: [{ name: 'json', type: 'boolean' }, { name: 'text', type: 'boolean' }] },
|
|
167
|
+
output: { data_shape: {
|
|
168
|
+
envelope_schema_version: 'string',
|
|
169
|
+
envelope_schema_uri: 'string',
|
|
170
|
+
cli_version: 'string',
|
|
171
|
+
commands: 'array<CommandSchema>',
|
|
172
|
+
error_codes: 'array<{ code: string }>',
|
|
173
|
+
next_step_actions: 'array<{ action: string, kind: string }>',
|
|
174
|
+
next_step_kinds: 'array<string>',
|
|
175
|
+
} },
|
|
176
|
+
errors: [],
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = { run, schema, build_schema_payload }
|
package/src/commands/search.js
CHANGED
|
@@ -129,7 +129,7 @@ const STRONG_TIERS = new Set(['strong', 'good'])
|
|
|
129
129
|
|
|
130
130
|
const build_search_next_step = (response, query, opts) => {
|
|
131
131
|
const { with_rerank, clarification_turns_used } = opts
|
|
132
|
-
if (!with_rerank) return
|
|
132
|
+
if (!with_rerank) return {}
|
|
133
133
|
|
|
134
134
|
const mode = response?.mode || null
|
|
135
135
|
const digests = Array.isArray(response?.rerank_digests) ? response.rerank_digests : null
|
|
@@ -138,17 +138,19 @@ const build_search_next_step = (response, query, opts) => {
|
|
|
138
138
|
const turns_remaining = 2 - turns_used
|
|
139
139
|
|
|
140
140
|
// Non-semantic modes (slug, scoped, exact-FTS) don't support rerank — server
|
|
141
|
-
// returns no digests.
|
|
142
|
-
if (mode !== 'semantic') return
|
|
141
|
+
// returns no digests. Empty next_step; agent just renders.
|
|
142
|
+
if (mode !== 'semantic') return {}
|
|
143
143
|
|
|
144
144
|
// Semantic + digests present → the protocol's primary path.
|
|
145
145
|
if (digests && digests.length > 0) {
|
|
146
146
|
return {
|
|
147
|
+
kind: 'continuation',
|
|
147
148
|
action: 'rank_digests_inline',
|
|
148
149
|
instructions: `Rank these candidates by how well their \`digest\` matches the query, using \`data.rerank_system_prompt\` as your system instructions. Emit JSON matching \`data.rerank_response_schema\`. Aim for ~20 items (10-30 acceptable; fewer if remaining candidates aren't clearly differentiated). Then pipe the result to \`npx happyskills postlex --query "${query}" --ranking - --clarification-turns-used ${turns_used}\`.`,
|
|
149
150
|
context: {
|
|
150
151
|
original_query: query,
|
|
151
152
|
clarification_turns_used: turns_used,
|
|
153
|
+
commands: [`npx happyskills postlex --query "${query}" --ranking - --clarification-turns-used ${turns_used} --json`],
|
|
152
154
|
},
|
|
153
155
|
}
|
|
154
156
|
}
|
|
@@ -158,36 +160,38 @@ const build_search_next_step = (response, query, opts) => {
|
|
|
158
160
|
if (match_notice) {
|
|
159
161
|
if (turns_remaining <= 0) {
|
|
160
162
|
return {
|
|
163
|
+
kind: 'continuation',
|
|
161
164
|
action: 'present_to_user',
|
|
162
165
|
instructions: 'No top result is a strong match and the clarification budget (2 turns) is spent. Render `data.results` honestly: note the weak signal and present what you have so the user can decide. Do NOT ask another clarifying question.',
|
|
163
|
-
context:
|
|
166
|
+
context: { original_query: query, clarification_turns_used: turns_used },
|
|
164
167
|
}
|
|
165
168
|
}
|
|
166
169
|
return {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
{
|
|
171
|
-
question: 'Quick clarification — what specifically are you looking for?',
|
|
172
|
-
options: [
|
|
173
|
-
{ label: 'A workflow or how-to guide', refined_query_hint: 'workflow' },
|
|
174
|
-
{ label: 'A specific tool or library', refined_query_hint: 'tool' },
|
|
175
|
-
{ label: 'A platform-specific recipe (AWS / GCP / Vercel)', refined_query_hint: 'platform' },
|
|
176
|
-
{ label: 'Just search anyway', refined_query_hint: null },
|
|
177
|
-
],
|
|
178
|
-
},
|
|
179
|
-
],
|
|
180
|
-
max_turns_remaining: turns_remaining,
|
|
170
|
+
kind: 'clarification',
|
|
171
|
+
action: 'clarify_query',
|
|
172
|
+
instructions: `The search returned weak results. Ask the user one of \`context.suggested_questions\` using your agent's question mechanism, then re-run \`npx happyskills search "<refined query>" --with-rerank --json --limit 50 --clarification-turns-used ${turns_used + 1}\`. The last option is always "Just search anyway" — honor it by re-running with the original query unchanged.`,
|
|
181
173
|
context: {
|
|
182
174
|
original_query: query,
|
|
183
175
|
clarification_turns_used: turns_used,
|
|
176
|
+
max_turns_remaining: turns_remaining,
|
|
177
|
+
suggested_questions: [
|
|
178
|
+
{
|
|
179
|
+
question: 'Quick clarification — what specifically are you looking for?',
|
|
180
|
+
options: [
|
|
181
|
+
{ label: 'A workflow or how-to guide', refined_query_hint: 'workflow' },
|
|
182
|
+
{ label: 'A specific tool or library', refined_query_hint: 'tool' },
|
|
183
|
+
{ label: 'A platform-specific recipe (AWS / GCP / Vercel)', refined_query_hint: 'platform' },
|
|
184
|
+
{ label: 'Just search anyway', refined_query_hint: null },
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
],
|
|
184
188
|
},
|
|
189
|
+
principal_authorization_required: true,
|
|
185
190
|
}
|
|
186
191
|
}
|
|
187
192
|
|
|
188
|
-
// Semantic, no digests, no match_notice (defensive
|
|
189
|
-
|
|
190
|
-
return null
|
|
193
|
+
// Semantic, no digests, no match_notice (defensive). Empty next_step.
|
|
194
|
+
return {}
|
|
191
195
|
}
|
|
192
196
|
|
|
193
197
|
const run_smart_search = (args, query, options) => catch_errors('Smart search failed', async () => {
|
|
@@ -234,7 +238,7 @@ const run_smart_search = (args, query, options) => catch_errors('Smart search fa
|
|
|
234
238
|
rerank_prompt_version: response?.rerank_prompt_version || null,
|
|
235
239
|
})
|
|
236
240
|
}
|
|
237
|
-
if (with_rerank && next_step?.action === '
|
|
241
|
+
if (with_rerank && next_step?.action === 'clarify_query') {
|
|
238
242
|
fire_discovery_telemetry({
|
|
239
243
|
event: 'clarify_triggered',
|
|
240
244
|
intent_id,
|