happyskills 0.1.0 → 0.2.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +2 -1
  3. package/bin/happyskills.js +1 -1
  4. package/package.json +18 -3
  5. package/src/api/auth.js +13 -7
  6. package/src/api/push.js +63 -0
  7. package/src/api/repos.js +6 -6
  8. package/src/api/upload.js +48 -0
  9. package/src/api/workspaces.js +1 -1
  10. package/src/auth/token_store.js +18 -0
  11. package/src/auth/token_store.test.js +202 -0
  12. package/src/commands/bump.js +104 -0
  13. package/src/commands/check.js +15 -4
  14. package/src/commands/convert.js +189 -0
  15. package/src/commands/fork.js +16 -2
  16. package/src/commands/init.js +16 -5
  17. package/src/commands/install.js +58 -14
  18. package/src/commands/list.js +29 -8
  19. package/src/commands/login.js +68 -12
  20. package/src/commands/login_device.js +69 -0
  21. package/src/commands/logout.js +8 -1
  22. package/src/commands/publish.js +54 -60
  23. package/src/commands/search.js +10 -1
  24. package/src/commands/uninstall.js +14 -3
  25. package/src/commands/update.js +25 -7
  26. package/src/commands/whoami.js +1 -5
  27. package/src/config/paths.js +27 -2
  28. package/src/config/paths.test.js +3 -3
  29. package/src/constants.js +2 -0
  30. package/src/engine/installer.js +99 -21
  31. package/src/engine/uninstaller.js +2 -3
  32. package/src/index.js +47 -4
  33. package/src/index.test.js +68 -1
  34. package/src/integration/cli.test.js +383 -0
  35. package/src/lock/reader.test.js +128 -0
  36. package/src/manifest/reader.test.js +87 -0
  37. package/src/manifest/writer.test.js +73 -0
  38. package/src/state.js +6 -0
  39. package/src/state.test.js +44 -0
  40. package/src/ui/colors.js +7 -1
  41. package/src/ui/colors.test.js +110 -0
  42. package/src/ui/output.js +65 -5
  43. package/src/ui/output.test.js +259 -0
  44. package/src/utils/errors.js +52 -0
  45. package/src/utils/file_collector.js +49 -0
  46. package/src/utils/logger.js +89 -0
  47. package/src/utils/resolve_skill.js +33 -0
  48. package/src/utils/skill_scanner.js +56 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-03-04
11
+
12
+ ### Added
13
+ - Add `bump` command to increment a skill's version (`patch`, `minor`, `major`, or explicit semver) and update `skills-lock.json` accordingly
14
+ - Add `convert` command to turn an external Claude Code skill (with `SKILL.md` but no registry presence) into a managed HappySkills package — reads frontmatter, generates `skill.json`, publishes, and moves the skill to a namespaced directory
15
+ - Add universal `--json` flag across all 14 commands for structured output (`{ data }` / `{ error }`) enabling LLM and agent automation
16
+ - Add `login --browser` and `login --password` flags to skip the interactive flow selector
17
+ - Add device authorization (browser) flow for `login` — opens a browser tab and polls until the user approves; supports MFA
18
+ - Add `@version` inline install syntax (e.g., `happyskills install acme/skill@1.2.0`)
19
+ - Add `@latest` special version for `install` to force the newest published version, bypassing the lock skip check
20
+ - Add `keywords` field to skill scaffolding in `init` and `convert`
21
+ - Add S3 pre-signed upload path for large skills (5 MB – 10 MB); small skills (< 5 MB) continue to use the direct JSON upload
22
+ - Add display of external Claude Code skills in `list` output — unregistered skills with a `SKILL.md` are shown with source `external`
23
+ - Add token auto-refresh: sessions are transparently renewed using the Cognito `refresh_token` when the access token expires
24
+ - Add error log files written to `~/.config/happyskills/logs/` on non-usage errors, with the log path printed in the terminal
25
+
26
+ ### Changed
27
+ - Change skill install directory from nested `owner/name` to flat `name` (e.g., `.claude/skills/deploy-aws/` instead of `.claude/skills/acme/deploy-aws/`)
28
+ - Change `install` without arguments to fall back to `skills-lock.json` when no `skill.json` is present in the current directory
29
+
30
+ ### Fixed
31
+ - Fix duplicate skill entries appearing in `list` output
32
+
10
33
  ## [0.1.0] - 2026-02-28
