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.
@@ -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 }
@@ -13,4 +13,19 @@ const confirm_prompt = (message) => new Promise((resolve) => {
13
13
  })
14
14
  })
15
15
 
16
- module.exports = { confirm_prompt }
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) {