happyskills 1.18.0 → 1.19.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
CHANGED
|
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.19.0] - 2026-07-06
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Add constellation-aware shared secrets. A non-kit skill that declares `sharedEnv: true` in `skill.json` now shares a single `./secrets/<core>.env` across itself and its skill-dependencies on install, instead of scaffolding an identical per-skill secrets file for every member (the redundant, drift-prone case for related skills). A consumer-set `envFile` still wins, standalone installs and kits are unaffected, and a collision guard warns when two shared members declare the same secret name with a different type. `happyskills validate` now checks `sharedEnv` (must be a boolean; error on a kit; warning when `true` without dependencies). Documented in `docs/skill-format.md` §4.6.
|
|
15
|
+
|
|
10
16
|
## [1.18.0] - 2026-07-05
|
|
11
17
|
|
|
12
18
|
### Added
|
package/package.json
CHANGED
|
@@ -75,11 +75,54 @@ const configure_installed_skills = (packages, options = {}) => catch_errors('Fai
|
|
|
75
75
|
|
|
76
76
|
const [, existing_config_file] = await read_skills_config(config_root)
|
|
77
77
|
|
|
78
|
+
// Constellation shared-env pre-pass (spec: `sharedEnv`). A non-kit package that declares
|
|
79
|
+
// `sharedEnv: true` is a constellation root: it and every skill-dependency being installed
|
|
80
|
+
// share ONE derived `.env` (`./secrets/<root>.env`) instead of a redundant, drift-prone file
|
|
81
|
+
// each. Kits (unrelated bundles) are excluded and keep per-member files. Deterministic: sorted
|
|
82
|
+
// iteration, first root wins, and a consumer's own envFile pointer (honored in the loop below)
|
|
83
|
+
// always overrides the shared default.
|
|
84
|
+
const pkg_keys = new Set((packages || []).map(p => p.skill))
|
|
85
|
+
const manifest_by_key = new Map()
|
|
86
|
+
for (const pkg of packages || []) {
|
|
87
|
+
const pkg_name = pkg.skill.split('/')[1]
|
|
88
|
+
const [, m] = await read_json(path.join(skill_install_dir(base_dir, pkg_name), SKILL_JSON))
|
|
89
|
+
if (m) manifest_by_key.set(pkg.skill, m)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const shared_env_file_by_key = new Map()
|
|
93
|
+
for (const root_key of [...pkg_keys].sort()) {
|
|
94
|
+
const m = manifest_by_key.get(root_key)
|
|
95
|
+
if (!m || m.sharedEnv !== true || m.type === 'kit') continue
|
|
96
|
+
const shared_file = `./secrets/${root_key.split('/')[1]}.env`
|
|
97
|
+
const dep_keys = Object.keys(as_object(m.dependencies) || {})
|
|
98
|
+
for (const member_key of [root_key, ...dep_keys]) {
|
|
99
|
+
if (pkg_keys.has(member_key) && !shared_env_file_by_key.has(member_key)) shared_env_file_by_key.set(member_key, shared_file)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Collision guard: within one shared file, two members declaring the same secret NAME with a
|
|
104
|
+
// different `type` may not mean the same secret. Warn once so the author reconciles.
|
|
105
|
+
const shared_secret_seen = new Map() // `${file}\0${name}` -> { type, owner }
|
|
106
|
+
for (const member_key of [...shared_env_file_by_key.keys()].sort()) {
|
|
107
|
+
const shared_file = shared_env_file_by_key.get(member_key)
|
|
108
|
+
const env = as_object((manifest_by_key.get(member_key) || {}).env) || {}
|
|
109
|
+
for (const [var_name, spec] of Object.entries(env)) {
|
|
110
|
+
if (!spec || spec.secret !== true) continue
|
|
111
|
+
const seen_key = `${shared_file}\0${var_name}`
|
|
112
|
+
const this_type = spec.type === undefined ? null : spec.type
|
|
113
|
+
const prev = shared_secret_seen.get(seen_key)
|
|
114
|
+
if (prev) {
|
|
115
|
+
if (prev.type !== this_type) warnings.push(`Shared-env collision: secret "${var_name}" is declared by ${prev.owner} and ${member_key} with different types in ${shared_file} — verify they are the same secret.`)
|
|
116
|
+
} else {
|
|
117
|
+
shared_secret_seen.set(seen_key, { type: this_type, owner: member_key })
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
78
122
|
for (const pkg of packages || []) {
|
|
79
123
|
const key = pkg.skill
|
|
80
124
|
const name = key.split('/')[1]
|
|
81
|
-
const
|
|
82
|
-
const [, manifest] = await read_json(path.join(install_dir, SKILL_JSON))
|
|
125
|
+
const manifest = manifest_by_key.get(key)
|
|
83
126
|
if (!manifest) continue
|
|
84
127
|
|
|
85
128
|
const config_schema = as_object(manifest.config)
|
|
@@ -122,7 +165,7 @@ const configure_installed_skills = (packages, options = {}) => catch_errors('Fai
|
|
|
122
165
|
|
|
123
166
|
let env_file_rel = (existing_entry && existing_entry.envFile) || null
|
|
124
167
|
if (secret_names.length) {
|
|
125
|
-
env_file_rel = env_file_rel || `./secrets/${name}.env`
|
|
168
|
+
env_file_rel = env_file_rel || shared_env_file_by_key.get(key) || `./secrets/${name}.env`
|
|
126
169
|
// Resolve the (possibly consumer-chosen) envFile relative to the config root.
|
|
127
170
|
const env_abs = path.resolve(config_root, env_file_rel)
|
|
128
171
|
const [scaffold_err] = await ensure_env_names(env_abs, secret_names.map(s => s.name))
|
|
@@ -127,6 +127,104 @@ describe('configure_installed_skills — interactive overrides', () => {
|
|
|
127
127
|
})
|
|
128
128
|
})
|
|
129
129
|
|
|
130
|
+
describe('configure_installed_skills — constellation sharedEnv', () => {
|
|
131
|
+
const TOKEN_ENV = { env: { CLOUDFLARE_API_TOKEN: { required: true, secret: true, description: 'CF token' } } }
|
|
132
|
+
|
|
133
|
+
it('routes a shared-env core and its dependency members to ONE derived envFile', async () => {
|
|
134
|
+
write_skill(base_dir, 'cloudflare', {
|
|
135
|
+
sharedEnv: true,
|
|
136
|
+
dependencies: { 'nicolasdao/cloudflare-deploy': '^0.1.0', 'nicolasdao/cloudflare-config': '^0.1.0' },
|
|
137
|
+
...TOKEN_ENV
|
|
138
|
+
})
|
|
139
|
+
write_skill(base_dir, 'cloudflare-deploy', TOKEN_ENV)
|
|
140
|
+
write_skill(base_dir, 'cloudflare-config', TOKEN_ENV)
|
|
141
|
+
|
|
142
|
+
const [err] = await configure_installed_skills(
|
|
143
|
+
[{ skill: 'nicolasdao/cloudflare' }, { skill: 'nicolasdao/cloudflare-deploy' }, { skill: 'nicolasdao/cloudflare-config' }],
|
|
144
|
+
{ base_dir, config_root, interactive: false }
|
|
145
|
+
)
|
|
146
|
+
assert.ifError(err)
|
|
147
|
+
|
|
148
|
+
const cfg = JSON.parse(read(path.join(config_root, 'skills-config.json')))
|
|
149
|
+
assert.strictEqual(cfg['nicolasdao/cloudflare'].envFile, './secrets/cloudflare.env')
|
|
150
|
+
assert.strictEqual(cfg['nicolasdao/cloudflare-deploy'].envFile, './secrets/cloudflare.env')
|
|
151
|
+
assert.strictEqual(cfg['nicolasdao/cloudflare-config'].envFile, './secrets/cloudflare.env')
|
|
152
|
+
|
|
153
|
+
// exactly ONE shared secrets file — the redundant per-member files are not created
|
|
154
|
+
assert.ok(fs.existsSync(path.join(config_root, 'secrets', 'cloudflare.env')))
|
|
155
|
+
assert.ok(!fs.existsSync(path.join(config_root, 'secrets', 'cloudflare-deploy.env')))
|
|
156
|
+
assert.ok(!fs.existsSync(path.join(config_root, 'secrets', 'cloudflare-config.env')))
|
|
157
|
+
|
|
158
|
+
// the shared file carries the token name exactly once
|
|
159
|
+
const env_content = read(path.join(config_root, 'secrets', 'cloudflare.env'))
|
|
160
|
+
assert.strictEqual((env_content.match(/CLOUDFLARE_API_TOKEN=/g) || []).length, 1)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('respects a consumer-set envFile over the shared default', async () => {
|
|
164
|
+
fs.writeFileSync(
|
|
165
|
+
path.join(config_root, 'skills-config.json'),
|
|
166
|
+
JSON.stringify({ 'nicolasdao/cloudflare-deploy': { envFile: './my-secrets/cf.env' } }, null, '\t')
|
|
167
|
+
)
|
|
168
|
+
write_skill(base_dir, 'cloudflare', { sharedEnv: true, dependencies: { 'nicolasdao/cloudflare-deploy': '^0.1.0' }, ...TOKEN_ENV })
|
|
169
|
+
write_skill(base_dir, 'cloudflare-deploy', TOKEN_ENV)
|
|
170
|
+
|
|
171
|
+
const [err] = await configure_installed_skills(
|
|
172
|
+
[{ skill: 'nicolasdao/cloudflare' }, { skill: 'nicolasdao/cloudflare-deploy' }],
|
|
173
|
+
{ base_dir, config_root, interactive: false }
|
|
174
|
+
)
|
|
175
|
+
assert.ifError(err)
|
|
176
|
+
|
|
177
|
+
const cfg = JSON.parse(read(path.join(config_root, 'skills-config.json')))
|
|
178
|
+
assert.strictEqual(cfg['nicolasdao/cloudflare-deploy'].envFile, './my-secrets/cf.env') // consumer override wins
|
|
179
|
+
assert.strictEqual(cfg['nicolasdao/cloudflare'].envFile, './secrets/cloudflare.env')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('does NOT share when the core lacks sharedEnv (regression: per-skill files)', async () => {
|
|
183
|
+
write_skill(base_dir, 'cloudflare', { dependencies: { 'nicolasdao/cloudflare-deploy': '^0.1.0' }, ...TOKEN_ENV })
|
|
184
|
+
write_skill(base_dir, 'cloudflare-deploy', TOKEN_ENV)
|
|
185
|
+
|
|
186
|
+
const [err] = await configure_installed_skills(
|
|
187
|
+
[{ skill: 'nicolasdao/cloudflare' }, { skill: 'nicolasdao/cloudflare-deploy' }],
|
|
188
|
+
{ base_dir, config_root, interactive: false }
|
|
189
|
+
)
|
|
190
|
+
assert.ifError(err)
|
|
191
|
+
|
|
192
|
+
const cfg = JSON.parse(read(path.join(config_root, 'skills-config.json')))
|
|
193
|
+
assert.strictEqual(cfg['nicolasdao/cloudflare'].envFile, './secrets/cloudflare.env')
|
|
194
|
+
assert.strictEqual(cfg['nicolasdao/cloudflare-deploy'].envFile, './secrets/cloudflare-deploy.env')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('does NOT share for a kit (kit members keep isolated secrets)', async () => {
|
|
198
|
+
write_skill(base_dir, '_kit-x', { type: 'kit', sharedEnv: true, dependencies: { 'acme/a': '^1.0.0' } })
|
|
199
|
+
write_skill(base_dir, 'a', TOKEN_ENV)
|
|
200
|
+
|
|
201
|
+
const [err] = await configure_installed_skills(
|
|
202
|
+
[{ skill: 'acme/_kit-x' }, { skill: 'acme/a' }],
|
|
203
|
+
{ base_dir, config_root, interactive: false }
|
|
204
|
+
)
|
|
205
|
+
assert.ifError(err)
|
|
206
|
+
|
|
207
|
+
const cfg = JSON.parse(read(path.join(config_root, 'skills-config.json')))
|
|
208
|
+
assert.strictEqual(cfg['acme/a'].envFile, './secrets/a.env') // NOT the kit's file
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('warns when two shared members declare the same secret with a different type', async () => {
|
|
212
|
+
write_skill(base_dir, 'core', {
|
|
213
|
+
sharedEnv: true,
|
|
214
|
+
dependencies: { 'acme/sat': '^1.0.0' },
|
|
215
|
+
env: { TOK: { required: true, secret: true, type: 'string' } }
|
|
216
|
+
})
|
|
217
|
+
write_skill(base_dir, 'sat', { env: { TOK: { required: true, secret: true, type: 'integer' } } })
|
|
218
|
+
|
|
219
|
+
const [err, result] = await configure_installed_skills(
|
|
220
|
+
[{ skill: 'acme/core' }, { skill: 'acme/sat' }],
|
|
221
|
+
{ base_dir, config_root, interactive: false }
|
|
222
|
+
)
|
|
223
|
+
assert.ifError(err)
|
|
224
|
+
assert.ok(result.warnings.some(w => /TOK/.test(w) && /shared/i.test(w)), 'a same-name/different-type shared secret must warn')
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
130
228
|
describe('configure_installed_skills — reconcile on update (only_required)', () => {
|
|
131
229
|
it('re-prompts exactly the newly-required field and leaves existing values untouched', async () => {
|
|
132
230
|
// v1: only `channel` config. Consumer sets it.
|
|
@@ -277,6 +277,40 @@ const validate_env = (manifest) => {
|
|
|
277
277
|
return results
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
// sharedEnv (constellation-only). When a non-kit skill sets `sharedEnv: true`, the CLI points the
|
|
281
|
+
// skill AND its skill-dependencies at one shared `.env` on install — a constellation shares a
|
|
282
|
+
// config namespace, so its members should not each scaffold an identical secrets file. Forbidden
|
|
283
|
+
// on kits: a kit bundles unrelated skills, which keep isolated secrets. A no-op without deps.
|
|
284
|
+
const validate_shared_env = (manifest) => {
|
|
285
|
+
const results = []
|
|
286
|
+
if (manifest.sharedEnv === undefined) return results
|
|
287
|
+
|
|
288
|
+
if (typeof manifest.sharedEnv !== 'boolean') {
|
|
289
|
+
results.push(result('sharedEnv', 'type', 'error', 'sharedEnv must be a boolean'))
|
|
290
|
+
return results
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (manifest.sharedEnv !== true) {
|
|
294
|
+
results.push(result('sharedEnv', 'valid', 'pass', 'sharedEnv is false'))
|
|
295
|
+
return results
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (manifest.type === SKILL_TYPES.KIT) {
|
|
299
|
+
results.push(result('sharedEnv', 'kit_forbidden', 'error', 'sharedEnv is not allowed on a kit — kit members bundle unrelated skills and keep isolated secrets. Use sharedEnv only on a constellation core.'))
|
|
300
|
+
return results
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const deps = manifest.dependencies
|
|
304
|
+
const dep_count = deps && typeof deps === 'object' && !Array.isArray(deps) ? Object.keys(deps).length : 0
|
|
305
|
+
if (dep_count === 0) {
|
|
306
|
+
results.push(result('sharedEnv', 'no_dependencies', 'warning', 'sharedEnv: true has no effect without dependencies — it shares one .env across the skill and its skill-dependencies (a constellation).'))
|
|
307
|
+
return results
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
results.push(result('sharedEnv', 'valid', 'pass', 'sharedEnv: true — the skill and its dependencies will share one .env on install'))
|
|
311
|
+
return results
|
|
312
|
+
}
|
|
313
|
+
|
|
280
314
|
const validate_skill_json = (skill_dir) => catch_errors('Failed to validate skill.json', async () => {
|
|
281
315
|
const results = []
|
|
282
316
|
const json_path = path.join(skill_dir, SKILL_JSON)
|
|
@@ -308,6 +342,7 @@ const validate_skill_json = (skill_dir) => catch_errors('Failed to validate skil
|
|
|
308
342
|
results.push(...validate_system_dependencies(manifest))
|
|
309
343
|
results.push(...validate_config(manifest))
|
|
310
344
|
results.push(...validate_env(manifest))
|
|
345
|
+
results.push(...validate_shared_env(manifest))
|
|
311
346
|
|
|
312
347
|
const is_kit = manifest.type === SKILL_TYPES.KIT
|
|
313
348
|
if (is_kit) {
|
|
@@ -69,6 +69,40 @@ describe('validate_skill_json — name', () => {
|
|
|
69
69
|
})
|
|
70
70
|
})
|
|
71
71
|
|
|
72
|
+
describe('validate_skill_json — sharedEnv', () => {
|
|
73
|
+
it('errors when sharedEnv is not a boolean', async () => {
|
|
74
|
+
write_json(tmp, { name: 'core', version: '1.0.0', sharedEnv: 'yes', dependencies: { 'a/b': '^1.0.0' } })
|
|
75
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
76
|
+
assert.ifError(err)
|
|
77
|
+
const check = data.results.find(r => r.field === 'sharedEnv' && r.rule === 'type')
|
|
78
|
+
assert.strictEqual(check.severity, 'error')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('errors when a kit declares sharedEnv: true', async () => {
|
|
82
|
+
write_json(tmp, { name: '_kit-x', version: '1.0.0', type: 'kit', sharedEnv: true, dependencies: { 'a/b': '^1.0.0' } })
|
|
83
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
84
|
+
assert.ifError(err)
|
|
85
|
+
const check = data.results.find(r => r.field === 'sharedEnv' && r.rule === 'kit_forbidden')
|
|
86
|
+
assert.strictEqual(check.severity, 'error')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('warns when sharedEnv: true has no dependencies', async () => {
|
|
90
|
+
write_json(tmp, { name: 'core', version: '1.0.0', sharedEnv: true })
|
|
91
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
92
|
+
assert.ifError(err)
|
|
93
|
+
const check = data.results.find(r => r.field === 'sharedEnv' && r.rule === 'no_dependencies')
|
|
94
|
+
assert.strictEqual(check.severity, 'warning')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('passes for sharedEnv: true with dependencies', async () => {
|
|
98
|
+
write_json(tmp, { name: 'core', version: '1.0.0', sharedEnv: true, dependencies: { 'a/b': '^1.0.0' } })
|
|
99
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
100
|
+
assert.ifError(err)
|
|
101
|
+
const check = data.results.find(r => r.field === 'sharedEnv' && r.rule === 'valid')
|
|
102
|
+
assert.strictEqual(check.severity, 'pass')
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
72
106
|
describe('validate_skill_json — version', () => {
|
|
73
107
|
it('errors when version is missing', async () => {
|
|
74
108
|
write_json(tmp, { name: 'my-skill' })
|