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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "description": "Package manager for AI agent skills",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Nicolas Dao <nic@cloudlesslabs.com> (https://cloudlesslabs.com)",
@@ -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
- return { agents: home_detected, source: 'home' }
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
- const err = new AuthError(err_msg)
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
  }
@@ -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
+ })
@@ -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('Check your authentication with happyskills login')
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', '')
@@ -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('Some skills require authentication.')
205
- print_hint(`Run ${code('happyskills login')} to refresh your session.`)
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
 
@@ -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('Authentication failed — run \'happyskills login\' to re-authenticate.', ws_err)
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
+ })
@@ -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
- const build_command_registry = () => {
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 payload = build_schema_payload()
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
@@ -51,6 +51,7 @@ const COMMANDS = [
51
51
  'visibility',
52
52
  'list',
53
53
  'search',
54
+ 'resolve',
54
55
  'star',
55
56
  'unstar',
56
57
  'check',
@@ -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
- print_info(`Linked to: ${agents.map(a => a.display_name).join(', ')}`)
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
- print_warn(`${access_denied.length} dependenc${access_denied.length === 1 ? 'y' : 'ies'} skipped (private, requires login):`)
365
- for (const s of access_denied) {
366
- print_warn(` ${s.skill} (required by ${s.required_by})`)
367
- }
368
- print_info(`Log in to install: happyskills login`)
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 warnings = [..._format_warnings(missing_deps), ...cycle_warnings]
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
+ })