happyskills 1.8.0 → 1.10.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 +18 -0
- package/package.json +1 -1
- package/src/agents/detector.js +12 -1
- package/src/agents/detector.test.js +60 -0
- package/src/api/client.js +11 -1
- package/src/api/client.test.js +71 -0
- package/src/auth/identity.js +1 -1
- package/src/commands/check.js +2 -2
- package/src/commands/publish.js +1 -1
- package/src/commands/resolve.js +325 -0
- package/src/commands/resolve.test.js +169 -0
- package/src/commands/schema.js +95 -8
- package/src/constants/next_step_by_error_code.js +23 -4
- package/src/constants.js +1 -0
- package/src/engine/installer.js +47 -9
- package/src/engine/installer.test.js +61 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.10.0] - 2026-06-06
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Add the `resolve` command — `happyskills resolve "<intent>"` maps a natural-language intent to the skill that owns it, whether that skill is installed, and how to install it if not. It matches the intent against installed skills' declared capabilities first (deterministic); if none match, it falls back to registry search gated on the server's `match_quality` (returning a `probabilistic_match`-flagged suggestion for a strong/good hit, or a graceful empty result for gibberish). Read-only; no auth required for public skills.
|
|
15
|
+
- Extend `happyskills schema --json` with a skill-declared capability registry: a top-level `data.skills[]` (each installed skill's `capabilities` declarations — `name`, `slug`, `version`, `bundled`, and `capabilities[]`) plus a per-command `owner_skill` field. Both are aggregated from installed skills' `skill.json` `capabilities`, never a hardcoded table, so an agent can map a command or intent to its owning skill deterministically.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Broaden `next_step.route_to_skill` coverage so a mis-routed recovery names the skill that should handle it: `VALIDATION_FAILED`, `DEPENDENCY_VALIDATION_FAILED`, `MISSING_CHANGELOG_ENTRY`, and `CHANGELOG_SOURCE_UNREADABLE` now route to `happyskills-publish`, and a new `CONFLICT` default routes to `happyskills-sync` (joining the existing `DRIFT_DETECTED` / `DIVERGED`). No new `error.code` or `next_step.action` values.
|
|
20
|
+
|
|
21
|
+
## [1.9.0] - 2026-06-06
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Fix first-time installs leaving a skill unlinked and invisible. When no agent was configured or detected (empty project, fresh machine, no `--agents` / `HAPPYSKILLS_AGENTS` / config / `~/.<agent>/skills/`), `install` wrote files to `.agents/skills/` but created no agent symlink and said nothing — the skill was installed yet invisible to every agent runtime. Agent resolution now falls back to Claude Code when nothing else is detected, links the skill, and surfaces a notice in both human output and the `--json` `warnings[]`. Target a different agent with `--agents` or `happyskills agents add <agent>`.
|
|
26
|
+
- Stop telling signed-in users they are "not logged in." When a private skill or dependency was inaccessible, the CLI showed an authentication error pointing to `happyskills login` even when the user was already signed in — the real issue was missing access. The `install` `access_denied` skipped-dependency warning and every 401-derived error are now session-state aware: a signed-in user is told their account lacks access and to ask the owner to grant it, while a genuinely signed-out user is prompted to sign in. Also corrects the `check`, `publish`, and `whoami` hints.
|
|
27
|
+
|
|
10
28
|
## [1.8.0] - 2026-06-05
|
|
11
29
|
|
|
12
30
|
### Changed
|
package/package.json
CHANGED
package/src/agents/detector.js
CHANGED
|
@@ -116,8 +116,19 @@ const resolve_agents = (agents_flag, options = {}) => catch_errors('Agent resolu
|
|
|
116
116
|
// 5. Home-physical fallback (legacy auto-detect)
|
|
117
117
|
const [errors, home_detected] = await detect_agents({ global: true })
|
|
118
118
|
if (errors) throw errors[0]
|
|
119
|
+
if (home_detected.length > 0) {
|
|
120
|
+
return { agents: home_detected, source: 'home' }
|
|
121
|
+
}
|
|
119
122
|
|
|
120
|
-
|
|
123
|
+
// 6. Default — nothing was configured or detected anywhere (the first-run
|
|
124
|
+
// case: empty project, fresh machine, no flag/env/config). Returning an
|
|
125
|
+
// empty list here is the trap: physical files would land in .agents/skills/
|
|
126
|
+
// but no agent symlink is created, so the skill is installed yet invisible
|
|
127
|
+
// to every agent runtime. Fall back to Claude Code — the primary agent —
|
|
128
|
+
// and tag the source as 'default' so callers can tell the user a default
|
|
129
|
+
// was chosen and how to target a different agent. resolve_agents never
|
|
130
|
+
// returns an empty agent list.
|
|
131
|
+
return { agents: [get_agent('claude')], source: 'default' }
|
|
121
132
|
})
|
|
122
133
|
|
|
123
134
|
module.exports = { detect_agents, resolve_agents }
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { describe, it, beforeEach, afterEach } = require('node:test')
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const fs = require('fs')
|
|
5
|
+
const path = require('path')
|
|
6
|
+
const os = require('os')
|
|
7
|
+
|
|
8
|
+
const { resolve_agents } = require('./detector')
|
|
9
|
+
|
|
10
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-detector-test-'))
|
|
11
|
+
const cleanup = (dir) => fs.rmSync(dir, { recursive: true, force: true })
|
|
12
|
+
|
|
13
|
+
describe('resolve_agents', () => {
|
|
14
|
+
let tmp
|
|
15
|
+
let saved_env
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
tmp = make_tmp()
|
|
19
|
+
// Isolate from the host's HAPPYSKILLS_AGENTS preference.
|
|
20
|
+
saved_env = process.env.HAPPYSKILLS_AGENTS
|
|
21
|
+
delete process.env.HAPPYSKILLS_AGENTS
|
|
22
|
+
})
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
cleanup(tmp)
|
|
25
|
+
if (saved_env === undefined) delete process.env.HAPPYSKILLS_AGENTS
|
|
26
|
+
else process.env.HAPPYSKILLS_AGENTS = saved_env
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('honors the --agents flag (source: flag)', async () => {
|
|
30
|
+
const [errors, result] = await resolve_agents('claude,cursor', { project_root: tmp })
|
|
31
|
+
assert.equal(errors, null)
|
|
32
|
+
assert.equal(result.source, 'flag')
|
|
33
|
+
assert.deepEqual(result.agents.map(a => a.id), ['claude', 'cursor'])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('reads HAPPYSKILLS_AGENTS when no flag is passed (source: env)', async () => {
|
|
37
|
+
process.env.HAPPYSKILLS_AGENTS = 'cursor'
|
|
38
|
+
const [errors, result] = await resolve_agents(undefined, { project_root: tmp })
|
|
39
|
+
assert.equal(errors, null)
|
|
40
|
+
assert.equal(result.source, 'env')
|
|
41
|
+
assert.deepEqual(result.agents.map(a => a.id), ['cursor'])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// The first-run trap regression guard: with nothing configured or detected,
|
|
45
|
+
// resolve_agents must NEVER return an empty agent list. An empty list is what
|
|
46
|
+
// left skills installed-but-invisible (no .claude/skills symlink) for new
|
|
47
|
+
// users. The fallback is Claude Code, tagged source: 'default'.
|
|
48
|
+
it('never returns an empty agent list — falls back to Claude Code', async () => {
|
|
49
|
+
const [errors, result] = await resolve_agents(undefined, { project_root: tmp })
|
|
50
|
+
assert.equal(errors, null)
|
|
51
|
+
assert.ok(result.agents.length >= 1, 'resolve_agents must return at least one agent')
|
|
52
|
+
// On a clean environment (no project/home agent dirs, no config) this is
|
|
53
|
+
// the default branch. On a dev machine an agent may be detected via home
|
|
54
|
+
// or config — in that case the source differs but the never-empty
|
|
55
|
+
// invariant above still holds.
|
|
56
|
+
if (result.source === 'default') {
|
|
57
|
+
assert.deepEqual(result.agents.map(a => a.id), ['claude'])
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
})
|
package/src/api/client.js
CHANGED
|
@@ -27,10 +27,12 @@ const request = (method, path, options = {}) => catch_errors(`API ${method} ${pa
|
|
|
27
27
|
headers['X-Intent-Envelope'] = active_envelope
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
let token_attached = false
|
|
30
31
|
if (auth) {
|
|
31
32
|
const [, token_data] = await load_token()
|
|
32
33
|
if (token_data) {
|
|
33
34
|
headers['Authorization'] = `Bearer ${token_data.id_token}`
|
|
35
|
+
token_attached = true
|
|
34
36
|
}
|
|
35
37
|
}
|
|
36
38
|
|
|
@@ -83,7 +85,15 @@ const request = (method, path, options = {}) => catch_errors(`API ${method} ${pa
|
|
|
83
85
|
const err_details = data?.error?.details
|
|
84
86
|
|
|
85
87
|
if (res.status === 401) {
|
|
86
|
-
|
|
88
|
+
// The API answers 401 for a private resource the caller can't see —
|
|
89
|
+
// which happens BOTH when nobody is signed in AND when a signed-in
|
|
90
|
+
// user simply lacks access (private repos return 401/404 so their
|
|
91
|
+
// existence isn't leaked). Telling a signed-in user to "log in" is
|
|
92
|
+
// wrong, so the message depends on whether we actually sent a token.
|
|
93
|
+
const message = token_attached
|
|
94
|
+
? 'Access denied. Your account does not have access to this private resource, or your session has expired. If you are already signed in, ask the owner to grant you access; otherwise run `happyskills login`.'
|
|
95
|
+
: 'You are not signed in. Run `happyskills login` to access private skills.'
|
|
96
|
+
const err = new AuthError(message)
|
|
87
97
|
if (err_details) err.details = err_details
|
|
88
98
|
throw err
|
|
89
99
|
}
|
package/src/api/client.test.js
CHANGED
|
@@ -10,7 +10,11 @@
|
|
|
10
10
|
const { describe, it } = require('node:test')
|
|
11
11
|
const assert = require('node:assert/strict')
|
|
12
12
|
const crypto = require('node:crypto')
|
|
13
|
+
const os = require('node:os')
|
|
14
|
+
const path = require('node:path')
|
|
15
|
+
const fs = require('node:fs')
|
|
13
16
|
const client = require('./client')
|
|
17
|
+
const { save_token } = require('../auth/token_store')
|
|
14
18
|
|
|
15
19
|
const EMPTY_SHA = crypto.createHash('sha256').update('').digest('hex')
|
|
16
20
|
|
|
@@ -51,3 +55,70 @@ describe('api client — x-amz-content-sha256 (CloudFront OAC SigV4 payload hash
|
|
|
51
55
|
assert.equal(c.headers['x-amz-content-sha256'], undefined)
|
|
52
56
|
})
|
|
53
57
|
})
|
|
58
|
+
|
|
59
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
60
|
+
// 401 messaging is login-state aware. The API returns 401 for a private resource
|
|
61
|
+
// the caller can't see — which happens BOTH when nobody is signed in AND when a
|
|
62
|
+
// signed-in user simply lacks access. The CLI must never tell a signed-in user
|
|
63
|
+
// to log in. token_store reads XDG_CONFIG_HOME lazily, so we point it at a temp
|
|
64
|
+
// dir to control whether a token gets attached.
|
|
65
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
// Stub fetch to return a 401 envelope regardless of input.
|
|
68
|
+
const with_401 = async (fn) => {
|
|
69
|
+
const orig = global.fetch
|
|
70
|
+
global.fetch = async () => ({
|
|
71
|
+
ok: false,
|
|
72
|
+
status: 401,
|
|
73
|
+
headers: { get: () => null },
|
|
74
|
+
json: async () => ({ error: { code: 'AUTH_REQUIRED', message: 'Unauthorized' } }),
|
|
75
|
+
})
|
|
76
|
+
try { return await fn() } finally { global.fetch = orig }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const with_tmp_config = async (fn) => {
|
|
80
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-client-401-'))
|
|
81
|
+
const orig = process.env.XDG_CONFIG_HOME
|
|
82
|
+
process.env.XDG_CONFIG_HOME = dir
|
|
83
|
+
try { await fn(dir) } finally {
|
|
84
|
+
if (orig !== undefined) process.env.XDG_CONFIG_HOME = orig
|
|
85
|
+
else delete process.env.XDG_CONFIG_HOME
|
|
86
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe('api client — 401 message never tells a signed-in user to log in', () => {
|
|
91
|
+
// client.request is wrapped in puffy-core catch_errors, so it RETURNS
|
|
92
|
+
// [errors, result] rather than throwing. Pull the AuthError out of the chain.
|
|
93
|
+
const auth_error_from = (errors) => {
|
|
94
|
+
assert.ok(errors, 'expected an error tuple')
|
|
95
|
+
const err = errors.find(e => e && e.name === 'AuthError')
|
|
96
|
+
assert.ok(err, 'expected an AuthError in the error chain')
|
|
97
|
+
return err
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
it('signed-in 401 → access-denied wording, NOT "log in"', async () => {
|
|
101
|
+
await with_tmp_config(async () => {
|
|
102
|
+
// A valid (non-expired) token → an Authorization header is attached.
|
|
103
|
+
await save_token({ id_token: 'a.b.c', access_token: 'x', refresh_token: 'r', expires_in: 3600 })
|
|
104
|
+
await with_401(async () => {
|
|
105
|
+
const [errors] = await client.get('/repos/acme/private-skill')
|
|
106
|
+
const err = auth_error_from(errors)
|
|
107
|
+
assert.match(err.message, /access denied|does not have access/i)
|
|
108
|
+
assert.doesNotMatch(err.message, /you are not signed in/i)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('not-signed-in 401 → tells the user to sign in', async () => {
|
|
114
|
+
await with_tmp_config(async () => {
|
|
115
|
+
// No token saved → no Authorization header attached.
|
|
116
|
+
await with_401(async () => {
|
|
117
|
+
const [errors] = await client.get('/repos/acme/private-skill')
|
|
118
|
+
const err = auth_error_from(errors)
|
|
119
|
+
assert.match(err.message, /not signed in/i)
|
|
120
|
+
assert.match(err.message, /happyskills login/)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
})
|
package/src/auth/identity.js
CHANGED
|
@@ -42,7 +42,7 @@ const render_identity_text = (identity) => {
|
|
|
42
42
|
if (identity.workspace_error) {
|
|
43
43
|
console.log()
|
|
44
44
|
print_warn(`Failed to list workspaces: ${identity.workspace_error}`)
|
|
45
|
-
print_hint('
|
|
45
|
+
print_hint('This is usually a temporary connection issue — try again in a moment.')
|
|
46
46
|
} else if (identity.workspaces && identity.workspaces.length > 0) {
|
|
47
47
|
console.log()
|
|
48
48
|
print_label('Workspaces', '')
|
package/src/commands/check.js
CHANGED
|
@@ -201,8 +201,8 @@ const run = (args) => catch_errors('Check failed', async () => {
|
|
|
201
201
|
}
|
|
202
202
|
if (no_access.length > 0) {
|
|
203
203
|
console.log()
|
|
204
|
-
print_warn(
|
|
205
|
-
print_hint(`
|
|
204
|
+
print_warn(`${no_access.length} installed skill(s) are private and your account can't access them.`)
|
|
205
|
+
print_hint(`If you're signed in, ask the owner to grant you access. If not, run ${code('happyskills login')}.`)
|
|
206
206
|
}
|
|
207
207
|
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
208
208
|
|
package/src/commands/publish.js
CHANGED
|
@@ -73,7 +73,7 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
73
73
|
|
|
74
74
|
// Verify auth early — fail fast before validation and upload
|
|
75
75
|
const [ws_err, workspaces] = await workspaces_api.list_workspaces()
|
|
76
|
-
if (ws_err) throw e('
|
|
76
|
+
if (ws_err) throw e('Could not load your workspaces before publishing — check your connection, or run `happyskills login` if your session has expired.', ws_err)
|
|
77
77
|
|
|
78
78
|
const [dir_err, dir] = await resolve_skill_dir(skill_name)
|
|
79
79
|
if (dir_err) throw e(`Skill "${skill_name}" not found`, dir_err)
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// happyskills resolve "<intent>" — spec 260606-01 Phase 4b.
|
|
3
|
+
//
|
|
4
|
+
// Read-only intent resolver. Given a natural-language intent, return a grounded
|
|
5
|
+
// envelope naming the capability, its owning skill, whether that skill is
|
|
6
|
+
// installed, and (when not) how to install it. CLI-only orchestration over the
|
|
7
|
+
// §4.1 skill-declared ownership map (deterministic) + the existing registry
|
|
8
|
+
// search endpoint (probabilistic fallback). No server-side endpoint, no in-CLI
|
|
9
|
+
// LLM call — the matching is string/registry-based; the *agent* supplies the
|
|
10
|
+
// intelligence by calling `resolve` and reading the result.
|
|
11
|
+
//
|
|
12
|
+
// Resolution order (deterministic-first):
|
|
13
|
+
// 2a. Match the intent against INSTALLED skills' declared capability.intents.
|
|
14
|
+
// A confident local match returns installed:true — fully deterministic.
|
|
15
|
+
// 2b. Otherwise query the registry for a skill/capability covering the intent
|
|
16
|
+
// and return the top candidate with installed:false + an install command.
|
|
17
|
+
// HONEST CAVEAT: step 2b leans on semantic registry search, which is
|
|
18
|
+
// PROBABILISTIC. `resolve` grounds it in the real registry rather than the
|
|
19
|
+
// LLM's memory — it does not make it deterministic. Do not over-trust a
|
|
20
|
+
// not-installed match.
|
|
21
|
+
|
|
22
|
+
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
23
|
+
const { build_ownership } = require('./schema')
|
|
24
|
+
const repos_api = require('../api/repos')
|
|
25
|
+
const { print_help, print_info, print_hint } = require('../ui/output')
|
|
26
|
+
const { bold, dim, cyan, gray } = require('../ui/colors')
|
|
27
|
+
const { emit_envelope } = require('../ui/envelope')
|
|
28
|
+
const { exit_with_error, UsageError } = require('../utils/errors')
|
|
29
|
+
const { EXIT_CODES } = require('../constants')
|
|
30
|
+
|
|
31
|
+
const HELP_TEXT = `Usage: happyskills resolve "<intent>" [options]
|
|
32
|
+
|
|
33
|
+
Resolve a natural-language intent to the HappySkills skill that owns it, whether
|
|
34
|
+
that skill is installed, and how to install it if not.
|
|
35
|
+
|
|
36
|
+
Resolution is deterministic-first: a confident match against an INSTALLED skill's
|
|
37
|
+
declared capabilities returns installed:true and is fully deterministic. If no
|
|
38
|
+
installed skill matches, resolve falls back to the registry — that step is
|
|
39
|
+
semantic search and is PROBABILISTIC; treat a not-installed match as a grounded
|
|
40
|
+
suggestion, not a guarantee.
|
|
41
|
+
|
|
42
|
+
Arguments:
|
|
43
|
+
intent A natural-language description of what you want to do
|
|
44
|
+
|
|
45
|
+
Options:
|
|
46
|
+
--json Output as JSON envelope (default for non-TTY callers)
|
|
47
|
+
|
|
48
|
+
Examples:
|
|
49
|
+
happyskills resolve "how many people installed my skills"
|
|
50
|
+
happyskills resolve "invite alice to my workspace" --json`
|
|
51
|
+
|
|
52
|
+
// ── deterministic local matcher (pure; exported for tests) ───────────────────
|
|
53
|
+
|
|
54
|
+
const STOPWORDS = new Set([
|
|
55
|
+
'a', 'an', 'the', 'my', 'me', 'i', 'to', 'of', 'for', 'is', 'are', 'be',
|
|
56
|
+
'do', 'does', 'did', 'how', 'what', 'which', 'can', 'could', 'would', 'you',
|
|
57
|
+
'please', 'with', 'on', 'in', 'it', 'this', 'that', 'and', 'or', 'your',
|
|
58
|
+
'we', 'us', 'am', 'have', 'has', 'had', 'about', 'into', 'from', 'so',
|
|
59
|
+
])
|
|
60
|
+
|
|
61
|
+
const tokenize = (s) => String(s || '')
|
|
62
|
+
.toLowerCase()
|
|
63
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
64
|
+
.split(' ')
|
|
65
|
+
.filter(t => t.length >= 2 && !STOPWORDS.has(t))
|
|
66
|
+
|
|
67
|
+
// Coverage of an intent phrase's significant tokens by the query token set,
|
|
68
|
+
// plus the raw count of shared tokens (used by the confidence gate).
|
|
69
|
+
const score_intent = (query_set, intent_phrase) => {
|
|
70
|
+
const it = [...new Set(tokenize(intent_phrase))]
|
|
71
|
+
if (it.length === 0) return { score: 0, shared: 0 }
|
|
72
|
+
let shared = 0
|
|
73
|
+
for (const t of it) if (query_set.has(t)) shared++
|
|
74
|
+
return { score: shared / it.length, shared }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Best (skill, capability, intent) match across all declared capabilities of the
|
|
78
|
+
// installed skills. Returns null when nothing overlaps. A query token that
|
|
79
|
+
// equals one of a capability's owned command names is treated as a strong
|
|
80
|
+
// signal (score floored to 1).
|
|
81
|
+
const match_local = (intent, skills) => {
|
|
82
|
+
const query_set = new Set(tokenize(intent))
|
|
83
|
+
if (query_set.size === 0) return null
|
|
84
|
+
let best = null
|
|
85
|
+
for (const skill of skills || []) {
|
|
86
|
+
for (const cap of skill.capabilities || []) {
|
|
87
|
+
for (const phrase of cap.intents || []) {
|
|
88
|
+
const { score, shared } = score_intent(query_set, phrase)
|
|
89
|
+
if (shared > 0 && (!best || score > best.score || (score === best.score && shared > best.shared))) {
|
|
90
|
+
best = { skill, capability: cap, intent_phrase: phrase, score, shared }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Command-name hit — ONLY when the query IS essentially the bare
|
|
94
|
+
// command (a single content token, e.g. "publish" or "stats").
|
|
95
|
+
// Command names are often common English words (access, check, list,
|
|
96
|
+
// star, status, diff, pull, update...), so matching one inside a
|
|
97
|
+
// longer, unrelated query produces confident false positives — e.g.
|
|
98
|
+
// "access the database" → collab, "check the weather" → core. Gating
|
|
99
|
+
// on a single-token query keeps bare-command resolution while leaving
|
|
100
|
+
// multi-word phrasing to the intent matcher.
|
|
101
|
+
if (query_set.size === 1) {
|
|
102
|
+
for (const cmd of cap.commands || []) {
|
|
103
|
+
if (query_set.has(cmd.toLowerCase())) {
|
|
104
|
+
const cand = { skill, capability: cap, intent_phrase: cap.intents?.[0] || cmd, score: 1, shared: 1 }
|
|
105
|
+
if (!best || cand.score > best.score) best = cand
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return best
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Confidence gate. Deliberately coarse — this is a deterministic floor, not a
|
|
115
|
+
// ranker (spec §6.5: do not tune ranking). Two shared significant tokens at ≥50%
|
|
116
|
+
// coverage, OR a near-exact single-strong match (≥80% coverage).
|
|
117
|
+
const is_confident = (m) => !!m && ((m.shared >= 2 && m.score >= 0.5) || m.score >= 0.8)
|
|
118
|
+
|
|
119
|
+
// Registry-fallback relevance floor (pure; exported for tests).
|
|
120
|
+
// Semantic search almost always returns SOMETHING, so taking items[0] blindly
|
|
121
|
+
// turns gibberish into a confident install instruction for an unrelated skill
|
|
122
|
+
// (spec §3 Phase-4b requires gibberish → graceful empty). relevance_score is
|
|
123
|
+
// uniformly ~0.03 on the pre-launch registry (untuned, spec §6.5) so it is NOT
|
|
124
|
+
// a usable floor — the server's own match_quality bucketing is. Accept only a
|
|
125
|
+
// strong/good top match; partial/weak/absent → null → graceful path.
|
|
126
|
+
const ACCEPTABLE_REGISTRY_QUALITY = new Set(['strong', 'good'])
|
|
127
|
+
|
|
128
|
+
const registry_candidate = (items) => {
|
|
129
|
+
const top = items && items.length ? items[0] : null
|
|
130
|
+
if (!top) return null
|
|
131
|
+
return ACCEPTABLE_REGISTRY_QUALITY.has(top.match_quality) ? top : null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── next_step builders (pure; exported for tests) ────────────────────────────
|
|
135
|
+
|
|
136
|
+
// Installed: the owning skill is present — name it via route_to_skill so the
|
|
137
|
+
// agent lets it fire. Reuses the existing continuation action present_to_user.
|
|
138
|
+
const installed_next_step = (owner_skill, trigger) => ({
|
|
139
|
+
kind: 'continuation',
|
|
140
|
+
action: 'present_to_user',
|
|
141
|
+
route_to_skill: owner_skill,
|
|
142
|
+
instructions: `The capability is owned by \`${owner_skill}\`, which is installed. Present the match to the principal — the skill will handle the request when the intent is re-stated.`,
|
|
143
|
+
context: { ...(trigger ? { trigger } : {}) },
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// Not installed: route to install. Reuses the existing routing action
|
|
147
|
+
// install_first; route_to_skill names the owning skill.
|
|
148
|
+
const not_installed_next_step = (owner_skill, slug) => ({
|
|
149
|
+
kind: 'routing',
|
|
150
|
+
action: 'install_first',
|
|
151
|
+
route_to_skill: owner_skill,
|
|
152
|
+
instructions: `The owning skill is not installed. Install it, then re-state the intent so it can handle the request.`,
|
|
153
|
+
context: { commands: [`npx happyskills install ${slug} --json`] },
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
// Nothing confidently matched — a valid, graceful outcome (never a crash, never
|
|
157
|
+
// an invented error code).
|
|
158
|
+
const empty_next_step = () => ({
|
|
159
|
+
kind: 'continuation',
|
|
160
|
+
action: 'present_to_user',
|
|
161
|
+
instructions: `No installed capability or registry skill confidently matches this intent. Present this honestly to the principal — consider rephrasing, or search the registry directly with \`happyskills search\`.`,
|
|
162
|
+
context: {},
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
// ── resolution ───────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
const resolve_intent = (intent) => catch_errors('Resolve failed', async () => {
|
|
168
|
+
const [, ownership] = await build_ownership()
|
|
169
|
+
const skills = ownership?.skills || []
|
|
170
|
+
const installed_slugs = new Set(skills.map(s => s.slug))
|
|
171
|
+
|
|
172
|
+
// 2a — deterministic local match against installed capabilities.
|
|
173
|
+
const local = match_local(intent, skills)
|
|
174
|
+
if (is_confident(local)) {
|
|
175
|
+
return {
|
|
176
|
+
data: {
|
|
177
|
+
intent,
|
|
178
|
+
capability: local.capability.id,
|
|
179
|
+
summary: local.capability.summary || null,
|
|
180
|
+
owner_skill: local.skill.name,
|
|
181
|
+
slug: local.skill.slug,
|
|
182
|
+
installed: true,
|
|
183
|
+
resolution: 'installed_capability',
|
|
184
|
+
},
|
|
185
|
+
next_step: installed_next_step(local.skill.name, local.intent_phrase),
|
|
186
|
+
warnings: [],
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 2b — registry search fallback (PROBABILISTIC). Public read; no auth required.
|
|
191
|
+
const [search_errors, response] = await repos_api.dispatch_search(intent, { limit: 5 })
|
|
192
|
+
if (search_errors) throw e('Registry search failed', search_errors)
|
|
193
|
+
const items = Array.isArray(response) ? response : (response?.data || response?.results || [])
|
|
194
|
+
const top = registry_candidate(items)
|
|
195
|
+
|
|
196
|
+
if (top) {
|
|
197
|
+
const owner = top.workspace_slug || top.owner || null
|
|
198
|
+
const slug = owner ? `${owner}/${top.name}` : top.name
|
|
199
|
+
const already_installed = installed_slugs.has(slug)
|
|
200
|
+
return {
|
|
201
|
+
data: {
|
|
202
|
+
intent,
|
|
203
|
+
capability: null,
|
|
204
|
+
summary: top.description || null,
|
|
205
|
+
owner_skill: top.name,
|
|
206
|
+
slug,
|
|
207
|
+
installed: already_installed,
|
|
208
|
+
resolution: 'registry_search',
|
|
209
|
+
},
|
|
210
|
+
next_step: already_installed
|
|
211
|
+
? installed_next_step(top.name, null)
|
|
212
|
+
: not_installed_next_step(top.name, slug),
|
|
213
|
+
warnings: [{
|
|
214
|
+
code: 'probabilistic_match',
|
|
215
|
+
message: 'This match comes from semantic registry search and is probabilistic — verify it fits the intent before trusting a not-installed result.',
|
|
216
|
+
}],
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// No match anywhere — graceful empty result.
|
|
221
|
+
return {
|
|
222
|
+
data: {
|
|
223
|
+
intent,
|
|
224
|
+
capability: null,
|
|
225
|
+
summary: null,
|
|
226
|
+
owner_skill: null,
|
|
227
|
+
slug: null,
|
|
228
|
+
installed: false,
|
|
229
|
+
resolution: 'none',
|
|
230
|
+
},
|
|
231
|
+
next_step: empty_next_step(),
|
|
232
|
+
warnings: [],
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
const print_human = (result) => {
|
|
237
|
+
const d = result.data
|
|
238
|
+
if (d.resolution === 'none') {
|
|
239
|
+
print_info(`No skill confidently owns: "${d.intent}"`)
|
|
240
|
+
print_hint('Try rephrasing, or run `happyskills search "<terms>"` to browse the registry.')
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
const where = d.installed ? cyan('installed') : dim('not installed')
|
|
244
|
+
console.log(`\n${bold(d.owner_skill)} ${where}`)
|
|
245
|
+
if (d.summary) console.log(` ${d.summary}`)
|
|
246
|
+
if (d.capability) console.log(` ${dim('capability: ' + d.capability)}`)
|
|
247
|
+
if (!d.installed && d.slug) {
|
|
248
|
+
console.log(`\n ${gray('Install with:')} happyskills install ${d.slug}`)
|
|
249
|
+
}
|
|
250
|
+
if (d.resolution === 'registry_search') {
|
|
251
|
+
console.log(`\n ${dim('(registry match — semantic search is probabilistic; verify it fits.)')}`)
|
|
252
|
+
}
|
|
253
|
+
console.log('')
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const run = (args) => catch_errors('Resolve failed', async () => {
|
|
257
|
+
if (args.flags._show_help) {
|
|
258
|
+
print_help(HELP_TEXT)
|
|
259
|
+
return process.exit(EXIT_CODES.SUCCESS)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const intent = args._.join(' ').trim()
|
|
263
|
+
if (!intent) {
|
|
264
|
+
throw new UsageError('An intent is required. Example: happyskills resolve "how many people installed my skills".')
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const [errors, result] = await resolve_intent(intent)
|
|
268
|
+
if (errors) throw e('Resolve failed', errors)
|
|
269
|
+
|
|
270
|
+
if (args.flags.json) {
|
|
271
|
+
emit_envelope({ data: result.data, next_step: result.next_step, warnings: result.warnings })
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
print_human(result)
|
|
275
|
+
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
276
|
+
|
|
277
|
+
const schema = {
|
|
278
|
+
name: 'resolve',
|
|
279
|
+
audience: 'consumer',
|
|
280
|
+
purpose: 'Resolve a natural-language intent to the owning skill, whether it is installed, and how to install it. Deterministic against installed capabilities; probabilistic registry fallback otherwise.',
|
|
281
|
+
mutation: false,
|
|
282
|
+
interactive_in_text_mode: false,
|
|
283
|
+
input: {
|
|
284
|
+
positional: [
|
|
285
|
+
{ name: 'intent', required: true, type: 'string', description: 'Natural-language description of what you want to do' },
|
|
286
|
+
],
|
|
287
|
+
flags: [
|
|
288
|
+
{ name: 'json', type: 'boolean', default: false, description: 'Output as JSON envelope' },
|
|
289
|
+
],
|
|
290
|
+
},
|
|
291
|
+
output: {
|
|
292
|
+
data_shape: {
|
|
293
|
+
intent: 'string',
|
|
294
|
+
capability: 'string|null',
|
|
295
|
+
summary: 'string|null',
|
|
296
|
+
owner_skill: 'string|null',
|
|
297
|
+
slug: 'string|null',
|
|
298
|
+
installed: 'boolean',
|
|
299
|
+
resolution: 'installed_capability | registry_search | none',
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
errors: [
|
|
303
|
+
{ code: 'USAGE_ERROR', next_step: { kind: 'routing', action: 'discover_schema' } },
|
|
304
|
+
{ code: 'NETWORK_ERROR', next_step: { kind: 'recovery', action: 'retry' } },
|
|
305
|
+
],
|
|
306
|
+
examples: [
|
|
307
|
+
'happyskills resolve "how many people installed my skills" --json',
|
|
308
|
+
'happyskills resolve "invite alice to my workspace" --json',
|
|
309
|
+
],
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
module.exports = {
|
|
313
|
+
run,
|
|
314
|
+
schema,
|
|
315
|
+
// pure helpers (exported for tests)
|
|
316
|
+
tokenize,
|
|
317
|
+
score_intent,
|
|
318
|
+
match_local,
|
|
319
|
+
is_confident,
|
|
320
|
+
registry_candidate,
|
|
321
|
+
installed_next_step,
|
|
322
|
+
not_installed_next_step,
|
|
323
|
+
empty_next_step,
|
|
324
|
+
resolve_intent,
|
|
325
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Tests for `happyskills resolve` — spec 260606-01 Phase 4b.
|
|
3
|
+
// Pure-function coverage (deterministic matcher + next_step builders) plus an
|
|
4
|
+
// envelope-conformance check: every next_step resolve can emit must be a valid
|
|
5
|
+
// six-key envelope with a closed-enum action. No network — 2b is not exercised.
|
|
6
|
+
|
|
7
|
+
const { describe, it } = require('node:test')
|
|
8
|
+
const assert = require('node:assert/strict')
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
tokenize,
|
|
12
|
+
score_intent,
|
|
13
|
+
match_local,
|
|
14
|
+
is_confident,
|
|
15
|
+
registry_candidate,
|
|
16
|
+
installed_next_step,
|
|
17
|
+
not_installed_next_step,
|
|
18
|
+
empty_next_step,
|
|
19
|
+
} = require('./resolve')
|
|
20
|
+
const { build_envelope } = require('../ui/envelope')
|
|
21
|
+
const { validate_envelope } = require('../schema/envelope_validator')
|
|
22
|
+
const { NEXT_STEP_ACTION_SET } = require('../constants/next_step_actions')
|
|
23
|
+
|
|
24
|
+
// Fixture mirroring the real skill-declared capabilities (a subset).
|
|
25
|
+
const SKILLS = [
|
|
26
|
+
{
|
|
27
|
+
name: 'happyskills-stats', slug: 'happyskillsai/happyskills-stats', bundled: false,
|
|
28
|
+
capabilities: [{
|
|
29
|
+
id: 'usage-stats',
|
|
30
|
+
summary: 'report your own HappySkills usage',
|
|
31
|
+
intents: ['how many people installed my skills', 'show my usage', 'my install or search history'],
|
|
32
|
+
commands: ['stats'],
|
|
33
|
+
}],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'happyskills-collab', slug: 'happyskillsai/happyskills-collab', bundled: false,
|
|
37
|
+
capabilities: [{
|
|
38
|
+
id: 'workspace-collaboration',
|
|
39
|
+
summary: 'invite members and grant access',
|
|
40
|
+
intents: ['invite someone to my workspace', 'grant or revoke skill access'],
|
|
41
|
+
commands: ['people', 'groups', 'access'],
|
|
42
|
+
}],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'happyskills-publish', slug: 'happyskillsai/happyskills-publish', bundled: true,
|
|
46
|
+
capabilities: [{
|
|
47
|
+
id: 'publish-skill',
|
|
48
|
+
summary: 'publish and release skills',
|
|
49
|
+
intents: ['publish my skill', 'release my skill'],
|
|
50
|
+
commands: ['publish', 'release', 'bump'],
|
|
51
|
+
}],
|
|
52
|
+
},
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
describe('resolve — tokenize', () => {
|
|
56
|
+
it('lowercases, strips punctuation, drops stopwords and short tokens', () => {
|
|
57
|
+
assert.deepEqual(tokenize('How many people installed MY skills?'), ['many', 'people', 'installed', 'skills'])
|
|
58
|
+
})
|
|
59
|
+
it('returns empty for pure stopwords / noise', () => {
|
|
60
|
+
assert.deepEqual(tokenize('how do I'), [])
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('resolve — score_intent', () => {
|
|
65
|
+
it('exact phrase scores 1.0 with full shared count', () => {
|
|
66
|
+
const q = new Set(tokenize('how many people installed my skills'))
|
|
67
|
+
const { score, shared } = score_intent(q, 'how many people installed my skills')
|
|
68
|
+
assert.equal(score, 1)
|
|
69
|
+
assert.equal(shared, 4)
|
|
70
|
+
})
|
|
71
|
+
it('partial overlap scores between 0 and 1', () => {
|
|
72
|
+
const q = new Set(tokenize('invite alice to my workspace'))
|
|
73
|
+
const { score, shared } = score_intent(q, 'invite someone to my workspace')
|
|
74
|
+
assert.ok(score > 0 && score < 1)
|
|
75
|
+
assert.equal(shared, 2) // invite, workspace
|
|
76
|
+
})
|
|
77
|
+
it('no overlap scores 0', () => {
|
|
78
|
+
const q = new Set(tokenize('asdfqwer nonsense'))
|
|
79
|
+
assert.deepEqual(score_intent(q, 'publish my skill'), { score: 0, shared: 0 })
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('resolve — match_local + confidence gate', () => {
|
|
84
|
+
it('routes a stats intent to happyskills-stats (confident)', () => {
|
|
85
|
+
const m = match_local('how many people installed my skills', SKILLS)
|
|
86
|
+
assert.equal(m.skill.name, 'happyskills-stats')
|
|
87
|
+
assert.equal(m.capability.id, 'usage-stats')
|
|
88
|
+
assert.equal(is_confident(m), true)
|
|
89
|
+
})
|
|
90
|
+
it('routes a collab intent to happyskills-collab (confident)', () => {
|
|
91
|
+
const m = match_local('invite alice to my workspace', SKILLS)
|
|
92
|
+
assert.equal(m.skill.name, 'happyskills-collab')
|
|
93
|
+
assert.equal(is_confident(m), true)
|
|
94
|
+
})
|
|
95
|
+
it('a bare command word ("publish") routes via command-name match', () => {
|
|
96
|
+
const m = match_local('publish', SKILLS)
|
|
97
|
+
assert.equal(m.skill.name, 'happyskills-publish')
|
|
98
|
+
assert.equal(is_confident(m), true)
|
|
99
|
+
})
|
|
100
|
+
it('gibberish yields no confident match', () => {
|
|
101
|
+
const m = match_local('asdfqwer zzz', SKILLS)
|
|
102
|
+
assert.equal(is_confident(m), false) // null or low-overlap
|
|
103
|
+
})
|
|
104
|
+
it('does not cross-wire collab and stats intents', () => {
|
|
105
|
+
assert.equal(match_local('grant or revoke skill access', SKILLS).skill.name, 'happyskills-collab')
|
|
106
|
+
assert.equal(match_local('show my usage', SKILLS).skill.name, 'happyskills-stats')
|
|
107
|
+
})
|
|
108
|
+
// Durable guard: command names are common English words (access, publish,
|
|
109
|
+
// stats...). A longer, unrelated query that merely CONTAINS one must NOT be
|
|
110
|
+
// a confident match — only a bare-command query may use the command shortcut.
|
|
111
|
+
it('does not confidently match an out-of-domain query that merely contains a command word', () => {
|
|
112
|
+
assert.equal(is_confident(match_local('access the production database', SKILLS)), false)
|
|
113
|
+
assert.equal(is_confident(match_local('publish a blog post about marketing', SKILLS)), false)
|
|
114
|
+
assert.equal(is_confident(match_local('show me the stats of the football game', SKILLS)), false)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('resolve — registry relevance floor (2b)', () => {
|
|
119
|
+
// Durable guard: semantic search almost always returns SOMETHING, so the
|
|
120
|
+
// registry fallback must gate on the server's match_quality, never blindly
|
|
121
|
+
// take items[0] (which turned gibberish into a confident install_first).
|
|
122
|
+
it('rejects a partial/weak top match (→ null → graceful path)', () => {
|
|
123
|
+
assert.equal(registry_candidate([{ name: 'abc-xyz-classifier', match_quality: 'partial' }]), null)
|
|
124
|
+
assert.equal(registry_candidate([{ name: 'x', match_quality: 'weak' }]), null)
|
|
125
|
+
})
|
|
126
|
+
it('rejects a top match with no match_quality (defensive)', () => {
|
|
127
|
+
assert.equal(registry_candidate([{ name: 'x', relevance_score: 0.9 }]), null)
|
|
128
|
+
})
|
|
129
|
+
it('accepts a strong or good top match', () => {
|
|
130
|
+
assert.equal(registry_candidate([{ name: 'happyskills-collab', match_quality: 'strong' }]).name, 'happyskills-collab')
|
|
131
|
+
assert.equal(registry_candidate([{ name: 'database', match_quality: 'good' }]).name, 'database')
|
|
132
|
+
})
|
|
133
|
+
it('returns null for an empty result set', () => {
|
|
134
|
+
assert.equal(registry_candidate([]), null)
|
|
135
|
+
assert.equal(registry_candidate(null), null)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('resolve — next_step builders emit valid envelopes', () => {
|
|
140
|
+
const assert_valid = (next_step) => {
|
|
141
|
+
const env = build_envelope({ data: { ok: 1 }, next_step })
|
|
142
|
+
const { ok, errors } = validate_envelope(env)
|
|
143
|
+
assert.ok(ok, `envelope invalid: ${JSON.stringify(errors)}`)
|
|
144
|
+
assert.ok(NEXT_STEP_ACTION_SET.has(env.next_step.action), `action ${env.next_step.action} not in closed enum`)
|
|
145
|
+
return env
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
it('installed → continuation/present_to_user with route_to_skill', () => {
|
|
149
|
+
const env = assert_valid(installed_next_step('happyskills-stats', 'show my usage'))
|
|
150
|
+
assert.equal(env.next_step.kind, 'continuation')
|
|
151
|
+
assert.equal(env.next_step.action, 'present_to_user')
|
|
152
|
+
assert.equal(env.next_step.route_to_skill, 'happyskills-stats')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('not installed → routing/install_first with the install command', () => {
|
|
156
|
+
const env = assert_valid(not_installed_next_step('happyskills-collab', 'happyskillsai/happyskills-collab'))
|
|
157
|
+
assert.equal(env.next_step.kind, 'routing')
|
|
158
|
+
assert.equal(env.next_step.action, 'install_first')
|
|
159
|
+
assert.equal(env.next_step.route_to_skill, 'happyskills-collab')
|
|
160
|
+
assert.equal(env.next_step.context.commands[0], 'npx happyskills install happyskillsai/happyskills-collab --json')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('empty → continuation/present_to_user, ok:true (graceful, no invented code)', () => {
|
|
164
|
+
const env = assert_valid(empty_next_step())
|
|
165
|
+
assert.equal(env.ok, true)
|
|
166
|
+
assert.equal(env.next_step.action, 'present_to_user')
|
|
167
|
+
assert.deepEqual(env.error, {})
|
|
168
|
+
})
|
|
169
|
+
})
|
package/src/commands/schema.js
CHANGED
|
@@ -10,8 +10,12 @@
|
|
|
10
10
|
// yet (defensive, additive). The schema lives next to the implementation
|
|
11
11
|
// so there's no drift.
|
|
12
12
|
|
|
13
|
+
const path = require('path')
|
|
13
14
|
const { error: { catch_errors } } = require('puffy-core')
|
|
14
|
-
const { COMMANDS, COMMAND_ALIASES, CLI_VERSION } = require('../constants')
|
|
15
|
+
const { COMMANDS, COMMAND_ALIASES, CLI_VERSION, SKILL_JSON } = require('../constants')
|
|
16
|
+
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
17
|
+
const { skills_dir, skill_install_dir, find_project_root, lock_root } = require('../config/paths')
|
|
18
|
+
const { read_json } = require('../utils/fs')
|
|
15
19
|
const { ERROR_CODE_LIST } = require('../constants/error_codes')
|
|
16
20
|
const {
|
|
17
21
|
NEXT_STEP_ACTION_LIST,
|
|
@@ -103,10 +107,90 @@ const load_command_schema = (name) => {
|
|
|
103
107
|
return default_schema(name)
|
|
104
108
|
}
|
|
105
109
|
|
|
106
|
-
|
|
110
|
+
// ── Skill-declared capability ownership (spec 260606-01 Phase 4a) ────────────
|
|
111
|
+
// Each installed skill MAY declare a `capabilities` array in its skill.json:
|
|
112
|
+
// [{ id, summary, intents: [...], commands: [...] }]
|
|
113
|
+
// We aggregate these from the locally-installed skills (project + global lock)
|
|
114
|
+
// and expose two derived shapes:
|
|
115
|
+
// - data.skills[] — the capability registry, one entry per
|
|
116
|
+
// declaring skill (name, slug, bundled, caps)
|
|
117
|
+
// - data.commands[].owner_skill — command → owning skill, DERIVED from the
|
|
118
|
+
// `commands` field of each declared capability
|
|
119
|
+
// Ownership is skill-DECLARED and CLI-AGGREGATED. There is deliberately NO
|
|
120
|
+
// hardcoded skill-family or command→skill table here (spec §5 — the one
|
|
121
|
+
// architectural rule). `bundled` is derived generically: a skill is bundled
|
|
122
|
+
// when it appears as a dependency of any other installed skill (i.e. it arrives
|
|
123
|
+
// pulled-in rather than installed on its own) — family-agnostic, lock-derived.
|
|
124
|
+
// This generalizes the spec's "membership in core's dependencies" to "any
|
|
125
|
+
// installed skill's dependencies" precisely so the CLI never names a family.
|
|
126
|
+
|
|
127
|
+
const read_installed_manifests = (is_global) => catch_errors('Failed to read installed skills', async () => {
|
|
128
|
+
const project_root = find_project_root()
|
|
129
|
+
const base_dir = skills_dir(is_global, project_root)
|
|
130
|
+
const [, lock_data] = await read_lock(lock_root(is_global, project_root))
|
|
131
|
+
const locked = get_all_locked_skills(lock_data)
|
|
132
|
+
const entries = await Promise.all(Object.keys(locked).map(async (slug) => {
|
|
133
|
+
const short = slug.split('/')[1] || slug
|
|
134
|
+
const dir = skill_install_dir(base_dir, short)
|
|
135
|
+
const [, manifest] = await read_json(path.join(dir, SKILL_JSON))
|
|
136
|
+
if (!manifest) return null
|
|
137
|
+
return { slug, short, manifest }
|
|
138
|
+
}))
|
|
139
|
+
return entries.filter(Boolean)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const normalise_capabilities = (caps) =>
|
|
143
|
+
(Array.isArray(caps) ? caps : []).map(c => ({
|
|
144
|
+
id: c && c.id ? c.id : null,
|
|
145
|
+
summary: c && c.summary ? c.summary : '',
|
|
146
|
+
intents: c && Array.isArray(c.intents) ? c.intents : [],
|
|
147
|
+
commands: c && Array.isArray(c.commands) ? c.commands : [],
|
|
148
|
+
}))
|
|
149
|
+
|
|
150
|
+
const build_ownership = () => catch_errors('Failed to build skill ownership', async () => {
|
|
151
|
+
// Merge project + global installs; project precedence on duplicate slug.
|
|
152
|
+
const [, project_entries] = await read_installed_manifests(false)
|
|
153
|
+
const [, global_entries] = await read_installed_manifests(true)
|
|
154
|
+
const by_slug = new Map()
|
|
155
|
+
for (const e of [...(global_entries || []), ...(project_entries || [])]) by_slug.set(e.slug, e)
|
|
156
|
+
// Sort by slug so command_owner tie-breaks (first-declarer-wins) are stable.
|
|
157
|
+
const all = [...by_slug.values()].sort((a, b) => a.slug.localeCompare(b.slug))
|
|
158
|
+
|
|
159
|
+
// bundled = appears as a dependency of any installed skill.
|
|
160
|
+
const dep_slugs = new Set()
|
|
161
|
+
for (const { manifest } of all) {
|
|
162
|
+
const deps = manifest && manifest.dependencies
|
|
163
|
+
if (deps && typeof deps === 'object') for (const k of Object.keys(deps)) dep_slugs.add(k)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const skills = []
|
|
167
|
+
const command_owner = {}
|
|
168
|
+
for (const { slug, short, manifest } of all) {
|
|
169
|
+
const caps = normalise_capabilities(manifest.capabilities)
|
|
170
|
+
if (caps.length === 0) continue
|
|
171
|
+
const name = manifest.name || short
|
|
172
|
+
skills.push({
|
|
173
|
+
name,
|
|
174
|
+
slug,
|
|
175
|
+
version: manifest.version || null,
|
|
176
|
+
bundled: dep_slugs.has(slug),
|
|
177
|
+
capabilities: caps,
|
|
178
|
+
})
|
|
179
|
+
// Derive command → owner_skill from declared capability.commands.
|
|
180
|
+
// First declarer wins (orthogonal by design; deterministic via slug sort).
|
|
181
|
+
for (const cap of caps) {
|
|
182
|
+
for (const cmd of cap.commands) {
|
|
183
|
+
if (!(cmd in command_owner)) command_owner[cmd] = name
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { skills, command_owner }
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
const build_command_registry = (command_owner = {}) => {
|
|
107
191
|
const all = [...COMMANDS]
|
|
108
192
|
if (!all.includes('schema')) all.push('schema')
|
|
109
|
-
return all.map(load_command_schema)
|
|
193
|
+
return all.map(name => ({ ...load_command_schema(name), owner_skill: command_owner[name] || null }))
|
|
110
194
|
}
|
|
111
195
|
|
|
112
196
|
const build_error_code_list = () => ERROR_CODE_LIST.map(code => ({ code }))
|
|
@@ -114,11 +198,12 @@ const build_error_code_list = () => ERROR_CODE_LIST.map(code => ({ code }))
|
|
|
114
198
|
const build_next_step_action_list = () =>
|
|
115
199
|
NEXT_STEP_ACTION_LIST.map(action => ({ action, kind: ACTION_KIND[action] || null }))
|
|
116
200
|
|
|
117
|
-
const build_schema_payload = () => ({
|
|
201
|
+
const build_schema_payload = (ownership = { skills: [], command_owner: {} }) => ({
|
|
118
202
|
envelope_schema_version: ENVELOPE_SCHEMA_VERSION,
|
|
119
203
|
envelope_schema_uri: 'https://schemas.happyskills.dev/envelope/v1.json',
|
|
120
204
|
cli_version: CLI_VERSION,
|
|
121
|
-
commands: build_command_registry(),
|
|
205
|
+
commands: build_command_registry(ownership.command_owner),
|
|
206
|
+
skills: ownership.skills || [],
|
|
122
207
|
error_codes: build_error_code_list(),
|
|
123
208
|
next_step_actions: build_next_step_action_list(),
|
|
124
209
|
next_step_kinds: [...NEXT_STEP_KINDS],
|
|
@@ -149,7 +234,8 @@ const run = (args) => catch_errors('Schema command failed', async () => {
|
|
|
149
234
|
print_help(HELP_TEXT)
|
|
150
235
|
return process.exit(EXIT_CODES.SUCCESS)
|
|
151
236
|
}
|
|
152
|
-
const
|
|
237
|
+
const [, ownership] = await build_ownership()
|
|
238
|
+
const payload = build_schema_payload(ownership || { skills: [], command_owner: {} })
|
|
153
239
|
if (args.flags.json) {
|
|
154
240
|
emit_envelope({ data: payload })
|
|
155
241
|
return
|
|
@@ -170,7 +256,8 @@ const schema = {
|
|
|
170
256
|
envelope_schema_version: 'string',
|
|
171
257
|
envelope_schema_uri: 'string',
|
|
172
258
|
cli_version: 'string',
|
|
173
|
-
commands: 'array<CommandSchema>',
|
|
259
|
+
commands: 'array<CommandSchema & { owner_skill: string|null }>',
|
|
260
|
+
skills: 'array<{ name: string, slug: string, version: string|null, bundled: boolean, capabilities: array<{ id: string|null, summary: string, intents: string[], commands: string[] }> }>',
|
|
174
261
|
error_codes: 'array<{ code: string }>',
|
|
175
262
|
next_step_actions: 'array<{ action: string, kind: string }>',
|
|
176
263
|
next_step_kinds: 'array<string>',
|
|
@@ -182,4 +269,4 @@ const schema = {
|
|
|
182
269
|
],
|
|
183
270
|
}
|
|
184
271
|
|
|
185
|
-
module.exports = { run, schema, build_schema_payload }
|
|
272
|
+
module.exports = { run, schema, build_schema_payload, build_ownership }
|
|
@@ -151,20 +151,28 @@ const NEXT_STEP_BY_ERROR_CODE = Object.freeze({
|
|
|
151
151
|
{ commands: [`npx happyskills pull ${ctx.skill || '<skill>'} --rebase --json`] },
|
|
152
152
|
{ route_to_skill: 'happyskills-sync' }
|
|
153
153
|
),
|
|
154
|
+
// Spec 260606-01 § 4.2 — these recoveries clearly belong to a sibling skill
|
|
155
|
+
// (validate/changelog are publish-pre-flight territory), so name it via
|
|
156
|
+
// route_to_skill. Literal slugs mirror the DRIFT_DETECTED/DIVERGED style:
|
|
157
|
+
// these factories are a static map with no runtime access to the async
|
|
158
|
+
// ownership map, and `happyskills-publish` is the published slug that owns
|
|
159
|
+
// the `validate`/`bump`/`publish`/`release` commands (per the §4.1 map).
|
|
154
160
|
VALIDATION_FAILED: (_msg, ctx = {}) => recovery(
|
|
155
161
|
FIX_VALIDATION_ERRORS,
|
|
156
162
|
'Validation failed. Fix the listed errors and re-run.',
|
|
157
163
|
{
|
|
158
164
|
...(ctx.validation_errors ? { validation_errors: ctx.validation_errors } : {}),
|
|
159
165
|
...(ctx.skill ? { commands: [`npx happyskills validate ${ctx.skill} --json`] } : {}),
|
|
160
|
-
}
|
|
166
|
+
},
|
|
167
|
+
{ route_to_skill: 'happyskills-publish' }
|
|
161
168
|
),
|
|
162
169
|
DEPENDENCY_VALIDATION_FAILED: (_msg, ctx = {}) => recovery(
|
|
163
170
|
FIX_VALIDATION_ERRORS,
|
|
164
171
|
'Dependency validation failed. Fix the listed dependency issues and re-run.',
|
|
165
172
|
{
|
|
166
173
|
...(ctx.validation_errors ? { validation_errors: ctx.validation_errors } : {}),
|
|
167
|
-
}
|
|
174
|
+
},
|
|
175
|
+
{ route_to_skill: 'happyskills-publish' }
|
|
168
176
|
),
|
|
169
177
|
MISSING_CHANGELOG_ENTRY: (_msg, ctx = {}) => recovery(
|
|
170
178
|
PROVIDE_CHANGELOG,
|
|
@@ -173,13 +181,13 @@ const NEXT_STEP_BY_ERROR_CODE = Object.freeze({
|
|
|
173
181
|
...(ctx.target_version ? { target_version: ctx.target_version } : {}),
|
|
174
182
|
...(ctx.current_top_entry ? { current_top_entry: ctx.current_top_entry } : {}),
|
|
175
183
|
},
|
|
176
|
-
{ principal_authorization_required: true }
|
|
184
|
+
{ principal_authorization_required: true, route_to_skill: 'happyskills-publish' }
|
|
177
185
|
),
|
|
178
186
|
CHANGELOG_SOURCE_UNREADABLE: () => recovery(
|
|
179
187
|
PROVIDE_CHANGELOG,
|
|
180
188
|
'CHANGELOG.md could not be read. Restore the file and re-run.',
|
|
181
189
|
{},
|
|
182
|
-
{ principal_authorization_required: true }
|
|
190
|
+
{ principal_authorization_required: true, route_to_skill: 'happyskills-publish' }
|
|
183
191
|
),
|
|
184
192
|
|
|
185
193
|
// Decision-kind defaults
|
|
@@ -226,6 +234,17 @@ const NEXT_STEP_BY_ERROR_CODE = Object.freeze({
|
|
|
226
234
|
...(ctx.candidates ? { candidates: ctx.candidates } : {}),
|
|
227
235
|
}
|
|
228
236
|
),
|
|
237
|
+
// Spec 260606-01 § 4.2 — unresolved merge conflicts are sync territory.
|
|
238
|
+
// Default factory (commands MAY still override with a richer next_step).
|
|
239
|
+
CONFLICT: (_msg, ctx = {}) => decision(
|
|
240
|
+
RESOLVE_CONFLICTS,
|
|
241
|
+
'Unresolved merge conflicts are present. Resolve the conflict markers, then retry the operation.',
|
|
242
|
+
{
|
|
243
|
+
...(ctx.conflict_files ? { conflict_files: ctx.conflict_files } : {}),
|
|
244
|
+
...(ctx.skill ? { commands: [`npx happyskills status ${ctx.skill} --json`] } : {}),
|
|
245
|
+
},
|
|
246
|
+
{ route_to_skill: 'happyskills-sync' }
|
|
247
|
+
),
|
|
229
248
|
|
|
230
249
|
// Confirmation-kind defaults
|
|
231
250
|
LOCAL_EDITS_PRESENT: (_msg, ctx = {}) => confirmation(
|
package/src/constants.js
CHANGED
package/src/engine/installer.js
CHANGED
|
@@ -14,6 +14,7 @@ const { ensure_dir, remove_dir, file_exists, read_json } = require('../utils/fs'
|
|
|
14
14
|
const { SKILL_JSON, SKILL_TYPES } = require('../constants')
|
|
15
15
|
const { resolve_agents, link_to_agents, verify_and_repair_symlinks } = require('../agents')
|
|
16
16
|
const { is_skill_enabled } = require('../agents/status')
|
|
17
|
+
const { load_token } = require('../auth/token_store')
|
|
17
18
|
const { create_spinner } = require('../ui/spinner')
|
|
18
19
|
const { print_success, print_warn, print_info } = require('../ui/output')
|
|
19
20
|
const { emit_batch } = require('../utils/analytics')
|
|
@@ -24,6 +25,36 @@ const _format_warnings = (missing_deps) => (missing_deps || []).map(dep => {
|
|
|
24
25
|
return `Missing system dependency: ${dep.name} required by ${dep.skill}${hint}`
|
|
25
26
|
})
|
|
26
27
|
|
|
28
|
+
// Build the warning shown when private dependencies are skipped for lack of
|
|
29
|
+
// access. The server returns these because the skill is private and the caller
|
|
30
|
+
// can't see it — which is NOT the same as "not logged in". A signed-in user is
|
|
31
|
+
// most often simply missing access, so the wording is keyed on `logged_in`:
|
|
32
|
+
// we never tell a logged-in user to log in. Pure + exported so the wording is
|
|
33
|
+
// unit-testable. Returns { warn_lines: string[], hint: string }.
|
|
34
|
+
const format_access_denied_lines = (access_denied, logged_in) => {
|
|
35
|
+
const noun = access_denied.length === 1 ? 'dependency' : 'dependencies'
|
|
36
|
+
const them = access_denied.length === 1 ? 'it' : 'them'
|
|
37
|
+
const head = logged_in
|
|
38
|
+
? `${access_denied.length} private ${noun} skipped — your account doesn't have access:`
|
|
39
|
+
: `${access_denied.length} private ${noun} skipped — you're not signed in:`
|
|
40
|
+
const warn_lines = [head, ...access_denied.map(s => ` ${s.skill} (required by ${s.required_by})`)]
|
|
41
|
+
const hint = logged_in
|
|
42
|
+
? `Ask the owner to grant you access to ${them}, then re-run install.`
|
|
43
|
+
: `Sign in, then re-run install: happyskills login`
|
|
44
|
+
return { warn_lines, hint }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// First-run transparency. When agent resolution falls all the way through to the
|
|
48
|
+
// Claude Code default (nothing was configured or detected — empty project, fresh
|
|
49
|
+
// machine, no flag/env/config), we must tell the user a default was chosen rather
|
|
50
|
+
// than silently linking. Returns the machine-readable warning for the --json
|
|
51
|
+
// `warnings[]`; empty unless the default actually drove a link (linked_count > 0),
|
|
52
|
+
// so kit-only installs (nothing agent-invocable to link) stay quiet.
|
|
53
|
+
const _format_default_agent_warnings = (agents_source, linked_count) =>
|
|
54
|
+
(agents_source === 'default' && linked_count > 0)
|
|
55
|
+
? ['No agent was configured; linked to Claude Code by default. Use --agents or `happyskills agents add <agent>` to target a different agent.']
|
|
56
|
+
: []
|
|
57
|
+
|
|
27
58
|
// Circular dependencies the resolver broke automatically. The install is safe and terminates,
|
|
28
59
|
// but the author should remove one edge — so we surface a warning rather than failing silently.
|
|
29
60
|
const _format_cycle_warnings = (cycles) => (cycles || []).map(({ from, to }) =>
|
|
@@ -66,7 +97,7 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
|
|
|
66
97
|
// Resolve target agents
|
|
67
98
|
const [agents_err, agents_result] = await resolve_agents(agents_flag, { global: is_global, project_root })
|
|
68
99
|
if (agents_err) throw e('Agent resolution failed', agents_err)
|
|
69
|
-
const { agents } = agents_result
|
|
100
|
+
const { agents, source: agents_source } = agents_result
|
|
70
101
|
const temp_dir = tmp_dir(base_dir)
|
|
71
102
|
const lock_dir = lock_root(is_global, project_root)
|
|
72
103
|
|
|
@@ -342,7 +373,12 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
|
|
|
342
373
|
|
|
343
374
|
const linked_count = downloaded.length - disabled_skills.size
|
|
344
375
|
if (agents.length > 0 && linked_count > 0) {
|
|
345
|
-
|
|
376
|
+
if (agents_source === 'default') {
|
|
377
|
+
print_warn('No agent was configured — linked to Claude Code by default.')
|
|
378
|
+
print_info('Target a different agent with --agents (e.g. --agents cursor) or run: happyskills agents add cursor')
|
|
379
|
+
} else {
|
|
380
|
+
print_info(`Linked to: ${agents.map(a => a.display_name).join(', ')}`)
|
|
381
|
+
}
|
|
346
382
|
}
|
|
347
383
|
|
|
348
384
|
const [, missing_deps] = await check_system_dependencies(packages)
|
|
@@ -361,11 +397,12 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
|
|
|
361
397
|
const not_found = skipped_deps.filter(s => s.reason === 'not_found')
|
|
362
398
|
const other = skipped_deps.filter(s => s.reason !== 'access_denied' && s.reason !== 'not_found')
|
|
363
399
|
if (access_denied.length > 0) {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
}
|
|
368
|
-
|
|
400
|
+
// Detect the session state so we never tell a logged-in user to
|
|
401
|
+
// log in (see format_access_denied_lines for the rationale).
|
|
402
|
+
const [, token_data] = await load_token()
|
|
403
|
+
const { warn_lines, hint } = format_access_denied_lines(access_denied, !!token_data)
|
|
404
|
+
for (const line of warn_lines) print_warn(line)
|
|
405
|
+
print_info(hint)
|
|
369
406
|
}
|
|
370
407
|
if (not_found.length > 0) {
|
|
371
408
|
print_warn(`${not_found.length} dependenc${not_found.length === 1 ? 'y' : 'ies'} skipped (not found in registry):`)
|
|
@@ -386,7 +423,8 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
|
|
|
386
423
|
const installed_set = new Set(packages_to_install.map(p => p.skill))
|
|
387
424
|
const installed = packages_to_install.map(p => ({ skill: p.skill, version: p.version }))
|
|
388
425
|
const skipped = packages.filter(p => !installed_set.has(p.skill)).map(p => ({ skill: p.skill, version: p.version, reason: 'already installed' }))
|
|
389
|
-
const
|
|
426
|
+
const default_agent_warnings = _format_default_agent_warnings(agents_source, linked_count)
|
|
427
|
+
const warnings = [..._format_warnings(missing_deps), ...cycle_warnings, ...default_agent_warnings]
|
|
390
428
|
const forced = forced_pkgs.map(p => ({ skill: p.skill, version: p.version }))
|
|
391
429
|
|
|
392
430
|
const linked_agents = agents.map(a => a.id)
|
|
@@ -446,4 +484,4 @@ const install_from_lock = (lock_data, options = {}) => catch_errors('Install fro
|
|
|
446
484
|
return { source: 'skills-lock.json', installed: all_installed, skipped: all_skipped, warnings: all_warnings }
|
|
447
485
|
})
|
|
448
486
|
|
|
449
|
-
module.exports = { install, install_from_manifest, install_from_lock, merge_requested_by }
|
|
487
|
+
module.exports = { install, install_from_manifest, install_from_lock, merge_requested_by, format_access_denied_lines, _format_default_agent_warnings }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { describe, it } = require('node:test')
|
|
2
2
|
const assert = require('node:assert')
|
|
3
|
-
const { merge_requested_by } = require('./installer')
|
|
3
|
+
const { merge_requested_by, format_access_denied_lines, _format_default_agent_warnings } = require('./installer')
|
|
4
4
|
|
|
5
5
|
describe('merge_requested_by', () => {
|
|
6
6
|
it('records the root skill for a fresh dependency', () => {
|
|
@@ -39,3 +39,63 @@ describe('merge_requested_by', () => {
|
|
|
39
39
|
assert.deepStrictEqual(merge_requested_by([], 'a/dep', 'a/root'), ['a/root'])
|
|
40
40
|
})
|
|
41
41
|
})
|
|
42
|
+
|
|
43
|
+
describe('format_access_denied_lines (skipped private-dependency wording)', () => {
|
|
44
|
+
const one = [{ skill: 'acme/secret', required_by: 'acme/_kit-x' }]
|
|
45
|
+
const two = [
|
|
46
|
+
{ skill: 'acme/secret', required_by: 'acme/_kit-x' },
|
|
47
|
+
{ skill: 'acme/other', required_by: 'acme/_kit-x' },
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
it('signed-in: blames access (not auth) and points to the owner — never "log in"', () => {
|
|
51
|
+
const { warn_lines, hint } = format_access_denied_lines(one, true)
|
|
52
|
+
// The reported bug: a logged-in user was told they were not logged in.
|
|
53
|
+
assert.match(warn_lines[0], /your account doesn't have access/)
|
|
54
|
+
assert.doesNotMatch(warn_lines[0], /not signed in|log ?in/i)
|
|
55
|
+
assert.match(hint, /ask the owner/i)
|
|
56
|
+
assert.doesNotMatch(hint, /happyskills login/)
|
|
57
|
+
// The offending skill is still listed for the user.
|
|
58
|
+
assert.match(warn_lines[1], /acme\/secret \(required by acme\/_kit-x\)/)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('signed-out: says not signed in and points to login', () => {
|
|
62
|
+
const { warn_lines, hint } = format_access_denied_lines(one, false)
|
|
63
|
+
assert.match(warn_lines[0], /you're not signed in/)
|
|
64
|
+
assert.match(hint, /happyskills login/)
|
|
65
|
+
assert.doesNotMatch(hint, /ask the owner/i)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('pluralizes the count and noun', () => {
|
|
69
|
+
assert.match(format_access_denied_lines(one, true).warn_lines[0], /^1 private dependency /)
|
|
70
|
+
assert.match(format_access_denied_lines(two, true).warn_lines[0], /^2 private dependencies /)
|
|
71
|
+
// One warn line per skipped dependency, plus the header.
|
|
72
|
+
assert.equal(format_access_denied_lines(two, true).warn_lines.length, 3)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('_format_default_agent_warnings (first-run default-agent transparency)', () => {
|
|
77
|
+
// Regression guard for the first-run trap: when nothing is configured or
|
|
78
|
+
// detected, resolve_agents falls back to Claude Code (source: 'default').
|
|
79
|
+
// The install must NOT be silent about that — it surfaces a warning so the
|
|
80
|
+
// human is told and an operating agent can report it. Before the fix the
|
|
81
|
+
// install completed with zero agent links and zero feedback.
|
|
82
|
+
it('emits a warning when the Claude default drove a link', () => {
|
|
83
|
+
const w = _format_default_agent_warnings('default', 1)
|
|
84
|
+
assert.equal(w.length, 1)
|
|
85
|
+
assert.match(w[0], /linked to Claude Code by default/)
|
|
86
|
+
// Tells the user how to target a different agent (no silent magic).
|
|
87
|
+
assert.match(w[0], /--agents|happyskills agents add/)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('stays silent when an agent was actually configured/detected', () => {
|
|
91
|
+
assert.deepEqual(_format_default_agent_warnings('flag', 1), [])
|
|
92
|
+
assert.deepEqual(_format_default_agent_warnings('env', 2), [])
|
|
93
|
+
assert.deepEqual(_format_default_agent_warnings('project', 1), [])
|
|
94
|
+
assert.deepEqual(_format_default_agent_warnings('config', 1), [])
|
|
95
|
+
assert.deepEqual(_format_default_agent_warnings('home', 3), [])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('stays silent when the default fired but nothing was linked (e.g. kit-only install)', () => {
|
|
99
|
+
assert.deepEqual(_format_default_agent_warnings('default', 0), [])
|
|
100
|
+
})
|
|
101
|
+
})
|