happyskills 1.8.0 → 1.9.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 +7 -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/engine/installer.js +47 -9
- package/src/engine/installer.test.js +61 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.9.0] - 2026-06-06
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- 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>`.
|
|
15
|
+
- 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.
|
|
16
|
+
|
|
10
17
|
## [1.8.0] - 2026-06-05
|
|
11
18
|
|
|
12
19
|
### 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)
|
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
|
+
})
|