happyskills 1.0.1 → 1.1.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 +16 -0
- package/package.json +1 -1
- package/src/constants/next_step_actions.js +3 -1
- package/src/constants/next_step_by_error_code.js +22 -6
- package/src/constants/next_step_by_error_code.test.js +30 -0
- package/src/engine/installer.js +30 -2
- package/src/engine/installer.test.js +41 -0
- package/src/engine/uninstaller.js +18 -1
- package/src/engine/uninstaller.test.js +38 -0
- package/src/index.js +1 -1
- package/src/integration/cli.test.js +17 -4
- package/src/ui/output.js +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.1.0] - 2026-06-02
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Add `discover_schema` routing action (`next_step.kind: routing`) so a confused agent is steered to the machine-readable CLI surface. Every `--help` output (global and per-command) now ends with a footer pointing to `happyskills schema --json`, and the text-mode unknown-command message points there too — agents reach for the conventional `help`, not the unconventional `schema`, so every help-seeking path now leads back to it. Added to both the CLI and API closed-enum mirrors (additive, non-breaking).
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Route the `COMMAND_NOT_FOUND` and `USAGE_ERROR` `--json` envelopes to `discover_schema` (previously `show_format` / none), with `context.commands: ["npx happyskills schema --json"]` — a mistyped-command or bad-flags agent now receives the full CLI contract through the structured `next_step` channel it is already parsing.
|
|
19
|
+
|
|
20
|
+
## [1.0.2] - 2026-06-01
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Stop `uninstall` from deleting a shared dependency that another installed skill still needs. When a skill depended on by two parents (e.g. `happyskills-design`, required by both `happyskills` and another skill) was resolved across separate install passes, the second pass overwrote its `requested_by` instead of unioning the parents — so uninstalling one parent orphan-pruned the dependency even though the other parent still declared it. Two fixes: `requested_by` is now unioned across install passes, and the orphan pruner additionally cross-checks every surviving skill's `dependencies` map before removing anything.
|
|
25
|
+
|
|
10
26
|
## [1.0.1] - 2026-06-01
|
|
11
27
|
|
|
12
28
|
### Fixed
|
package/package.json
CHANGED
|
@@ -59,6 +59,7 @@ const ATTACH_SCREENSHOT = 'attach_screenshot' // § 15.3.4
|
|
|
59
59
|
|
|
60
60
|
// kind: routing
|
|
61
61
|
const INSTALL_FIRST = 'install_first'
|
|
62
|
+
const DISCOVER_SCHEMA = 'discover_schema'
|
|
62
63
|
|
|
63
64
|
// kind lookup ──────────────────────────────────────────────────────────────
|
|
64
65
|
const ACTION_KIND = Object.freeze({
|
|
@@ -94,6 +95,7 @@ const ACTION_KIND = Object.freeze({
|
|
|
94
95
|
[ATTACH_SCREENSHOT]: CONTINUATION,
|
|
95
96
|
|
|
96
97
|
[INSTALL_FIRST]: ROUTING,
|
|
98
|
+
[DISCOVER_SCHEMA]: ROUTING,
|
|
97
99
|
})
|
|
98
100
|
|
|
99
101
|
const NEXT_STEP_ACTIONS = Object.freeze({
|
|
@@ -106,7 +108,7 @@ const NEXT_STEP_ACTIONS = Object.freeze({
|
|
|
106
108
|
CONFIRM_DISCARD_OR_SNAPSHOT_FIRST, CONFIRM_CASCADE, CONFIRM_DESTRUCTIVE,
|
|
107
109
|
PASS_YES_FLAG,
|
|
108
110
|
RANK_DIGESTS_INLINE, PRESENT_TO_USER, ATTACH_SCREENSHOT,
|
|
109
|
-
INSTALL_FIRST,
|
|
111
|
+
INSTALL_FIRST, DISCOVER_SCHEMA,
|
|
110
112
|
})
|
|
111
113
|
|
|
112
114
|
const NEXT_STEP_ACTION_LIST = Object.freeze(Object.values(NEXT_STEP_ACTIONS).slice().sort())
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
// applies — agent falls back to "explain to principal").
|
|
10
10
|
|
|
11
11
|
const {
|
|
12
|
-
RECOVERY, DECISION, CONFIRMATION,
|
|
12
|
+
RECOVERY, DECISION, CONFIRMATION, ROUTING,
|
|
13
13
|
LOGIN, RETRY, RECONCILE_FIRST, PULL_REBASE_FIRST, FIX_VALIDATION_ERRORS,
|
|
14
|
-
PROVIDE_CHANGELOG, SELF_UPDATE, SHOW_FORMAT,
|
|
14
|
+
PROVIDE_CHANGELOG, SELF_UPDATE, SHOW_FORMAT, DISCOVER_SCHEMA,
|
|
15
15
|
RESOLVE_REGRESSION, RESOLVE_MISSING_SKILL_JSON, RESOLVE_MISSING_DIR,
|
|
16
16
|
RESOLVE_CONFLICTS, RESOLVE_PATCH_REJECTIONS, SPECIFY_WORKSPACE,
|
|
17
17
|
SPECIFY_BUMP_TYPE, RESOLVE_BUMP_DISAGREEMENT, PICK_VERSION,
|
|
@@ -45,6 +45,14 @@ const confirmation = (action, instructions, context = {}) => ({
|
|
|
45
45
|
principal_authorization_required: true,
|
|
46
46
|
})
|
|
47
47
|
|
|
48
|
+
const routing = (action, instructions, context = {}, opts = {}) => ({
|
|
49
|
+
kind: ROUTING,
|
|
50
|
+
action,
|
|
51
|
+
instructions,
|
|
52
|
+
context,
|
|
53
|
+
...(opts.route_to_skill ? { route_to_skill: opts.route_to_skill } : {}),
|
|
54
|
+
})
|
|
55
|
+
|
|
48
56
|
const NEXT_STEP_BY_ERROR_CODE = Object.freeze({
|
|
49
57
|
AUTH_REQUIRED: () => recovery(
|
|
50
58
|
LOGIN,
|
|
@@ -86,13 +94,21 @@ const NEXT_STEP_BY_ERROR_CODE = Object.freeze({
|
|
|
86
94
|
'The embedding service is temporarily unavailable. Retry shortly.',
|
|
87
95
|
{ retry_after_seconds: 10, max_attempts: 3 }
|
|
88
96
|
),
|
|
89
|
-
COMMAND_NOT_FOUND: (_msg, ctx = {}) =>
|
|
90
|
-
|
|
91
|
-
'The command does not exist.
|
|
97
|
+
COMMAND_NOT_FOUND: (_msg, ctx = {}) => routing(
|
|
98
|
+
DISCOVER_SCHEMA,
|
|
99
|
+
'The command does not exist. Run `happyskills schema --json` to discover every available command with its exact input, output, and error contract. If a suggestion is present and it matches the intent, you may re-run with that command instead.',
|
|
92
100
|
{
|
|
93
101
|
got: ctx.got,
|
|
94
102
|
...(ctx.suggestion ? { suggestion: ctx.suggestion } : {}),
|
|
95
|
-
commands: ['npx happyskills --
|
|
103
|
+
commands: ['npx happyskills schema --json'],
|
|
104
|
+
}
|
|
105
|
+
),
|
|
106
|
+
USAGE_ERROR: (_msg, ctx = {}) => routing(
|
|
107
|
+
DISCOVER_SCHEMA,
|
|
108
|
+
'The command was invoked with invalid arguments or flags. Run `happyskills schema --json` to see the exact input contract for every command, then re-run with corrected arguments.',
|
|
109
|
+
{
|
|
110
|
+
...(ctx.got ? { got: ctx.got } : {}),
|
|
111
|
+
commands: ['npx happyskills schema --json'],
|
|
96
112
|
}
|
|
97
113
|
),
|
|
98
114
|
INVALID_SLUG: () => recovery(
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Unit coverage for the schema-discovery routing defaults. A generic agent
|
|
3
|
+
// that reaches for the conventional `help` fallback must be routed to the
|
|
4
|
+
// `schema --json` surface — the machine-readable contract is what gives it
|
|
5
|
+
// clarity and correctness on how to use the CLI, not just discoverability.
|
|
6
|
+
|
|
7
|
+
const { describe, it } = require('node:test')
|
|
8
|
+
const assert = require('node:assert/strict')
|
|
9
|
+
const { next_step_for_error } = require('./next_step_by_error_code')
|
|
10
|
+
const { DISCOVER_SCHEMA, ROUTING, kind_for_action } = require('./next_step_actions')
|
|
11
|
+
|
|
12
|
+
describe('next_step_by_error_code — schema discovery routing', () => {
|
|
13
|
+
it('discover_schema is a routing-kind action', () => {
|
|
14
|
+
assert.strictEqual(kind_for_action(DISCOVER_SCHEMA), ROUTING)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
for (const code of ['COMMAND_NOT_FOUND', 'USAGE_ERROR']) {
|
|
18
|
+
it(`${code} routes the agent to \`schema --json\``, () => {
|
|
19
|
+
const ns = next_step_for_error(code, 'boom', { got: 'foo' })
|
|
20
|
+
assert.ok(ns, `${code} must produce a next_step`)
|
|
21
|
+
assert.strictEqual(ns.kind, ROUTING)
|
|
22
|
+
assert.strictEqual(ns.action, DISCOVER_SCHEMA)
|
|
23
|
+
assert.ok(Array.isArray(ns.context.commands))
|
|
24
|
+
assert.ok(
|
|
25
|
+
ns.context.commands.some(c => c.includes('schema --json')),
|
|
26
|
+
`${code} next_step must point at \`happyskills schema --json\``
|
|
27
|
+
)
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
})
|
package/src/engine/installer.js
CHANGED
|
@@ -22,6 +22,34 @@ const _format_warnings = (missing_deps) => (missing_deps || []).map(dep => {
|
|
|
22
22
|
return `Missing system dependency: ${dep.name} required by ${dep.skill}${hint}`
|
|
23
23
|
})
|
|
24
24
|
|
|
25
|
+
// Order-preserving, de-duplicated union of requester lists.
|
|
26
|
+
const _union = (...lists) => {
|
|
27
|
+
const seen = new Set()
|
|
28
|
+
const out = []
|
|
29
|
+
for (const list of lists) {
|
|
30
|
+
for (const requester of (list || [])) {
|
|
31
|
+
if (!seen.has(requester)) {
|
|
32
|
+
seen.add(requester)
|
|
33
|
+
out.push(requester)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Compute the `requested_by` for a package's lock entry. A package is requested
|
|
41
|
+
// either by the user directly (`__root__`) when it IS the skill being installed,
|
|
42
|
+
// or by `root_skill` when it is a (transitive) dependency. We union that with
|
|
43
|
+
// whatever the lock already recorded so a SHARED dependency keeps every parent
|
|
44
|
+
// across separate install passes — install() resolves one root's tree at a time,
|
|
45
|
+
// so a dependency of two roots (e.g. happyskills + create-release-skill both
|
|
46
|
+
// needing happyskills-design) would otherwise have its `requested_by` clobbered
|
|
47
|
+
// by the last pass, and later be wrongly orphan-pruned on uninstall.
|
|
48
|
+
const merge_requested_by = (prev_requested_by, pkg_skill, root_skill) => {
|
|
49
|
+
const own = pkg_skill === root_skill ? ['__root__'] : [root_skill]
|
|
50
|
+
return _union(own, prev_requested_by)
|
|
51
|
+
}
|
|
52
|
+
|
|
25
53
|
const install = (skill, options = {}) => catch_errors('Install failed', async () => {
|
|
26
54
|
const { version, global: is_global = false, force = false, fresh = false, project_root, agents: agents_flag } = options
|
|
27
55
|
const base_dir = skills_dir(is_global, project_root)
|
|
@@ -246,7 +274,7 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
|
|
|
246
274
|
integrity: integrity || null,
|
|
247
275
|
base_commit: pkg.commit || null,
|
|
248
276
|
base_integrity: integrity || null,
|
|
249
|
-
requested_by: pkg.skill
|
|
277
|
+
requested_by: merge_requested_by(lock_data?.skills?.[pkg.skill]?.requested_by, pkg.skill, skill),
|
|
250
278
|
dependencies: pkg.dependencies || {},
|
|
251
279
|
...(pkg_type ? { type: pkg_type } : {}),
|
|
252
280
|
...(pkg.forced ? { forced: true } : {})
|
|
@@ -378,4 +406,4 @@ const install_from_lock = (lock_data, options = {}) => catch_errors('Install fro
|
|
|
378
406
|
return { source: 'skills-lock.json', installed: all_installed, skipped: all_skipped, warnings: all_warnings }
|
|
379
407
|
})
|
|
380
408
|
|
|
381
|
-
module.exports = { install, install_from_manifest, install_from_lock }
|
|
409
|
+
module.exports = { install, install_from_manifest, install_from_lock, merge_requested_by }
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const { describe, it } = require('node:test')
|
|
2
|
+
const assert = require('node:assert')
|
|
3
|
+
const { merge_requested_by } = require('./installer')
|
|
4
|
+
|
|
5
|
+
describe('merge_requested_by', () => {
|
|
6
|
+
it('records the root skill for a fresh dependency', () => {
|
|
7
|
+
const result = merge_requested_by(undefined, 'happyskillsai/happyskills-design', 'happyskillsai/happyskills')
|
|
8
|
+
assert.deepStrictEqual(result, ['happyskillsai/happyskills'])
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('records __root__ when the package IS the skill being installed', () => {
|
|
12
|
+
const result = merge_requested_by(['__root__'], 'happyskillsai/happyskills', 'happyskillsai/happyskills')
|
|
13
|
+
assert.deepStrictEqual(result, ['__root__'])
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('unions a shared dependency across separate install passes', () => {
|
|
17
|
+
// Pass 1 wrote design as a dependency of happyskills.
|
|
18
|
+
// Pass 2 re-resolves design as a dependency of create-release-skill and
|
|
19
|
+
// must NOT clobber the existing parent — both must be retained.
|
|
20
|
+
const result = merge_requested_by(
|
|
21
|
+
['happyskillsai/happyskills'],
|
|
22
|
+
'happyskillsai/happyskills-design',
|
|
23
|
+
'nicolasdao/create-release-skill'
|
|
24
|
+
)
|
|
25
|
+
assert.deepStrictEqual(result, ['nicolasdao/create-release-skill', 'happyskillsai/happyskills'])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('de-duplicates when the same requester reappears', () => {
|
|
29
|
+
const result = merge_requested_by(
|
|
30
|
+
['happyskillsai/happyskills'],
|
|
31
|
+
'happyskillsai/happyskills-design',
|
|
32
|
+
'happyskillsai/happyskills'
|
|
33
|
+
)
|
|
34
|
+
assert.deepStrictEqual(result, ['happyskillsai/happyskills'])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('tolerates a null/empty previous list', () => {
|
|
38
|
+
assert.deepStrictEqual(merge_requested_by(null, 'a/dep', 'a/root'), ['a/root'])
|
|
39
|
+
assert.deepStrictEqual(merge_requested_by([], 'a/dep', 'a/root'), ['a/root'])
|
|
40
|
+
})
|
|
41
|
+
})
|
|
@@ -7,12 +7,29 @@ const { resolve_agents, unlink_from_agents } = require('../agents')
|
|
|
7
7
|
const { print_success, print_info } = require('../ui/output')
|
|
8
8
|
|
|
9
9
|
const find_orphans = (skills, removed_skill) => {
|
|
10
|
+
// A skill is still needed if a SURVIVING skill (anything other than the one
|
|
11
|
+
// being removed) declares it in its `dependencies` map. The dependencies map
|
|
12
|
+
// mirrors each skill's skill.json and is the authoritative statement of what a
|
|
13
|
+
// skill needs — independent of `requested_by`, which can be stale or clobbered
|
|
14
|
+
// when a shared dependency is resolved across multiple install passes. Without
|
|
15
|
+
// this cross-check, a dependency whose `requested_by` was overwritten to point
|
|
16
|
+
// only at the removed skill gets pruned even though another installed skill
|
|
17
|
+
// still requires it (the happyskills-design data-loss bug).
|
|
18
|
+
const declared_by_survivor = (name) => {
|
|
19
|
+
for (const [other_name, other] of Object.entries(skills)) {
|
|
20
|
+
if (other_name === removed_skill) continue
|
|
21
|
+
const deps = (other && other.dependencies) || {}
|
|
22
|
+
if (Object.prototype.hasOwnProperty.call(deps, name)) return true
|
|
23
|
+
}
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
|
|
10
27
|
const orphans = []
|
|
11
28
|
for (const [name, data] of Object.entries(skills)) {
|
|
12
29
|
if (name === removed_skill) continue
|
|
13
30
|
const requested_by = data.requested_by || []
|
|
14
31
|
const remaining = requested_by.filter(r => r === '__root__' || (r !== removed_skill && skills[r]))
|
|
15
|
-
if (remaining.length === 0) {
|
|
32
|
+
if (remaining.length === 0 && !declared_by_survivor(name)) {
|
|
16
33
|
orphans.push(name)
|
|
17
34
|
}
|
|
18
35
|
}
|
|
@@ -95,4 +95,42 @@ describe('find_orphans', () => {
|
|
|
95
95
|
const result = find_orphans(skills, 'acme/other')
|
|
96
96
|
assert.ok(!result.includes('acme/deploy'))
|
|
97
97
|
})
|
|
98
|
+
|
|
99
|
+
it('keeps a shared dependency a surviving skill still declares, even when requested_by points only at the removed skill', () => {
|
|
100
|
+
// Reproduces the happyskills-design data-loss bug: design's requested_by was
|
|
101
|
+
// clobbered to point only at create-release-skill, but happyskills still
|
|
102
|
+
// declares it in its dependencies map. Uninstalling create-release-skill must
|
|
103
|
+
// NOT prune design.
|
|
104
|
+
const skills = {
|
|
105
|
+
'happyskillsai/happyskills': {
|
|
106
|
+
requested_by: ['__root__'],
|
|
107
|
+
dependencies: { 'happyskillsai/happyskills-design': '^0.1.0' }
|
|
108
|
+
},
|
|
109
|
+
'nicolasdao/create-release-skill': {
|
|
110
|
+
requested_by: ['__root__'],
|
|
111
|
+
dependencies: { 'happyskillsai/happyskills-design': '^0.9.0' }
|
|
112
|
+
},
|
|
113
|
+
'happyskillsai/happyskills-design': {
|
|
114
|
+
requested_by: ['nicolasdao/create-release-skill']
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const result = find_orphans(skills, 'nicolasdao/create-release-skill')
|
|
118
|
+
assert.ok(!result.includes('happyskillsai/happyskills-design'))
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('prunes a dependency once no surviving skill declares it', () => {
|
|
122
|
+
// Same shape, but happyskills does NOT declare design — removing its sole
|
|
123
|
+
// remaining requester should orphan it.
|
|
124
|
+
const skills = {
|
|
125
|
+
'nicolasdao/create-release-skill': {
|
|
126
|
+
requested_by: ['__root__'],
|
|
127
|
+
dependencies: { 'happyskillsai/happyskills-design': '^0.9.0' }
|
|
128
|
+
},
|
|
129
|
+
'happyskillsai/happyskills-design': {
|
|
130
|
+
requested_by: ['nicolasdao/create-release-skill']
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const result = find_orphans(skills, 'nicolasdao/create-release-skill')
|
|
134
|
+
assert.ok(result.includes('happyskillsai/happyskills-design'))
|
|
135
|
+
})
|
|
98
136
|
})
|
package/src/index.js
CHANGED
|
@@ -206,7 +206,7 @@ const run = (argv) => {
|
|
|
206
206
|
if (suggestion) {
|
|
207
207
|
console.error(dim(` Did you mean: happyskills ${suggestion}?`))
|
|
208
208
|
} else {
|
|
209
|
-
console.error(dim(` Run happyskills --help for available commands.`))
|
|
209
|
+
console.error(dim(` Run happyskills --help for available commands, or happyskills schema --json for the full machine-readable surface.`))
|
|
210
210
|
}
|
|
211
211
|
return process.exit(EXIT_CODES.USAGE)
|
|
212
212
|
}
|
|
@@ -280,14 +280,27 @@ describe('CLI — --json: stdout is always valid JSON', () => {
|
|
|
280
280
|
assert.ok(typeof env.error.message === 'string' && env.error.message.length > 0)
|
|
281
281
|
})
|
|
282
282
|
|
|
283
|
-
it('unknown command with --json
|
|
283
|
+
it('unknown command with --json routes the agent to schema discovery', () => {
|
|
284
284
|
const { stdout, code } = run(['not-a-command', '--json'])
|
|
285
285
|
assert.strictEqual(code, 2)
|
|
286
286
|
const env = parse_json_output(stdout, 'unknown-command --json')
|
|
287
287
|
assert_error_envelope(env, 'COMMAND_NOT_FOUND', 'unknown command')
|
|
288
|
-
//
|
|
289
|
-
|
|
290
|
-
assert.
|
|
288
|
+
// Routing hint: discover_schema, pointing at `schema --json`. A confused
|
|
289
|
+
// agent that hit a bad command is handed the full machine-readable surface.
|
|
290
|
+
assert.strictEqual(env.next_step.kind, 'routing')
|
|
291
|
+
assert.strictEqual(env.next_step.action, 'discover_schema')
|
|
292
|
+
assert.ok(env.next_step.context.commands.some(c => c.includes('schema --json')))
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('usage error with --json routes the agent to schema discovery', () => {
|
|
296
|
+
// `--json --text` is mutually exclusive → USAGE_ERROR with no command-
|
|
297
|
+
// specific next_step, so the default discover_schema routing applies.
|
|
298
|
+
const { stdout, code } = run(['--json', '--text'])
|
|
299
|
+
assert.strictEqual(code, 2)
|
|
300
|
+
const env = parse_json_output(stdout, 'usage-error --json')
|
|
301
|
+
assert_error_envelope(env, 'USAGE_ERROR', 'mutually exclusive')
|
|
302
|
+
assert.strictEqual(env.next_step.action, 'discover_schema')
|
|
303
|
+
assert.ok(env.next_step.context.commands.some(c => c.includes('schema --json')))
|
|
291
304
|
})
|
|
292
305
|
|
|
293
306
|
it('network error produces an envelope with code NETWORK_ERROR + retry next_step', () => {
|
package/src/ui/output.js
CHANGED
|
@@ -44,8 +44,15 @@ const format_help = (text) => {
|
|
|
44
44
|
}).join('\n')
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// Every --help (global and per-command) ends with a pointer to `schema
|
|
48
|
+
// --json`. An agent that reaches for the conventional `help` as a fallback
|
|
49
|
+
// then discovers the machine-readable surface, which is what it actually
|
|
50
|
+
// wants for clarity and correctness on how to use the CLI.
|
|
51
|
+
const SCHEMA_HINT = 'For the complete machine-readable CLI surface — every command\'s inputs, outputs, and error contracts in one call — run: happyskills schema --json'
|
|
52
|
+
|
|
47
53
|
const print_help = (text) => {
|
|
48
54
|
console.log(format_help(text))
|
|
55
|
+
if (!is_json_mode()) console.log(dim(`\n${SCHEMA_HINT}`))
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
// Visible length of a string, ignoring ANSI escape codes
|