happyskills 1.17.0 → 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 +10 -0
- package/package.json +1 -1
- package/src/commands/validate.js +4 -1
- package/src/engine/uninstaller.js +24 -1
- package/src/skills_config/cleanup.js +107 -0
- package/src/skills_config/cleanup.test.js +123 -0
- package/src/utils/fs.js +5 -1
- package/src/validation/config_usage_rules.js +86 -0
- package/src/validation/config_usage_rules.test.js +61 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.18.0] - 2026-07-05
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Add a static config-usage lint to `happyskills validate`. For a skill whose `skill.json` declares a `config`/`env` block, `validate` now reads the skill's instruction text (SKILL.md + everything under `references/`) and emits advisory **warnings** — never blocking — when the instructions don't reference `happyskills skills-config get` (rule `config_resolution_guidance`, i.e. the skill should embed the one canonical resolution recipe rather than improvise a resolver) or when a declared `config` field / `env` variable is never referenced anywhere in the skill's text (rule `declared_unused`). The check is purely static — it never executes skill code.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- `happyskills uninstall` now cleans up a skill's consumer-side configuration instead of leaving it behind. Previously install wrote a skill's `skills-config.json` block and scaffolded its `.env` secrets, but uninstall removed neither — the config entry and secret lines persisted after the skill was gone. Uninstall (for the named skill and every pruned orphan) now removes the `owner/name` entry from `skills-config.json` and surgically strips the skill's declared secret names from the resolved `envFile` and `.env.example`, deleting the `envFile` if it is left empty. The strip is shared-`envFile` safe — a secret still declared by a skill that remains installed is preserved. Cleanup is best-effort: a failure warns but never fails the uninstall.
|
|
19
|
+
|
|
10
20
|
## [1.17.0] - 2026-07-05
|
|
11
21
|
|
|
12
22
|
### Added
|
package/package.json
CHANGED
package/src/commands/validate.js
CHANGED
|
@@ -7,6 +7,7 @@ const { validate_no_conflict_markers } = require('../validation/conflict_marker_
|
|
|
7
7
|
const { validate_file_sizes } = require('../validation/file_size_rules')
|
|
8
8
|
const { validate_changelog_version } = require('../validation/changelog_rules')
|
|
9
9
|
const { validate_dependencies } = require('../validation/dependency_rules')
|
|
10
|
+
const { validate_config_usage } = require('../validation/config_usage_rules')
|
|
10
11
|
const { file_exists, read_json } = require('../utils/fs')
|
|
11
12
|
const { skills_dir, find_project_root, lock_root } = require('../config/paths')
|
|
12
13
|
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
@@ -178,6 +179,8 @@ const run = (args) => catch_errors('Validate failed', async () => {
|
|
|
178
179
|
if (size_err) throw size_err
|
|
179
180
|
const [cl_err, cl_results] = await validate_changelog_version(skill_dir, json_data.manifest)
|
|
180
181
|
if (cl_err) throw cl_err
|
|
182
|
+
const [cfg_err, cfg_results] = await validate_config_usage(skill_dir, json_data.manifest, md_data.content)
|
|
183
|
+
if (cfg_err) throw cfg_err
|
|
181
184
|
|
|
182
185
|
// Dependency validation — registry checks when authenticated
|
|
183
186
|
let dep_results = []
|
|
@@ -219,7 +222,7 @@ const run = (args) => catch_errors('Validate failed', async () => {
|
|
|
219
222
|
}
|
|
220
223
|
}
|
|
221
224
|
|
|
222
|
-
const all_results = [...md_data.results, ...json_data.results, ...cross_results, ...marker_results, ...size_results, ...cl_results, ...dep_results]
|
|
225
|
+
const all_results = [...md_data.results, ...json_data.results, ...cross_results, ...marker_results, ...size_results, ...cl_results, ...(cfg_results || []), ...dep_results]
|
|
223
226
|
const type_label = is_kit ? ' [kit]' : ''
|
|
224
227
|
|
|
225
228
|
if (args.flags.json) {
|
|
@@ -4,7 +4,8 @@ const { write_lock, update_lock_skills } = require('../lock/writer')
|
|
|
4
4
|
const { skills_dir, skill_install_dir, lock_root } = require('../config/paths')
|
|
5
5
|
const { remove_dir } = require('../utils/fs')
|
|
6
6
|
const { resolve_agents, unlink_from_agents } = require('../agents')
|
|
7
|
-
const {
|
|
7
|
+
const { cleanup_uninstalled_configs, read_skill_secret_names } = require('../skills_config/cleanup')
|
|
8
|
+
const { print_success, print_info, print_warn } = require('../ui/output')
|
|
8
9
|
|
|
9
10
|
const find_orphans = (skills, removed_skill) => {
|
|
10
11
|
// A skill is still needed if a SURVIVING skill (anything other than the one
|
|
@@ -98,6 +99,15 @@ const uninstall = (skill, options = {}) => catch_errors('Uninstall failed', asyn
|
|
|
98
99
|
const orphans = find_orphans_cascading(all_skills, skill)
|
|
99
100
|
const to_remove = [skill, ...orphans]
|
|
100
101
|
|
|
102
|
+
// Capture each removed skill's declared secret NAMES BEFORE its install dir is deleted —
|
|
103
|
+
// the manifest is unreadable afterward, and the config cleanup below needs them to surgically
|
|
104
|
+
// strip the right .env lines. Non-secret / non-config skills yield an empty list.
|
|
105
|
+
const removed_config_info = []
|
|
106
|
+
for (const name of to_remove) {
|
|
107
|
+
const [, secret_names] = await read_skill_secret_names(base_dir, name)
|
|
108
|
+
removed_config_info.push({ key: name, name: name.split('/')[1], secret_names: secret_names || [] })
|
|
109
|
+
}
|
|
110
|
+
|
|
101
111
|
for (const name of to_remove) {
|
|
102
112
|
const install_dir = skill_install_dir(base_dir, name.split('/')[1])
|
|
103
113
|
const [rm_errors] = await remove_dir(install_dir)
|
|
@@ -120,6 +130,19 @@ const uninstall = (skill, options = {}) => catch_errors('Uninstall failed', asyn
|
|
|
120
130
|
const [write_errors] = await write_lock(lock_dir, new_skills)
|
|
121
131
|
if (write_errors) throw e('Failed to update lock file', write_errors)
|
|
122
132
|
|
|
133
|
+
// Consumer-side config cleanup (spec 260704-01 Tier 6 — prune policy). Drop each removed
|
|
134
|
+
// skill's skills-config.json block and surgically strip its now-unused secret names from the
|
|
135
|
+
// .env / .env.example, preserving any secret a surviving skill still declares. Best-effort:
|
|
136
|
+
// a failure here warns but never fails the uninstall — the skill is already gone.
|
|
137
|
+
const surviving_keys = Object.keys(all_skills).filter(k => !to_remove.includes(k))
|
|
138
|
+
const [cleanup_err] = await cleanup_uninstalled_configs({
|
|
139
|
+
config_root: lock_dir,
|
|
140
|
+
base_dir,
|
|
141
|
+
removed: removed_config_info,
|
|
142
|
+
surviving_keys
|
|
143
|
+
})
|
|
144
|
+
if (cleanup_err) print_warn(`Config cleanup step failed: ${cleanup_err[0]?.message || 'unknown error'}`)
|
|
145
|
+
|
|
123
146
|
print_success(`Removed ${skill}`)
|
|
124
147
|
if (orphans.length > 0) {
|
|
125
148
|
print_info(`Pruned ${orphans.length} orphaned dependency(s): ${orphans.join(', ')}`)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
const path = require('path')
|
|
2
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
3
|
+
const { read_json, read_file, write_file, file_exists, remove_file } = require('../utils/fs')
|
|
4
|
+
const { skill_install_dir } = require('../config/paths')
|
|
5
|
+
const { read_skills_config, get_skill_config_entry } = require('./reader')
|
|
6
|
+
const { set_skill_config_entry } = require('./writer')
|
|
7
|
+
const { SKILL_JSON } = require('../constants')
|
|
8
|
+
|
|
9
|
+
const as_object = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null)
|
|
10
|
+
|
|
11
|
+
// The env-var names an installed skill declares as SECRETS (secret: true). These are the only
|
|
12
|
+
// names install ever scaffolds into a .env, so they are the only names uninstall may strip.
|
|
13
|
+
const secret_names_from_manifest = (manifest) => {
|
|
14
|
+
const env = as_object(manifest && manifest.env) || {}
|
|
15
|
+
return Object.entries(env).filter(([, spec]) => spec && spec.secret).map(([name]) => name)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Reads a skill's secret var names from its installed skill.json. Called BEFORE the install dir
|
|
19
|
+
// is removed (the manifest is unreadable afterward), so the uninstaller can hand them to cleanup.
|
|
20
|
+
const read_skill_secret_names = (base_dir, key) => catch_errors('Failed to read skill secret names', async () => {
|
|
21
|
+
const name = key.split('/')[1]
|
|
22
|
+
const [, manifest] = await read_json(path.join(skill_install_dir(base_dir, name), SKILL_JSON))
|
|
23
|
+
return secret_names_from_manifest(manifest)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// Pure: drop every `NAME=...` line whose key is in `names`, leaving all other lines (other
|
|
27
|
+
// assignments, comments, blanks) untouched. `all_blank` is true when nothing but whitespace
|
|
28
|
+
// survives — the signal to delete the file rather than leave an empty husk.
|
|
29
|
+
const strip_env_var_lines = (content, names) => {
|
|
30
|
+
const set = new Set(names)
|
|
31
|
+
const kept = []
|
|
32
|
+
let changed = false
|
|
33
|
+
for (const line of String(content || '').split('\n')) {
|
|
34
|
+
const idx = line.indexOf('=')
|
|
35
|
+
const key = idx === -1 ? null : line.slice(0, idx).trim()
|
|
36
|
+
if (key && set.has(key)) { changed = true; continue }
|
|
37
|
+
kept.push(line)
|
|
38
|
+
}
|
|
39
|
+
return { changed, next: kept.join('\n'), all_blank: kept.every(l => l.trim() === '') }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Strip `names` from an env file in place. Deletes the file when it is left all-blank; otherwise
|
|
43
|
+
// rewrites it. A missing file or a no-op strip touches nothing.
|
|
44
|
+
const strip_names_from_env_file = (abs, names) => catch_errors('Failed to strip env file', async () => {
|
|
45
|
+
const [, exists] = await file_exists(abs)
|
|
46
|
+
if (!exists) return
|
|
47
|
+
const [, content] = await read_file(abs)
|
|
48
|
+
const { changed, next, all_blank } = strip_env_var_lines(content, names)
|
|
49
|
+
if (!changed) return
|
|
50
|
+
if (all_blank) { const [rm_err] = await remove_file(abs); if (rm_err) throw rm_err[0]; return }
|
|
51
|
+
const [w_err] = await write_file(abs, next.endsWith('\n') ? next : next + '\n')
|
|
52
|
+
if (w_err) throw w_err[0]
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// Uninstall-time counterpart to configure_installed_skills: for every removed skill, drop its
|
|
56
|
+
// `owner/name` block from skills-config.json and surgically strip its secret NAMES from the
|
|
57
|
+
// resolved envFile (and .env.example). A secret name still declared by a SURVIVING installed
|
|
58
|
+
// skill is preserved (shared-envFile safe). Never touches the .gitignore line. Best-effort —
|
|
59
|
+
// the caller treats a failure here as a warning, not an uninstall failure.
|
|
60
|
+
const cleanup_uninstalled_configs = ({ config_root, base_dir, removed, surviving_keys } = {}) => catch_errors('Failed to clean up skills-config on uninstall', async () => {
|
|
61
|
+
if (!config_root || !(removed && removed.length)) return { config_keys_removed: [], secrets_stripped: [] }
|
|
62
|
+
|
|
63
|
+
const [, existing] = await read_skills_config(config_root)
|
|
64
|
+
|
|
65
|
+
// Union of secret names declared by every skill that REMAINS installed — the shared-safe guard.
|
|
66
|
+
const surviving_secrets = new Set()
|
|
67
|
+
for (const key of surviving_keys || []) {
|
|
68
|
+
const name = key.split('/')[1]
|
|
69
|
+
const [, manifest] = await read_json(path.join(skill_install_dir(base_dir, name), SKILL_JSON))
|
|
70
|
+
for (const secret of secret_names_from_manifest(manifest)) surviving_secrets.add(secret)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const config_keys_removed = []
|
|
74
|
+
const secrets_stripped = []
|
|
75
|
+
for (const { key, name, secret_names } of removed) {
|
|
76
|
+
const entry = get_skill_config_entry(existing, key)
|
|
77
|
+
|
|
78
|
+
// Remove the config block (committed, git-recoverable — safe to prune).
|
|
79
|
+
if (entry) {
|
|
80
|
+
const [set_err] = await set_skill_config_entry(config_root, key, null)
|
|
81
|
+
if (set_err) throw set_err[0]
|
|
82
|
+
config_keys_removed.push(key)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const names_to_strip = (secret_names || []).filter(n => !surviving_secrets.has(n))
|
|
86
|
+
if (!names_to_strip.length) continue
|
|
87
|
+
|
|
88
|
+
// envFile: the consumer's declared pointer, else the install-time default location.
|
|
89
|
+
const env_file_rel = (entry && entry.envFile) || `./secrets/${name}.env`
|
|
90
|
+
const env_abs = path.resolve(config_root, env_file_rel)
|
|
91
|
+
const [env_err] = await strip_names_from_env_file(env_abs, names_to_strip)
|
|
92
|
+
if (env_err) throw env_err[0]
|
|
93
|
+
// The committed name-only twin gets the same treatment.
|
|
94
|
+
const [ex_err] = await strip_names_from_env_file(path.join(config_root, '.env.example'), names_to_strip)
|
|
95
|
+
if (ex_err) throw ex_err[0]
|
|
96
|
+
secrets_stripped.push(...names_to_strip)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { config_keys_removed, secrets_stripped }
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
module.exports = {
|
|
103
|
+
cleanup_uninstalled_configs,
|
|
104
|
+
read_skill_secret_names,
|
|
105
|
+
strip_env_var_lines,
|
|
106
|
+
secret_names_from_manifest
|
|
107
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
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 { cleanup_uninstalled_configs, strip_env_var_lines, read_skill_secret_names } = require('./cleanup')
|
|
9
|
+
|
|
10
|
+
describe('strip_env_var_lines (pure)', () => {
|
|
11
|
+
it('removes only the named assignment lines, keeps the rest', () => {
|
|
12
|
+
const content = 'SLACK_BOT_TOKEN=xoxb-123\nDB_URL=postgres://x\n'
|
|
13
|
+
const r = strip_env_var_lines(content, ['SLACK_BOT_TOKEN'])
|
|
14
|
+
assert.equal(r.changed, true)
|
|
15
|
+
assert.ok(r.next.includes('DB_URL=postgres://x'))
|
|
16
|
+
assert.ok(!r.next.includes('SLACK_BOT_TOKEN'))
|
|
17
|
+
assert.equal(r.all_blank, false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('reports all_blank when nothing but whitespace remains', () => {
|
|
21
|
+
const r = strip_env_var_lines('SLACK_BOT_TOKEN=xoxb\n', ['SLACK_BOT_TOKEN'])
|
|
22
|
+
assert.equal(r.changed, true)
|
|
23
|
+
assert.equal(r.all_blank, true)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('no-op when no named line is present', () => {
|
|
27
|
+
const r = strip_env_var_lines('DB_URL=x\n', ['NOPE'])
|
|
28
|
+
assert.equal(r.changed, false)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// Build a fake project: base_dir holds installed skill dirs (each with skill.json),
|
|
33
|
+
// config_root holds skills-config.json + secrets/*.env + .env.example.
|
|
34
|
+
const scaffold = (root, { skills, config, envFiles, example }) => {
|
|
35
|
+
const base_dir = path.join(root, 'skills')
|
|
36
|
+
for (const [key, manifest] of Object.entries(skills)) {
|
|
37
|
+
const dir = path.join(base_dir, key.split('/')[1])
|
|
38
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
39
|
+
fs.writeFileSync(path.join(dir, 'skill.json'), JSON.stringify(manifest))
|
|
40
|
+
}
|
|
41
|
+
fs.writeFileSync(path.join(root, 'skills-config.json'), JSON.stringify(config))
|
|
42
|
+
fs.mkdirSync(path.join(root, 'secrets'), { recursive: true })
|
|
43
|
+
for (const [rel, body] of Object.entries(envFiles || {})) {
|
|
44
|
+
fs.writeFileSync(path.join(root, rel), body)
|
|
45
|
+
}
|
|
46
|
+
if (example !== undefined) fs.writeFileSync(path.join(root, '.env.example'), example)
|
|
47
|
+
return base_dir
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('cleanup_uninstalled_configs', () => {
|
|
51
|
+
let root
|
|
52
|
+
beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-cfg-cleanup-')) })
|
|
53
|
+
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }) })
|
|
54
|
+
|
|
55
|
+
it('removes the uninstalled skill config entry and deletes its now-empty secrets file', async () => {
|
|
56
|
+
const base_dir = scaffold(root, {
|
|
57
|
+
skills: {
|
|
58
|
+
'acme/slack': { name: 'slack', env: { SLACK_BOT_TOKEN: { secret: true, required: true } } },
|
|
59
|
+
'acme/keep': { name: 'keep', env: { DB_URL: { secret: true } } }
|
|
60
|
+
},
|
|
61
|
+
config: {
|
|
62
|
+
'acme/slack': { envFile: './secrets/slack.env', config: { channel: '#ops' } },
|
|
63
|
+
'acme/keep': { envFile: './secrets/keep.env' }
|
|
64
|
+
},
|
|
65
|
+
envFiles: { 'secrets/slack.env': 'SLACK_BOT_TOKEN=xoxb-123\n', 'secrets/keep.env': 'DB_URL=postgres://x\n' },
|
|
66
|
+
example: 'SLACK_BOT_TOKEN=\nDB_URL=\n'
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const [err] = await cleanup_uninstalled_configs({
|
|
70
|
+
config_root: root,
|
|
71
|
+
base_dir,
|
|
72
|
+
removed: [{ key: 'acme/slack', name: 'slack', secret_names: ['SLACK_BOT_TOKEN'] }],
|
|
73
|
+
surviving_keys: ['acme/keep']
|
|
74
|
+
})
|
|
75
|
+
assert.ifError(err)
|
|
76
|
+
|
|
77
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(root, 'skills-config.json'), 'utf-8'))
|
|
78
|
+
assert.ok(!cfg['acme/slack'], 'removed skill entry should be gone')
|
|
79
|
+
assert.ok(cfg['acme/keep'], 'surviving skill entry should remain')
|
|
80
|
+
assert.equal(fs.existsSync(path.join(root, 'secrets/slack.env')), false, 'empty secrets file should be deleted')
|
|
81
|
+
assert.equal(fs.existsSync(path.join(root, 'secrets/keep.env')), true, 'other skill secrets file untouched')
|
|
82
|
+
const example = fs.readFileSync(path.join(root, '.env.example'), 'utf-8')
|
|
83
|
+
assert.ok(!example.includes('SLACK_BOT_TOKEN'), 'removed secret name stripped from .env.example')
|
|
84
|
+
assert.ok(example.includes('DB_URL'), 'surviving secret name kept in .env.example')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('preserves a secret still declared by a surviving skill (shared envFile)', async () => {
|
|
88
|
+
const base_dir = scaffold(root, {
|
|
89
|
+
skills: {
|
|
90
|
+
'acme/slack': { name: 'slack', env: { SLACK_BOT_TOKEN: { secret: true }, SHARED: { secret: true } } },
|
|
91
|
+
'acme/keep': { name: 'keep', env: { SHARED: { secret: true } } }
|
|
92
|
+
},
|
|
93
|
+
config: {
|
|
94
|
+
'acme/slack': { envFile: './secrets/shared.env' },
|
|
95
|
+
'acme/keep': { envFile: './secrets/shared.env' }
|
|
96
|
+
},
|
|
97
|
+
envFiles: { 'secrets/shared.env': 'SLACK_BOT_TOKEN=xoxb\nSHARED=zzz\n' }
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const [err] = await cleanup_uninstalled_configs({
|
|
101
|
+
config_root: root,
|
|
102
|
+
base_dir,
|
|
103
|
+
removed: [{ key: 'acme/slack', name: 'slack', secret_names: ['SLACK_BOT_TOKEN', 'SHARED'] }],
|
|
104
|
+
surviving_keys: ['acme/keep']
|
|
105
|
+
})
|
|
106
|
+
assert.ifError(err)
|
|
107
|
+
|
|
108
|
+
const env = fs.readFileSync(path.join(root, 'secrets/shared.env'), 'utf-8')
|
|
109
|
+
assert.ok(!env.includes('SLACK_BOT_TOKEN'), 'unshared secret stripped')
|
|
110
|
+
assert.ok(env.includes('SHARED=zzz'), 'secret shared with a surviving skill is preserved')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('read_skill_secret_names returns the manifest secret var names', async () => {
|
|
114
|
+
const base_dir = scaffold(root, {
|
|
115
|
+
skills: { 'acme/slack': { name: 'slack', env: { SLACK_BOT_TOKEN: { secret: true }, PUBLIC: { secret: false } } } },
|
|
116
|
+
config: {},
|
|
117
|
+
envFiles: {}
|
|
118
|
+
})
|
|
119
|
+
const [err, names] = await read_skill_secret_names(base_dir, 'acme/slack')
|
|
120
|
+
assert.ifError(err)
|
|
121
|
+
assert.deepEqual(names, ['SLACK_BOT_TOKEN'])
|
|
122
|
+
})
|
|
123
|
+
})
|
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 }
|
|
@@ -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
|
+
})
|