happyskills 1.2.2 → 1.4.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,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.4.0] - 2026-06-03
11
+
12
+ ### Added
13
+
14
+ - Add `star <owner/name>` and `unstar <owner/name>` commands to curate your favorites. Both require auth and are idempotent (starring an already-starred skill, or unstarring one you haven't starred, succeeds without error).
15
+ - Add `--favorites` flag to `search` — query only the skills you've starred (`scope=favorites`). Routes through the smart dispatcher; a bare `search --favorites` (no query) browses all your starred skills. Requires auth; cannot be combined with `--exact`.
16
+
17
+ ## [1.3.0] - 2026-06-03
18
+
19
+ ### Added
20
+
21
+ - `login` now returns the full identity payload — `username`, `email`, and `workspaces` — on every success path (`logged_in` and `already_logged_in`), so an agent no longer needs a follow-up `whoami` call after authenticating. Both commands build the block from one shared assembler, so their shapes stay identical. The workspace lookup is best-effort: a failed `GET /workspaces` degrades to an empty list plus `workspace_error` and never fails the login.
22
+ - Detect circular dependencies in the dependency graph. `validate` and `publish` run a `dep-circular` rule that resolves each dependency's published subtree, grafts the local skill's own edges, and DFS-searches for cycles — so it also catches a cycle created by the edge being published. A detected cycle is a hard error that blocks publish. Registry-scoped checks run only when authenticated.
23
+
24
+ ### Changed
25
+
26
+ - Text-mode `login` now short-circuits when a valid session already exists — it prints the identity block instead of launching a fresh auth flow, mirroring the `--json` path. Run `happyskills logout` to force a fresh login.
27
+ - `install` is now loop-safe: a cycle among already-published skills never causes infinite recursion. Every skill in the tree is still installed, and the CLI prints a circular-dependency warning (also surfaced in `--json` under `warnings`).
28
+
10
29
  ## [1.2.2] - 2026-06-02
11
30
 
12
31
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "1.2.2",
3
+ "version": "1.4.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)",
package/src/api/repos.js CHANGED
@@ -105,6 +105,21 @@ const patch_repo = (owner, name, fields) => catch_errors(`Failed to update ${own
105
105
  return data
106
106
  })
107
107
 
108
+ // Star / unstar a repo. Both endpoints are idempotent server-side (starring an
109
+ // already-starred repo, or unstarring an unstarred one, succeeds). The user's
110
+ // own stars power the `favorites` search scope.
111
+ const star = (owner, name) => catch_errors(`Failed to star ${owner}/${name}`, async () => {
112
+ const [errors, data] = await client.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/star`)
113
+ if (errors) throw errors[errors.length - 1]
114
+ return data
115
+ })
116
+
117
+ const unstar = (owner, name) => catch_errors(`Failed to unstar ${owner}/${name}`, async () => {
118
+ const [errors, data] = await client.del(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/star`)
119
+ if (errors) throw errors[errors.length - 1]
120
+ return data
121
+ })
122
+
108
123
  const compare = (owner, repo, base_commit) => catch_errors(`Compare ${owner}/${repo} failed`, async () => {
109
124
  const [errors, data] = await client.post(`/repos/${owner}/${repo}/compare`, { base_commit })
110
125
  if (errors) throw errors[errors.length - 1]
@@ -195,4 +210,4 @@ const semantic_search = (query, options = {}) => catch_errors('Semantic search f
195
210
  return data
196
211
  })
197
212
 
198
- module.exports = { search, dispatch_search, semantic_search, resolve_dependencies, clone, push, get_refs, get_repo, check_updates, del_repo, patch_repo, compare, get_blob, get_tree }
213
+ module.exports = { search, dispatch_search, semantic_search, resolve_dependencies, clone, push, get_refs, get_repo, check_updates, del_repo, patch_repo, star, unstar, compare, get_blob, get_tree }
@@ -169,3 +169,51 @@ describe('repos.get_blob — content integrity verification', () => {
169
169
  assert.strictEqual(data.size, content.length)
170
170
  })
171
171
  })
172
+
173
+ // Capturing client stub: records (method, path) for each call so star/unstar
174
+ // path construction can be asserted. Mirrors stub_client above.
175
+ const stub_client_capture = () => {
176
+ const calls = []
177
+ const record = (method) => async (path) => { calls.push([method, path]); return [null, { message: 'ok' }] }
178
+ const client_path = require.resolve('./client')
179
+ require.cache[client_path] = {
180
+ id: client_path,
181
+ filename: client_path,
182
+ loaded: true,
183
+ exports: {
184
+ get: record('get'),
185
+ post: record('post'),
186
+ put: record('put'),
187
+ patch: record('patch'),
188
+ del: record('del'),
189
+ request: record('request'),
190
+ get_base_url: () => 'http://test'
191
+ }
192
+ }
193
+ delete require.cache[require.resolve('./repos')]
194
+ return { repos: require('./repos'), calls }
195
+ }
196
+
197
+ describe('repos.star / repos.unstar — endpoint + path', () => {
198
+ afterEach(restore)
199
+
200
+ it('star() POSTs to /repos/<owner>/<name>/star', async () => {
201
+ const { repos, calls } = stub_client_capture()
202
+ const [err] = await repos.star('acme', 'deploy-aws')
203
+ assert.strictEqual(err, null)
204
+ assert.deepStrictEqual(calls[0], ['post', '/repos/acme/deploy-aws/star'])
205
+ })
206
+
207
+ it('unstar() DELETEs /repos/<owner>/<name>/star', async () => {
208
+ const { repos, calls } = stub_client_capture()
209
+ const [err] = await repos.unstar('acme', 'deploy-aws')
210
+ assert.strictEqual(err, null)
211
+ assert.deepStrictEqual(calls[0], ['del', '/repos/acme/deploy-aws/star'])
212
+ })
213
+
214
+ it('URL-encodes owner and name in the star path', async () => {
215
+ const { repos, calls } = stub_client_capture()
216
+ await repos.star('acme corp', 'deploy/aws')
217
+ assert.strictEqual(calls[0][1], `/repos/${encodeURIComponent('acme corp')}/${encodeURIComponent('deploy/aws')}/star`)
218
+ })
219
+ })
@@ -0,0 +1,58 @@
1
+ const { error: { catch_errors } } = require('puffy-core')
2
+ const workspaces_api = require('../api/workspaces')
3
+ const { print_label, print_warn, print_hint } = require('../ui/output')
4
+
5
+ // Decode a JWT payload for display only — no signature verification. Returns
6
+ // null on any malformed input.
7
+ const decode_jwt_payload = (token) => {
8
+ try {
9
+ const parts = (token || '').split('.')
10
+ if (parts.length !== 3) return null
11
+ const payload = Buffer.from(parts[1], 'base64url').toString('utf-8')
12
+ return JSON.parse(payload)
13
+ } catch {
14
+ return null
15
+ }
16
+ }
17
+
18
+ // Assemble the canonical identity payload shared by `whoami` and `login`, so
19
+ // the two commands can never disagree on shape or content. Identity fields
20
+ // (username/email) come straight off the in-hand id_token; workspaces are
21
+ // fetched best-effort — a failed lookup degrades to an empty list plus a
22
+ // `workspace_error` string and NEVER fails the caller. A network hiccup must
23
+ // not turn a valid session into an error: the user is logged in regardless.
24
+ const resolve_identity = (token_data) => catch_errors('Resolve identity failed', async () => {
25
+ const payload = decode_jwt_payload(token_data?.id_token)
26
+ const email = payload?.email || 'unknown'
27
+ const username = payload?.['cognito:username'] || payload?.sub || 'unknown'
28
+
29
+ const [ws_err, workspaces] = await workspaces_api.list_workspaces()
30
+ const identity = { username, email, workspaces: ws_err ? [] : (workspaces || []) }
31
+ if (ws_err) identity.workspace_error = ws_err.map(er => er.message).join(': ')
32
+ return identity
33
+ })
34
+
35
+ // Render an identity payload to the human (text-mode) surface. Both `whoami`
36
+ // and `login` use this so the warm output stays content-equal with `--json`:
37
+ // the format differs, the information does not.
38
+ const render_identity_text = (identity) => {
39
+ print_label('Username', identity.username)
40
+ print_label('Email', identity.email)
41
+
42
+ if (identity.workspace_error) {
43
+ console.log()
44
+ print_warn(`Failed to list workspaces: ${identity.workspace_error}`)
45
+ print_hint('Check your authentication with happyskills login')
46
+ } else if (identity.workspaces && identity.workspaces.length > 0) {
47
+ console.log()
48
+ print_label('Workspaces', '')
49
+ for (const ws of identity.workspaces) {
50
+ const type_label = ws.is_owner
51
+ ? ws.type === 'personal' ? ' (personal)' : ' (organization)'
52
+ : ''
53
+ console.log(` ${ws.slug}${type_label}`)
54
+ }
55
+ }
56
+ }
57
+
58
+ module.exports = { decode_jwt_payload, resolve_identity, render_identity_text }
@@ -4,8 +4,9 @@ const readline = require('readline')
4
4
  const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
5
5
  const auth_api = require('../api/auth')
6
6
  const { save_token, load_token } = require('../auth/token_store')
7
+ const { resolve_identity, render_identity_text } = require('../auth/identity')
7
8
  const { create_spinner } = require('../ui/spinner')
8
- const { print_help, print_success, print_info, print_hint, print_json, code } = require('../ui/output')
9
+ const { print_help, print_success, print_info, print_json } = require('../ui/output')
9
10
  const { exit_with_error } = require('../utils/errors')
10
11
  const { EXIT_CODES, CLI_VERSION } = require('../constants')
11
12
  const { run_browser_flow } = require('./login_device')
@@ -136,17 +137,6 @@ const run_password_flow = () => catch_errors('Password login failed', async () =
136
137
  await handle_post_login_telemetry()
137
138
  })
138
139
 
139
- const decode_jwt_payload = (token) => {
140
- try {
141
- const parts = token.split('.')
142
- if (parts.length !== 3) return null
143
- const payload = Buffer.from(parts[1], 'base64url').toString('utf-8')
144
- return JSON.parse(payload)
145
- } catch {
146
- return null
147
- }
148
- }
149
-
150
140
  const run = (args) => catch_errors('Login failed', async () => {
151
141
  if (args.flags._show_help) {
152
142
  print_help(HELP_TEXT)
@@ -156,10 +146,8 @@ const run = (args) => catch_errors('Login failed', async () => {
156
146
  if (args.flags.json) {
157
147
  const [, token_data] = await load_token()
158
148
  if (token_data) {
159
- const payload = decode_jwt_payload(token_data.id_token)
160
- const email = payload?.email || 'unknown'
161
- const username = payload?.['cognito:username'] || payload?.sub || 'unknown'
162
- print_json({ data: { status: 'already_logged_in', username, email } })
149
+ const [, identity] = await resolve_identity(token_data)
150
+ print_json({ data: { status: 'already_logged_in', ...identity } })
163
151
  return
164
152
  }
165
153
 
@@ -181,10 +169,8 @@ const run = (args) => catch_errors('Login failed', async () => {
181
169
  // Browser flow succeeded — read saved token
182
170
  const [, new_token] = await load_token()
183
171
  if (new_token) {
184
- const payload = decode_jwt_payload(new_token.id_token)
185
- const email = payload?.email || 'unknown'
186
- const username = payload?.['cognito:username'] || payload?.sub || 'unknown'
187
- print_json({ data: { status: 'logged_in', username, email } })
172
+ const [, identity] = await resolve_identity(new_token)
173
+ print_json({ data: { status: 'logged_in', ...identity } })
188
174
  } else {
189
175
  print_json({ error: { code: 'AUTH_FAILED', message: 'Login flow completed but token could not be loaded', exit_code: EXIT_CODES.ERROR } })
190
176
  process.exit(EXIT_CODES.ERROR)
@@ -192,6 +178,20 @@ const run = (args) => catch_errors('Login failed', async () => {
192
178
  return
193
179
  }
194
180
 
181
+ // Text mode short-circuits on an existing session, mirroring the --json
182
+ // path (which returns `already_logged_in` before consulting any flow flag).
183
+ // "Already logged in" must mean the same thing in text as in JSON — same
184
+ // information, different encoding. To force a fresh login, run `logout`
185
+ // first. The workspace lookup stays best-effort and never fails the check.
186
+ const [, existing_token] = await load_token()
187
+ if (existing_token) {
188
+ const [, identity] = await resolve_identity(existing_token)
189
+ print_info('You are already logged in.')
190
+ console.log()
191
+ render_identity_text(identity)
192
+ return
193
+ }
194
+
195
195
  let use_password = args.flags.password === true
196
196
  let use_browser = args.flags.browser === true
197
197
 
@@ -214,8 +214,16 @@ const run = (args) => catch_errors('Login failed', async () => {
214
214
  await handle_post_login_telemetry()
215
215
  }
216
216
 
217
- console.log()
218
- print_hint(`Verify your identity: ${code('happyskills whoami')}`)
217
+ // Show the same identity block `whoami` shows, so login fully answers
218
+ // "who am I" on the human surface too — no redundant follow-up call. This
219
+ // keeps text mode content-equal with `--json` (same information, different
220
+ // encoding). Best-effort: a workspace lookup failure never fails the login.
221
+ const [, token_data] = await load_token()
222
+ if (token_data) {
223
+ const [, identity] = await resolve_identity(token_data)
224
+ console.log()
225
+ render_identity_text(identity)
226
+ }
219
227
  }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
220
228
 
221
229
  const schema = {
@@ -234,9 +242,10 @@ const schema = {
234
242
  },
235
243
  output: {
236
244
  data_shape: {
237
- status: 'string',
238
- username: 'string',
239
- email: 'string',
245
+ status: 'string',
246
+ username: 'string',
247
+ email: 'string',
248
+ workspaces: 'array',
240
249
  },
241
250
  },
242
251
  errors: [
@@ -16,12 +16,13 @@ fuzzy slug, and "workspace/skill" form goes to fuzzy scoped (both parts are
16
16
  typo-tolerant). Use --exact to force keyword-only FTS instead.
17
17
 
18
18
  Arguments:
19
- query Search term (optional with --mine, --personal, or --workspace)
19
+ query Search term (optional with --mine, --personal, --workspace, or --favorites)
20
20
 
21
21
  Options:
22
22
  -w, --workspace <slug> Search within specific workspace(s) (comma-separated)
23
23
  --mine Search across all your workspaces
24
24
  --personal Search only your personal workspace
25
+ --favorites Search only skills you've starred (requires auth)
25
26
  --tags <tags> Filter by tags (comma-separated)
26
27
  --type <type> Filter by type (skill, kit)
27
28
  --exact Force keyword-only FTS matching (skip smart routing)
@@ -42,6 +43,8 @@ Examples:
42
43
  happyskills search deploy-aws --limit 10 # → fuzzy slug (typo-tolerant)
43
44
  happyskills search letta-ai/remotion --limit 5 # → fuzzy scoped
44
45
  happyskills search --mine --limit 20
46
+ happyskills search --favorites --limit 20 # → your starred skills
47
+ happyskills search deploy --favorites --limit 10 # → favorites matching "deploy"
45
48
  happyskills search deploy --workspace acme --limit 50
46
49
  happyskills search --type kit --limit 10
47
50
  happyskills search deploy --exact --limit 10 # → keyword FTS only`
@@ -380,15 +383,19 @@ const run = (args) => catch_errors('Search failed', async () => {
380
383
  }
381
384
 
382
385
  const query = args._.join(' ') || null
383
- const { mine, personal, workspace, tags, type } = args.flags
386
+ const { mine, personal, workspace, tags, type, favorites } = args.flags
384
387
  const is_exact = !!args.flags.exact
385
388
  const with_rerank = !!args.flags['with-rerank']
386
- // --smart / -S accepted for backward compat (now the default when a query is provided)
387
- const use_smart = query && !is_exact
388
- const has_scope_flag = mine || personal || workspace
389
+ // --smart / -S accepted for backward compat (now the default when a query is provided).
390
+ // --favorites also forces the smart (POST /repos:search) path even with no query:
391
+ // favorites is a dispatcher-only scope, so it must never fall through to the
392
+ // legacy keyword GET, which doesn't understand it. A bare `--favorites` browses
393
+ // the user's starred skills (list mode).
394
+ const use_smart = (query || favorites) && !is_exact
395
+ const has_scope_flag = mine || personal || workspace || favorites
389
396
 
390
397
  if (!query && !has_scope_flag && !tags && !type) {
391
- throw new UsageError('Please provide a search query or use --mine, --personal, or --workspace.')
398
+ throw new UsageError('Please provide a search query or use --mine, --personal, --workspace, or --favorites.')
392
399
  }
393
400
 
394
401
  if (!args.flags.limit) {
@@ -405,9 +412,15 @@ const run = (args) => catch_errors('Search failed', async () => {
405
412
  throw new UsageError('--with-rerank cannot be combined with --exact. The rerank protocol applies only to semantic-mode dispatches.')
406
413
  }
407
414
 
408
- const scope = mine ? 'mine' : personal ? 'personal' : undefined
415
+ // Favorites is a dispatcher-only scope (POST /repos:search); --exact forces the
416
+ // legacy keyword endpoint, which doesn't support it. Refuse the combination.
417
+ if (favorites && is_exact) {
418
+ throw new UsageError('--favorites cannot be combined with --exact. Favorites search uses the smart dispatcher, not keyword-only FTS.')
419
+ }
420
+
421
+ const scope = favorites ? 'favorites' : mine ? 'mine' : personal ? 'personal' : undefined
409
422
 
410
- if (mine || personal) {
423
+ if (mine || personal || favorites) {
411
424
  const [, token_data] = await load_token()
412
425
  if (!token_data) {
413
426
  throw new AuthError('Authentication required. Run `happyskills login` first.')
@@ -9,7 +9,7 @@
9
9
  // short names (`clarify`, `present_to_user` alone) no longer apply where the
10
10
  // new enum supersedes them.
11
11
 
12
- const { describe, it } = require('node:test')
12
+ const { describe, it, afterEach } = require('node:test')
13
13
  const assert = require('node:assert/strict')
14
14
  const { build_search_next_step } = require('./search')
15
15
  const {
@@ -146,3 +146,62 @@ describe('build_search_next_step — envelope shape', () => {
146
146
  assert.strictEqual(opts[3].refined_query_hint, null)
147
147
  })
148
148
  })
149
+
150
+ // --- search --favorites wiring (spec 260601-02 § 4.3) ---------------------
151
+ // Stub the API client + token store so run() exercises the flag-routing logic
152
+ // without a network call. favorites is a dispatcher-only scope, so the key
153
+ // assertions are: it sets scope='favorites' on dispatch_search (POST) and never
154
+ // falls through to the keyword GET, and a bare --favorites (no query) is allowed.
155
+
156
+ const stub_search_deps = (capture) => {
157
+ const repos_path = require.resolve('../api/repos')
158
+ require.cache[repos_path] = {
159
+ id: repos_path, filename: repos_path, loaded: true,
160
+ exports: {
161
+ dispatch_search: async (query, opts) => {
162
+ capture.dispatch = { query, opts }
163
+ return [null, { data: [], mode: 'list', match_notice: null }]
164
+ },
165
+ search: async (query, opts) => { capture.keyword = { query, opts }; return [null, []] },
166
+ },
167
+ }
168
+ const token_path = require.resolve('../auth/token_store')
169
+ require.cache[token_path] = {
170
+ id: token_path, filename: token_path, loaded: true,
171
+ exports: {
172
+ load_token: async () => [null, { token: 'test-token' }],
173
+ require_token: async () => ({ token: 'test-token' }),
174
+ save_token: async () => {}, clear_token: async () => {},
175
+ },
176
+ }
177
+ delete require.cache[require.resolve('./search')]
178
+ return require('./search')
179
+ }
180
+
181
+ const restore_search_deps = () => {
182
+ delete require.cache[require.resolve('../api/repos')]
183
+ delete require.cache[require.resolve('../auth/token_store')]
184
+ delete require.cache[require.resolve('./search')]
185
+ }
186
+
187
+ describe('search --favorites — scope wiring', () => {
188
+ afterEach(restore_search_deps)
189
+
190
+ it('bare --favorites (no query) is allowed and dispatches scope=favorites via POST, not keyword GET', async () => {
191
+ const capture = {}
192
+ const search = stub_search_deps(capture)
193
+ await search.run({ _: [], flags: { favorites: true, limit: '20', json: true } })
194
+ assert.ok(capture.dispatch, 'dispatch_search (POST) must be called for favorites')
195
+ assert.strictEqual(capture.keyword, undefined, 'keyword GET must NOT be used for favorites')
196
+ assert.strictEqual(capture.dispatch.opts.scope, 'favorites')
197
+ assert.strictEqual(capture.dispatch.query, null, 'bare --favorites sends a null query (list mode)')
198
+ })
199
+
200
+ it('--favorites with a query keeps scope=favorites and forwards the query', async () => {
201
+ const capture = {}
202
+ const search = stub_search_deps(capture)
203
+ await search.run({ _: ['deploy'], flags: { favorites: true, limit: '10', json: true } })
204
+ assert.strictEqual(capture.dispatch.opts.scope, 'favorites')
205
+ assert.strictEqual(capture.dispatch.query, 'deploy')
206
+ })
207
+ })
@@ -0,0 +1,82 @@
1
+ const { error: { catch_errors } } = require('puffy-core')
2
+ const { star } = require('../api/repos')
3
+ const { require_token } = require('../auth/token_store')
4
+ const { print_help, print_json, print_success } = require('../ui/output')
5
+ const { exit_with_error, UsageError } = require('../utils/errors')
6
+ const { EXIT_CODES } = require('../constants')
7
+
8
+ const HELP_TEXT = `Usage: happyskills star <owner/name> [options]
9
+
10
+ Star a skill. Your starred skills are your favorites — search them with
11
+ \`happyskills search --favorites\`. Idempotent: starring an already-starred
12
+ skill succeeds without error.
13
+
14
+ Arguments:
15
+ owner/name Skill to star (e.g., acme/deploy-aws)
16
+
17
+ Options:
18
+ --json Output as JSON
19
+
20
+ Examples:
21
+ happyskills star acme/deploy-aws
22
+ happyskills star acme/deploy-aws --json`
23
+
24
+ const run = (args) => catch_errors('Star failed', async () => {
25
+ if (args.flags._show_help) {
26
+ print_help(HELP_TEXT)
27
+ return process.exit(EXIT_CODES.SUCCESS)
28
+ }
29
+
30
+ const skill = args._[0]
31
+ if (!skill) {
32
+ throw new UsageError('Please specify a skill to star (e.g., happyskills star acme/deploy-aws).')
33
+ }
34
+ if (!skill.includes('/')) {
35
+ throw new UsageError('Skill must be in owner/name format (e.g., acme/deploy-aws).')
36
+ }
37
+
38
+ await require_token()
39
+
40
+ const [owner, name] = skill.split('/')
41
+ const [errors, result] = await star(owner, name)
42
+ if (errors) throw errors[errors.length - 1]
43
+
44
+ if (args.flags.json) {
45
+ print_json({ data: { starred: true, skill, ...(result || {}) } })
46
+ return
47
+ }
48
+
49
+ print_success(`Starred "${skill}". Browse your favorites with: happyskills search --favorites`)
50
+ }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
51
+
52
+ const schema = {
53
+ name: 'star',
54
+ audience: 'consumer',
55
+ purpose: 'Star a skill. Starred skills power the favorites search scope. Idempotent.',
56
+ mutation: true,
57
+ interactive_in_text_mode: false,
58
+ input: {
59
+ positional: [
60
+ { name: 'skill', required: true, type: 'string', pattern: '<owner>/<name>' },
61
+ ],
62
+ flags: [
63
+ { name: 'json', type: 'boolean', default: false },
64
+ ],
65
+ },
66
+ output: {
67
+ data_shape: {
68
+ starred: 'boolean',
69
+ skill: 'string',
70
+ },
71
+ },
72
+ errors: [
73
+ { code: 'USAGE_ERROR', next_step: { kind: 'routing', action: 'discover_schema' } },
74
+ { code: 'AUTH_REQUIRED', next_step: { kind: 'recovery', action: 'login' } },
75
+ ],
76
+ examples: [
77
+ 'happyskills star acme/deploy-aws',
78
+ 'happyskills star acme/deploy-aws --json',
79
+ ],
80
+ }
81
+
82
+ module.exports = { run, schema }
@@ -0,0 +1,81 @@
1
+ const { error: { catch_errors } } = require('puffy-core')
2
+ const { unstar } = require('../api/repos')
3
+ const { require_token } = require('../auth/token_store')
4
+ const { print_help, print_json, print_success } = require('../ui/output')
5
+ const { exit_with_error, UsageError } = require('../utils/errors')
6
+ const { EXIT_CODES } = require('../constants')
7
+
8
+ const HELP_TEXT = `Usage: happyskills unstar <owner/name> [options]
9
+
10
+ Remove a skill from your favorites. Idempotent: unstarring a skill you have
11
+ not starred succeeds without error.
12
+
13
+ Arguments:
14
+ owner/name Skill to unstar (e.g., acme/deploy-aws)
15
+
16
+ Options:
17
+ --json Output as JSON
18
+
19
+ Examples:
20
+ happyskills unstar acme/deploy-aws
21
+ happyskills unstar acme/deploy-aws --json`
22
+
23
+ const run = (args) => catch_errors('Unstar failed', async () => {
24
+ if (args.flags._show_help) {
25
+ print_help(HELP_TEXT)
26
+ return process.exit(EXIT_CODES.SUCCESS)
27
+ }
28
+
29
+ const skill = args._[0]
30
+ if (!skill) {
31
+ throw new UsageError('Please specify a skill to unstar (e.g., happyskills unstar acme/deploy-aws).')
32
+ }
33
+ if (!skill.includes('/')) {
34
+ throw new UsageError('Skill must be in owner/name format (e.g., acme/deploy-aws).')
35
+ }
36
+
37
+ await require_token()
38
+
39
+ const [owner, name] = skill.split('/')
40
+ const [errors, result] = await unstar(owner, name)
41
+ if (errors) throw errors[errors.length - 1]
42
+
43
+ if (args.flags.json) {
44
+ print_json({ data: { starred: false, skill, ...(result || {}) } })
45
+ return
46
+ }
47
+
48
+ print_success(`Unstarred "${skill}".`)
49
+ }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
50
+
51
+ const schema = {
52
+ name: 'unstar',
53
+ audience: 'consumer',
54
+ purpose: 'Remove a skill from your favorites (unstar). Idempotent.',
55
+ mutation: true,
56
+ interactive_in_text_mode: false,
57
+ input: {
58
+ positional: [
59
+ { name: 'skill', required: true, type: 'string', pattern: '<owner>/<name>' },
60
+ ],
61
+ flags: [
62
+ { name: 'json', type: 'boolean', default: false },
63
+ ],
64
+ },
65
+ output: {
66
+ data_shape: {
67
+ starred: 'boolean',
68
+ skill: 'string',
69
+ },
70
+ },
71
+ errors: [
72
+ { code: 'USAGE_ERROR', next_step: { kind: 'routing', action: 'discover_schema' } },
73
+ { code: 'AUTH_REQUIRED', next_step: { kind: 'recovery', action: 'login' } },
74
+ ],
75
+ examples: [
76
+ 'happyskills unstar acme/deploy-aws',
77
+ 'happyskills unstar acme/deploy-aws --json',
78
+ ],
79
+ }
80
+
81
+ module.exports = { run, schema }
@@ -1,7 +1,7 @@
1
- const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
1
+ const { error: { catch_errors } } = require('puffy-core')
2
2
  const { require_token } = require('../auth/token_store')
3
- const workspaces_api = require('../api/workspaces')
4
- const { print_help, print_label, print_json, print_warn, print_hint } = require('../ui/output')
3
+ const { resolve_identity, render_identity_text } = require('../auth/identity')
4
+ const { print_help, print_json } = require('../ui/output')
5
5
  const { exit_with_error } = require('../utils/errors')
6
6
  const { EXIT_CODES } = require('../constants')
7
7
 
@@ -16,17 +16,6 @@ Examples:
16
16
  happyskills whoami
17
17
  happyskills whoami --json`
18
18
 
19
- const decode_jwt_payload = (token) => {
20
- try {
21
- const parts = token.split('.')
22
- if (parts.length !== 3) return null
23
- const payload = Buffer.from(parts[1], 'base64url').toString('utf-8')
24
- return JSON.parse(payload)
25
- } catch {
26
- return null
27
- }
28
- }
29
-
30
19
  const run = (args) => catch_errors('Whoami failed', async () => {
31
20
  if (args.flags._show_help) {
32
21
  print_help(HELP_TEXT)
@@ -34,37 +23,14 @@ const run = (args) => catch_errors('Whoami failed', async () => {
34
23
  }
35
24
 
36
25
  const token_data = await require_token()
37
-
38
- const payload = decode_jwt_payload(token_data.id_token)
39
- const email = payload?.email || 'unknown'
40
- const username = payload?.['cognito:username'] || payload?.sub || 'unknown'
41
-
42
- const [ws_err, workspaces] = await workspaces_api.list_workspaces()
26
+ const [, identity] = await resolve_identity(token_data)
43
27
 
44
28
  if (args.flags.json) {
45
- const json_data = { username, email, workspaces: ws_err ? [] : workspaces }
46
- if (ws_err) json_data.workspace_error = ws_err.map(er => er.message).join(': ')
47
- print_json({ data: json_data })
29
+ print_json({ data: identity })
48
30
  return
49
31
  }
50
32
 
51
- print_label('Username', username)
52
- print_label('Email', email)
53
-
54
- if (ws_err) {
55
- console.log()
56
- print_warn(`Failed to list workspaces: ${ws_err.map(er => er.message).join(': ')}`)
57
- print_hint('Check your authentication with happyskills login')
58
- } else if (workspaces && workspaces.length > 0) {
59
- console.log()
60
- print_label('Workspaces', '')
61
- for (const ws of workspaces) {
62
- const type_label = ws.is_owner
63
- ? ws.type === 'personal' ? ' (personal)' : ' (organization)'
64
- : ''
65
- console.log(` ${ws.slug}${type_label}`)
66
- }
67
- }
33
+ render_identity_text(identity)
68
34
  }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
69
35
 
70
36
  const schema = {
package/src/constants.js CHANGED
@@ -51,6 +51,8 @@ const COMMANDS = [
51
51
  'visibility',
52
52
  'list',
53
53
  'search',
54
+ 'star',
55
+ 'unstar',
54
56
  'check',
55
57
  'status',
56
58
  'pull',
@@ -22,6 +22,13 @@ const _format_warnings = (missing_deps) => (missing_deps || []).map(dep => {
22
22
  return `Missing system dependency: ${dep.name} required by ${dep.skill}${hint}`
23
23
  })
24
24
 
25
+ // Circular dependencies the resolver broke automatically. The install is safe and terminates,
26
+ // but the author should remove one edge — so we surface a warning rather than failing silently.
27
+ const _format_cycle_warnings = (cycles) => (cycles || []).map(({ from, to }) =>
28
+ `Circular dependency detected: ${from} depends on ${to}, which depends back on ${from}. ` +
29
+ `The install completed safely (the cycle was broken automatically), but you should remove one ` +
30
+ `direction of the dependency.`)
31
+
25
32
  // Order-preserving, de-duplicated union of requester lists.
26
33
  const _union = (...lists) => {
27
34
  const seen = new Set()
@@ -103,16 +110,19 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
103
110
 
104
111
  let packages
105
112
  let skipped_deps = []
113
+ let cycles = []
106
114
  if (force) {
107
115
  const [errors, result] = await resolve_with_force(skill, version || 'latest', {})
108
116
  if (errors) { spinner.fail('Resolution failed'); throw e('Resolution failed', errors) }
109
117
  packages = result.packages
110
118
  skipped_deps = result.skipped || []
119
+ cycles = result.cycles || []
111
120
  } else {
112
121
  const [errors, result] = await resolve(skill, version || 'latest', {})
113
122
  if (errors) { spinner.fail('Resolution failed'); throw e('Resolution failed', errors) }
114
123
  packages = result.packages
115
124
  skipped_deps = result.skipped || []
125
+ cycles = result.cycles || []
116
126
  }
117
127
 
118
128
  // Filter out packages already installed at the resolved version (handles @latest efficiently)
@@ -343,10 +353,15 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
343
353
  }
344
354
  }
345
355
 
356
+ // Warn about circular dependencies. The resolver broke the cycle automatically (so the
357
+ // install is safe and terminates), but the author should still remove one edge.
358
+ const cycle_warnings = _format_cycle_warnings(cycles)
359
+ for (const w of cycle_warnings) print_warn(w)
360
+
346
361
  const installed_set = new Set(packages_to_install.map(p => p.skill))
347
362
  const installed = packages_to_install.map(p => ({ skill: p.skill, version: p.version }))
348
363
  const skipped = packages.filter(p => !installed_set.has(p.skill)).map(p => ({ skill: p.skill, version: p.version, reason: 'already installed' }))
349
- const warnings = _format_warnings(missing_deps)
364
+ const warnings = [..._format_warnings(missing_deps), ...cycle_warnings]
350
365
  const forced = forced_pkgs.map(p => ({ skill: p.skill, version: p.version }))
351
366
 
352
367
  const linked_agents = agents.map(a => a.id)
@@ -12,7 +12,7 @@ const resolve = (skill, version, installed = {}) => catch_errors('Dependency res
12
12
  throw new Error(`Dependency conflicts detected:\n${conflict_msg}\n\nUse --force to install anyway (takes highest version).`)
13
13
  }
14
14
 
15
- return { packages: result.packages || [], skipped: result.skipped || [] }
15
+ return { packages: result.packages || [], skipped: result.skipped || [], cycles: result.cycles || [] }
16
16
  })
17
17
 
18
18
  const resolve_with_force = (skill, version, installed = {}) => catch_errors('Forced resolution failed', async () => {
@@ -30,7 +30,7 @@ const resolve_with_force = (skill, version, installed = {}) => catch_errors('For
30
30
  }
31
31
  }
32
32
 
33
- return { packages, skipped: result.skipped || [] }
33
+ return { packages, skipped: result.skipped || [], cycles: result.cycles || [] }
34
34
  })
35
35
 
36
36
  module.exports = { resolve, resolve_with_force }
package/src/index.js CHANGED
@@ -103,6 +103,8 @@ Commands:
103
103
  visibility <owner/skill> Check or set visibility (alias: vis)
104
104
  list List installed skills (alias: ls)
105
105
  search <query> Search the registry (alias: s)
106
+ star <owner/skill> Star a skill (add to your favorites)
107
+ unstar <owner/skill> Remove a skill from your favorites
106
108
  postlex Apply deterministic rerank finalization (agent-only; see happyskills-help skill)
107
109
  versions <owner/skill> List all published versions of a skill
108
110
  changelog <owner/skill> Print a skill's CHANGELOG.md
@@ -403,6 +403,10 @@ describe('CLI — --json: success envelopes', () => {
403
403
  assert.strictEqual(env.data.status, 'already_logged_in')
404
404
  assert.strictEqual(env.data.username, 'testuser')
405
405
  assert.strictEqual(env.data.email, 'test@example.com')
406
+ // Content parity with whoami: login carries the workspaces block too.
407
+ // The dummy API host (localhost:0) is unreachable, so the best-effort
408
+ // lookup degrades to an empty list — it must never fail the login.
409
+ assert.ok(Array.isArray(env.data.workspaces), 'login data.workspaces must be an array')
406
410
  } finally {
407
411
  fs.rmSync(tmp_xdg, { recursive: true, force: true })
408
412
  }
@@ -172,27 +172,40 @@ const check_visibility_transitive = async (manifest, visibility, repos_api) => {
172
172
  const check_circular = async (manifest, repos_api) => {
173
173
  const results = []
174
174
  const deps = manifest.dependencies || {}
175
+ const dep_names = Object.keys(deps)
175
176
 
176
- if (Object.keys(deps).length === 0) return results
177
+ if (dep_names.length === 0) return results
177
178
 
178
179
  const skill_name = manifest._full_name
179
180
  if (!skill_name) return results
180
181
 
181
- const [err, data] = await repos_api.resolve_dependencies(skill_name, manifest.version || 'latest', {})
182
- if (err) return results
183
-
184
- const packages = data.packages || []
185
-
186
- // Build adjacency map from declared dependencies
182
+ // Resolve each DIRECT dependency's published subtree — NOT the root's. The root's published
183
+ // manifest can be stale: when you publish the very edge that closes a cycle, the registry's
184
+ // view of the root does not yet contain that edge, so resolving the root would hide the
185
+ // cycle. Resolving the dependencies' subtrees and then grafting the LOCAL (about-to-be-
186
+ // published) root edges on top reflects the graph as it will be AFTER this publish — which
187
+ // is exactly what we must validate.
188
+ const subtrees = await Promise.all(
189
+ dep_names.map(dep => repos_api.resolve_dependencies(dep, 'latest', {}))
190
+ )
191
+
192
+ // Merge every resolved package into one adjacency map (the registry's current view).
187
193
  const adj = {}
188
- for (const pkg of packages) {
189
- adj[pkg.skill] = Object.keys(pkg.dependencies || {})
194
+ for (const [sub_err, data] of subtrees) {
195
+ if (sub_err || !data) continue
196
+ for (const pkg of (data.packages || [])) {
197
+ if (adj[pkg.skill] === undefined) adj[pkg.skill] = Object.keys(pkg.dependencies || {})
198
+ }
190
199
  }
191
200
 
192
- // DFS cycle detection
201
+ // Graft the LOCAL edges for the root. This overrides any stale published view of the root
202
+ // that a dependency's subtree may have included, making a cycle the new edges create visible.
203
+ adj[skill_name] = dep_names
204
+
205
+ // DFS cycle detection (WHITE/GRAY/BLACK colouring). Start from the root so reported cycles
206
+ // are rooted at the skill being published; then cover any unreachable nodes defensively.
193
207
  const WHITE = 0, GRAY = 1, BLACK = 2
194
208
  const color = {}
195
- const parent = {}
196
209
  const cycles = []
197
210
 
198
211
  const dfs = (node, path_so_far) => {
@@ -213,16 +226,20 @@ const check_circular = async (manifest, repos_api) => {
213
226
  color[node] = BLACK
214
227
  }
215
228
 
229
+ dfs(skill_name, [skill_name])
216
230
  for (const node of Object.keys(adj)) {
217
- if ((color[node] || WHITE) === WHITE) {
218
- dfs(node, [node])
219
- }
231
+ if ((color[node] || WHITE) === WHITE) dfs(node, [node])
220
232
  }
221
233
 
222
234
  if (cycles.length > 0) {
235
+ // De-duplicate identical cycle paths (the same cycle can be reached from multiple starts).
236
+ const seen = new Set()
223
237
  for (const cycle of cycles) {
238
+ const key = cycle.join(' → ')
239
+ if (seen.has(key)) continue
240
+ seen.add(key)
224
241
  results.push(result('dependencies', 'dep-circular', 'error',
225
- `Circular dependency detected: ${cycle.join(' → ')}. ` +
242
+ `Circular dependency detected: ${key}. ` +
226
243
  `Remove one direction of the dependency to break the cycle.`,
227
244
  cycle))
228
245
  }
@@ -158,6 +158,30 @@ describe('dep-circular (registry)', () => {
158
158
  const check = results.find(r => r.rule === 'dep-circular')
159
159
  assert.strictEqual(check.severity, 'pass')
160
160
  })
161
+
162
+ it('detects a cycle created by the very edge being published (stale root view)', async () => {
163
+ // Registry view: the dependency (acme/b) depends back on the root (acme/a), but the
164
+ // root's PUBLISHED manifest still has empty deps — the cycle-closing edge (acme/a → acme/b)
165
+ // exists only in the LOCAL manifest being validated. The check must graft the local edge
166
+ // onto the resolved subtree, otherwise this cycle slips into the registry undetected.
167
+ const manifest = { name: 'a', version: '0.2.0', dependencies: { 'acme/b': '*' } }
168
+ const resolve_result = {
169
+ packages: [
170
+ { skill: 'acme/b', version: '1.0.0', dependencies: { 'acme/a': '*' } },
171
+ { skill: 'acme/a', version: '1.0.0', dependencies: {} } // stale: published root has no deps yet
172
+ ],
173
+ skipped: []
174
+ }
175
+ const api = make_mock_api({ 'acme/b': { visibility: 'public' } }, resolve_result)
176
+ const [err, results] = await validate_dependencies(tmp, manifest, {
177
+ registry: true, full_name: 'acme/a', repos_api: api
178
+ })
179
+ assert.ifError(err)
180
+ const check = results.find(r => r.rule === 'dep-circular' && r.severity === 'error')
181
+ assert.ok(check, 'should detect the cycle created by the local closing edge')
182
+ assert.ok(check.message.includes('acme/a'))
183
+ assert.ok(check.message.includes('acme/b'))
184
+ })
161
185
  })
162
186
 
163
187
  describe('dep-visibility-transitive (registry)', () => {