happyskills 1.16.3 → 1.17.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.
@@ -7,6 +7,7 @@ const { SKILL_JSON, VALID_SKILL_TYPES, SKILL_TYPES, KIT_PREFIX } = require('../c
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
9
  const REQUIRED_PLATFORMS = ['darwin', 'linux', 'win32']
10
+ const CONFIG_TYPES = ['string', 'integer', 'number', 'boolean']
10
11
 
11
12
  const result = (field, rule, severity, message, value) => ({
12
13
  file: SKILL_JSON, field, rule, severity, message, ...(value !== undefined ? { value } : {})
@@ -180,6 +181,102 @@ const validate_system_dependencies = (manifest) => {
180
181
  return results
181
182
  }
182
183
 
184
+ // config: map of field -> { type, default?, description?, required? }. Non-secret,
185
+ // typed tunables that override the skill's hardcoded defaults. `type` is required.
186
+ const validate_config = (manifest) => {
187
+ const results = []
188
+ const config = manifest.config
189
+
190
+ if (config === undefined) return results
191
+
192
+ if (typeof config !== 'object' || Array.isArray(config)) {
193
+ results.push(result('config', 'type', 'error', 'config must be an object'))
194
+ return results
195
+ }
196
+
197
+ for (const [key, entry] of Object.entries(config)) {
198
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
199
+ results.push(result('config', 'entry_structure', 'error', `config field "${key}" must be an object`, key))
200
+ continue
201
+ }
202
+
203
+ if (!CONFIG_TYPES.includes(entry.type)) {
204
+ results.push(result('config', 'type_value', 'error', `config field "${key}" must declare a "type" of: ${CONFIG_TYPES.join(', ')} — got "${entry.type}"`, key))
205
+ }
206
+
207
+ if (entry.required !== undefined && typeof entry.required !== 'boolean') {
208
+ results.push(result('config', 'required_flag', 'error', `config field "${key}" "required" must be a boolean`, key))
209
+ }
210
+
211
+ if (entry.description !== undefined && typeof entry.description !== 'string') {
212
+ results.push(result('config', 'description_type', 'error', `config field "${key}" "description" must be a string`, key))
213
+ }
214
+ }
215
+
216
+ return results
217
+ }
218
+
219
+ // env: map of VAR_NAME -> { required?, secret?, description?, type?, default?, validate? }.
220
+ // `required: true` entries are the activation gate. Secrets are routed to the .env, never
221
+ // committed — so a secret with an inline default is a warning, not a hard error.
222
+ const validate_env = (manifest) => {
223
+ const results = []
224
+ const env = manifest.env
225
+
226
+ if (env === undefined) return results
227
+
228
+ if (typeof env !== 'object' || Array.isArray(env)) {
229
+ results.push(result('env', 'type', 'error', 'env must be an object'))
230
+ return results
231
+ }
232
+
233
+ const seen_lower = new Map()
234
+
235
+ for (const [key, entry] of Object.entries(env)) {
236
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
237
+ results.push(result('env', 'entry_structure', 'error', `env var "${key}" must be an object`, key))
238
+ continue
239
+ }
240
+
241
+ if (entry.required !== undefined && typeof entry.required !== 'boolean') {
242
+ results.push(result('env', 'required_flag', 'error', `env var "${key}" "required" must be a boolean`, key))
243
+ }
244
+
245
+ if (entry.secret !== undefined && typeof entry.secret !== 'boolean') {
246
+ results.push(result('env', 'secret_flag', 'error', `env var "${key}" "secret" must be a boolean`, key))
247
+ }
248
+
249
+ if (entry.secret === true && entry.default !== undefined) {
250
+ results.push(result('env', 'secret_default', 'warning', `env var "${key}" is secret but carries a committed default — secret fields must not carry a committed default`, key))
251
+ }
252
+
253
+ if (entry.type !== undefined && !CONFIG_TYPES.includes(entry.type)) {
254
+ results.push(result('env', 'type_value', 'error', `env var "${key}" has unknown type "${entry.type}" (allowed: ${CONFIG_TYPES.join(', ')})`, key))
255
+ }
256
+
257
+ if (entry.validate !== undefined) {
258
+ if (typeof entry.validate !== 'string') {
259
+ results.push(result('env', 'validate_regex', 'error', `env var "${key}" "validate" must be a string regex`, key))
260
+ } else {
261
+ try {
262
+ new RegExp(entry.validate)
263
+ } catch (err) {
264
+ results.push(result('env', 'validate_regex', 'error', `env var "${key}" "validate" is not a valid regex: ${err.message}`, key))
265
+ }
266
+ }
267
+ }
268
+
269
+ const lower = key.toLowerCase()
270
+ if (seen_lower.has(lower)) {
271
+ results.push(result('env', 'case_collision', 'error', `env var "${key}" collides case-insensitively with "${seen_lower.get(lower)}" — env names must be unique regardless of case`, key))
272
+ } else {
273
+ seen_lower.set(lower, key)
274
+ }
275
+ }
276
+
277
+ return results
278
+ }
279
+
183
280
  const validate_skill_json = (skill_dir) => catch_errors('Failed to validate skill.json', async () => {
184
281
  const results = []
185
282
  const json_path = path.join(skill_dir, SKILL_JSON)
@@ -209,6 +306,8 @@ const validate_skill_json = (skill_dir) => catch_errors('Failed to validate skil
209
306
  results.push(...validate_description(manifest))
210
307
  results.push(...validate_dependencies(manifest))
211
308
  results.push(...validate_system_dependencies(manifest))
309
+ results.push(...validate_config(manifest))
310
+ results.push(...validate_env(manifest))
212
311
 
213
312
  const is_kit = manifest.type === SKILL_TYPES.KIT
214
313
  if (is_kit) {
@@ -334,3 +334,133 @@ describe('validate_skill_json — systemDependencies', () => {
334
334
  assert.strictEqual(check.severity, 'pass')
335
335
  })
336
336
  })
337
+
338
+ describe('validate_skill_json — config schema', () => {
339
+ it('errors when config is an array', async () => {
340
+ write_json(tmp, { name: 'my-skill', version: '1.0.0', config: [] })
341
+ const [err, data] = await validate_skill_json(tmp)
342
+ assert.ifError(err)
343
+ const check = data.results.find(r => r.field === 'config' && r.rule === 'type')
344
+ assert.strictEqual(check.severity, 'error')
345
+ })
346
+
347
+ it('errors when a config field has an unknown type', async () => {
348
+ write_json(tmp, {
349
+ name: 'my-skill', version: '1.0.0',
350
+ config: { channel: { type: 'colour', default: '#general' } }
351
+ })
352
+ const [err, data] = await validate_skill_json(tmp)
353
+ assert.ifError(err)
354
+ const check = data.results.find(r => r.field === 'config' && r.rule === 'type_value')
355
+ assert.strictEqual(check.severity, 'error')
356
+ assert.ok(check.message.includes('colour'))
357
+ })
358
+
359
+ it('errors when a config field omits type', async () => {
360
+ write_json(tmp, {
361
+ name: 'my-skill', version: '1.0.0',
362
+ config: { channel: { default: '#general' } }
363
+ })
364
+ const [err, data] = await validate_skill_json(tmp)
365
+ assert.ifError(err)
366
+ const check = data.results.find(r => r.field === 'config' && r.rule === 'type_value')
367
+ assert.strictEqual(check.severity, 'error')
368
+ })
369
+
370
+ it('errors when a config field required flag is non-boolean', async () => {
371
+ write_json(tmp, {
372
+ name: 'my-skill', version: '1.0.0',
373
+ config: { channel: { type: 'string', required: 'yes' } }
374
+ })
375
+ const [err, data] = await validate_skill_json(tmp)
376
+ assert.ifError(err)
377
+ const check = data.results.find(r => r.field === 'config' && r.rule === 'required_flag')
378
+ assert.strictEqual(check.severity, 'error')
379
+ })
380
+ })
381
+
382
+ describe('validate_skill_json — env schema', () => {
383
+ it('errors when env is an array', async () => {
384
+ write_json(tmp, { name: 'my-skill', version: '1.0.0', env: [] })
385
+ const [err, data] = await validate_skill_json(tmp)
386
+ assert.ifError(err)
387
+ const check = data.results.find(r => r.field === 'env' && r.rule === 'type')
388
+ assert.strictEqual(check.severity, 'error')
389
+ })
390
+
391
+ it('errors when required is non-boolean', async () => {
392
+ write_json(tmp, {
393
+ name: 'my-skill', version: '1.0.0',
394
+ env: { SLACK_BOT_TOKEN: { required: 'true', secret: true } }
395
+ })
396
+ const [err, data] = await validate_skill_json(tmp)
397
+ assert.ifError(err)
398
+ const check = data.results.find(r => r.field === 'env' && r.rule === 'required_flag')
399
+ assert.strictEqual(check.severity, 'error')
400
+ })
401
+
402
+ it('errors when secret is non-boolean', async () => {
403
+ write_json(tmp, {
404
+ name: 'my-skill', version: '1.0.0',
405
+ env: { SLACK_BOT_TOKEN: { required: true, secret: 'yes' } }
406
+ })
407
+ const [err, data] = await validate_skill_json(tmp)
408
+ assert.ifError(err)
409
+ const check = data.results.find(r => r.field === 'env' && r.rule === 'secret_flag')
410
+ assert.strictEqual(check.severity, 'error')
411
+ })
412
+
413
+ it('warns when a secret env var carries an inline default', async () => {
414
+ write_json(tmp, {
415
+ name: 'my-skill', version: '1.0.0',
416
+ env: { SLACK_BOT_TOKEN: { required: true, secret: true, default: 'xoxb-hardcoded' } }
417
+ })
418
+ const [err, data] = await validate_skill_json(tmp)
419
+ assert.ifError(err)
420
+ const check = data.results.find(r => r.field === 'env' && r.rule === 'secret_default')
421
+ assert.strictEqual(check.severity, 'warning')
422
+ })
423
+
424
+ it('errors when a validate regex does not compile', async () => {
425
+ write_json(tmp, {
426
+ name: 'my-skill', version: '1.0.0',
427
+ env: { DEPLOY_TIMEOUT: { type: 'integer', validate: '([0-9' } }
428
+ })
429
+ const [err, data] = await validate_skill_json(tmp)
430
+ assert.ifError(err)
431
+ const check = data.results.find(r => r.field === 'env' && r.rule === 'validate_regex')
432
+ assert.strictEqual(check.severity, 'error')
433
+ })
434
+
435
+ it('errors when two env vars differ only by case', async () => {
436
+ write_json(tmp, {
437
+ name: 'my-skill', version: '1.0.0',
438
+ env: { TOKEN: { required: true }, token: { required: false } }
439
+ })
440
+ const [err, data] = await validate_skill_json(tmp)
441
+ assert.ifError(err)
442
+ const check = data.results.find(r => r.field === 'env' && r.rule === 'case_collision')
443
+ assert.strictEqual(check.severity, 'error')
444
+ })
445
+ })
446
+
447
+ describe('validate_skill_json — well-formed config + env', () => {
448
+ it('emits no error or warning results for a well-formed config + env block', async () => {
449
+ write_json(tmp, {
450
+ name: 'slack-notify', version: '1.0.0',
451
+ config: {
452
+ channel: { type: 'string', default: '#general', description: 'Channel to post to' },
453
+ max_chars: { type: 'integer', default: 500, description: 'Truncate longer messages' }
454
+ },
455
+ env: {
456
+ SLACK_BOT_TOKEN: { required: true, secret: true, description: 'Slack bot token' },
457
+ DEPLOY_TIMEOUT: { required: false, secret: false, type: 'integer', default: 300, validate: '^[0-9]+$' }
458
+ }
459
+ })
460
+ const [err, data] = await validate_skill_json(tmp)
461
+ assert.ifError(err)
462
+ const config_env_problems = data.results.filter(r =>
463
+ (r.field === 'config' || r.field === 'env') && (r.severity === 'error' || r.severity === 'warning'))
464
+ assert.deepStrictEqual(config_env_problems, [])
465
+ })
466
+ })