happyskills 1.16.3 → 1.18.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 +19 -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/commands/validate.js +4 -1
- 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/engine/uninstaller.js +24 -1
- package/src/integration/skills-config.test.js +98 -0
- package/src/skills_config/cleanup.js +107 -0
- package/src/skills_config/cleanup.test.js +123 -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/fs.js +5 -1
- package/src/utils/prompt.js +16 -1
- package/src/validation/config_usage_rules.js +86 -0
- package/src/validation/config_usage_rules.test.js +61 -0
- package/src/validation/skill_json_rules.js +99 -0
- package/src/validation/skill_json_rules.test.js +130 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { describe, it, beforeEach, afterEach } = require('node:test')
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const fs = require('fs')
|
|
5
|
+
const os = require('os')
|
|
6
|
+
const path = require('path')
|
|
7
|
+
|
|
8
|
+
const { resolve_skill_config, deep_merge, author_defaults } = require('./resolve')
|
|
9
|
+
const { set_skill_config_entry } = require('./writer')
|
|
10
|
+
|
|
11
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-resolve-'))
|
|
12
|
+
|
|
13
|
+
const MANIFEST = {
|
|
14
|
+
name: 'slack-notify',
|
|
15
|
+
config: {
|
|
16
|
+
channel: { type: 'string', default: '#general' },
|
|
17
|
+
max_chars: { type: 'integer', default: 500 }
|
|
18
|
+
},
|
|
19
|
+
env: {
|
|
20
|
+
SLACK_BOT_TOKEN: { required: true, secret: true },
|
|
21
|
+
DEPLOY_TIMEOUT: { required: false, secret: false, type: 'integer', default: 300 }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let root
|
|
26
|
+
beforeEach(() => { root = make_tmp() })
|
|
27
|
+
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }) })
|
|
28
|
+
|
|
29
|
+
describe('deep_merge + author_defaults', () => {
|
|
30
|
+
it('project wins over global wins over defaults', () => {
|
|
31
|
+
const merged = deep_merge(deep_merge({ a: 1, b: 1, c: 1 }, { b: 2, c: 2 }), { c: 3 })
|
|
32
|
+
assert.deepStrictEqual(merged, { a: 1, b: 2, c: 3 })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('author_defaults pulls config defaults + NON-secret env defaults, never secret defaults', () => {
|
|
36
|
+
const d = author_defaults(MANIFEST)
|
|
37
|
+
assert.strictEqual(d.channel, '#general')
|
|
38
|
+
assert.strictEqual(d.max_chars, 500)
|
|
39
|
+
assert.strictEqual(d.DEPLOY_TIMEOUT, 300)
|
|
40
|
+
assert.ok(!('SLACK_BOT_TOKEN' in d))
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('resolve_skill_config — cascade + secrets', () => {
|
|
45
|
+
it('merges project ⊕ global ⊕ author-default and returns required secret NAMES only', async () => {
|
|
46
|
+
await set_skill_config_entry(root, 'acme/slack-notify', {
|
|
47
|
+
envFile: './secrets/slack-notify.env',
|
|
48
|
+
config: { channel: '#deploys' } // project override
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const [err, resolved] = await resolve_skill_config('acme/slack-notify', {
|
|
52
|
+
project_root: root,
|
|
53
|
+
manifest: MANIFEST,
|
|
54
|
+
global_config: { 'acme/slack-notify': { config: { max_chars: 900 } } } // global override
|
|
55
|
+
})
|
|
56
|
+
assert.ifError(err)
|
|
57
|
+
|
|
58
|
+
// project channel wins; global max_chars wins over default; DEPLOY_TIMEOUT falls to default
|
|
59
|
+
assert.strictEqual(resolved.config.channel, '#deploys')
|
|
60
|
+
assert.strictEqual(resolved.config.max_chars, 900)
|
|
61
|
+
assert.strictEqual(resolved.config.DEPLOY_TIMEOUT, 300)
|
|
62
|
+
|
|
63
|
+
// required secret NAME only — never a value
|
|
64
|
+
assert.deepStrictEqual(resolved.requiredSecrets, ['SLACK_BOT_TOKEN'])
|
|
65
|
+
assert.strictEqual(resolved.secretsPresent, false) // .env has no value
|
|
66
|
+
|
|
67
|
+
// resolver returns the ABSOLUTE envFile internally; the command relativizes it to the
|
|
68
|
+
// caller's cwd for output (see skills-config.test.js)
|
|
69
|
+
assert.strictEqual(resolved.envFile, path.resolve(root, 'secrets/slack-notify.env'))
|
|
70
|
+
|
|
71
|
+
// the whole resolved object never contains a secret value
|
|
72
|
+
assert.ok(!JSON.stringify(resolved).includes('xoxb'))
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('reports secretsPresent:true when the secret is set in the .env', async () => {
|
|
76
|
+
fs.mkdirSync(path.join(root, 'secrets'), { recursive: true })
|
|
77
|
+
fs.writeFileSync(path.join(root, 'secrets', 'slack-notify.env'), 'SLACK_BOT_TOKEN=xoxb-real\n')
|
|
78
|
+
await set_skill_config_entry(root, 'acme/slack-notify', { envFile: './secrets/slack-notify.env', config: {} })
|
|
79
|
+
|
|
80
|
+
const [err, resolved] = await resolve_skill_config('acme/slack-notify', {
|
|
81
|
+
project_root: root, manifest: MANIFEST, global_config: null
|
|
82
|
+
})
|
|
83
|
+
assert.ifError(err)
|
|
84
|
+
assert.strictEqual(resolved.secretsPresent, true)
|
|
85
|
+
// value is read for presence, but never surfaced
|
|
86
|
+
assert.ok(!JSON.stringify(resolved).includes('xoxb'))
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('NEVER surfaces a secret value even when the author wrongly gives a secret a default (§0 hard rule)', async () => {
|
|
90
|
+
const bad_manifest = {
|
|
91
|
+
name: 'leaky',
|
|
92
|
+
env: { API_KEY: { required: true, secret: true, default: 'sk-LEAKED-DEFAULT' } }
|
|
93
|
+
}
|
|
94
|
+
const [err, resolved] = await resolve_skill_config('acme/leaky', {
|
|
95
|
+
project_root: root, manifest: bad_manifest, global_config: null
|
|
96
|
+
})
|
|
97
|
+
assert.ifError(err)
|
|
98
|
+
assert.ok(!('API_KEY' in resolved.config), 'secret default must not appear in resolved config')
|
|
99
|
+
assert.ok(!JSON.stringify(resolved).includes('sk-LEAKED-DEFAULT'), 'secret value must never appear anywhere in the resolved object')
|
|
100
|
+
assert.deepStrictEqual(resolved.requiredSecrets, ['API_KEY'])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('resolves to author defaults when no consumer config exists', async () => {
|
|
104
|
+
const [err, resolved] = await resolve_skill_config('acme/slack-notify', {
|
|
105
|
+
project_root: root, manifest: MANIFEST, global_config: null
|
|
106
|
+
})
|
|
107
|
+
assert.ifError(err)
|
|
108
|
+
assert.strictEqual(resolved.config.channel, '#general')
|
|
109
|
+
assert.strictEqual(resolved.envFile, null)
|
|
110
|
+
assert.strictEqual(resolved.secretsPresent, false) // required secret, none set
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { describe, it, beforeEach, afterEach } = require('node:test')
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const fs = require('fs')
|
|
5
|
+
const os = require('os')
|
|
6
|
+
const path = require('path')
|
|
7
|
+
|
|
8
|
+
const { read_skills_config, get_skill_config_entry } = require('./reader')
|
|
9
|
+
const { write_skills_config, upsert_skill_config, set_skill_config_entry, build_config_entry } = require('./writer')
|
|
10
|
+
const { SKILLS_CONFIG_FILE } = require('../constants')
|
|
11
|
+
|
|
12
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-skills-config-'))
|
|
13
|
+
const read_raw = (root) => fs.readFileSync(path.join(root, SKILLS_CONFIG_FILE), 'utf-8')
|
|
14
|
+
|
|
15
|
+
let root
|
|
16
|
+
beforeEach(() => { root = make_tmp() })
|
|
17
|
+
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }) })
|
|
18
|
+
|
|
19
|
+
describe('skills_config reader', () => {
|
|
20
|
+
it('returns null when the file does not exist', async () => {
|
|
21
|
+
const [err, data] = await read_skills_config(root)
|
|
22
|
+
assert.ifError(err)
|
|
23
|
+
assert.strictEqual(data, null)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('reads back a written entry', async () => {
|
|
27
|
+
await set_skill_config_entry(root, 'acme/slack-notify', { envFile: './secrets/x.env', config: { channel: '#deploys' } })
|
|
28
|
+
const [err, data] = await read_skills_config(root)
|
|
29
|
+
assert.ifError(err)
|
|
30
|
+
const entry = get_skill_config_entry(data, 'acme/slack-notify')
|
|
31
|
+
assert.strictEqual(entry.envFile, './secrets/x.env')
|
|
32
|
+
assert.deepStrictEqual(entry.config, { channel: '#deploys' })
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('skills_config writer — key-scoped writes', () => {
|
|
37
|
+
it('adding skill B leaves skill A byte-identical', async () => {
|
|
38
|
+
await set_skill_config_entry(root, 'acme/aaa', { envFile: './secrets/a.env', config: { x: 1 } })
|
|
39
|
+
const raw_a_before = read_raw(root)
|
|
40
|
+
const block_a_before = JSON.stringify(JSON.parse(raw_a_before)['acme/aaa'])
|
|
41
|
+
|
|
42
|
+
await set_skill_config_entry(root, 'acme/bbb', { envFile: './secrets/b.env', config: { y: 2 } })
|
|
43
|
+
const parsed = JSON.parse(read_raw(root))
|
|
44
|
+
assert.strictEqual(JSON.stringify(parsed['acme/aaa']), block_a_before)
|
|
45
|
+
assert.ok(parsed['acme/bbb'])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('produces sorted, byte-stable output across two identical writes', async () => {
|
|
49
|
+
await set_skill_config_entry(root, 'acme/zzz', { envFile: './z.env', config: { b: 2, a: 1 } })
|
|
50
|
+
await set_skill_config_entry(root, 'acme/aaa', { envFile: './a.env', config: { d: 4, c: 3 } })
|
|
51
|
+
const first = read_raw(root)
|
|
52
|
+
|
|
53
|
+
// Top-level keys sorted: acme/aaa before acme/zzz
|
|
54
|
+
assert.ok(first.indexOf('acme/aaa') < first.indexOf('acme/zzz'))
|
|
55
|
+
// Nested config keys sorted too
|
|
56
|
+
assert.ok(first.indexOf('"c"') < first.indexOf('"d"'))
|
|
57
|
+
|
|
58
|
+
// Re-writing the same entry yields identical bytes
|
|
59
|
+
await set_skill_config_entry(root, 'acme/aaa', { envFile: './a.env', config: { d: 4, c: 3 } })
|
|
60
|
+
assert.strictEqual(read_raw(root), first)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('never persists a secret value — build_config_entry drops everything but envFile and config', () => {
|
|
64
|
+
const entry = build_config_entry({
|
|
65
|
+
envFile: './secrets/x.env',
|
|
66
|
+
config: { channel: '#deploys' },
|
|
67
|
+
SLACK_BOT_TOKEN: 'xoxb-super-secret',
|
|
68
|
+
secrets: { SLACK_BOT_TOKEN: 'xoxb-super-secret' }
|
|
69
|
+
})
|
|
70
|
+
assert.deepStrictEqual(Object.keys(entry).sort(), ['config', 'envFile'])
|
|
71
|
+
assert.ok(!JSON.stringify(entry).includes('xoxb-super-secret'))
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('upsert with null removes the key', () => {
|
|
75
|
+
const next = upsert_skill_config({ 'acme/a': { config: {} }, 'acme/b': { config: {} } }, 'acme/a', null)
|
|
76
|
+
assert.deepStrictEqual(Object.keys(next), ['acme/b'])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('refuses to clobber an existing but corrupt skills-config.json (data-loss guard)', async () => {
|
|
80
|
+
fs.writeFileSync(path.join(root, SKILLS_CONFIG_FILE), '{ this is not valid json ')
|
|
81
|
+
const [err] = await set_skill_config_entry(root, 'acme/new', { config: { x: 1 } })
|
|
82
|
+
assert.ok(err, 'a corrupt existing file must surface an error, not be silently overwritten')
|
|
83
|
+
// original corrupt content is preserved, not replaced
|
|
84
|
+
assert.strictEqual(read_raw(root), '{ this is not valid json ')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('write_skills_config sorts keys deeply', async () => {
|
|
88
|
+
await write_skills_config(root, { 'z/z': { config: { b: 1, a: 2 } }, 'a/a': { envFile: './a.env' } })
|
|
89
|
+
const raw = read_raw(root)
|
|
90
|
+
assert.ok(raw.indexOf('a/a') < raw.indexOf('z/z'))
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
2
|
+
const { atomic_write_json, read_json, file_exists } = require('../utils/fs')
|
|
3
|
+
const { skills_config_file_path } = require('../config/paths')
|
|
4
|
+
|
|
5
|
+
// Deterministic serialization: recursively sort object keys so concurrent git additions
|
|
6
|
+
// merge cleanly and re-writing an unchanged entry is byte-stable. Arrays and scalars pass
|
|
7
|
+
// through untouched.
|
|
8
|
+
const sort_keys_deep = (value) => {
|
|
9
|
+
if (Array.isArray(value)) return value.map(sort_keys_deep)
|
|
10
|
+
if (value === null || typeof value !== 'object') return value
|
|
11
|
+
const out = {}
|
|
12
|
+
for (const key of Object.keys(value).sort()) out[key] = sort_keys_deep(value[key])
|
|
13
|
+
return out
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// A skills-config.json entry carries ONLY a non-secret `envFile` pointer and non-secret
|
|
17
|
+
// `config` overrides. This helper is the structural guard that a secret value can never
|
|
18
|
+
// leak into the committed file — anything but envFile/config is dropped here.
|
|
19
|
+
const build_config_entry = ({ envFile, config } = {}) => {
|
|
20
|
+
const entry = {}
|
|
21
|
+
if (envFile !== undefined) entry.envFile = envFile
|
|
22
|
+
if (config !== undefined) entry.config = config
|
|
23
|
+
return entry
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const upsert_skill_config = (existing, key, entry) => {
|
|
27
|
+
const out = { ...(existing || {}) }
|
|
28
|
+
if (entry === null) delete out[key]
|
|
29
|
+
else out[key] = entry
|
|
30
|
+
return out
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const write_skills_config = (project_root, config_data) => catch_errors('Failed to write skills-config.json', async () => {
|
|
34
|
+
const config_path = skills_config_file_path(project_root)
|
|
35
|
+
const [errors] = await atomic_write_json(config_path, sort_keys_deep(config_data || {}))
|
|
36
|
+
if (errors) throw errors[0]
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// Read-modify-write a single `owner/name` subtree: read the current file, replace only that
|
|
40
|
+
// key's block (leaving every other skill's block untouched), and atomically re-serialize with
|
|
41
|
+
// sorted keys. Pass `entry: null` to remove the key.
|
|
42
|
+
const set_skill_config_entry = (project_root, key, entry) => catch_errors('Failed to set skills-config.json entry', async () => {
|
|
43
|
+
const config_path = skills_config_file_path(project_root)
|
|
44
|
+
const [read_errors, existing] = await read_json(config_path)
|
|
45
|
+
// Distinguish "no file yet" (start fresh) from "file exists but is corrupt". Clobbering a
|
|
46
|
+
// corrupt file would silently discard every OTHER skill's block — the exact opposite of the
|
|
47
|
+
// key-scoped guarantee. On a corrupt existing file, fail loud instead of overwriting.
|
|
48
|
+
if (read_errors) {
|
|
49
|
+
const [, exists] = await file_exists(config_path)
|
|
50
|
+
if (exists) throw new Error(`skills-config.json exists but could not be parsed at ${config_path} — refusing to overwrite it. Fix or remove the file, then retry.`)
|
|
51
|
+
}
|
|
52
|
+
const current = read_errors ? {} : (existing || {})
|
|
53
|
+
const next = upsert_skill_config(current, key, entry)
|
|
54
|
+
const [write_errors] = await atomic_write_json(config_path, sort_keys_deep(next))
|
|
55
|
+
if (write_errors) throw write_errors[0]
|
|
56
|
+
return next
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
module.exports = { write_skills_config, upsert_skill_config, set_skill_config_entry, build_config_entry, sort_keys_deep }
|
package/src/utils/fs.js
CHANGED
|
@@ -38,6 +38,10 @@ const remove_dir = (dir_path) => catch_errors(`Failed to remove directory ${dir_
|
|
|
38
38
|
await fs.promises.rm(dir_path, { recursive: true, force: true })
|
|
39
39
|
})
|
|
40
40
|
|
|
41
|
+
const remove_file = (file_path) => catch_errors(`Failed to remove file ${file_path}`, async () => {
|
|
42
|
+
await fs.promises.rm(file_path, { force: true })
|
|
43
|
+
})
|
|
44
|
+
|
|
41
45
|
const read_file = (file_path) => catch_errors(`Failed to read file ${file_path}`, async () => {
|
|
42
46
|
return await fs.promises.readFile(file_path, 'utf-8')
|
|
43
47
|
})
|
|
@@ -46,4 +50,4 @@ const write_file = (file_path, content) => catch_errors(`Failed to write file ${
|
|
|
46
50
|
await fs.promises.writeFile(file_path, content, 'utf-8')
|
|
47
51
|
})
|
|
48
52
|
|
|
49
|
-
module.exports = { read_json, write_json, atomic_write_json, ensure_dir, file_exists, remove_dir, read_file, write_file }
|
|
53
|
+
module.exports = { read_json, write_json, atomic_write_json, ensure_dir, file_exists, remove_dir, remove_file, read_file, write_file }
|
package/src/utils/prompt.js
CHANGED
|
@@ -13,4 +13,19 @@ const confirm_prompt = (message) => new Promise((resolve) => {
|
|
|
13
13
|
})
|
|
14
14
|
})
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// Free-text prompt. Returns the trimmed answer, or '' when there is no TTY (so callers
|
|
17
|
+
// fall back to the schema default). Writes to stderr to keep stdout envelope-clean.
|
|
18
|
+
const question_prompt = (message) => new Promise((resolve) => {
|
|
19
|
+
if (!process.stdin.isTTY) {
|
|
20
|
+
resolve('')
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr })
|
|
25
|
+
rl.question(`${message} `, (answer) => {
|
|
26
|
+
rl.close()
|
|
27
|
+
resolve(answer.trim())
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
module.exports = { confirm_prompt, question_prompt }
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Static config-usage lint — the deterministic authoring gate for config/secret skills.
|
|
3
|
+
//
|
|
4
|
+
// It CANNOT prove a skill's runtime resolver is correct (that needs execution, and we
|
|
5
|
+
// deliberately don't execute skill code). Instead it verifies, statically, that a skill which
|
|
6
|
+
// declares config/env ships the ONE correct resolution recipe rather than improvising: (a) its
|
|
7
|
+
// instructions reference `happyskills skills-config get` (the canonical resolver — the file
|
|
8
|
+
// fallback embedded alongside it is our vetted recipe, not a per-skill invention), and (b) every
|
|
9
|
+
// declared config field / env var is actually referenced in the skill's text. No code execution.
|
|
10
|
+
|
|
11
|
+
const path = require('path')
|
|
12
|
+
const fs = require('fs')
|
|
13
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
14
|
+
const { file_exists, read_file } = require('../utils/fs')
|
|
15
|
+
const { SKILL_JSON, SKILL_MD } = require('../constants')
|
|
16
|
+
|
|
17
|
+
const RESOLVER_HINT = 'skills-config get'
|
|
18
|
+
|
|
19
|
+
const as_object = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null)
|
|
20
|
+
|
|
21
|
+
const has_config_schema = (m) => {
|
|
22
|
+
const c = as_object(m && m.config)
|
|
23
|
+
const e = as_object(m && m.env)
|
|
24
|
+
return !!((c && Object.keys(c).length) || (e && Object.keys(e).length))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const result = (file, field, rule, severity, message, value) => ({
|
|
28
|
+
file, field, rule, severity, message, ...(value !== undefined ? { value } : {})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// Recursively collect files under a directory (best-effort; references are text/markdown).
|
|
32
|
+
const collect_files = (dir) => {
|
|
33
|
+
const out = []
|
|
34
|
+
const walk = (d) => {
|
|
35
|
+
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
|
36
|
+
const full = path.join(d, entry.name)
|
|
37
|
+
if (entry.isDirectory()) walk(full)
|
|
38
|
+
else out.push(full)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
try { walk(dir) } catch { /* unreadable dir — nothing to scan */ }
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Pure static lint over the manifest + the skill's full instruction text (SKILL.md + references).
|
|
46
|
+
const check_config_usage = (manifest, text) => {
|
|
47
|
+
if (!has_config_schema(manifest)) return []
|
|
48
|
+
const results = []
|
|
49
|
+
const body = String(text || '')
|
|
50
|
+
|
|
51
|
+
if (!body.includes(RESOLVER_HINT)) {
|
|
52
|
+
results.push(result(SKILL_MD, 'config', 'config_resolution_guidance', 'warning',
|
|
53
|
+
'Skill declares config/env but its instructions never reference `happyskills skills-config get`. Embed the canonical resolution snippet (skill-authoring.md § Configuration) so the agent resolves config deterministically instead of improvising — a hand-derived resolver risks reading a relative envFile against the wrong directory.'))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const config = as_object(manifest.config)
|
|
57
|
+
if (config) for (const field of Object.keys(config)) {
|
|
58
|
+
if (!body.includes(field)) results.push(result(SKILL_JSON, 'config', 'declared_unused', 'warning',
|
|
59
|
+
`config field "${field}" is declared in skill.json but never referenced in the skill's instructions — use it or remove the declaration.`, field))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const env = as_object(manifest.env)
|
|
63
|
+
if (env) for (const name of Object.keys(env)) {
|
|
64
|
+
if (!body.includes(name)) results.push(result(SKILL_JSON, 'env', 'declared_unused', 'warning',
|
|
65
|
+
`env variable "${name}" is declared in skill.json but never referenced in the skill's instructions — use it or remove the declaration.`, name))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return results
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Reads SKILL.md (passed in) + every file under references/, then runs the static lint.
|
|
72
|
+
const validate_config_usage = (skill_dir, manifest, skill_md_content) => catch_errors('Failed to validate config usage', async () => {
|
|
73
|
+
if (!has_config_schema(manifest)) return []
|
|
74
|
+
let text = String(skill_md_content || '')
|
|
75
|
+
const refs_dir = path.join(skill_dir, 'references')
|
|
76
|
+
const [, refs_exists] = await file_exists(refs_dir)
|
|
77
|
+
if (refs_exists) {
|
|
78
|
+
for (const file of collect_files(refs_dir)) {
|
|
79
|
+
const [, content] = await read_file(file)
|
|
80
|
+
if (content) text += '\n' + content
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return check_config_usage(manifest, text)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
module.exports = { check_config_usage, validate_config_usage, has_config_schema }
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { describe, it, beforeEach, afterEach } = require('node:test')
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const fs = require('fs')
|
|
5
|
+
const os = require('os')
|
|
6
|
+
const path = require('path')
|
|
7
|
+
|
|
8
|
+
const { check_config_usage, validate_config_usage } = require('./config_usage_rules')
|
|
9
|
+
|
|
10
|
+
const SLACK = {
|
|
11
|
+
name: 'slack-notify',
|
|
12
|
+
config: { channel: { type: 'string', default: '#general' } },
|
|
13
|
+
env: { SLACK_BOT_TOKEN: { required: true, secret: true } }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('check_config_usage (pure)', () => {
|
|
17
|
+
it('warns when a config/env skill never references `skills-config get`', () => {
|
|
18
|
+
const r = check_config_usage(SLACK, 'Posts to a channel using a token.')
|
|
19
|
+
assert.ok(r.some(x => x.rule === 'config_resolution_guidance' && x.severity === 'warning'))
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('does NOT warn about resolution guidance when `skills-config get` is embedded', () => {
|
|
23
|
+
const text = 'Run `happyskills skills-config get acme/slack-notify --json`. Uses channel and SLACK_BOT_TOKEN.'
|
|
24
|
+
const r = check_config_usage(SLACK, text)
|
|
25
|
+
assert.ok(!r.some(x => x.rule === 'config_resolution_guidance'))
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('warns per declared config field / env var that is never referenced', () => {
|
|
29
|
+
// references the recipe + channel, but NOT SLACK_BOT_TOKEN
|
|
30
|
+
const text = 'happyskills skills-config get acme/slack-notify. Posts to channel.'
|
|
31
|
+
const r = check_config_usage(SLACK, text)
|
|
32
|
+
assert.ok(r.some(x => x.rule === 'declared_unused' && x.value === 'SLACK_BOT_TOKEN'), 'unused env var should warn')
|
|
33
|
+
assert.ok(!r.some(x => x.rule === 'declared_unused' && x.value === 'channel'), 'referenced config field should not warn')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('returns no results for a skill with no config/env block', () => {
|
|
37
|
+
assert.deepStrictEqual(check_config_usage({ name: 'plain' }, 'anything at all'), [])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('a well-formed, fully-referenced config skill yields no warnings', () => {
|
|
41
|
+
const text = 'Resolve via `happyskills skills-config get acme/slack-notify --json`. Uses channel and SLACK_BOT_TOKEN.'
|
|
42
|
+
const r = check_config_usage(SLACK, text)
|
|
43
|
+
assert.deepStrictEqual(r, [])
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('validate_config_usage (reads SKILL.md + references/)', () => {
|
|
48
|
+
let dir
|
|
49
|
+
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-cfg-usage-')) })
|
|
50
|
+
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }) })
|
|
51
|
+
|
|
52
|
+
it('finds the recipe + token references across references/ files', async () => {
|
|
53
|
+
fs.writeFileSync(path.join(dir, 'SKILL.md'), '# Slack\nSee references for setup.\n')
|
|
54
|
+
fs.mkdirSync(path.join(dir, 'references'))
|
|
55
|
+
fs.writeFileSync(path.join(dir, 'references', 'setup.md'),
|
|
56
|
+
'Run `happyskills skills-config get acme/slack-notify --json`. Uses channel and SLACK_BOT_TOKEN.')
|
|
57
|
+
const [err, results] = await validate_config_usage(dir, SLACK, '# Slack\nSee references for setup.\n')
|
|
58
|
+
assert.ifError(err)
|
|
59
|
+
assert.deepStrictEqual(results, [], 'recipe + tokens in references/ should satisfy the lint')
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -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) {
|