happyskills 1.2.1 → 1.3.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,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.3.0] - 2026-06-03
11
+
12
+ ### Added
13
+
14
+ - `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.
15
+ - 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.
16
+
17
+ ### Changed
18
+
19
+ - 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.
20
+ - `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`).
21
+
22
+ ## [1.2.2] - 2026-06-02
23
+
24
+ ### Changed
25
+
26
+ - `validate` no longer warns when `skill.json` `keywords` is missing or omits a canonical slug. The `keywords` field is deprecated — it is not used for search, ranking, or discovery — so the warning and the canonical-slug check were removed. The field is still accepted in the manifest.
27
+
10
28
  ## [1.2.1] - 2026-06-02
11
29
 
12
30
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "1.2.1",
3
+ "version": "1.3.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)",
@@ -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: [
@@ -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 = {
@@ -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 }
@@ -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)', () => {
@@ -6,7 +6,6 @@ const { SKILL_JSON, VALID_SKILL_TYPES, SKILL_TYPES, KIT_PREFIX } = require('../c
6
6
 
7
7
  const NAME_PATTERN = /^[a-z0-9][a-z0-9_-]*$/
8
8
  const KIT_NAME_PATTERN = /^_kit-[a-z0-9][a-z0-9-]*$/
9
- const CANONICAL_SLUGS = ['deployment', 'database', 'security', 'ai', 'api', 'monitoring', 'testing', 'devops', 'cloud', 'analytics']
10
9
  const REQUIRED_PLATFORMS = ['darwin', 'linux', 'win32']
11
10
 
12
11
  const result = (field, rule, severity, message, value) => ({
@@ -90,27 +89,6 @@ const validate_description = (manifest) => {
90
89
  return results
91
90
  }
92
91
 
93
- const validate_keywords = (manifest) => {
94
- const results = []
95
- const kw = manifest.keywords
96
-
97
- if (!kw || !Array.isArray(kw)) {
98
- results.push(result('keywords', 'recommended_field', 'warning', 'Keywords array is recommended — include at least one canonical slug'))
99
- return results
100
- }
101
-
102
- results.push(result('keywords', 'recommended_field', 'pass', `keywords: ${kw.length} entries`))
103
-
104
- const has_slug = kw.some(k => CANONICAL_SLUGS.includes(k))
105
- if (!has_slug) {
106
- results.push(result('keywords', 'canonical_slug', 'warning', `Keywords should include at least one canonical slug: ${CANONICAL_SLUGS.join(', ')}`))
107
- } else {
108
- results.push(result('keywords', 'canonical_slug', 'pass', 'Contains canonical slug'))
109
- }
110
-
111
- return results
112
- }
113
-
114
92
  const validate_type = (manifest) => {
115
93
  const results = []
116
94
  const type = manifest.type
@@ -229,7 +207,6 @@ const validate_skill_json = (skill_dir) => catch_errors('Failed to validate skil
229
207
  results.push(...validate_version(manifest))
230
208
  results.push(...validate_type(manifest))
231
209
  results.push(...validate_description(manifest))
232
- results.push(...validate_keywords(manifest))
233
210
  results.push(...validate_dependencies(manifest))
234
211
  results.push(...validate_system_dependencies(manifest))
235
212
 
@@ -121,32 +121,6 @@ describe('validate_skill_json — description', () => {
121
121
  })
122
122
  })
123
123
 
124
- describe('validate_skill_json — keywords', () => {
125
- it('warns when keywords are missing', async () => {
126
- write_json(tmp, { name: 'my-skill', version: '1.0.0' })
127
- const [err, data] = await validate_skill_json(tmp)
128
- assert.ifError(err)
129
- const check = data.results.find(r => r.field === 'keywords' && r.rule === 'recommended_field')
130
- assert.strictEqual(check.severity, 'warning')
131
- })
132
-
133
- it('warns when no canonical slug is present', async () => {
134
- write_json(tmp, { name: 'my-skill', version: '1.0.0', keywords: ['custom'] })
135
- const [err, data] = await validate_skill_json(tmp)
136
- assert.ifError(err)
137
- const check = data.results.find(r => r.rule === 'canonical_slug')
138
- assert.strictEqual(check.severity, 'warning')
139
- })
140
-
141
- it('passes when a canonical slug is present', async () => {
142
- write_json(tmp, { name: 'my-skill', version: '1.0.0', keywords: ['deployment', 'custom'] })
143
- const [err, data] = await validate_skill_json(tmp)
144
- assert.ifError(err)
145
- const check = data.results.find(r => r.rule === 'canonical_slug')
146
- assert.strictEqual(check.severity, 'pass')
147
- })
148
- })
149
-
150
124
  describe('validate_skill_json — dependencies', () => {
151
125
  it('errors when dependencies is an array', async () => {
152
126
  write_json(tmp, { name: 'my-skill', version: '1.0.0', dependencies: ['acme/deploy'] })