11
34
 
12
35
  ### Added
package/README.md CHANGED
@@ -39,6 +39,7 @@ happyskills publish
39
39
  | `check [owner/skill]` | Check for available updates |
40
40
  | `update [owner/skill]` | Upgrade to latest compatible versions |
41
41
  | `publish` | Push the current skill to the registry |
42
+ | `convert <skill-name>` | Convert an external Claude Code skill into a managed HappySkills package |
42
43
  | `fork <owner/skill>` | Fork a skill to your workspace |
43
44
  | `login` | Authenticate with the registry |
44
45
  | `logout` | Clear stored credentials |
@@ -63,7 +64,7 @@ happyskills publish
63
64
  | `--version` | Show CLI version |
64
65
  | `-g`, `--global` | Use global scope (`~/.claude/skills/`) |
65
66
  | `-y`, `--yes` | Skip confirmation prompts |
66
- | `--json` | Output as JSON (supported by `list`, `search`, `check`, `whoami`) |
67
+ | `--json` | Output as JSON (all commands) |
67
68
 
68
69
  ## License
69
70
 
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- try { require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') }) } catch {}
3
+ try { require('dotenv').config({ path: require('path').join(__dirname, '..', '.env'), quiet: true }) } catch {}
4
4
  const { run } = require('../src/index')
5
5
  run(process.argv.slice(2))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.1.0",
3
+ "version": "0.2.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)",
@@ -17,11 +17,26 @@
17
17
  "bugs": {
18
18
  "url": "https://github.com/cloudlesslabs/skillsbang/issues"
19
19
  },
20
- "keywords": ["cli", "skills", "package-manager", "ai", "claude", "agent", "dependency-resolution"],
21
- "files": ["bin/", "src/", "LICENSE", "CHANGELOG.md"],
20
+ "keywords": [
21
+ "cli",
22
+ "skills",
23
+ "package-manager",
24
+ "ai",
25
+ "claude",
26
+ "agent",
27
+ "dependency-resolution"
28
+ ],
29
+ "files": [
30
+ "bin/",
31
+ "src/",
32
+ "LICENSE",
33
+ "CHANGELOG.md"
34
+ ],
22
35
  "scripts": {
23
36
  "dev": "node bin/happyskills.js",
24
37
  "test": "node --test src/**/*.test.js",
38
+ "test:unit": "node --test $(find src -name '*.test.js' ! -path '*/integration/*')",
39
+ "test:integration": "node --test src/integration/cli.test.js",
25
40
  "release": "./scripts/release.sh"
26
41
  },
27
42
  "engines": {
package/src/api/auth.js CHANGED
@@ -3,38 +3,44 @@ const client = require('./client')
3
3
 
4
4
  const login = (email, password) => catch_errors('Login failed', async () => {
5
5
  const [errors, data] = await client.post('/auth/login', { email, password }, { auth: false })
6
- if (errors) throw errors[0]
6
+ if (errors) throw errors[errors.length - 1]
7
7
  return data
8
8
  })
9
9
 
10
10
  const signup = (email, password) => catch_errors('Signup failed', async () => {
11
11
  const [errors, data] = await client.post('/auth/signup', { email, password }, { auth: false })
12
- if (errors) throw errors[0]
12
+ if (errors) throw errors[errors.length - 1]
13
13
  return data
14
14
  })
15
15
 
16
16
  const confirm = (email, code) => catch_errors('Confirmation failed', async () => {
17
17
  const [errors, data] = await client.post('/auth/confirm', { email, code }, { auth: false })
18
- if (errors) throw errors[0]
18
+ if (errors) throw errors[errors.length - 1]
19
19
  return data
20
20
  })
21
21
 
22
22
  const resend_code = (email) => catch_errors('Resend code failed', async () => {
23
23
  const [errors, data] = await client.post('/auth/resend-code', { email }, { auth: false })
24
- if (errors) throw errors[0]
24
+ if (errors) throw errors[errors.length - 1]
25
25
  return data
26
26
  })
27
27
 
28
28
  const device_start = () => catch_errors('Device auth start failed', async () => {
29
29
  const [errors, data] = await client.post('/auth/device/start', {}, { auth: false })
30
- if (errors) throw errors[0]
30
+ if (errors) throw errors[errors.length - 1]
31
31
  return data
32
32
  })
33
33
 
34
34
  const device_token = (device_code) => catch_errors('Device auth token failed', async () => {
35
35
  const [errors, data] = await client.post('/auth/device/token', { device_code }, { auth: false })
36
- if (errors) throw errors[0]
36
+ if (errors) throw errors[errors.length - 1]
37
37
  return data
38
38
  })
39
39
 
40
- module.exports = { login, signup, confirm, resend_code, device_start, device_token }
40
+ const refresh = (refresh_token) => catch_errors('Token refresh failed', async () => {
41
+ const [errors, data] = await client.post('/auth/refresh', { refresh_token }, { auth: false })
42
+ if (errors) throw errors[errors.length - 1]
43
+ return data
44
+ })
45
+
46
+ module.exports = { login, signup, confirm, resend_code, device_start, device_token, refresh }
@@ -0,0 +1,63 @@
1
+ const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
2
+ const repos_api = require('./repos')
3
+ const { initiate_upload, complete_upload, upload_files_parallel } = require('./upload')
4
+
5
+ const DIRECT_PUSH_THRESHOLD = 5 * 1024 * 1024 // 5MB
6
+
7
+ const estimate_payload_size = (files) => {
8
+ let size = 0
9
+ for (const file of files) {
10
+ size += file.content.length + file.path.length + 100 // base64 content + path + JSON overhead
11
+ }
12
+ return size
13
+ }
14
+
15
+ const smart_push = (owner, repo, { version, message, files }, on_progress) =>
16
+ catch_errors('Smart push failed', async () => {
17
+ const payload_size = estimate_payload_size(files)
18
+
19
+ if (payload_size < DIRECT_PUSH_THRESHOLD) {
20
+ // Small payload — use direct push
21
+ const [err, data] = await repos_api.push(owner, repo, { version, message, files })
22
+ if (err) throw e('Direct push failed', err)
23
+ return data
24
+ }
25
+
26
+ // Large payload — use presigned upload flow
27
+ const file_meta = files.map(f => ({ path: f.path, sha: f.sha, size: f.size }))
28
+
29
+ // Step 1: Initiate
30
+ const [init_err, init_data] = await initiate_upload(owner, repo, {
31
+ version, message, files: file_meta
32
+ })
33
+ if (init_err) throw e('Upload initiation failed', init_err)
34
+
35
+ const { upload_id, presigned_urls } = init_data
36
+
37
+ // Step 2: Upload files to S3 (deduplicated by SHA)
38
+ const seen_shas = new Set()
39
+ const files_to_upload = []
40
+ for (const file of files) {
41
+ if (seen_shas.has(file.sha)) continue
42
+ seen_shas.add(file.sha)
43
+ const url = presigned_urls[file.sha]
44
+ if (!url) throw new Error(`No presigned URL for file ${file.path} (sha: ${file.sha})`)
45
+ files_to_upload.push({
46
+ buffer: Buffer.from(file.content, 'base64'),
47
+ url
48
+ })
49
+ }
50
+
51
+ const [upload_err] = await upload_files_parallel(files_to_upload, on_progress)
52
+ if (upload_err) throw e('File uploads failed', upload_err)
53
+
54
+ // Step 3: Complete
55
+ const [complete_err, complete_data] = await complete_upload(owner, repo, {
56
+ upload_id, version, message, files: file_meta
57
+ })
58
+ if (complete_err) throw e('Upload completion failed', complete_err)
59
+
60
+ return complete_data
61
+ })
62
+
63
+ module.exports = { smart_push }
package/src/api/repos.js CHANGED
@@ -6,38 +6,38 @@ const search = (query, options = {}) => catch_errors('Search failed', async () =
6
6
  if (options.limit) params.set('limit', options.limit)
7
7
  if (options.offset) params.set('offset', options.offset)
8
8
  const [errors, data] = await client.get(`/repos/search?${params}`, { auth: false })
9
- if (errors) throw errors[0]
9
+ if (errors) throw errors[errors.length - 1]
10
10
  return data
11
11
  })
12
12
 
13
13
  const resolve_dependencies = (skill, version, installed = {}) => catch_errors('Dependency resolution failed', async () => {
14
14
  const [errors, data] = await client.post('/repos:resolve-dependencies', { skill, version, installed })
15
- if (errors) throw errors[0]
15
+ if (errors) throw errors[errors.length - 1]
16
16
  return data
17
17
  })
18
18
 
19
19
  const clone = (owner, repo, ref) => catch_errors(`Clone ${owner}/${repo} failed`, async () => {
20
20
  const params = ref ? `?ref=${encodeURIComponent(ref)}` : ''
21
21
  const [errors, data] = await client.get(`/repos/${owner}/${repo}/clone${params}`)
22
- if (errors) throw errors[0]
22
+ if (errors) throw errors[errors.length - 1]
23
23
  return data
24
24
  })
25
25
 
26
26
  const push = (owner, repo, body) => catch_errors(`Push to ${owner}/${repo} failed`, async () => {
27
27
  const [errors, data] = await client.post(`/repos/${owner}/${repo}/push`, body)
28
- if (errors) throw errors[0]
28
+ if (errors) throw errors[errors.length - 1]
29
29
  return data
30
30
  })
31
31
 
32
32
  const get_refs = (owner, repo) => catch_errors(`Get refs for ${owner}/${repo} failed`, async () => {
33
33
  const [errors, data] = await client.get(`/repos/${owner}/${repo}/refs`)
34
- if (errors) throw errors[0]
34
+ if (errors) throw errors[errors.length - 1]
35
35
  return data
36
36
  })
37
37
 
38
38
  const get_repo = (owner, repo) => catch_errors(`Get repo ${owner}/${repo} failed`, async () => {
39
39
  const [errors, data] = await client.get(`/repos/${owner}/${repo}`, { auth: false })
40
- if (errors) throw errors[0]
40
+ if (errors) throw errors[errors.length - 1]
41
41
  return data
42
42
  })
43
43
 
@@ -0,0 +1,48 @@
1
+ const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
2
+ const client = require('./client')
3
+
4
+ const CONCURRENT_UPLOADS = 5
5
+
6
+ const initiate_upload = (owner, repo, body) => catch_errors('Initiate upload failed', async () => {
7
+ const [errors, data] = await client.post(`/repos/${owner}/${repo}/push/initiate`, body)
8
+ if (errors) throw errors[errors.length - 1]
9
+ return data
10
+ })
11
+
12
+ const complete_upload = (owner, repo, body) => catch_errors('Complete upload failed', async () => {
13
+ const [errors, data] = await client.post(`/repos/${owner}/${repo}/push/complete`, body)
14
+ if (errors) throw errors[errors.length - 1]
15
+ return data
16
+ })
17
+
18
+ const upload_file_to_s3 = (presigned_url, buffer) => catch_errors('S3 upload failed', async () => {
19
+ const res = await fetch(presigned_url, {
20
+ method: 'PUT',
21
+ body: buffer,
22
+ headers: { 'Content-Length': buffer.length.toString() }
23
+ })
24
+ if (!res.ok) {
25
+ throw new Error(`S3 PUT failed with status ${res.status}`)
26
+ }
27
+ })
28
+
29
+ const upload_files_parallel = (files_with_urls, on_progress) => catch_errors('Parallel upload failed', async () => {
30
+ let completed = 0
31
+ const total = files_with_urls.length
32
+
33
+ const run_batch = async (batch) => {
34
+ await Promise.all(batch.map(async ({ buffer, url }) => {
35
+ const [err] = await upload_file_to_s3(url, buffer)
36
+ if (err) throw e('File upload failed', err)
37
+ completed++
38
+ if (on_progress) on_progress(completed, total)
39
+ }))
40
+ }
41
+
42
+ for (let i = 0; i < files_with_urls.length; i += CONCURRENT_UPLOADS) {
43
+ const batch = files_with_urls.slice(i, i + CONCURRENT_UPLOADS)
44
+ await run_batch(batch)
45
+ }
46
+ })
47
+
48
+ module.exports = { initiate_upload, complete_upload, upload_file_to_s3, upload_files_parallel }
@@ -3,7 +3,7 @@ const client = require('./client')
3
3
 
4
4
  const list_workspaces = () => catch_errors('List workspaces failed', async () => {
5
5
  const [errors, data] = await client.get('/workspaces')
6
- if (errors) throw errors[0]
6
+ if (errors) throw errors[errors.length - 1]
7
7
  return data
8
8
  })
9
9
 
@@ -3,6 +3,20 @@ 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 _try_refresh = async (refresh_token) => {
7
+ try {
8
+ const { refresh } = require('../api/auth')
9
+ const [errors, data] = await refresh(refresh_token)
10
+ if (errors || !data) return null
11
+ const merged = { ...data, refresh_token }
12
+ const [save_err] = await save_token(merged)
13
+ if (save_err) return null
14
+ return { ...merged, stored_at: new Date().toISOString() }
15
+ } catch {
16
+ return null
17
+ }
18
+ }
19
+
6
20
  const save_token = (token_data) => catch_errors('Failed to save token', async () => {
7
21
  const creds_path = credentials_path()
8
22
  const dir = config_dir()
@@ -40,6 +54,10 @@ const load_token = () => catch_errors('Failed to load token', async () => {
40
54
  const now = Date.now()
41
55
  const elapsed_sec = (now - stored) / 1000
42
56
  if (elapsed_sec >= data.expires_in) {
57
+ if (data.refresh_token) {
58
+ const refreshed = await _try_refresh(data.refresh_token)
59
+ if (refreshed) return refreshed
60
+ }
43
61
  await clear_token()
44
62
  return null
45
63
  }
@@ -0,0 +1,202 @@
1
+ 'use strict'
2
+ const { describe, it, before, after, beforeEach } = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+ const os = require('os')
5
+ const path = require('path')
6
+ const fs = require('fs')
7
+
8
+ // ─────────────────────────────────────────────────────────────────────────────
9
+ // token_store reads XDG_CONFIG_HOME lazily (at call time) via config/paths.js,
10
+ // so we can control the credentials location by setting the env var before each
11
+ // test. The module itself can be required once at the top.
12
+ // ─────────────────────────────────────────────────────────────────────────────
13
+
14
+ const { save_token, load_token, clear_token, require_token } = require('./token_store')
15
+ const { AuthError } = require('../utils/errors')
16
+
17
+ // ─── helpers ──────────────────────────────────────────────────────────────────
18
+
19
+ const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-token-test-'))
20
+ const cleanup = (dir) => fs.rmSync(dir, { recursive: true, force: true })
21
+
22
+ const make_token = (overrides = {}) => ({
23
+ id_token: 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMTIzIn0.sig',
24
+ access_token: 'access-token-value',
25
+ refresh_token: 'refresh-token-value',
26
+ expires_in: 3600,
27
+ ...overrides
28
+ })
29
+
30
+ // Each test uses its own tmp directory to ensure full isolation.
31
+ // We set XDG_CONFIG_HOME to point at it so credentials_path() resolves there.
32
+ const with_tmp = async (fn) => {
33
+ const dir = make_tmp()
34
+ const orig = process.env.XDG_CONFIG_HOME
35
+ process.env.XDG_CONFIG_HOME = dir
36
+ try {
37
+ await fn(dir)
38
+ } finally {
39
+ if (orig !== undefined) process.env.XDG_CONFIG_HOME = orig
40
+ else delete process.env.XDG_CONFIG_HOME
41
+ cleanup(dir)
42
+ }
43
+ }
44
+
45
+ const creds_file = (dir) => path.join(dir, 'happyskills', 'credentials.json')
46
+
47
+ // ─── save_token ───────────────────────────────────────────────────────────────
48
+
49
+ describe('save_token', () => {
50
+ it('creates the credentials file with the token data', async () => {
51
+ await with_tmp(async (dir) => {
52
+ const token = make_token()
53
+ const [err] = await save_token(token)
54
+ assert.strictEqual(err, null)
55
+ assert.ok(fs.existsSync(creds_file(dir)), 'credentials.json should exist')
56
+
57
+ const content = JSON.parse(fs.readFileSync(creds_file(dir), 'utf-8'))
58
+ assert.strictEqual(content.id_token, token.id_token)
59
+ assert.strictEqual(content.access_token, token.access_token)
60
+ })
61
+ })
62
+
63
+ it('injects a stored_at timestamp into the saved file', async () => {
64
+ await with_tmp(async () => {
65
+ const before = Date.now()
66
+ const [err] = await save_token(make_token())
67
+ assert.strictEqual(err, null)
68
+ const [, loaded] = await load_token()
69
+ assert.ok(loaded.stored_at, 'stored_at should be present')
70
+ const stored_ms = new Date(loaded.stored_at).getTime()
71
+ assert.ok(stored_ms >= before, 'stored_at should be >= the time before save')
72
+ assert.ok(stored_ms <= Date.now(), 'stored_at should be <= now')
73
+ })
74
+ })
75
+
76
+ it('creates the config directory automatically when it does not exist', async () => {
77
+ await with_tmp(async (dir) => {
78
+ // The happyskills/ subdirectory does not exist yet
79
+ assert.ok(!fs.existsSync(path.join(dir, 'happyskills')))
80
+ const [err] = await save_token(make_token())
81
+ assert.strictEqual(err, null)
82
+ assert.ok(fs.existsSync(creds_file(dir)))
83
+ })
84
+ })
85
+
86
+ it('writes the file with 0o600 permissions (owner read/write only)', async () => {
87
+ await with_tmp(async (dir) => {
88
+ await save_token(make_token())
89
+ const stat = fs.statSync(creds_file(dir))
90
+ assert.strictEqual(stat.mode & 0o777, 0o600)
91
+ })
92
+ })
93
+ })
94
+
95
+ // ─── load_token ───────────────────────────────────────────────────────────────
96
+
97
+ describe('load_token', () => {
98
+ it('returns [null, null] when no credentials file exists', async () => {
99
+ await with_tmp(async () => {
100
+ const [err, token] = await load_token()
101
+ assert.strictEqual(err, null)
102
+ assert.strictEqual(token, null)
103
+ })
104
+ })
105
+
106
+ it('returns the saved token when it has not expired', async () => {
107
+ await with_tmp(async () => {
108
+ const token = make_token({ expires_in: 3600 })
109
+ await save_token(token)
110
+
111
+ const [err, loaded] = await load_token()
112
+ assert.strictEqual(err, null)
113
+ assert.ok(loaded, 'token should be returned')
114
+ assert.strictEqual(loaded.id_token, token.id_token)
115
+ assert.strictEqual(loaded.refresh_token, token.refresh_token)
116
+ })
117
+ })
118
+
119
+ it('returns null for an expired token that has no refresh_token', async () => {
120
+ await with_tmp(async (dir) => {
121
+ // Write an already-expired token directly, bypassing save_token's
122
+ // stored_at injection so we can backdate it ourselves.
123
+ const expired = {
124
+ id_token: 'expired-id-token',
125
+ access_token: 'expired-access-token',
126
+ expires_in: 1, // 1 second
127
+ stored_at: new Date(Date.now() - 5000).toISOString() // stored 5s ago
128
+ // no refresh_token
129
+ }
130
+ fs.mkdirSync(path.join(dir, 'happyskills'), { recursive: true })
131
+ fs.writeFileSync(creds_file(dir), JSON.stringify(expired), { mode: 0o600 })
132
+
133
+ const [err, token] = await load_token()
134
+ assert.strictEqual(err, null)
135
+ assert.strictEqual(token, null, 'expired token without refresh_token should return null')
136
+ })
137
+ })
138
+
139
+ it('deletes the credentials file when a token has expired and no refresh_token', async () => {
140
+ await with_tmp(async (dir) => {
141
+ const expired = {
142
+ id_token: 'expired-id-token',
143
+ expires_in: 1,
144
+ stored_at: new Date(Date.now() - 5000).toISOString()
145
+ }
146
+ fs.mkdirSync(path.join(dir, 'happyskills'), { recursive: true })
147
+ fs.writeFileSync(creds_file(dir), JSON.stringify(expired), { mode: 0o600 })
148
+
149
+ await load_token() // should clear the file
150
+ assert.ok(!fs.existsSync(creds_file(dir)), 'expired credentials file should be deleted')
151
+ })
152
+ })
153
+ })
154
+
155
+ // ─── clear_token ──────────────────────────────────────────────────────────────
156
+
157
+ describe('clear_token', () => {
158
+ it('deletes the credentials file', async () => {
159
+ await with_tmp(async (dir) => {
160
+ await save_token(make_token())
161
+ assert.ok(fs.existsSync(creds_file(dir)))
162
+
163
+ const [err] = await clear_token()
164
+ assert.strictEqual(err, null)
165
+ assert.ok(!fs.existsSync(creds_file(dir)), 'credentials.json should be deleted')
166
+ })
167
+ })
168
+
169
+ it('succeeds silently when no credentials file exists', async () => {
170
+ await with_tmp(async () => {
171
+ const [err] = await clear_token()
172
+ assert.strictEqual(err, null)
173
+ })
174
+ })
175
+ })
176
+
177
+ // ─── require_token ────────────────────────────────────────────────────────────
178
+
179
+ describe('require_token', () => {
180
+ it('returns the token when one is saved and valid', async () => {
181
+ await with_tmp(async () => {
182
+ const token = make_token()
183
+ await save_token(token)
184
+
185
+ const result = await require_token()
186
+ assert.ok(result, 'should return token data')
187
+ assert.strictEqual(result.id_token, token.id_token)
188
+ })
189
+ })
190
+
191
+ it('throws AuthError when no token is stored', async () => {
192
+ await with_tmp(async () => {
193
+ await assert.rejects(
194
+ () => require_token(),
195
+ (err) => {
196
+ assert.ok(err instanceof AuthError, `expected AuthError, got ${err?.constructor?.name}`)
197
+ return true
198
+ }
199
+ )
200
+ })
201
+ })
202
+ })
@@ -0,0 +1,104 @@
1
+ const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
2
+ const { read_manifest } = require('../manifest/reader')
3
+ const { write_manifest } = require('../manifest/writer')
4
+ const { validate_manifest } = require('../manifest/validator')
5
+ const { inc, valid } = require('../utils/semver')
6
+ const { resolve_skill_dir } = require('../utils/resolve_skill')
7
+ const { find_project_root } = require('../config/paths')
8
+ const { read_lock, get_all_locked_skills } = require('../lock/reader')
9
+ const { write_lock, update_lock_skills } = require('../lock/writer')
10
+ const { hash_directory } = require('../lock/integrity')
11
+ const { print_help, print_success, print_json } = require('../ui/output')
12
+ const { exit_with_error, UsageError } = require('../utils/errors')
13
+ const { EXIT_CODES } = require('../constants')
14
+
15
+ const BUMP_TYPES = ['patch', 'minor', 'major']
16
+
17
+ const HELP_TEXT = `Usage: happyskills bump <type|version> <skill-name>
18
+
19
+ Bump the version in a skill's skill.json.
20
+
21
+ Arguments:
22
+ type Bump type: patch, minor, or major
23
+ version Explicit semver version (e.g., 2.0.0)
24
+ skill-name Name of the installed skill
25
+
26
+ Examples:
27
+ happyskills bump patch my-skill
28
+ happyskills bump minor deploy-aws
29
+ happyskills bump major my-skill
30
+ happyskills bump 2.0.0 my-skill`
31
+
32
+ const run = (args) => catch_errors('Bump failed', async () => {
33
+ if (args.flags._show_help) {
34
+ print_help(HELP_TEXT)
35
+ return process.exit(EXIT_CODES.SUCCESS)
36
+ }
37
+
38
+ const input = args._[0]
39
+ const skill_name = args._[1]
40
+
41
+ if (!input || !skill_name) {
42
+ throw new UsageError('Usage: happyskills bump <patch|minor|major|version> <skill-name>')
43
+ }
44
+
45
+ const [dir_err, dir] = await resolve_skill_dir(skill_name)
46
+ if (dir_err) throw e(`Skill "${skill_name}" not found`, dir_err)
47
+
48
+ const [manifest_err, manifest] = await read_manifest(dir)
49
+ if (manifest_err) throw e('No skill.json found', manifest_err)
50
+
51
+ const old_version = manifest.version
52
+
53
+ if (BUMP_TYPES.includes(input)) {
54
+ const new_version = inc(old_version, input)
55
+ if (!new_version) throw new Error(`Failed to bump version from ${old_version}`)
56
+ manifest.version = new_version
57
+ } else {
58
+ const cleaned = valid(input)
59
+ if (!cleaned) throw new UsageError(`"${input}" is not a valid semver version.`)
60
+ manifest.version = cleaned
61
+ }
62
+
63
+ const validation = validate_manifest(manifest)
64
+ if (!validation.valid) {
65
+ throw new Error(`Invalid manifest after bump: ${validation.errors.join(', ')}`)
66
+ }
67
+
68
+ const [write_err] = await write_manifest(dir, manifest)
69
+ if (write_err) throw e('Failed to update skill.json', write_err)
70
+
71
+ const project_root = find_project_root()
72
+ const [lock_err, lock_data] = await read_lock(project_root)
73
+ if (!lock_err && lock_data) {
74
+ const all_skills = get_all_locked_skills(lock_data)
75
+ const suffix = `/${skill_name}`
76
+ const lock_key = Object.keys(all_skills).find(k => k.endsWith(suffix))
77
+ if (lock_key && all_skills[lock_key]) {
78
+ const [hash_err, integrity] = await hash_directory(dir)
79
+ const updated_entry = {
80
+ ...all_skills[lock_key],
81
+ version: manifest.version,
82
+ ref: `refs/tags/v${manifest.version}`
83
+ }
84
+ if (!hash_err && integrity) updated_entry.integrity = integrity
85
+ const updated_skills = update_lock_skills(lock_data, { [lock_key]: updated_entry })
86
+ await write_lock(project_root, updated_skills)
87
+ }
88
+ }
89
+
90
+ if (args.flags.json) {
91
+ const bump_type = BUMP_TYPES.includes(input) ? input : 'explicit'
92
+ print_json({ data: {
93
+ skill: skill_name,
94
+ old_version,
95
+ new_version: manifest.version,
96
+ bump_type
97
+ } })
98
+ return
99
+ }
100
+
101
+ print_success(`${skill_name}: ${old_version} → ${manifest.version}`)
102
+ }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
103
+
104
+ module.exports = { run }