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.
- package/CHANGELOG.md +9 -0
- package/package.json +1 -1
- package/src/commands/install.js +7 -1
- package/src/commands/skills-config.js +140 -0
- package/src/commands/update.js +6 -2
- package/src/config/paths.js +28 -0
- package/src/config/paths.test.js +36 -0
- package/src/constants.js +3 -0
- package/src/engine/installer.js +19 -1
- package/src/integration/skills-config.test.js +98 -0
- package/src/skills_config/configure.js +172 -0
- package/src/skills_config/configure.test.js +165 -0
- package/src/skills_config/reader.js +27 -0
- package/src/skills_config/resolve.js +126 -0
- package/src/skills_config/resolve.test.js +112 -0
- package/src/skills_config/skills_config.test.js +92 -0
- package/src/skills_config/writer.js +59 -0
- package/src/utils/prompt.js +16 -1
- package/src/validation/skill_json_rules.js +99 -0
- package/src/validation/skill_json_rules.test.js +130 -0
|
@@ -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
|
+
})
|