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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "1.18.0",
3
+ "version": "1.19.0",
4
4
  "description": "Package manager for AI agent skills",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Nicolas Dao <nic@cloudlesslabs.com> (https://cloudlesslabs.com)",
@@ -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 install_dir = skill_install_dir(base_dir, name)
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' })