happyskills 0.6.0 → 0.7.1

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,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.1] - 2026-03-06
11
+
12
+ ### Fixed
13
+ - Fix `uninstall` incorrectly orphaning all user-installed skills — `find_orphans` now treats `__root__` as a protected requester, so only skills that were installed solely as transitive dependencies are pruned
14
+
15
+ ## [0.7.0] - 2026-03-06
16
+
17
+ ### Added
18
+ - Add `--json --browser` combined flags to `login` command for single-command authentication — returns already-logged-in status or launches the browser flow and returns the result as JSON, eliminating the two-step check-then-login dance
19
+
20
+ ### Changed
21
+ - Change `login_device` expired-token handling from `process.exit()` to a thrown error so callers (including `--json` mode) can handle it gracefully
22
+
10
23
  ## [0.6.0] - 2026-03-06
11
24
 
12
25
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
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)",
@@ -22,7 +22,8 @@ Options:
22
22
  Examples:
23
23
  happyskills login
24
24
  happyskills login --browser
25
- happyskills login --password`
25
+ happyskills login --password
26
+ happyskills login --json --browser Check status or authenticate (for automation)`
26
27
 
27
28
  const prompt = (question) => new Promise((resolve) => {
28
29
  const rl = readline.createInterface({ input: process.stdin, output: process.stderr })
@@ -120,8 +121,33 @@ const run = (args) => catch_errors('Login failed', async () => {
120
121
  const email = payload?.email || 'unknown'
121
122
  const username = payload?.['cognito:username'] || payload?.sub || 'unknown'
122
123
  print_json({ data: { status: 'already_logged_in', username, email } })
124
+ return
125
+ }
126
+
127
+ // Not logged in — check if browser flow requested
128
+ if (!args.flags.browser) {
129
+ print_json({ error: { code: 'INTERACTIVE_REQUIRED', message: 'Login requires browser interaction. Run `happyskills login --json --browser` to authenticate.', exit_code: EXIT_CODES.ERROR } })
130
+ process.exit(EXIT_CODES.ERROR)
131
+ return
132
+ }
133
+
134
+ // --json --browser: run browser flow, return JSON result
135
+ const [errors] = await run_browser_flow()
136
+ if (errors) {
137
+ print_json({ error: { code: 'AUTH_FAILED', message: errors[0]?.message || 'Browser login failed', exit_code: EXIT_CODES.ERROR } })
138
+ process.exit(EXIT_CODES.ERROR)
139
+ return
140
+ }
141
+
142
+ // Browser flow succeeded — read saved token
143
+ const [, new_token] = await load_token()
144
+ if (new_token) {
145
+ const payload = decode_jwt_payload(new_token.id_token)
146
+ const email = payload?.email || 'unknown'
147
+ const username = payload?.['cognito:username'] || payload?.sub || 'unknown'
148
+ print_json({ data: { status: 'logged_in', username, email } })
123
149
  } else {
124
- print_json({ error: { code: 'INTERACTIVE_REQUIRED', message: 'Login requires browser interaction. Run `happyskills login --browser` manually.', exit_code: EXIT_CODES.ERROR } })
150
+ print_json({ error: { code: 'AUTH_FAILED', message: 'Login flow completed but token could not be loaded', exit_code: EXIT_CODES.ERROR } })
125
151
  process.exit(EXIT_CODES.ERROR)
126
152
  }
127
153
  return
@@ -45,7 +45,7 @@ const run_browser_flow = () => catch_errors('Browser login failed', async () =>
45
45
  const api_err = token_err.find(err => err.error_code)
46
46
  if (api_err?.error_code === 'EXPIRED_TOKEN') {
47
47
  spinner.fail('Authorization expired. Please run `happyskills login` again.')
48
- process.exit(EXIT_CODES.ERROR)
48
+ throw new Error('Authorization expired. Please run `happyskills login` again.')
49
49
  }
50
50
  // Transient error — keep polling
51
51
  continue
@@ -9,11 +9,8 @@ const find_orphans = (skills, removed_skill) => {
9
9
  const orphans = []
10
10
  for (const [name, data] of Object.entries(skills)) {
11
11
  if (name === removed_skill) continue
12
- if (!data.requested_by || data.requested_by.length === 0) {
13
- orphans.push(name)
14
- continue
15
- }
16
- const remaining = data.requested_by.filter(r => r !== removed_skill && skills[r])
12
+ const requested_by = data.requested_by || []
13
+ const remaining = requested_by.filter(r => r === '__root__' || (r !== removed_skill && skills[r]))
17
14
  if (remaining.length === 0) {
18
15
  orphans.push(name)
19
16
  }
@@ -87,12 +87,12 @@ describe('find_orphans', () => {
87
87
  assert.ok(result.includes('acme/auth'))
88
88
  })
89
89
 
90
- it('treats __root__ like any other requester (not in skills map)', () => {
90
+ it('protects skills installed by the user (requested_by includes __root__)', () => {
91
91
  const skills = {
92
92
  'acme/deploy': { requested_by: ['__root__'] }
93
93
  }
94
- // __root__ is not a key in skills, so deploy is considered orphaned
94
+ // __root__ marks a user-installed (root) package should never be orphaned
95
95
  const result = find_orphans(skills, 'acme/other')
96
- assert.ok(result.includes('acme/deploy'))
96
+ assert.ok(!result.includes('acme/deploy'))
97
97
  })
98
98
  })
@@ -340,6 +340,32 @@ describe('CLI — --json: success responses use { data } envelope', () => {
340
340
  fs.rmSync(tmp_xdg, { recursive: true, force: true })
341
341
  }
342
342
  })
343
+
344
+ it('login --json --browser with stored credentials returns already_logged_in', () => {
345
+ const tmp_xdg = make_tmp()
346
+ try {
347
+ const creds_dir = path.join(tmp_xdg, 'happyskills')
348
+ fs.mkdirSync(creds_dir, { recursive: true })
349
+ const payload = { email: 'test@example.com', 'cognito:username': 'testuser', sub: 'test-sub-123' }
350
+ const fake_jwt = `eyJhbGciOiJSUzI1NiJ9.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.fakesig`
351
+ fs.writeFileSync(path.join(creds_dir, 'credentials.json'), JSON.stringify({
352
+ id_token: fake_jwt,
353
+ access_token: 'fake-access',
354
+ refresh_token: 'fake-refresh',
355
+ expires_in: 3600,
356
+ stored_at: new Date().toISOString()
357
+ }, null, '\t'))
358
+ const { stdout, code } = run(['login', '--json', '--browser'], { XDG_CONFIG_HOME: tmp_xdg })
359
+ assert.strictEqual(code, 0)
360
+ const out = parse_json_output(stdout, 'login --json --browser with credentials')
361
+ assert.ok('data' in out)
362
+ assert.strictEqual(out.data.status, 'already_logged_in')
363
+ assert.strictEqual(out.data.username, 'testuser')
364
+ assert.strictEqual(out.data.email, 'test@example.com')
365
+ } finally {
366
+ fs.rmSync(tmp_xdg, { recursive: true, force: true })
367
+ }
368
+ })
343
369
  })
344
370
 
345
371
  // ─── --json flag: existing commands use { data } envelope ─────────────────────