happyskills 1.16.3 → 1.17.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 +9 -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/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/integration/skills-config.test.js +98 -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/prompt.js +16 -1
- package/src/validation/skill_json_rules.js +99 -0
- package/src/validation/skill_json_rules.test.js +130 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.17.0] - 2026-07-05
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Add consumer-side per-skill configuration via a project-root `skills-config.json` (a committed sibling of `skills-lock.json`, keyed by `owner/name`). A skill author declares optional `config` (typed, defaulted non-secret tunables) and `env` (an environment-variable contract, with `required: true` as the activation gate and `secret: true` for credentials) blocks in `skill.json`; the consumer's overrides and an `envFile` pointer live in `skills-config.json`. Secret **values** are never written to any committed file.
|
|
15
|
+
- Add `happyskills skills-config get <owner/name>` — a deterministic resolver that deep-merges the project `skills-config.json`, the global `~/.agents/skills-config.json`, and the skill's author defaults (project wins) and returns the effective non-secret config, the `envFile` path (relative to the caller's cwd), and required-secret **names** plus a `secretsPresent` boolean — never a secret value. Resolves from a subdirectory by walking up to the nearest `skills-config.json`/`skills-lock.json` (stopping at a `.git` boundary); reads are CLI-preferred, plain-file-fallback.
|
|
16
|
+
- Prompt for a configurable skill's declared `config`/`env` on `install` and `update` (interactive terminal only — `--json`, `--yes`, non-TTY, and lockfile/manifest reinstalls scaffold silently). Secrets are routed to a git-ignored `./secrets/<skill>.env` with a committed `.env.example` (names only) and a `.gitignore` entry; non-secret overrides are written to `skills-config.json`. `update` re-prompts only newly-required fields and leaves existing values untouched; any required-but-unset field is surfaced as a warning.
|
|
17
|
+
- Validate the `skill.json` `config`/`env` schema in `happyskills validate` (unknown types, non-boolean `required`/`secret` flags, a secret carrying a committed default, an uncompilable `validate` regex, and case-only env-name collisions).
|
|
18
|
+
|
|
10
19
|
## [1.16.3] - 2026-07-03
|
|
11
20
|
|
|
12
21
|
### Fixed
|
package/package.json
CHANGED
package/src/commands/install.js
CHANGED
|
@@ -200,12 +200,18 @@ const run = (args) => catch_errors('Install failed', async () => {
|
|
|
200
200
|
}
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
// Prompt for skill config only on a direct, interactive install — never on --json,
|
|
204
|
+
// --yes, or a non-TTY invocation (spec 260704-01 §4.3.1 rule 4). The no-arg / lock /
|
|
205
|
+
// manifest reinstall paths use base_options (which omits prompt_config) so they never
|
|
206
|
+
// prompt (spec §6 #8).
|
|
207
|
+
const prompt_config = !!process.stdin.isTTY && !base_options.yes && !args.flags.json
|
|
208
|
+
|
|
203
209
|
const results = []
|
|
204
210
|
const failures = []
|
|
205
211
|
for (const { skill, version: inline_version } of parsed) {
|
|
206
212
|
const version = flag_version || inline_version
|
|
207
213
|
emit_analytics('install.started', { cli_version: CLI_VERSION })
|
|
208
|
-
const [errors, result] = await install(skill, { ...base_options, version })
|
|
214
|
+
const [errors, result] = await install(skill, { ...base_options, version, prompt_config })
|
|
209
215
|
if (errors) {
|
|
210
216
|
const chain = errors?.map ? errors.map(x => x?.message).filter(Boolean) : [errors?.message || String(errors)]
|
|
211
217
|
const msg = chain[chain.length - 1] || chain[0] || 'Unknown error'
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// happyskills skills-config get <owner/name> — spec 260704-01 Tier 3 §4.4.
|
|
3
|
+
//
|
|
4
|
+
// The deterministic resolver for consumer-side skill configuration. Reads the
|
|
5
|
+
// project skills-config.json ⊕ global ~/.agents/skills-config.json ⊕ the installed
|
|
6
|
+
// skill.json author defaults, deep-merges them (project winning), and returns the
|
|
7
|
+
// resolved NON-secret config, the resolved envFile path, the names of required
|
|
8
|
+
// secrets, and a present/absent boolean.
|
|
9
|
+
//
|
|
10
|
+
// It NEVER reads-and-returns a secret VALUE (§0 hard rule) — only names and a
|
|
11
|
+
// boolean. The agent does the merge via this command so it never hand-parses or
|
|
12
|
+
// hallucinates config; a CLI-less context falls back to reading the file directly
|
|
13
|
+
// (see the canonical SKILL.md snippet in happyskills-design).
|
|
14
|
+
|
|
15
|
+
const path = require('path')
|
|
16
|
+
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
17
|
+
const { EXIT_CODES } = require('../constants')
|
|
18
|
+
const { print_help, print_info, print_table } = require('../ui/output')
|
|
19
|
+
const { emit_envelope } = require('../ui/envelope')
|
|
20
|
+
const { exit_with_error, UsageError } = require('../utils/errors')
|
|
21
|
+
const { find_config_root } = require('../config/paths')
|
|
22
|
+
const { resolve_skill_config } = require('../skills_config/resolve')
|
|
23
|
+
|
|
24
|
+
// Emit envFile as a forward-slash path relative to the caller's cwd — usable directly by the
|
|
25
|
+
// agent wherever it runs, and never an absolute local path (which would leak the home dir).
|
|
26
|
+
const to_cwd_relative = (abs) => {
|
|
27
|
+
if (!abs) return null
|
|
28
|
+
const rel = path.relative(process.cwd(), abs).split(path.sep).join('/')
|
|
29
|
+
return rel.startsWith('.') ? rel : `./${rel}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const HELP_TEXT = `Usage: happyskills skills-config get <owner/name> [options]
|
|
33
|
+
|
|
34
|
+
Resolve the effective configuration for an installed skill. Deep-merges the
|
|
35
|
+
project skills-config.json, the global ~/.agents/skills-config.json, and the
|
|
36
|
+
skill's own author defaults (project winning) and reports the required secret
|
|
37
|
+
names plus whether they are set — never the secret values themselves.
|
|
38
|
+
|
|
39
|
+
Arguments:
|
|
40
|
+
owner/name The skill to resolve (e.g. acme/slack-notify)
|
|
41
|
+
|
|
42
|
+
Options:
|
|
43
|
+
--json Output as JSON envelope (default for non-TTY callers)
|
|
44
|
+
|
|
45
|
+
Examples:
|
|
46
|
+
happyskills skills-config get acme/slack-notify --json`
|
|
47
|
+
|
|
48
|
+
const run = (args) => catch_errors('skills-config failed', async () => {
|
|
49
|
+
if (args.flags._show_help) {
|
|
50
|
+
print_help(HELP_TEXT)
|
|
51
|
+
return process.exit(EXIT_CODES.SUCCESS)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const subcommand = args._[0]
|
|
55
|
+
const key = args._[1]
|
|
56
|
+
|
|
57
|
+
if (subcommand !== 'get') {
|
|
58
|
+
throw new UsageError(`Unknown skills-config subcommand "${subcommand || ''}". Expected: get <owner/name>.`)
|
|
59
|
+
}
|
|
60
|
+
if (!key || !key.includes('/')) {
|
|
61
|
+
throw new UsageError(`Skill must be in owner/name format (e.g. acme/slack-notify). Got: ${key || '(none)'}`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Walk up to the nearest project marker so the command works from a subdirectory —
|
|
65
|
+
// the same root the CLI-less fallback resolves (spec 260704-01 §6 #7).
|
|
66
|
+
const project_root = find_config_root()
|
|
67
|
+
const [resolve_err, resolved] = await resolve_skill_config(key, { project_root })
|
|
68
|
+
if (resolve_err) throw e(`Failed to resolve config for ${key}`, resolve_err)
|
|
69
|
+
|
|
70
|
+
const env_file = to_cwd_relative(resolved.envFile)
|
|
71
|
+
|
|
72
|
+
const data = {
|
|
73
|
+
skill: key,
|
|
74
|
+
config: resolved.config,
|
|
75
|
+
envFile: env_file,
|
|
76
|
+
requiredSecrets: resolved.requiredSecrets,
|
|
77
|
+
secretsPresent: resolved.secretsPresent
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const warnings = []
|
|
81
|
+
if (!resolved.manifestFound) {
|
|
82
|
+
warnings.push(`${key} is not installed — returning consumer overrides without author defaults.`)
|
|
83
|
+
}
|
|
84
|
+
if (resolved.requiredSecrets.length && !resolved.secretsPresent) {
|
|
85
|
+
warnings.push(`Required secret(s) not set: ${resolved.requiredSecrets.join(', ')}${env_file ? ` — set them in ${env_file}` : ''}.`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (args.flags.json) {
|
|
89
|
+
emit_envelope({
|
|
90
|
+
data,
|
|
91
|
+
next_step: { kind: 'continuation', action: 'present_to_user' },
|
|
92
|
+
warnings
|
|
93
|
+
})
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
print_info(`Resolved config for ${key}:`)
|
|
98
|
+
const rows = Object.entries(resolved.config).map(([k, v]) => [k, String(v)])
|
|
99
|
+
if (rows.length) print_table(['key', 'value'], rows)
|
|
100
|
+
else print_info(' (no config — using author defaults)')
|
|
101
|
+
if (env_file) print_info(`envFile: ${env_file}`)
|
|
102
|
+
if (resolved.requiredSecrets.length) {
|
|
103
|
+
print_info(`required secrets: ${resolved.requiredSecrets.join(', ')} — ${resolved.secretsPresent ? 'all set' : 'NOT all set'}`)
|
|
104
|
+
}
|
|
105
|
+
for (const w of warnings) print_info(`⚠ ${w}`)
|
|
106
|
+
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
107
|
+
|
|
108
|
+
const schema = {
|
|
109
|
+
name: 'skills-config',
|
|
110
|
+
audience: 'consumer',
|
|
111
|
+
purpose: 'Resolve the effective configuration for an installed skill (project ⊕ global ⊕ author defaults), reporting required secret names and whether they are set — never secret values.',
|
|
112
|
+
mutation: false,
|
|
113
|
+
interactive_in_text_mode: false,
|
|
114
|
+
input: {
|
|
115
|
+
positional: [
|
|
116
|
+
{ name: 'subcommand', required: true, type: 'string', description: 'Currently only "get"' },
|
|
117
|
+
{ name: 'owner/name', required: true, type: 'string', description: 'The skill to resolve (owner/name)' }
|
|
118
|
+
],
|
|
119
|
+
flags: [
|
|
120
|
+
{ name: 'json', type: 'boolean', default: false, description: 'Output as JSON envelope' }
|
|
121
|
+
]
|
|
122
|
+
},
|
|
123
|
+
output: {
|
|
124
|
+
data_shape: {
|
|
125
|
+
skill: 'string',
|
|
126
|
+
config: 'object',
|
|
127
|
+
envFile: 'string|null',
|
|
128
|
+
requiredSecrets: 'string[]',
|
|
129
|
+
secretsPresent: 'boolean'
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
errors: [
|
|
133
|
+
{ code: 'USAGE_ERROR', next_step: { kind: 'routing', action: 'discover_schema' } }
|
|
134
|
+
],
|
|
135
|
+
examples: [
|
|
136
|
+
'happyskills skills-config get acme/slack-notify --json'
|
|
137
|
+
]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = { run, schema }
|
package/src/commands/update.js
CHANGED
|
@@ -118,7 +118,10 @@ const run_scope = (scope_is_global, ctx) => catch_errors('Update scope failed',
|
|
|
118
118
|
// ─── --force path: skip the version check, re-install everything ─────────
|
|
119
119
|
if (force) {
|
|
120
120
|
print_header()
|
|
121
|
-
|
|
121
|
+
// Reconcile skills-config.json on update: prompt only for newly-required fields
|
|
122
|
+
// absent from the existing entry, leaving other values untouched (spec 260704-01 §4.3.2).
|
|
123
|
+
const config_reconcile = { prompt_config: !!process.stdin.isTTY && !json_mode && !auto_yes, config_only_required: true }
|
|
124
|
+
const options = { global: scope_is_global, fresh: true, force: true, agents: agents_flag || undefined, project_root, ...config_reconcile }
|
|
122
125
|
const updated = []
|
|
123
126
|
for (const [name, data] of candidates) {
|
|
124
127
|
const before_version = data?.version || null
|
|
@@ -289,7 +292,8 @@ const run_scope = (scope_is_global, ctx) => catch_errors('Update scope failed',
|
|
|
289
292
|
}
|
|
290
293
|
|
|
291
294
|
// Install only the outdated, non-modified skills
|
|
292
|
-
const
|
|
295
|
+
const config_reconcile = { prompt_config: !!process.stdin.isTTY && !json_mode && !auto_yes, config_only_required: true }
|
|
296
|
+
const options = { global: scope_is_global, fresh: true, agents: agents_flag || undefined, project_root, ...config_reconcile }
|
|
293
297
|
const updated = []
|
|
294
298
|
const update_errors = []
|
|
295
299
|
|
package/src/config/paths.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const fs = require('fs')
|
|
2
2
|
const path = require('path')
|
|
3
3
|
const os = require('os')
|
|
4
|
+
const { SKILLS_CONFIG_FILE, LOCK_FILE } = require('../constants')
|
|
4
5
|
|
|
5
6
|
const home_dir = os.homedir()
|
|
6
7
|
|
|
@@ -44,10 +45,34 @@ const lock_root = (is_global, project_root) => {
|
|
|
44
45
|
|
|
45
46
|
const lock_file_path = (project_root = process.cwd()) => path.join(project_root, 'skills-lock.json')
|
|
46
47
|
|
|
48
|
+
// Consumer-side per-skill config (spec 260704-01). Sibling of skills-lock.json at the
|
|
49
|
+
// project root; the global copy is co-located with the global lock root (~/.agents),
|
|
50
|
+
// derived from lock_root so it tracks wherever the lock actually lives.
|
|
51
|
+
const skills_config_file_path = (project_root = process.cwd()) => path.join(project_root, SKILLS_CONFIG_FILE)
|
|
52
|
+
|
|
53
|
+
const global_skills_config_file_path = () => path.join(lock_root(true), SKILLS_CONFIG_FILE)
|
|
54
|
+
|
|
47
55
|
const skill_install_dir = (base_skills_dir, name) => assert_within(base_skills_dir, path.join(base_skills_dir, name))
|
|
48
56
|
|
|
49
57
|
const find_project_root = (start_dir = process.cwd()) => path.resolve(start_dir)
|
|
50
58
|
|
|
59
|
+
// Config read-path root discovery (spec 260704-01 §6 #7). Unlike find_project_root (cwd-only,
|
|
60
|
+
// used by install/publish/etc.), the config RESOLVER walks upward for the nearest project marker
|
|
61
|
+
// — skills-config.json or skills-lock.json — stopping at a .git boundary, so `skills-config get`
|
|
62
|
+
// finds the same root a CLI-less agent would via the canonical SKILL.md fallback. Falls back to
|
|
63
|
+
// the start dir when no marker is found (preserving today's behavior for a bare directory).
|
|
64
|
+
const find_config_root = (start_dir = process.cwd()) => {
|
|
65
|
+
let dir = path.resolve(start_dir)
|
|
66
|
+
// eslint-disable-next-line no-constant-condition
|
|
67
|
+
while (true) {
|
|
68
|
+
if (fs.existsSync(path.join(dir, SKILLS_CONFIG_FILE)) || fs.existsSync(path.join(dir, LOCK_FILE))) return dir
|
|
69
|
+
if (fs.existsSync(path.join(dir, '.git'))) return dir
|
|
70
|
+
const parent = path.dirname(dir)
|
|
71
|
+
if (parent === dir) return path.resolve(start_dir)
|
|
72
|
+
dir = parent
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
51
76
|
const agent_skills_dir = (agent, global = false, project_root) => {
|
|
52
77
|
if (global) return path.join(home_dir, agent.global_skills_dir)
|
|
53
78
|
return path.join(project_root || process.cwd(), agent.skills_dir)
|
|
@@ -70,8 +95,11 @@ module.exports = {
|
|
|
70
95
|
install_lock_path,
|
|
71
96
|
lock_root,
|
|
72
97
|
lock_file_path,
|
|
98
|
+
skills_config_file_path,
|
|
99
|
+
global_skills_config_file_path,
|
|
73
100
|
skill_install_dir,
|
|
74
101
|
find_project_root,
|
|
102
|
+
find_config_root,
|
|
75
103
|
agent_skills_dir,
|
|
76
104
|
agent_skill_install_dir,
|
|
77
105
|
assert_within
|
package/src/config/paths.test.js
CHANGED
|
@@ -144,6 +144,42 @@ describe('paths', () => {
|
|
|
144
144
|
})
|
|
145
145
|
})
|
|
146
146
|
|
|
147
|
+
// spec 260704-01 §6 #7 — the config read path walks up to the nearest
|
|
148
|
+
// skills-config.json / skills-lock.json, stopping at a .git boundary, so
|
|
149
|
+
// `skills-config get` resolves the same root the CLI-less fallback would.
|
|
150
|
+
describe('find_config_root (walk-up)', () => {
|
|
151
|
+
let root
|
|
152
|
+
beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-find-config-')) })
|
|
153
|
+
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }) })
|
|
154
|
+
|
|
155
|
+
it('returns the nearest ancestor holding skills-config.json', () => {
|
|
156
|
+
fs.writeFileSync(path.join(root, 'skills-config.json'), '{}')
|
|
157
|
+
const sub = path.join(root, 'a', 'b')
|
|
158
|
+
fs.mkdirSync(sub, { recursive: true })
|
|
159
|
+
assert.strictEqual(paths.find_config_root(sub), root)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('finds the root via skills-lock.json when no config file exists yet', () => {
|
|
163
|
+
fs.writeFileSync(path.join(root, 'skills-lock.json'), '{}')
|
|
164
|
+
const sub = path.join(root, 'sub')
|
|
165
|
+
fs.mkdirSync(sub, { recursive: true })
|
|
166
|
+
assert.strictEqual(paths.find_config_root(sub), root)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('stops at a .git boundary even without a config/lock file', () => {
|
|
170
|
+
fs.mkdirSync(path.join(root, '.git'))
|
|
171
|
+
const sub = path.join(root, 'x', 'y')
|
|
172
|
+
fs.mkdirSync(sub, { recursive: true })
|
|
173
|
+
assert.strictEqual(paths.find_config_root(sub), root)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('falls back to the start dir when no marker is found', () => {
|
|
177
|
+
const sub = path.join(root, 'lonely')
|
|
178
|
+
fs.mkdirSync(sub, { recursive: true })
|
|
179
|
+
assert.strictEqual(paths.find_config_root(sub), sub)
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
147
183
|
// NEW-C3 — the server-derived install-dir name must not escape the skills dir.
|
|
148
184
|
describe('install-dir path safety (NEW-C3)', () => {
|
|
149
185
|
const base = path.join(os.tmpdir(), 'hs-skills-base')
|
package/src/constants.js
CHANGED
|
@@ -22,6 +22,7 @@ const SKILL_JSON = 'skill.json'
|
|
|
22
22
|
const SKILL_MD = 'SKILL.md'
|
|
23
23
|
const README_MD = 'README.md'
|
|
24
24
|
const LOCK_FILE = 'skills-lock.json'
|
|
25
|
+
const SKILLS_CONFIG_FILE = 'skills-config.json'
|
|
25
26
|
const INSTALL_LOCK = '.install.lock'
|
|
26
27
|
|
|
27
28
|
const COMMAND_ALIASES = {
|
|
@@ -71,6 +72,7 @@ const COMMANDS = [
|
|
|
71
72
|
'validate',
|
|
72
73
|
'self-update',
|
|
73
74
|
'config',
|
|
75
|
+
'skills-config',
|
|
74
76
|
'people',
|
|
75
77
|
'groups',
|
|
76
78
|
'access',
|
|
@@ -100,6 +102,7 @@ module.exports = {
|
|
|
100
102
|
SKILL_MD,
|
|
101
103
|
README_MD,
|
|
102
104
|
LOCK_FILE,
|
|
105
|
+
SKILLS_CONFIG_FILE,
|
|
103
106
|
INSTALL_LOCK,
|
|
104
107
|
COMMAND_ALIASES,
|
|
105
108
|
COMMANDS
|
package/src/engine/installer.js
CHANGED
|
@@ -383,6 +383,24 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
|
|
|
383
383
|
|
|
384
384
|
spinner.succeed(`Installed ${packages_to_install.length} package(s)`)
|
|
385
385
|
|
|
386
|
+
// Consumer-side skill configuration (spec 260704-01). For any installed package
|
|
387
|
+
// that declares a config/env schema, scaffold its .env and write skills-config.json
|
|
388
|
+
// (beside the lock file). Prompting is opt-in (direct interactive install only); the
|
|
389
|
+
// no-arg/lock/manifest reinstall paths never set prompt_config, so they scaffold
|
|
390
|
+
// defaults silently and surface unset required secrets as warnings.
|
|
391
|
+
const { configure_installed_skills } = require('../skills_config/configure')
|
|
392
|
+
const [config_err, config_result] = await configure_installed_skills(
|
|
393
|
+
downloaded.map(({ pkg }) => pkg),
|
|
394
|
+
{
|
|
395
|
+
base_dir,
|
|
396
|
+
config_root: lock_dir,
|
|
397
|
+
interactive: options.prompt_config === true,
|
|
398
|
+
only_required: options.config_only_required === true
|
|
399
|
+
}
|
|
400
|
+
)
|
|
401
|
+
if (config_err) print_warn(`Skill configuration step failed: ${config_err[0]?.message || 'unknown error'}`)
|
|
402
|
+
const config_warnings = config_result?.warnings || []
|
|
403
|
+
|
|
386
404
|
const linked_count = downloaded.length - disabled_skills.size
|
|
387
405
|
if (agents.length > 0 && linked_count > 0) {
|
|
388
406
|
if (agents_source === 'default') {
|
|
@@ -436,7 +454,7 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
|
|
|
436
454
|
const installed = packages_to_install.map(p => ({ skill: p.skill, version: p.version }))
|
|
437
455
|
const skipped = packages.filter(p => !installed_set.has(p.skill)).map(p => ({ skill: p.skill, version: p.version, reason: 'already installed' }))
|
|
438
456
|
const default_agent_warnings = _format_default_agent_warnings(agents_source, linked_count)
|
|
439
|
-
const warnings = [..._format_warnings(missing_deps), ...cycle_warnings, ...default_agent_warnings]
|
|
457
|
+
const warnings = [..._format_warnings(missing_deps), ...cycle_warnings, ...default_agent_warnings, ...config_warnings]
|
|
440
458
|
const forced = forced_pkgs.map(p => ({ skill: p.skill, version: p.version }))
|
|
441
459
|
|
|
442
460
|
const linked_agents = agents.map(a => a.id)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Conformance test for `happyskills skills-config get` — spec 260704-01 Tier 3 §4.4.
|
|
3
|
+
// Exercises the real command end-to-end (spawned CLI) against a tmp project: envelope
|
|
4
|
+
// conformance (STRICT), owner/name validation, and the §0 hard rule that no secret VALUE
|
|
5
|
+
// ever reaches stdout/stderr.
|
|
6
|
+
|
|
7
|
+
const { describe, it, beforeEach, afterEach } = require('node:test')
|
|
8
|
+
const assert = require('node:assert/strict')
|
|
9
|
+
const { spawnSync } = require('child_process')
|
|
10
|
+
const fs = require('fs')
|
|
11
|
+
const os = require('os')
|
|
12
|
+
const path = require('path')
|
|
13
|
+
|
|
14
|
+
const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
|
|
15
|
+
const NODE = process.execPath
|
|
16
|
+
|
|
17
|
+
const run = (args, cwd) => {
|
|
18
|
+
const result = spawnSync(NODE, [CLI, ...args], {
|
|
19
|
+
cwd,
|
|
20
|
+
env: { ...process.env, NO_COLOR: '1', HAPPYSKILLS_ENVELOPE_STRICT: '1', HAPPYSKILLS_API_URL: 'http://localhost:0', CI: '' },
|
|
21
|
+
encoding: 'utf-8',
|
|
22
|
+
timeout: 10000,
|
|
23
|
+
})
|
|
24
|
+
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SECRET = 'xoxb-SUPER-SECRET-DO-NOT-LEAK'
|
|
28
|
+
|
|
29
|
+
let proj
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
proj = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-sc-cmd-'))
|
|
32
|
+
const skill_dir = path.join(proj, '.agents', 'skills', 'slack-notify')
|
|
33
|
+
fs.mkdirSync(skill_dir, { recursive: true })
|
|
34
|
+
fs.writeFileSync(path.join(skill_dir, 'skill.json'), JSON.stringify({
|
|
35
|
+
name: 'slack-notify', version: '1.0.0',
|
|
36
|
+
config: { channel: { type: 'string', default: '#general' }, max_chars: { type: 'integer', default: 500 } },
|
|
37
|
+
env: { SLACK_BOT_TOKEN: { required: true, secret: true }, DEPLOY_TIMEOUT: { required: false, secret: false, type: 'integer', default: 300 } }
|
|
38
|
+
}))
|
|
39
|
+
fs.writeFileSync(path.join(skill_dir, 'SKILL.md'), '# slack\n')
|
|
40
|
+
fs.writeFileSync(path.join(proj, 'skills-config.json'), JSON.stringify({
|
|
41
|
+
'acme/slack-notify': { envFile: './secrets/slack-notify.env', config: { channel: '#deploys' } }
|
|
42
|
+
}))
|
|
43
|
+
fs.mkdirSync(path.join(proj, 'secrets'), { recursive: true })
|
|
44
|
+
fs.writeFileSync(path.join(proj, 'secrets', 'slack-notify.env'), `SLACK_BOT_TOKEN=${SECRET}\n`)
|
|
45
|
+
})
|
|
46
|
+
afterEach(() => { fs.rmSync(proj, { recursive: true, force: true }) })
|
|
47
|
+
|
|
48
|
+
describe('happyskills skills-config get', () => {
|
|
49
|
+
it('emits a valid success envelope with the resolved non-secret config — and NO secret value', () => {
|
|
50
|
+
const { stdout, stderr, code } = run(['skills-config', 'get', 'acme/slack-notify', '--json'], proj)
|
|
51
|
+
assert.strictEqual(code, 0)
|
|
52
|
+
const env = JSON.parse(stdout)
|
|
53
|
+
assert.strictEqual(env.ok, true)
|
|
54
|
+
assert.strictEqual(env.meta.command, 'skills-config')
|
|
55
|
+
assert.strictEqual(typeof env.data, 'object')
|
|
56
|
+
assert.ok(!Array.isArray(env.data))
|
|
57
|
+
assert.strictEqual(env.data.config.channel, '#deploys') // project override wins
|
|
58
|
+
assert.strictEqual(env.data.config.max_chars, 500) // author default
|
|
59
|
+
assert.deepStrictEqual(env.data.requiredSecrets, ['SLACK_BOT_TOKEN'])
|
|
60
|
+
assert.strictEqual(env.data.secretsPresent, true)
|
|
61
|
+
assert.strictEqual(env.data.envFile, './secrets/slack-notify.env')
|
|
62
|
+
|
|
63
|
+
// §0 hard rule: the secret VALUE must never appear in any output stream
|
|
64
|
+
assert.ok(!stdout.includes(SECRET), 'secret leaked to stdout')
|
|
65
|
+
assert.ok(!stderr.includes(SECRET), 'secret leaked to stderr')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('resolves from a SUBDIRECTORY by walking up to the project root (§6 #7)', () => {
|
|
69
|
+
const sub = path.join(proj, 'services', 'api')
|
|
70
|
+
fs.mkdirSync(sub, { recursive: true })
|
|
71
|
+
const { stdout, code } = run(['skills-config', 'get', 'acme/slack-notify', '--json'], sub)
|
|
72
|
+
assert.strictEqual(code, 0)
|
|
73
|
+
const env = JSON.parse(stdout)
|
|
74
|
+
assert.strictEqual(env.ok, true)
|
|
75
|
+
// same resolution as from the root — proves the CLI now matches the file-fallback walk-up
|
|
76
|
+
assert.strictEqual(env.data.config.channel, '#deploys')
|
|
77
|
+
assert.strictEqual(env.data.secretsPresent, true)
|
|
78
|
+
// envFile is relative to the caller's cwd (the subdir), so it stays usable from here
|
|
79
|
+
assert.strictEqual(env.data.envFile, '../../secrets/slack-notify.env')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('rejects a non-owner/name argument with a USAGE_ERROR envelope', () => {
|
|
83
|
+
const { stdout, code } = run(['skills-config', 'get', 'not-a-scoped-name', '--json'], proj)
|
|
84
|
+
assert.strictEqual(code, 2)
|
|
85
|
+
const env = JSON.parse(stdout)
|
|
86
|
+
assert.strictEqual(env.ok, false)
|
|
87
|
+
assert.strictEqual(env.error.code, 'USAGE_ERROR')
|
|
88
|
+
assert.strictEqual(typeof env.data, 'object')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('rejects an unknown subcommand', () => {
|
|
92
|
+
const { stdout, code } = run(['skills-config', 'wat', '--json'], proj)
|
|
93
|
+
assert.strictEqual(code, 2)
|
|
94
|
+
const env = JSON.parse(stdout)
|
|
95
|
+
assert.strictEqual(env.ok, false)
|
|
96
|
+
assert.strictEqual(env.error.code, 'USAGE_ERROR')
|
|
97
|
+
})
|
|
98
|
+
})
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
const path = require('path')
|
|
2
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
3
|
+
const { file_exists, read_json, ensure_dir, read_file, write_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, build_config_entry } = require('./writer')
|
|
7
|
+
const { question_prompt } = require('../utils/prompt')
|
|
8
|
+
const { SKILL_JSON } = require('../constants')
|
|
9
|
+
|
|
10
|
+
const to_posix = (p) => p.split(path.sep).join('/')
|
|
11
|
+
|
|
12
|
+
const coerce = (raw, type) => {
|
|
13
|
+
if (type === 'integer' || type === 'number') {
|
|
14
|
+
const n = Number(raw)
|
|
15
|
+
return Number.isNaN(n) ? raw : n
|
|
16
|
+
}
|
|
17
|
+
if (type === 'boolean') return /^(y|yes|true|1)$/i.test(raw)
|
|
18
|
+
return raw
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const as_object = (value) => (value && typeof value === 'object' && !Array.isArray(value) ? value : null)
|
|
22
|
+
|
|
23
|
+
// Append missing `NAME=` lines to an env file, never touching lines that already exist —
|
|
24
|
+
// so a value the consumer already set is preserved on reinstall/update.
|
|
25
|
+
const ensure_env_names = (file_path, names) => catch_errors('Failed to scaffold env file', async () => {
|
|
26
|
+
const [, exists] = await file_exists(file_path)
|
|
27
|
+
let content = ''
|
|
28
|
+
if (exists) { const [, c] = await read_file(file_path); content = c || '' }
|
|
29
|
+
|
|
30
|
+
const present = new Set(content.split('\n').map(l => l.split('=')[0].trim()).filter(Boolean))
|
|
31
|
+
const additions = names.filter(n => !present.has(n)).map(n => `${n}=`)
|
|
32
|
+
if (!additions.length && exists) return
|
|
33
|
+
|
|
34
|
+
await ensure_dir(path.dirname(file_path))
|
|
35
|
+
const base = content.length && !content.endsWith('\n') ? content + '\n' : content
|
|
36
|
+
await write_file(file_path, `${base}${additions.join('\n')}${additions.length ? '\n' : ''}`)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const ensure_gitignore_entry = (root, pattern) => catch_errors('Failed to update .gitignore', async () => {
|
|
40
|
+
const gitignore = path.join(root, '.gitignore')
|
|
41
|
+
const [, exists] = await file_exists(gitignore)
|
|
42
|
+
let content = ''
|
|
43
|
+
if (exists) { const [, c] = await read_file(gitignore); content = c || '' }
|
|
44
|
+
|
|
45
|
+
const lines = content.split('\n').map(l => l.trim())
|
|
46
|
+
if (lines.includes(pattern)) return
|
|
47
|
+
|
|
48
|
+
const base = content.length && !content.endsWith('\n') ? content + '\n' : content
|
|
49
|
+
await write_file(gitignore, `${base}${pattern}\n`)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// A required secret is "unset" unless it is present with a non-empty value in ambient
|
|
53
|
+
// process.env or in the scaffolded .env file. Presence gates the fail-loud warning.
|
|
54
|
+
const secret_is_set = (env_abs, name) => catch_errors('Failed to check secret presence', async () => {
|
|
55
|
+
if (process.env[name] && process.env[name].trim() !== '') return true
|
|
56
|
+
const [, exists] = await file_exists(env_abs)
|
|
57
|
+
if (!exists) return false
|
|
58
|
+
const [, content] = await read_file(env_abs)
|
|
59
|
+
for (const line of (content || '').split('\n')) {
|
|
60
|
+
const idx = line.indexOf('=')
|
|
61
|
+
if (idx === -1) continue
|
|
62
|
+
if (line.slice(0, idx).trim() === name && line.slice(idx + 1).trim() !== '') return true
|
|
63
|
+
}
|
|
64
|
+
return false
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// For every installed package that declares a `config`/`env` schema, resolve the consumer's
|
|
68
|
+
// choices and persist them: non-secret overrides → skills-config.json `config`; secret names →
|
|
69
|
+
// the gitignored .env (+ committed .env.example, name only); the pointer → `envFile`. Secret
|
|
70
|
+
// VALUES are never written anywhere by this function — build_config_entry structurally drops
|
|
71
|
+
// anything but envFile/config. Returns warnings naming required secrets still unset.
|
|
72
|
+
const configure_installed_skills = (packages, options = {}) => catch_errors('Failed to configure installed skills', async () => {
|
|
73
|
+
const { base_dir, config_root, interactive = false, only_required = false, ask = question_prompt } = options
|
|
74
|
+
const warnings = []
|
|
75
|
+
|
|
76
|
+
const [, existing_config_file] = await read_skills_config(config_root)
|
|
77
|
+
|
|
78
|
+
for (const pkg of packages || []) {
|
|
79
|
+
const key = pkg.skill
|
|
80
|
+
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))
|
|
83
|
+
if (!manifest) continue
|
|
84
|
+
|
|
85
|
+
const config_schema = as_object(manifest.config)
|
|
86
|
+
const env_schema = as_object(manifest.env)
|
|
87
|
+
if (!config_schema && !env_schema) continue
|
|
88
|
+
|
|
89
|
+
// Existing consumer choices are preserved verbatim; we only ever ADD to them.
|
|
90
|
+
// `only_required` (update/reconcile) narrows prompting to newly-required fields
|
|
91
|
+
// absent from the existing entry, so unrelated values are never re-asked.
|
|
92
|
+
const existing_entry = get_skill_config_entry(existing_config_file, key)
|
|
93
|
+
const existing_overrides = as_object(existing_entry && existing_entry.config) || {}
|
|
94
|
+
const overrides = { ...existing_overrides }
|
|
95
|
+
|
|
96
|
+
const maybe_prompt = async (field, spec) => {
|
|
97
|
+
if (!interactive) return
|
|
98
|
+
const already_set = Object.prototype.hasOwnProperty.call(existing_overrides, field)
|
|
99
|
+
const is_required = spec && spec.required === true
|
|
100
|
+
if (already_set) return
|
|
101
|
+
if (only_required && !is_required) return
|
|
102
|
+
const def = spec && spec.default
|
|
103
|
+
const label = `${field}${spec && spec.description ? ` — ${spec.description}` : ''} [${def ?? ''}]:`
|
|
104
|
+
const answer = await ask(label)
|
|
105
|
+
if (answer !== '' && answer !== String(def ?? '')) overrides[field] = coerce(answer, spec && spec.type)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// config fields — prompt (per the rules above); store non-default answers.
|
|
109
|
+
for (const [field, spec] of Object.entries(config_schema || {})) {
|
|
110
|
+
await maybe_prompt(field, spec)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// env fields — secrets route to the .env; non-secret env is treated like config.
|
|
114
|
+
const secret_names = []
|
|
115
|
+
for (const [var_name, spec] of Object.entries(env_schema || {})) {
|
|
116
|
+
if (spec && spec.secret) {
|
|
117
|
+
secret_names.push({ name: var_name, required: spec.required === true })
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
await maybe_prompt(var_name, spec)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let env_file_rel = (existing_entry && existing_entry.envFile) || null
|
|
124
|
+
if (secret_names.length) {
|
|
125
|
+
env_file_rel = env_file_rel || `./secrets/${name}.env`
|
|
126
|
+
// Resolve the (possibly consumer-chosen) envFile relative to the config root.
|
|
127
|
+
const env_abs = path.resolve(config_root, env_file_rel)
|
|
128
|
+
const [scaffold_err] = await ensure_env_names(env_abs, secret_names.map(s => s.name))
|
|
129
|
+
if (scaffold_err) throw scaffold_err[0]
|
|
130
|
+
const [example_err] = await ensure_env_names(path.join(config_root, '.env.example'), secret_names.map(s => s.name))
|
|
131
|
+
if (example_err) throw example_err[0]
|
|
132
|
+
|
|
133
|
+
const dir_rel = to_posix(path.relative(config_root, path.dirname(env_abs)))
|
|
134
|
+
const [gi_err] = await ensure_gitignore_entry(config_root, dir_rel ? `${dir_rel}/*.env` : '*.env')
|
|
135
|
+
if (gi_err) throw gi_err[0]
|
|
136
|
+
|
|
137
|
+
for (const secret of secret_names.filter(s => s.required)) {
|
|
138
|
+
const [, is_set] = await secret_is_set(env_abs, secret.name)
|
|
139
|
+
if (!is_set) warnings.push(`Required secret ${secret.name} for ${key} is not set — add it to ${env_file_rel}`)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Fail-loud (§4.3.2): a required NON-secret config/env field with no value and no
|
|
144
|
+
// author default would be silently undefined at runtime. Surface it as a warning —
|
|
145
|
+
// the same discipline the secret loop above applies to required secrets.
|
|
146
|
+
const warn_required_unset = (field, spec) => {
|
|
147
|
+
if (!spec || spec.required !== true) return
|
|
148
|
+
if (spec.default !== undefined) return
|
|
149
|
+
if (Object.prototype.hasOwnProperty.call(overrides, field)) return
|
|
150
|
+
warnings.push(`Required config "${field}" for ${key} is not set — no value provided and the skill declares no default.`)
|
|
151
|
+
}
|
|
152
|
+
for (const [field, spec] of Object.entries(config_schema || {})) warn_required_unset(field, spec)
|
|
153
|
+
for (const [var_name, spec] of Object.entries(env_schema || {})) {
|
|
154
|
+
if (spec && spec.secret) continue
|
|
155
|
+
warn_required_unset(var_name, spec)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const entry = build_config_entry({
|
|
159
|
+
...(env_file_rel ? { envFile: env_file_rel } : {}),
|
|
160
|
+
...(Object.keys(overrides).length ? { config: overrides } : {})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
if (Object.keys(entry).length) {
|
|
164
|
+
const [set_err] = await set_skill_config_entry(config_root, key, entry)
|
|
165
|
+
if (set_err) throw set_err[0]
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { warnings }
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
module.exports = { configure_installed_skills }
|