happyskills 0.30.0 → 0.31.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 +19 -0
- package/package.json +1 -1
- package/src/agents/index.js +3 -1
- package/src/agents/linker.test.js +202 -0
- package/src/agents/status.js +42 -0
- package/src/agents/status.test.js +127 -0
- package/src/auth/token_store.js +44 -13
- package/src/auth/token_store.test.js +197 -1
- package/src/commands/disable.js +105 -0
- package/src/commands/enable.js +114 -0
- package/src/commands/list.js +24 -8
- package/src/constants.js +6 -2
- package/src/engine/installer.js +33 -8
- package/src/index.js +2 -0
- package/src/integration/cli.test.js +261 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.31.0] - 2026-04-06
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Add `enable` command (alias: `on`) to restore agent symlinks for previously disabled skills
|
|
14
|
+
- Add `disable` command (alias: `off`) to remove agent symlinks without uninstalling — physical files and lock entries are preserved
|
|
15
|
+
- Add Enabled column to `list` output showing whether each managed skill has active agent symlinks
|
|
16
|
+
- Add `enabled` boolean field to `list --json` output for managed skills
|
|
17
|
+
- Add `--agents` flag to `list` for controlling which agents are checked for enabled/disabled detection
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- Install, update, and refresh now respect disabled state — disabled skills are not re-linked after updates
|
|
21
|
+
|
|
22
|
+
## [0.30.1] - 2026-04-05
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- Fix frequent unnecessary re-login caused by transient refresh failures (network errors, Lambda/NeonDB cold starts) destroying the credentials file — now only clears credentials on permanent failures (Cognito token rejection), preserves the refresh token for transient errors so the next CLI invocation can retry
|
|
26
|
+
- Add diagnostic logging to token refresh failures (`print_warn` to stderr) with actual error reason, replacing silent failure that made auth issues impossible to diagnose
|
|
27
|
+
- Add single retry on transient refresh failure within the same CLI invocation
|
|
28
|
+
|
|
10
29
|
## [0.30.0] - 2026-04-03
|
|
11
30
|
|
|
12
31
|
### Added
|
package/package.json
CHANGED
package/src/agents/index.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
const { AGENTS, get_agent, get_all_agent_ids } = require('./registry')
|
|
2
2
|
const { detect_agents, resolve_agents } = require('./detector')
|
|
3
3
|
const { link_to_agents, unlink_from_agents, is_symlink } = require('./linker')
|
|
4
|
+
const { is_skill_enabled, get_skills_enabled_map } = require('./status')
|
|
4
5
|
|
|
5
6
|
module.exports = {
|
|
6
7
|
AGENTS, get_agent, get_all_agent_ids,
|
|
7
8
|
detect_agents, resolve_agents,
|
|
8
|
-
link_to_agents, unlink_from_agents, is_symlink
|
|
9
|
+
link_to_agents, unlink_from_agents, is_symlink,
|
|
10
|
+
is_skill_enabled, get_skills_enabled_map
|
|
9
11
|
}
|
|
@@ -0,0 +1,202 @@
|
|
|
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 { link_to_agents, unlink_from_agents, is_symlink } = require('./linker')
|
|
9
|
+
|
|
10
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-linker-test-'))
|
|
11
|
+
const cleanup = (dir) => fs.rmSync(dir, { recursive: true, force: true })
|
|
12
|
+
|
|
13
|
+
const make_agent = (id, skills_dir_rel) => ({
|
|
14
|
+
id,
|
|
15
|
+
display_name: id,
|
|
16
|
+
skills_dir: skills_dir_rel,
|
|
17
|
+
global_skills_dir: skills_dir_rel,
|
|
18
|
+
detect_paths: []
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('is_symlink', () => {
|
|
22
|
+
let tmp
|
|
23
|
+
beforeEach(() => { tmp = make_tmp() })
|
|
24
|
+
afterEach(() => { cleanup(tmp) })
|
|
25
|
+
|
|
26
|
+
it('returns true for a symlink', async () => {
|
|
27
|
+
const target = path.join(tmp, 'target')
|
|
28
|
+
const link = path.join(tmp, 'link')
|
|
29
|
+
fs.mkdirSync(target)
|
|
30
|
+
fs.symlinkSync(target, link, 'junction')
|
|
31
|
+
|
|
32
|
+
const [errors, result] = await is_symlink(link)
|
|
33
|
+
assert.equal(errors, null)
|
|
34
|
+
assert.equal(result, true)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('returns false for a regular directory', async () => {
|
|
38
|
+
const dir = path.join(tmp, 'regular')
|
|
39
|
+
fs.mkdirSync(dir)
|
|
40
|
+
|
|
41
|
+
const [errors, result] = await is_symlink(dir)
|
|
42
|
+
assert.equal(errors, null)
|
|
43
|
+
assert.equal(result, false)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('returns false for a non-existent path', async () => {
|
|
47
|
+
const [errors, result] = await is_symlink(path.join(tmp, 'nope'))
|
|
48
|
+
assert.equal(errors, null)
|
|
49
|
+
assert.equal(result, false)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('link_to_agents', () => {
|
|
54
|
+
let tmp
|
|
55
|
+
beforeEach(() => { tmp = make_tmp() })
|
|
56
|
+
afterEach(() => { cleanup(tmp) })
|
|
57
|
+
|
|
58
|
+
it('creates symlinks in agent directories', async () => {
|
|
59
|
+
const source = path.join(tmp, '.agents', 'skills', 'deploy-aws')
|
|
60
|
+
fs.mkdirSync(source, { recursive: true })
|
|
61
|
+
fs.writeFileSync(path.join(source, 'SKILL.md'), 'test')
|
|
62
|
+
|
|
63
|
+
const agents = [make_agent('claude', '.claude/skills')]
|
|
64
|
+
const [errors, results] = await link_to_agents(source, agents, { global: false, project_root: tmp, skill_name: 'deploy-aws' })
|
|
65
|
+
|
|
66
|
+
assert.equal(errors, null)
|
|
67
|
+
assert.equal(results.length, 1)
|
|
68
|
+
assert.equal(results[0].agent_id, 'claude')
|
|
69
|
+
assert.equal(results[0].method, 'symlink')
|
|
70
|
+
|
|
71
|
+
const link_path = path.join(tmp, '.claude', 'skills', 'deploy-aws')
|
|
72
|
+
const stat = fs.lstatSync(link_path)
|
|
73
|
+
assert.ok(stat.isSymbolicLink())
|
|
74
|
+
assert.equal(fs.readlinkSync(link_path), source)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('creates symlinks for multiple agents', async () => {
|
|
78
|
+
const source = path.join(tmp, '.agents', 'skills', 'deploy-aws')
|
|
79
|
+
fs.mkdirSync(source, { recursive: true })
|
|
80
|
+
|
|
81
|
+
const agents = [
|
|
82
|
+
make_agent('claude', '.claude/skills'),
|
|
83
|
+
make_agent('cursor', '.cursor/skills')
|
|
84
|
+
]
|
|
85
|
+
const [errors, results] = await link_to_agents(source, agents, { global: false, project_root: tmp, skill_name: 'deploy-aws' })
|
|
86
|
+
|
|
87
|
+
assert.equal(errors, null)
|
|
88
|
+
assert.equal(results.length, 2)
|
|
89
|
+
|
|
90
|
+
for (const agent of agents) {
|
|
91
|
+
const link_path = path.join(tmp, agent.skills_dir, 'deploy-aws')
|
|
92
|
+
assert.ok(fs.lstatSync(link_path).isSymbolicLink())
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('skips if symlink already points to the correct source', async () => {
|
|
97
|
+
const source = path.join(tmp, '.agents', 'skills', 'deploy-aws')
|
|
98
|
+
fs.mkdirSync(source, { recursive: true })
|
|
99
|
+
|
|
100
|
+
const agent_dir = path.join(tmp, '.claude', 'skills')
|
|
101
|
+
fs.mkdirSync(agent_dir, { recursive: true })
|
|
102
|
+
fs.symlinkSync(source, path.join(agent_dir, 'deploy-aws'), 'junction')
|
|
103
|
+
|
|
104
|
+
const agents = [make_agent('claude', '.claude/skills')]
|
|
105
|
+
const [errors, results] = await link_to_agents(source, agents, { global: false, project_root: tmp, skill_name: 'deploy-aws' })
|
|
106
|
+
|
|
107
|
+
assert.equal(errors, null)
|
|
108
|
+
assert.equal(results[0].skipped, true)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('replaces stale symlink pointing elsewhere', async () => {
|
|
112
|
+
const source = path.join(tmp, '.agents', 'skills', 'deploy-aws')
|
|
113
|
+
fs.mkdirSync(source, { recursive: true })
|
|
114
|
+
|
|
115
|
+
const old_source = path.join(tmp, 'old-location')
|
|
116
|
+
fs.mkdirSync(old_source, { recursive: true })
|
|
117
|
+
const agent_dir = path.join(tmp, '.claude', 'skills')
|
|
118
|
+
fs.mkdirSync(agent_dir, { recursive: true })
|
|
119
|
+
fs.symlinkSync(old_source, path.join(agent_dir, 'deploy-aws'), 'junction')
|
|
120
|
+
|
|
121
|
+
const agents = [make_agent('claude', '.claude/skills')]
|
|
122
|
+
const [errors, results] = await link_to_agents(source, agents, { global: false, project_root: tmp, skill_name: 'deploy-aws' })
|
|
123
|
+
|
|
124
|
+
assert.equal(errors, null)
|
|
125
|
+
assert.equal(results[0].method, 'symlink')
|
|
126
|
+
assert.equal(results[0].skipped, undefined)
|
|
127
|
+
|
|
128
|
+
const link_path = path.join(tmp, '.claude', 'skills', 'deploy-aws')
|
|
129
|
+
assert.equal(fs.readlinkSync(link_path), source)
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe('unlink_from_agents', () => {
|
|
134
|
+
let tmp
|
|
135
|
+
beforeEach(() => { tmp = make_tmp() })
|
|
136
|
+
afterEach(() => { cleanup(tmp) })
|
|
137
|
+
|
|
138
|
+
it('removes symlinks from agent directories', async () => {
|
|
139
|
+
const source = path.join(tmp, '.agents', 'skills', 'deploy-aws')
|
|
140
|
+
fs.mkdirSync(source, { recursive: true })
|
|
141
|
+
|
|
142
|
+
const agent_dir = path.join(tmp, '.claude', 'skills')
|
|
143
|
+
fs.mkdirSync(agent_dir, { recursive: true })
|
|
144
|
+
fs.symlinkSync(source, path.join(agent_dir, 'deploy-aws'), 'junction')
|
|
145
|
+
|
|
146
|
+
const agents = [make_agent('claude', '.claude/skills')]
|
|
147
|
+
const [errors, results] = await unlink_from_agents('deploy-aws', agents, { global: false, project_root: tmp })
|
|
148
|
+
|
|
149
|
+
assert.equal(errors, null)
|
|
150
|
+
assert.equal(results[0].removed, true)
|
|
151
|
+
assert.ok(!fs.existsSync(path.join(agent_dir, 'deploy-aws')))
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('reports removed=false when symlink does not exist', async () => {
|
|
155
|
+
const agents = [make_agent('claude', '.claude/skills')]
|
|
156
|
+
const [errors, results] = await unlink_from_agents('deploy-aws', agents, { global: false, project_root: tmp })
|
|
157
|
+
|
|
158
|
+
assert.equal(errors, null)
|
|
159
|
+
assert.equal(results[0].removed, false)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('removes from multiple agents', async () => {
|
|
163
|
+
const source = path.join(tmp, '.agents', 'skills', 'deploy-aws')
|
|
164
|
+
fs.mkdirSync(source, { recursive: true })
|
|
165
|
+
|
|
166
|
+
const agents = [
|
|
167
|
+
make_agent('claude', '.claude/skills'),
|
|
168
|
+
make_agent('cursor', '.cursor/skills')
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
for (const agent of agents) {
|
|
172
|
+
const agent_dir = path.join(tmp, agent.skills_dir)
|
|
173
|
+
fs.mkdirSync(agent_dir, { recursive: true })
|
|
174
|
+
fs.symlinkSync(source, path.join(agent_dir, 'deploy-aws'), 'junction')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const [errors, results] = await unlink_from_agents('deploy-aws', agents, { global: false, project_root: tmp })
|
|
178
|
+
|
|
179
|
+
assert.equal(errors, null)
|
|
180
|
+
assert.ok(results.every(r => r.removed === true))
|
|
181
|
+
for (const agent of agents) {
|
|
182
|
+
assert.ok(!fs.existsSync(path.join(tmp, agent.skills_dir, 'deploy-aws')))
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('does not remove the canonical source directory', async () => {
|
|
187
|
+
const source = path.join(tmp, '.agents', 'skills', 'deploy-aws')
|
|
188
|
+
fs.mkdirSync(source, { recursive: true })
|
|
189
|
+
fs.writeFileSync(path.join(source, 'SKILL.md'), 'test')
|
|
190
|
+
|
|
191
|
+
const agent_dir = path.join(tmp, '.claude', 'skills')
|
|
192
|
+
fs.mkdirSync(agent_dir, { recursive: true })
|
|
193
|
+
fs.symlinkSync(source, path.join(agent_dir, 'deploy-aws'), 'junction')
|
|
194
|
+
|
|
195
|
+
const agents = [make_agent('claude', '.claude/skills')]
|
|
196
|
+
await unlink_from_agents('deploy-aws', agents, { global: false, project_root: tmp })
|
|
197
|
+
|
|
198
|
+
// Canonical source must still exist
|
|
199
|
+
assert.ok(fs.existsSync(source))
|
|
200
|
+
assert.ok(fs.existsSync(path.join(source, 'SKILL.md')))
|
|
201
|
+
})
|
|
202
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
2
|
+
const { is_symlink } = require('./linker')
|
|
3
|
+
const { agent_skill_install_dir } = require('../config/paths')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Check whether a skill is enabled (has at least one symlink in an agent folder).
|
|
7
|
+
*
|
|
8
|
+
* @param {string} skill_name — short name (e.g. "deploy-aws")
|
|
9
|
+
* @param {Agent[]} agents — detected agent objects
|
|
10
|
+
* @param {boolean} is_global
|
|
11
|
+
* @param {string} project_root
|
|
12
|
+
* @returns {Promise} [errors, boolean]
|
|
13
|
+
*/
|
|
14
|
+
const is_skill_enabled = (skill_name, agents, is_global, project_root) => catch_errors('Skill enabled check failed', async () => {
|
|
15
|
+
for (const agent of agents) {
|
|
16
|
+
const target = agent_skill_install_dir(agent, is_global, project_root, skill_name)
|
|
17
|
+
const [, link] = await is_symlink(target)
|
|
18
|
+
if (link) return true
|
|
19
|
+
}
|
|
20
|
+
return false
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Batch-check enabled status for multiple skills.
|
|
25
|
+
*
|
|
26
|
+
* @param {string[]} skill_names — array of short names
|
|
27
|
+
* @param {Agent[]} agents — detected agent objects
|
|
28
|
+
* @param {boolean} is_global
|
|
29
|
+
* @param {string} project_root
|
|
30
|
+
* @returns {Promise} [errors, Map<string, boolean>] — skill_name → enabled
|
|
31
|
+
*/
|
|
32
|
+
const get_skills_enabled_map = (skill_names, agents, is_global, project_root) => catch_errors('Skill enabled map failed', async () => {
|
|
33
|
+
const map = new Map()
|
|
34
|
+
const checks = skill_names.map(async (name) => {
|
|
35
|
+
const [, enabled] = await is_skill_enabled(name, agents, is_global, project_root)
|
|
36
|
+
map.set(name, enabled === true)
|
|
37
|
+
})
|
|
38
|
+
await Promise.all(checks)
|
|
39
|
+
return map
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
module.exports = { is_skill_enabled, get_skills_enabled_map }
|
|
@@ -0,0 +1,127 @@
|
|
|
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 { is_skill_enabled, get_skills_enabled_map } = require('./status')
|
|
9
|
+
|
|
10
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-status-test-'))
|
|
11
|
+
const cleanup = (dir) => fs.rmSync(dir, { recursive: true, force: true })
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Helper: create a fake agent definition for testing.
|
|
15
|
+
*/
|
|
16
|
+
const make_agent = (id, skills_dir_rel) => ({
|
|
17
|
+
id,
|
|
18
|
+
display_name: id,
|
|
19
|
+
skills_dir: skills_dir_rel,
|
|
20
|
+
global_skills_dir: skills_dir_rel,
|
|
21
|
+
detect_paths: []
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('is_skill_enabled', () => {
|
|
25
|
+
let tmp
|
|
26
|
+
beforeEach(() => { tmp = make_tmp() })
|
|
27
|
+
afterEach(() => { cleanup(tmp) })
|
|
28
|
+
|
|
29
|
+
it('returns true when a symlink exists in an agent folder', async () => {
|
|
30
|
+
const canonical = path.join(tmp, '.agents', 'skills', 'deploy-aws')
|
|
31
|
+
fs.mkdirSync(canonical, { recursive: true })
|
|
32
|
+
fs.writeFileSync(path.join(canonical, 'SKILL.md'), '---\nname: deploy-aws\n---\n')
|
|
33
|
+
|
|
34
|
+
const agent_dir = path.join(tmp, '.claude', 'skills')
|
|
35
|
+
fs.mkdirSync(agent_dir, { recursive: true })
|
|
36
|
+
fs.symlinkSync(canonical, path.join(agent_dir, 'deploy-aws'), 'junction')
|
|
37
|
+
|
|
38
|
+
const agent = make_agent('claude', '.claude/skills')
|
|
39
|
+
const [errors, enabled] = await is_skill_enabled('deploy-aws', [agent], false, tmp)
|
|
40
|
+
assert.equal(errors, null)
|
|
41
|
+
assert.equal(enabled, true)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('returns false when no symlink exists in any agent folder', async () => {
|
|
45
|
+
const canonical = path.join(tmp, '.agents', 'skills', 'deploy-aws')
|
|
46
|
+
fs.mkdirSync(canonical, { recursive: true })
|
|
47
|
+
|
|
48
|
+
const agent = make_agent('claude', '.claude/skills')
|
|
49
|
+
const [errors, enabled] = await is_skill_enabled('deploy-aws', [agent], false, tmp)
|
|
50
|
+
assert.equal(errors, null)
|
|
51
|
+
assert.equal(enabled, false)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('returns false when agent folder does not exist', async () => {
|
|
55
|
+
const agent = make_agent('claude', '.claude/skills')
|
|
56
|
+
const [errors, enabled] = await is_skill_enabled('deploy-aws', [agent], false, tmp)
|
|
57
|
+
assert.equal(errors, null)
|
|
58
|
+
assert.equal(enabled, false)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('returns true if any one of multiple agents has the symlink', async () => {
|
|
62
|
+
const canonical = path.join(tmp, '.agents', 'skills', 'deploy-aws')
|
|
63
|
+
fs.mkdirSync(canonical, { recursive: true })
|
|
64
|
+
|
|
65
|
+
// Only cursor has the symlink, not claude
|
|
66
|
+
const cursor_dir = path.join(tmp, '.cursor', 'skills')
|
|
67
|
+
fs.mkdirSync(cursor_dir, { recursive: true })
|
|
68
|
+
fs.symlinkSync(canonical, path.join(cursor_dir, 'deploy-aws'), 'junction')
|
|
69
|
+
|
|
70
|
+
const agents = [
|
|
71
|
+
make_agent('claude', '.claude/skills'),
|
|
72
|
+
make_agent('cursor', '.cursor/skills')
|
|
73
|
+
]
|
|
74
|
+
const [errors, enabled] = await is_skill_enabled('deploy-aws', agents, false, tmp)
|
|
75
|
+
assert.equal(errors, null)
|
|
76
|
+
assert.equal(enabled, true)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('returns false with an empty agents array', async () => {
|
|
80
|
+
const [errors, enabled] = await is_skill_enabled('deploy-aws', [], false, tmp)
|
|
81
|
+
assert.equal(errors, null)
|
|
82
|
+
assert.equal(enabled, false)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('get_skills_enabled_map', () => {
|
|
87
|
+
let tmp
|
|
88
|
+
beforeEach(() => { tmp = make_tmp() })
|
|
89
|
+
afterEach(() => { cleanup(tmp) })
|
|
90
|
+
|
|
91
|
+
it('returns a map with correct enabled/disabled status for multiple skills', async () => {
|
|
92
|
+
const agents_skills = path.join(tmp, '.agents', 'skills')
|
|
93
|
+
const skill_a = path.join(agents_skills, 'skill-a')
|
|
94
|
+
const skill_b = path.join(agents_skills, 'skill-b')
|
|
95
|
+
fs.mkdirSync(skill_a, { recursive: true })
|
|
96
|
+
fs.mkdirSync(skill_b, { recursive: true })
|
|
97
|
+
|
|
98
|
+
// Only skill-a is symlinked (enabled)
|
|
99
|
+
const claude_dir = path.join(tmp, '.claude', 'skills')
|
|
100
|
+
fs.mkdirSync(claude_dir, { recursive: true })
|
|
101
|
+
fs.symlinkSync(skill_a, path.join(claude_dir, 'skill-a'), 'junction')
|
|
102
|
+
|
|
103
|
+
const agents = [make_agent('claude', '.claude/skills')]
|
|
104
|
+
const [errors, map] = await get_skills_enabled_map(['skill-a', 'skill-b'], agents, false, tmp)
|
|
105
|
+
|
|
106
|
+
assert.equal(errors, null)
|
|
107
|
+
assert.equal(map.get('skill-a'), true)
|
|
108
|
+
assert.equal(map.get('skill-b'), false)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('returns all false when no symlinks exist', async () => {
|
|
112
|
+
const agents = [make_agent('claude', '.claude/skills')]
|
|
113
|
+
const [errors, map] = await get_skills_enabled_map(['foo', 'bar'], agents, false, tmp)
|
|
114
|
+
|
|
115
|
+
assert.equal(errors, null)
|
|
116
|
+
assert.equal(map.get('foo'), false)
|
|
117
|
+
assert.equal(map.get('bar'), false)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('handles empty skill list', async () => {
|
|
121
|
+
const agents = [make_agent('claude', '.claude/skills')]
|
|
122
|
+
const [errors, map] = await get_skills_enabled_map([], agents, false, tmp)
|
|
123
|
+
|
|
124
|
+
assert.equal(errors, null)
|
|
125
|
+
assert.equal(map.size, 0)
|
|
126
|
+
})
|
|
127
|
+
})
|
package/src/auth/token_store.js
CHANGED
|
@@ -3,18 +3,43 @@ const path = require('path')
|
|
|
3
3
|
const { error: { catch_errors } } = require('puffy-core')
|
|
4
4
|
const { credentials_path, config_dir } = require('../config/paths')
|
|
5
5
|
|
|
6
|
+
const _is_permanent_failure = (errors) => {
|
|
7
|
+
if (!errors) return false
|
|
8
|
+
const { AuthError } = require('../utils/errors')
|
|
9
|
+
return errors.some(e => e instanceof AuthError)
|
|
10
|
+
}
|
|
11
|
+
|
|
6
12
|
const _try_refresh = async (refresh_token) => {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
const max_attempts = 2
|
|
14
|
+
let permanent = false
|
|
15
|
+
for (let attempt = 1; attempt <= max_attempts; attempt++) {
|
|
16
|
+
try {
|
|
17
|
+
const { refresh } = require('../api/auth')
|
|
18
|
+
const [errors, data] = await refresh(refresh_token)
|
|
19
|
+
if (errors || !data) {
|
|
20
|
+
const reason = errors
|
|
21
|
+
? errors.map(e => e.message || String(e)).join('; ')
|
|
22
|
+
: 'empty response from server'
|
|
23
|
+
const { print_warn } = require('../ui/output')
|
|
24
|
+
print_warn(`Token refresh failed (attempt ${attempt}/${max_attempts}): ${reason}`)
|
|
25
|
+
permanent = _is_permanent_failure(errors)
|
|
26
|
+
if (permanent || attempt >= max_attempts) return { ok: false, permanent }
|
|
27
|
+
continue
|
|
28
|
+
}
|
|
29
|
+
const merged = { ...data, refresh_token }
|
|
30
|
+
const [save_err] = await save_token(merged)
|
|
31
|
+
if (save_err) return { ok: false, permanent: false }
|
|
32
|
+
return { ok: true, data: { ...merged, stored_at: new Date().toISOString() } }
|
|
33
|
+
} catch (err) {
|
|
34
|
+
const { print_warn } = require('../ui/output')
|
|
35
|
+
print_warn(`Token refresh exception (attempt ${attempt}/${max_attempts}): ${err.message || String(err)}`)
|
|
36
|
+
const { AuthError } = require('../utils/errors')
|
|
37
|
+
permanent = err instanceof AuthError
|
|
38
|
+
if (permanent || attempt >= max_attempts) return { ok: false, permanent }
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
17
41
|
}
|
|
42
|
+
return { ok: false, permanent: false }
|
|
18
43
|
}
|
|
19
44
|
|
|
20
45
|
const save_token = (token_data) => catch_errors('Failed to save token', async () => {
|
|
@@ -55,10 +80,16 @@ const load_token = () => catch_errors('Failed to load token', async () => {
|
|
|
55
80
|
const elapsed_sec = (now - stored) / 1000
|
|
56
81
|
if (elapsed_sec >= data.expires_in) {
|
|
57
82
|
if (data.refresh_token) {
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
83
|
+
const result = await _try_refresh(data.refresh_token)
|
|
84
|
+
if (result.ok) return result.data
|
|
85
|
+
// Permanent failure (Cognito rejected the token) → clear the file.
|
|
86
|
+
// Transient failure (network, cold start) → preserve the file so
|
|
87
|
+
// the next invocation can retry with the still-valid refresh token.
|
|
88
|
+
if (result.permanent) await clear_token()
|
|
89
|
+
} else {
|
|
90
|
+
// No refresh token — nothing to preserve, clean up the expired file.
|
|
91
|
+
await clear_token()
|
|
60
92
|
}
|
|
61
|
-
await clear_token()
|
|
62
93
|
return null
|
|
63
94
|
}
|
|
64
95
|
}
|