happyskills 0.49.0 → 0.51.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 +29 -0
- package/package.json +5 -1
- package/src/api/client.js +5 -2
- package/src/api/feedback.js +71 -0
- package/src/commands/feedback.js +260 -0
- package/src/commands/init.js +1 -1
- package/src/commands/list.js +27 -5
- package/src/constants.js +2 -1
- package/src/index.js +1 -0
- package/src/integration/cli.test.js +38 -1
- package/src/utils/image_compression.js +70 -0
- package/src/utils/scrub_secrets.js +49 -0
- package/src/utils/skill_scanner.js +34 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.51.0] - 2026-05-25
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`list --json` now splits unclaimed on-disk skills into `data.drafts[]` and `data.external[]`** (`cli/src/commands/list.js`, `cli/src/utils/skill_scanner.js`). Drafts are skills scaffolded by `init` — they have a HappySkills-shaped `skill.json` (non-empty `name`, valid-semver `version`, `type` of `"skill"` or `"kit"`) and a `SKILL.md`, but no entry in `skills-lock.json` yet because the user hasn't published. External skills are genuinely foreign — they have a `SKILL.md` but no manifest or a foreign-shaped one, and they require `convert` before they can be published. Each draft entry includes `{ name, description, version, type }`; external entries keep the existing `{ name, description }` shape. Human output gains a `draft (unpublished)` row label and a closing hint pointing at `happyskills release` so the principal sees what to do next without having to read the docs.
|
|
15
|
+
- **`is_happyskills_shaped_manifest()` exported from `cli/src/utils/skill_scanner.js`** — small predicate that other call sites (e.g. `happyskills-publish`'s pre-flight) can use to classify a found-on-disk skill the same way `list` does.
|
|
16
|
+
|
|
17
|
+
### Rationale
|
|
18
|
+
|
|
19
|
+
Spec 260522-02: when a user scaffolded a skill with `npx happyskills init` and immediately asked an LLM agent to "publish it", the previous `list --json` output reported the skill under `data.external[]` — the same bucket used for genuinely-foreign skills. Routing skills (notably `happyskills-publish`) read this as "external, must `convert` first", inserted a redundant `convert` step into the happy path, and surfaced the words `external` and `convert` to a user who had created their skill with the official tool five minutes earlier. That violated the mission's principal-surface promise of "no unexplained jargon" and "consistency over novelty". The CLI fix is a data-layer split (`data.drafts[]` vs `data.external[]`) so every consumer — routing skills, human output, future tooling — can tell the two states apart at the source. The companion routing-skill fix lands in `happyskills-publish@0.5.0` (in the same monorepo change set), which now reads `data.drafts` and routes drafts straight to `release` without ever mentioning `convert` or `external` to the user.
|
|
20
|
+
|
|
21
|
+
## [0.50.0] - 2026-05-24
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- **New `feedback` command** — `happyskills feedback <category> [body] [--subject "..."] [--attach a,b,c] [--json]`. Lodge a bug report, feature wish, compliment, question, or other feedback against the HappySkills platform from the terminal. Five categories: `bug | wish | compliment | question | other`. The body can be supplied inline or omitted to open `$EDITOR` (vim / nano fallback) — same UX as `git commit`. Optional `--subject` (≤ 200 chars) and `--attach` (comma-separated image paths, max 10, PNG/JPEG accepted on the CLI). Auto-captures `cli_version`, `node_version`, `os`, `arch`, `cwd_is_skill_dir`, and `current_skill` (read from a local `skill.json` if present) into the API's silent `client_context`, with secrets scrubbed before sending. `--json` emits the `{ data: { feedback, attachments }, error: null, next_step }` envelope at the response root — the `next_step` is what the `happyskills-help` skill reads to offer the post-creation attachment follow-up.
|
|
26
|
+
- **Image attachments via cherry-picked jimp 1.x** — `cli/src/utils/image_compression.js` compresses each `--attach` file to ≤ 2000 px longest side / JPEG quality 80 / ≤ 1 MB before upload via the two-step pre-signed S3 flow. Uses `@jimp/core` + `@jimp/js-jpeg` + `@jimp/js-png` + `@jimp/plugin-resize` cherry-picked from the jimp 1.x family — NOT the `jimp` meta-package (which pulls in BMP, GIF, TIFF, blur, color, mask, threshold, dither, print, and a dozen other plugins we don't need). WebP is not supported on the CLI in this release (jimp 1.x ships no WebP codec); the web modal still accepts WebP via `browser-image-compression`. **Lazy-loaded**: jimp is only `require()`d when at least one `--attach` is provided, so every other CLI command (`install`, `search`, `publish`, etc.) pays zero require cost. An early-bail check after compression rejects images that still exceed 1 MB with a clean local error before any initiate round-trip.
|
|
27
|
+
- **New `cli/src/api/feedback.js`** — API client for the new `/feedback` endpoints: `create_feedback`, `initiate_attachment_upload`, `put_attachment_to_s3`, `complete_attachment_upload`, `list_feedback`, `get_feedback`. Follows the existing per-resource module pattern (`api/repos.js`, `api/auth.js`, etc.).
|
|
28
|
+
- **New `cli/src/utils/scrub_secrets.js`** — recursively scrubs OpenAI keys (`sk-…`), GitHub tokens (`ghp_…` / `gho_…`), Cognito JWT-shaped strings (`eyJ…`), and any value whose key matches `/(_TOKEN|_KEY|_SECRET|_PASSWORD|_API_KEY)$/i` before `client_context` leaves the CLI. Applied automatically by the feedback command; reusable from any future command that captures user environment data. Mirror of `web/src/lib/scrub-secrets.js` — the regexes must stay in sync.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- **All API calls now send `User-Agent: happyskills-cli/<version>`** (`cli/src/api/client.js`). Required so the API can derive `feedback.source = 'cli'` server-side and never trust the source from the request body. No behaviour change for existing endpoints — none of them inspect User-Agent today; this is forward-compatible plumbing for any future endpoint that wants to know who's calling.
|
|
33
|
+
- **Install size increased from 5.1 MB → ~15 MB** due to jimp 1.x's transitive deps (`zod` 5 MB for runtime type checks; `bare-fs`/`bare-os`/`bare-url` 3.6 MB combined for Bare-runtime polyfills). The cherry-pick keeps jimp itself to 1.4 MB on disk, but the dependency closure dominates. This violates the original spec § 8.3's 8 MB target — accepted as a deliberate trade-off because: (a) **cold-start cost is unaffected** (lazy require means non-feedback commands never load jimp); (b) `npm install -g happyskills` install cost is paid once per machine, not per invocation; (c) the mission-aligned goal of "friction is the enemy of getting feedback at all" outweighs install footprint. A follow-up could investigate shelling out to system `sips` / `magick` to shrink the install — flagged but not in this release.
|
|
34
|
+
|
|
35
|
+
### Notes
|
|
36
|
+
|
|
37
|
+
- New gotcha entries documenting browser-direct-to-S3 patterns (signed Content-Type on PUT, response-Content-Type override on GET, SDK v3.729+ checksum opt-out, bucket CORS as CSRF guard) were added to `docs/gotchas/aws-sdk.md` in the same monorepo change set. These pertain to the API and infra; mentioned here for context since the CLI's `--attach` flow is the other client of that pipeline.
|
|
38
|
+
|
|
10
39
|
## [0.49.0] - 2026-05-23
|
|
11
40
|
|
|
12
41
|
This release combines two streams of work: **spec 260523-02 (Skill Update Determinism)** which introduces four new CLI primitives plus an `ahead` status to make skill update flows deterministic and safe-to-attempt, AND a **bundle-size enforcement** pass that adds a total-bundle cap to pre-publish validation.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "happyskills",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.51.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)",
|
|
@@ -43,6 +43,10 @@
|
|
|
43
43
|
"node": ">=22.0.0"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
+
"@jimp/core": "^1.6.0",
|
|
47
|
+
"@jimp/js-jpeg": "^1.6.0",
|
|
48
|
+
"@jimp/js-png": "^1.6.0",
|
|
49
|
+
"@jimp/plugin-resize": "^1.6.0",
|
|
46
50
|
"node-diff3": "^3.2.0",
|
|
47
51
|
"puffy-core": "^1.3.1",
|
|
48
52
|
"semver": "^7.6.0",
|
package/src/api/client.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
const { createHash } = require('crypto')
|
|
2
2
|
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
3
|
-
const { API_URL } = require('../constants')
|
|
3
|
+
const { API_URL, CLI_VERSION } = require('../constants')
|
|
4
4
|
const { ApiError, NetworkError, AuthError } = require('../utils/errors')
|
|
5
5
|
const { load_token } = require('../auth/token_store')
|
|
6
6
|
|
|
7
7
|
const get_base_url = () => process.env.HAPPYSKILLS_API_URL || API_URL
|
|
8
|
+
const USER_AGENT = `happyskills-cli/${CLI_VERSION}`
|
|
8
9
|
|
|
9
10
|
const request = (method, path, options = {}) => catch_errors(`API ${method} ${path} failed`, async () => {
|
|
10
11
|
const { body, auth = true, raw_response = false, unwrap = true, headers: extra_headers = {} } = options
|
|
11
12
|
const url = `${get_base_url()}${path}`
|
|
12
|
-
|
|
13
|
+
// User-Agent enables the API to identify CLI traffic (e.g. feedback API
|
|
14
|
+
// derives `source = 'cli'` from this header). Spec 260524-01 § 12.
|
|
15
|
+
const headers = { 'User-Agent': USER_AGENT, ...extra_headers }
|
|
13
16
|
|
|
14
17
|
if (auth) {
|
|
15
18
|
const [, token_data] = await load_token()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Spec 260524-01 — CLI client for the /feedback API.
|
|
2
|
+
//
|
|
3
|
+
// Returns the full response (envelope unwrapped to `data`) for `create` so the
|
|
4
|
+
// caller can access the `next_step` envelope baked into the API response. The
|
|
5
|
+
// attachment endpoints unwrap normally — callers only need the payload.
|
|
6
|
+
|
|
7
|
+
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
8
|
+
const client = require('./client')
|
|
9
|
+
|
|
10
|
+
// POST /feedback — does NOT unwrap, so the caller can read `next_step` which
|
|
11
|
+
// the API places alongside `feedback` inside `data`. (Our envelope is
|
|
12
|
+
// `{ data: { feedback, next_step } }`.)
|
|
13
|
+
const create_feedback = (payload) => catch_errors('Create feedback failed', async () => {
|
|
14
|
+
const [errors, data] = await client.post('/feedback', payload)
|
|
15
|
+
if (errors) throw errors[errors.length - 1]
|
|
16
|
+
return data // { feedback, next_step }
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const initiate_attachment_upload = (feedback_id, payload) => catch_errors('Initiate attachment upload failed', async () => {
|
|
20
|
+
const [errors, data] = await client.post(`/feedback/${feedback_id}/attachments/initiate`, payload)
|
|
21
|
+
if (errors) throw errors[errors.length - 1]
|
|
22
|
+
return data // { upload_id, storage_key, upload_url, expires_at, original_name }
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const complete_attachment_upload = (feedback_id, payload) => catch_errors('Complete attachment upload failed', async () => {
|
|
26
|
+
const [errors, data] = await client.post(`/feedback/${feedback_id}/attachments/complete`, payload)
|
|
27
|
+
if (errors) throw errors[errors.length - 1]
|
|
28
|
+
return data // { attachment }
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const put_attachment_to_s3 = (presigned_url, buffer) => catch_errors('S3 attachment upload failed', async () => {
|
|
32
|
+
const res = await fetch(presigned_url, {
|
|
33
|
+
method: 'PUT',
|
|
34
|
+
body: buffer,
|
|
35
|
+
headers: {
|
|
36
|
+
'Content-Type': 'image/jpeg',
|
|
37
|
+
'Content-Length': buffer.length.toString()
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
const text = await res.text().catch(() => '')
|
|
42
|
+
throw new Error(`S3 PUT failed with status ${res.status}${text ? ': ' + text.slice(0, 200) : ''}`)
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const list_feedback = (query = {}) => catch_errors('List feedback failed', async () => {
|
|
47
|
+
const params = new URLSearchParams()
|
|
48
|
+
if (query.limit != null) params.set('limit', String(query.limit))
|
|
49
|
+
if (query.offset != null) params.set('offset', String(query.offset))
|
|
50
|
+
if (query.category) params.set('category', query.category)
|
|
51
|
+
if (query.status) params.set('status', query.status)
|
|
52
|
+
const qs = params.toString()
|
|
53
|
+
const [errors, data] = await client.get(`/feedback${qs ? '?' + qs : ''}`)
|
|
54
|
+
if (errors) throw errors[errors.length - 1]
|
|
55
|
+
return data
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const get_feedback = (feedback_id) => catch_errors('Get feedback failed', async () => {
|
|
59
|
+
const [errors, data] = await client.get(`/feedback/${feedback_id}`)
|
|
60
|
+
if (errors) throw errors[errors.length - 1]
|
|
61
|
+
return data
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
module.exports = {
|
|
65
|
+
create_feedback,
|
|
66
|
+
initiate_attachment_upload,
|
|
67
|
+
complete_attachment_upload,
|
|
68
|
+
put_attachment_to_s3,
|
|
69
|
+
list_feedback,
|
|
70
|
+
get_feedback
|
|
71
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// Spec 260524-01 — `happyskills feedback` command.
|
|
2
|
+
//
|
|
3
|
+
// Surfaces:
|
|
4
|
+
// happyskills feedback <category> [body] [--subject "..."] [--attach a,b,c] [--json]
|
|
5
|
+
//
|
|
6
|
+
// --json mode emits the API's response wrapped in the universal CLI envelope:
|
|
7
|
+
// { data: { feedback, next_step, attachments? }, error: null }
|
|
8
|
+
// The `next_step` envelope is what the `happyskills-help` skill reads to
|
|
9
|
+
// route the post-creation conversation (offer attachment, etc.) — see spec
|
|
10
|
+
// § 6.2 + § 11.
|
|
11
|
+
//
|
|
12
|
+
// LAZY-LOAD: jimp / image_compression is only require()d when --attach is
|
|
13
|
+
// present, so the 99% of CLI invocations that don't lodge feedback pay zero
|
|
14
|
+
// startup cost (D8).
|
|
15
|
+
|
|
16
|
+
const fs = require('fs')
|
|
17
|
+
const os = require('os')
|
|
18
|
+
const path = require('path')
|
|
19
|
+
const { spawnSync } = require('child_process')
|
|
20
|
+
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
21
|
+
const { require_token } = require('../auth/token_store')
|
|
22
|
+
const feedback_api = require('../api/feedback')
|
|
23
|
+
const { scrub } = require('../utils/scrub_secrets')
|
|
24
|
+
const { print_help, print_json, print_success, print_info, print_hint } = require('../ui/output')
|
|
25
|
+
const { exit_with_error, UsageError } = require('../utils/errors')
|
|
26
|
+
const { EXIT_CODES, CLI_VERSION } = require('../constants')
|
|
27
|
+
|
|
28
|
+
const HELP_TEXT = `Usage: happyskills feedback <category> [body] [options]
|
|
29
|
+
|
|
30
|
+
Lodge feedback with HappySkills — bug reports, feature wishes, compliments,
|
|
31
|
+
questions. Auto-captures CLI version / OS / current skill context silently
|
|
32
|
+
(secrets are scrubbed before sending).
|
|
33
|
+
|
|
34
|
+
Arguments:
|
|
35
|
+
category One of: bug | wish | compliment | question | other
|
|
36
|
+
body Your feedback. Omit to open $EDITOR (vim / nano fallback).
|
|
37
|
+
|
|
38
|
+
Options:
|
|
39
|
+
--subject "..." Short title (max 200 chars)
|
|
40
|
+
--attach <paths> Comma-separated image paths (max 10, PNG/JPEG accepted).
|
|
41
|
+
Each compressed to ≤2000px / quality 80 / JPEG before upload.
|
|
42
|
+
Per-image cap after compression: 1 MB (server-enforced).
|
|
43
|
+
--json Emit JSON envelope (intended for use inside an agentic
|
|
44
|
+
session — see the happyskills-help skill).
|
|
45
|
+
|
|
46
|
+
Examples:
|
|
47
|
+
happyskills feedback bug "search returns nothing for partial slugs"
|
|
48
|
+
happyskills feedback wish "I wish I could export my catalog to JSON"
|
|
49
|
+
happyskills feedback bug --attach screenshot.png --json
|
|
50
|
+
happyskills feedback compliment # → opens $EDITOR for the body`
|
|
51
|
+
|
|
52
|
+
const VALID_CATEGORIES = new Set(['bug', 'wish', 'compliment', 'question', 'other'])
|
|
53
|
+
|
|
54
|
+
const try_editor_for_body = () => {
|
|
55
|
+
const editors = [
|
|
56
|
+
process.env.EDITOR,
|
|
57
|
+
process.env.VISUAL,
|
|
58
|
+
'vim',
|
|
59
|
+
'nano',
|
|
60
|
+
].filter(Boolean)
|
|
61
|
+
|
|
62
|
+
const tmp = path.join(os.tmpdir(), `happyskills-feedback-${process.pid}-${Date.now()}.txt`)
|
|
63
|
+
fs.writeFileSync(tmp, '# Write your feedback above this line. Lines starting with # are ignored.\n')
|
|
64
|
+
|
|
65
|
+
let succeeded = false
|
|
66
|
+
for (const editor of editors) {
|
|
67
|
+
try {
|
|
68
|
+
const r = spawnSync(editor, [tmp], { stdio: 'inherit' })
|
|
69
|
+
if (r.status === 0) { succeeded = true; break }
|
|
70
|
+
} catch (_) {
|
|
71
|
+
// try next
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (!succeeded) {
|
|
75
|
+
try { fs.unlinkSync(tmp) } catch (_) {}
|
|
76
|
+
throw new UsageError('Could not open an editor. Set $EDITOR, or pass the body as a positional argument.')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const raw = fs.readFileSync(tmp, 'utf-8')
|
|
80
|
+
try { fs.unlinkSync(tmp) } catch (_) {}
|
|
81
|
+
const body = raw
|
|
82
|
+
.split('\n')
|
|
83
|
+
.filter(line => !line.startsWith('#'))
|
|
84
|
+
.join('\n')
|
|
85
|
+
.trim()
|
|
86
|
+
return body
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Best-effort: read a local skill.json if the cwd looks like a skill directory.
|
|
90
|
+
const read_current_skill = () => {
|
|
91
|
+
try {
|
|
92
|
+
const p = path.join(process.cwd(), 'skill.json')
|
|
93
|
+
if (!fs.existsSync(p)) return null
|
|
94
|
+
const raw = fs.readFileSync(p, 'utf-8')
|
|
95
|
+
const parsed = JSON.parse(raw)
|
|
96
|
+
const name = parsed.name || null
|
|
97
|
+
if (!name) return null
|
|
98
|
+
// name may be "ns/skill" or just "skill"
|
|
99
|
+
const slash = name.indexOf('/')
|
|
100
|
+
return {
|
|
101
|
+
namespace: slash > 0 ? name.slice(0, slash) : null,
|
|
102
|
+
name: slash > 0 ? name.slice(slash + 1) : name,
|
|
103
|
+
version: parsed.version || null
|
|
104
|
+
}
|
|
105
|
+
} catch (_) {
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const build_client_context = () => {
|
|
111
|
+
const current_skill = read_current_skill()
|
|
112
|
+
const ctx = {
|
|
113
|
+
source: 'cli',
|
|
114
|
+
cli_version: CLI_VERSION,
|
|
115
|
+
node_version: process.version,
|
|
116
|
+
os: process.platform,
|
|
117
|
+
arch: process.arch,
|
|
118
|
+
cwd_is_skill_dir: !!current_skill,
|
|
119
|
+
}
|
|
120
|
+
if (current_skill) ctx.current_skill = current_skill
|
|
121
|
+
return scrub(ctx)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const MAX_ATTACHMENTS = 10
|
|
125
|
+
const MAX_ATTACHMENT_BYTES = 1 * 1000 * 1000 // 1 MB post-compression — matches API MAX_FEEDBACK_ATTACHMENT_BYTES
|
|
126
|
+
|
|
127
|
+
const parse_attach_paths = (raw_value) => {
|
|
128
|
+
if (!raw_value) return []
|
|
129
|
+
const items = String(raw_value).split(',').map(s => s.trim()).filter(Boolean)
|
|
130
|
+
if (items.length > MAX_ATTACHMENTS) {
|
|
131
|
+
throw new UsageError(`At most ${MAX_ATTACHMENTS} attachments are allowed.`)
|
|
132
|
+
}
|
|
133
|
+
for (const p of items) {
|
|
134
|
+
if (!fs.existsSync(p)) {
|
|
135
|
+
throw new UsageError(`Attachment not found: ${p}`)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return items
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const upload_attachments = (feedback_id, attach_paths, opts) => catch_errors('Failed to upload attachments', async () => {
|
|
142
|
+
const { print_progress } = opts
|
|
143
|
+
// Lazy import — keeps jimp out of the require graph for non-feedback commands.
|
|
144
|
+
const { compress_image } = require('../utils/image_compression')
|
|
145
|
+
|
|
146
|
+
const uploaded = []
|
|
147
|
+
for (let i = 0; i < attach_paths.length; i++) {
|
|
148
|
+
const src = attach_paths[i]
|
|
149
|
+
if (print_progress) print_progress(`Compressing ${path.basename(src)} (${i + 1}/${attach_paths.length})`)
|
|
150
|
+
|
|
151
|
+
const [c_err, compressed] = await compress_image(src)
|
|
152
|
+
if (c_err) throw e(`Failed to compress ${src}`, c_err)
|
|
153
|
+
|
|
154
|
+
// Early bail before paying for an initiate round-trip if the compressed
|
|
155
|
+
// image still exceeds the per-image cap. Server enforces the same limit;
|
|
156
|
+
// this is a clean local error rather than a 413 round trip away.
|
|
157
|
+
if (compressed.bytes > MAX_ATTACHMENT_BYTES) {
|
|
158
|
+
throw new UsageError(`Compressed "${path.basename(src)}" is ${compressed.bytes} bytes — exceeds the ${MAX_ATTACHMENT_BYTES}-byte (1 MB) per-image cap. Try cropping or sending a smaller region.`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const [init_err, init] = await feedback_api.initiate_attachment_upload(feedback_id, {
|
|
162
|
+
bytes: compressed.bytes,
|
|
163
|
+
content_type: 'image/jpeg',
|
|
164
|
+
original_name: path.basename(src),
|
|
165
|
+
})
|
|
166
|
+
if (init_err) throw e(`Failed to initiate upload for ${src}`, init_err)
|
|
167
|
+
|
|
168
|
+
if (print_progress) print_progress(`Uploading ${path.basename(src)} (${i + 1}/${attach_paths.length})`)
|
|
169
|
+
const [put_err] = await feedback_api.put_attachment_to_s3(init.upload_url, compressed.buffer)
|
|
170
|
+
if (put_err) throw e(`S3 upload failed for ${src}`, put_err)
|
|
171
|
+
|
|
172
|
+
const [comp_err, comp] = await feedback_api.complete_attachment_upload(feedback_id, {
|
|
173
|
+
upload_id: init.upload_id,
|
|
174
|
+
bytes: compressed.bytes,
|
|
175
|
+
width: compressed.width,
|
|
176
|
+
height: compressed.height,
|
|
177
|
+
original_name: path.basename(src),
|
|
178
|
+
})
|
|
179
|
+
if (comp_err) throw e(`Failed to confirm upload for ${src}`, comp_err)
|
|
180
|
+
|
|
181
|
+
uploaded.push(comp.attachment)
|
|
182
|
+
}
|
|
183
|
+
return uploaded
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
const run = (args) => catch_errors('Feedback command failed', async () => {
|
|
187
|
+
if (args.flags._show_help) {
|
|
188
|
+
print_help(HELP_TEXT)
|
|
189
|
+
return process.exit(EXIT_CODES.SUCCESS)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const json_mode = !!args.flags.json
|
|
193
|
+
const category = args._[0]
|
|
194
|
+
const body_positional = args._.slice(1).join(' ').trim()
|
|
195
|
+
|
|
196
|
+
if (!category) {
|
|
197
|
+
throw new UsageError(`Category is required. One of: ${Array.from(VALID_CATEGORIES).join(' | ')}`)
|
|
198
|
+
}
|
|
199
|
+
if (!VALID_CATEGORIES.has(category)) {
|
|
200
|
+
throw new UsageError(`Invalid category "${category}". Must be one of: ${Array.from(VALID_CATEGORIES).join(' | ')}`)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let body = body_positional
|
|
204
|
+
if (!body) {
|
|
205
|
+
if (json_mode) {
|
|
206
|
+
// In agentic / scripted contexts we can't open an editor — fail fast.
|
|
207
|
+
throw new UsageError('In --json mode the feedback body must be passed as a positional argument; the editor fallback is interactive.')
|
|
208
|
+
}
|
|
209
|
+
body = try_editor_for_body()
|
|
210
|
+
if (!body) {
|
|
211
|
+
throw new UsageError('No feedback body was entered. Aborted.')
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const subject = args.flags.subject && typeof args.flags.subject === 'string' ? args.flags.subject : null
|
|
216
|
+
const attach_paths = parse_attach_paths(args.flags.attach)
|
|
217
|
+
|
|
218
|
+
// Auth required — surfaces a clean AuthError exit code 3 on no/expired token.
|
|
219
|
+
await require_token()
|
|
220
|
+
|
|
221
|
+
const client_context = build_client_context()
|
|
222
|
+
|
|
223
|
+
const [create_err, create_result] = await feedback_api.create_feedback({
|
|
224
|
+
category,
|
|
225
|
+
subject,
|
|
226
|
+
body,
|
|
227
|
+
client_context,
|
|
228
|
+
})
|
|
229
|
+
if (create_err) throw e('Failed to create feedback', create_err)
|
|
230
|
+
|
|
231
|
+
const { feedback, next_step } = create_result
|
|
232
|
+
|
|
233
|
+
// Attachment phase
|
|
234
|
+
let attachments = []
|
|
235
|
+
if (attach_paths.length > 0) {
|
|
236
|
+
const print_progress = json_mode ? null : (msg) => print_info(msg)
|
|
237
|
+
const [upload_err, uploaded] = await upload_attachments(feedback.id, attach_paths, { print_progress })
|
|
238
|
+
if (upload_err) throw e('Attachment upload failed', upload_err)
|
|
239
|
+
attachments = uploaded
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (json_mode) {
|
|
243
|
+
// Universal CLI envelope shape. The skill's envelope-reader (per spec
|
|
244
|
+
// § 11) consumes `next_step` at the response root.
|
|
245
|
+
const data = { feedback, attachments }
|
|
246
|
+
print_json({ data, error: null, next_step })
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Human-readable
|
|
251
|
+
const short_id = String(feedback.id).slice(0, 8)
|
|
252
|
+
print_success(`Feedback recorded (#${short_id}).`)
|
|
253
|
+
if (attachments.length > 0) {
|
|
254
|
+
print_info(`Uploaded ${attachments.length} attachment${attachments.length === 1 ? '' : 's'}.`)
|
|
255
|
+
} else if (next_step && next_step.attachments_supported && attach_paths.length === 0) {
|
|
256
|
+
print_hint(`Pass --attach <path> to add a screenshot (max ${next_step.max_attachments}).`)
|
|
257
|
+
}
|
|
258
|
+
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
259
|
+
|
|
260
|
+
module.exports = { run }
|
package/src/commands/init.js
CHANGED
|
@@ -133,7 +133,7 @@ const run = (args) => catch_errors('Init failed', async () => {
|
|
|
133
133
|
print_info(`Linked to: ${agents_result.agents.map(a => a.display_name).join(', ')}`)
|
|
134
134
|
}
|
|
135
135
|
console.log()
|
|
136
|
-
print_hint(`Edit these files, then run ${code(
|
|
136
|
+
print_hint(`Edit these files, then run ${code(`happyskills release ${final_name} --workspace <slug>`)} to publish.`)
|
|
137
137
|
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
138
138
|
|
|
139
139
|
module.exports = { run }
|
package/src/commands/list.js
CHANGED
|
@@ -51,16 +51,23 @@ const run = (args) => catch_errors('List failed', async () => {
|
|
|
51
51
|
|
|
52
52
|
const [, disk_skills] = await scan_skills_dir(base_dir)
|
|
53
53
|
const managed_names = new Set(managed_short_names)
|
|
54
|
-
|
|
54
|
+
// Unclaimed skills (on disk, not in lock) split into two coherent buckets:
|
|
55
|
+
// - drafts → scaffolded by `init`, HappySkills-shaped skill.json present.
|
|
56
|
+
// Publish/release pick these up directly — no `convert` needed.
|
|
57
|
+
// - external → genuinely foreign (no skill.json, or foreign-shaped).
|
|
58
|
+
// `convert` is the path to bring these into the managed set.
|
|
59
|
+
const unclaimed = (disk_skills || []).filter(s => !managed_names.has(s.name))
|
|
60
|
+
const draft_skills = unclaimed.filter(s => s.is_draft)
|
|
61
|
+
const external_skills = unclaimed.filter(s => !s.is_draft)
|
|
55
62
|
|
|
56
63
|
// Scan agent-specific dirs for skills placed directly in agent folders (not symlinked from .agents/skills/)
|
|
57
|
-
const all_known_names = new Set([...managed_names, ...external_skills.map(s => s.name)])
|
|
64
|
+
const all_known_names = new Set([...managed_names, ...draft_skills.map(s => s.name), ...external_skills.map(s => s.name)])
|
|
58
65
|
const [, agent_orphans] = await scan_agent_orphan_skills(AGENTS, is_global, project_root, all_known_names)
|
|
59
66
|
const orphan_skills = agent_orphans || []
|
|
60
67
|
|
|
61
|
-
if (managed_entries.length === 0 && external_skills.length === 0 && orphan_skills.length === 0) {
|
|
68
|
+
if (managed_entries.length === 0 && draft_skills.length === 0 && external_skills.length === 0 && orphan_skills.length === 0) {
|
|
62
69
|
if (args.flags.json) {
|
|
63
|
-
print_json({ data: { skills: {}, external: [], agent_orphans: [] } })
|
|
70
|
+
print_json({ data: { skills: {}, drafts: [], external: [], agent_orphans: [] } })
|
|
64
71
|
return
|
|
65
72
|
}
|
|
66
73
|
print_info('No skills installed.')
|
|
@@ -123,13 +130,19 @@ const run = (args) => catch_errors('List failed', async () => {
|
|
|
123
130
|
}
|
|
124
131
|
skills_map[name] = entry
|
|
125
132
|
}
|
|
133
|
+
const drafts = draft_skills.map(s => ({
|
|
134
|
+
name: s.name,
|
|
135
|
+
description: s.description || '',
|
|
136
|
+
version: s.version || null,
|
|
137
|
+
type: s.type || SKILL_TYPES.SKILL
|
|
138
|
+
}))
|
|
126
139
|
const external = external_skills.map(s => ({ name: s.name, description: s.description || '' }))
|
|
127
140
|
const agent_orphan_list = orphan_skills.map(s => ({
|
|
128
141
|
name: s.name,
|
|
129
142
|
description: s.description || '',
|
|
130
143
|
agents: s.agents
|
|
131
144
|
}))
|
|
132
|
-
print_json({ data: { skills: skills_map, external, agent_orphans: agent_orphan_list } })
|
|
145
|
+
print_json({ data: { skills: skills_map, drafts, external, agent_orphans: agent_orphan_list } })
|
|
133
146
|
return
|
|
134
147
|
}
|
|
135
148
|
|
|
@@ -153,6 +166,11 @@ const run = (args) => catch_errors('List failed', async () => {
|
|
|
153
166
|
rows.push([display_name, data.version, source, status_label, enabled_label])
|
|
154
167
|
}
|
|
155
168
|
|
|
169
|
+
for (const s of draft_skills) {
|
|
170
|
+
const type_label = s.type === SKILL_TYPES.KIT ? `${s.name} [kit]` : s.name
|
|
171
|
+
rows.push([type_label, s.version || '-', 'draft', 'unpublished', '-'])
|
|
172
|
+
}
|
|
173
|
+
|
|
156
174
|
for (const s of external_skills) {
|
|
157
175
|
rows.push([s.name, '-', 'external', 'installed', '-'])
|
|
158
176
|
}
|
|
@@ -175,6 +193,10 @@ const run = (args) => catch_errors('List failed', async () => {
|
|
|
175
193
|
console.log()
|
|
176
194
|
print_info(`${ahead_count} skill(s) ahead of lock — bumped locally, not yet published. Run publish when ready.`)
|
|
177
195
|
}
|
|
196
|
+
if (draft_skills.length > 0) {
|
|
197
|
+
console.log()
|
|
198
|
+
print_info(`${draft_skills.length} draft(s) ready to publish — run ${code('happyskills release <name>')} to ship.`)
|
|
199
|
+
}
|
|
178
200
|
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
179
201
|
|
|
180
202
|
module.exports = { run }
|
package/src/constants.js
CHANGED
package/src/index.js
CHANGED
|
@@ -116,6 +116,7 @@ Commands:
|
|
|
116
116
|
snapshot <sub> Capture and restore skill state (create, list, restore, delete, prune)
|
|
117
117
|
reconcile <owner/skill> Diagnose and repair lock-vs-disk drift
|
|
118
118
|
release <skill-name> Atomic release: snapshot + validate + bump + publish
|
|
119
|
+
feedback <category> Lodge feedback (bug, wish, compliment, question, other)
|
|
119
120
|
|
|
120
121
|
Global flags:
|
|
121
122
|
--help Show help for a command
|
|
@@ -379,7 +379,7 @@ describe('CLI — --json: success responses use { data } envelope', () => {
|
|
|
379
379
|
// ─── --json flag: existing commands use { data } envelope ─────────────────────
|
|
380
380
|
|
|
381
381
|
describe('CLI — --json: existing json commands now use { data } envelope', () => {
|
|
382
|
-
it('list --json returns { data: { skills, external } }', () => {
|
|
382
|
+
it('list --json returns { data: { skills, drafts, external } }', () => {
|
|
383
383
|
// Run in a temp dir with no lock file — produces empty result
|
|
384
384
|
const tmp = make_tmp()
|
|
385
385
|
try {
|
|
@@ -388,14 +388,51 @@ describe('CLI — --json: existing json commands now use { data } envelope', ()
|
|
|
388
388
|
const out = parse_json_output(stdout, 'list --json empty')
|
|
389
389
|
assert.ok('data' in out, 'should have top-level "data" key')
|
|
390
390
|
assert.ok('skills' in out.data, 'data.skills should exist')
|
|
391
|
+
assert.ok('drafts' in out.data, 'data.drafts should exist')
|
|
391
392
|
assert.ok('external' in out.data, 'data.external should exist')
|
|
392
393
|
assert.ok(typeof out.data.skills === 'object' && !Array.isArray(out.data.skills))
|
|
394
|
+
assert.ok(Array.isArray(out.data.drafts))
|
|
393
395
|
assert.ok(Array.isArray(out.data.external))
|
|
394
396
|
} finally {
|
|
395
397
|
fs.rmSync(tmp, { recursive: true, force: true })
|
|
396
398
|
}
|
|
397
399
|
})
|
|
398
400
|
|
|
401
|
+
it('list --json classifies init-scaffolded skills as drafts, foreign-shaped ones as external', () => {
|
|
402
|
+
// Two on-disk skills, no lock file:
|
|
403
|
+
// - "my-draft" has a HappySkills-shaped skill.json (init-style)
|
|
404
|
+
// - "my-foreign" has only SKILL.md (foreign / hand-rolled)
|
|
405
|
+
const tmp = make_tmp()
|
|
406
|
+
try {
|
|
407
|
+
const draft_dir = path.join(tmp, '.agents', 'skills', 'my-draft')
|
|
408
|
+
fs.mkdirSync(draft_dir, { recursive: true })
|
|
409
|
+
fs.writeFileSync(path.join(draft_dir, 'SKILL.md'), '---\nname: my-draft\ndescription: a draft skill\n---\n\n# my-draft\n')
|
|
410
|
+
fs.writeFileSync(path.join(draft_dir, 'skill.json'), JSON.stringify({
|
|
411
|
+
name: 'my-draft',
|
|
412
|
+
version: '0.1.0',
|
|
413
|
+
type: 'skill',
|
|
414
|
+
description: '',
|
|
415
|
+
keywords: [],
|
|
416
|
+
dependencies: {}
|
|
417
|
+
}))
|
|
418
|
+
|
|
419
|
+
const foreign_dir = path.join(tmp, '.agents', 'skills', 'my-foreign')
|
|
420
|
+
fs.mkdirSync(foreign_dir, { recursive: true })
|
|
421
|
+
fs.writeFileSync(path.join(foreign_dir, 'SKILL.md'), '---\nname: my-foreign\ndescription: a foreign skill\n---\n\n# my-foreign\n')
|
|
422
|
+
|
|
423
|
+
const { stdout, code } = run(['list', '--json'], {}, { cwd: tmp })
|
|
424
|
+
assert.strictEqual(code, 0)
|
|
425
|
+
const out = parse_json_output(stdout, 'list --json mixed')
|
|
426
|
+
assert.strictEqual(out.data.drafts.length, 1, 'should have exactly one draft')
|
|
427
|
+
assert.strictEqual(out.data.drafts[0].name, 'my-draft')
|
|
428
|
+
assert.strictEqual(out.data.drafts[0].version, '0.1.0')
|
|
429
|
+
assert.strictEqual(out.data.external.length, 1, 'should have exactly one external')
|
|
430
|
+
assert.strictEqual(out.data.external[0].name, 'my-foreign')
|
|
431
|
+
} finally {
|
|
432
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
433
|
+
}
|
|
434
|
+
})
|
|
435
|
+
|
|
399
436
|
it('search --json usage error returns { error } not raw text', () => {
|
|
400
437
|
const { stdout, code } = run(['search', '--json'])
|
|
401
438
|
assert.strictEqual(code, 2)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Spec 260524-01 § 8.3 — CLI-side image compression for feedback attachments.
|
|
2
|
+
//
|
|
3
|
+
// FOOTPRINT DISCIPLINE (D8): this module cherry-picks @jimp/core +
|
|
4
|
+
// @jimp/js-jpeg + @jimp/js-png + @jimp/plugin-resize from jimp 1.x. We
|
|
5
|
+
// deliberately do NOT depend on the `jimp` meta-package — that pulls in
|
|
6
|
+
// BMP, GIF, TIFF, blur, color, mask, threshold, dither, print, hash, blit,
|
|
7
|
+
// circle, contain, cover, crop, displace, fisheye, flip, mask, quantize,
|
|
8
|
+
// rotate, and a half-dozen more plugins we don't need.
|
|
9
|
+
//
|
|
10
|
+
// WEBP NOTE: jimp 1.x does NOT ship a WebP codec sub-package. CLI accepts
|
|
11
|
+
// PNG and JPEG only; the web surface still accepts WebP via
|
|
12
|
+
// `browser-image-compression`. Spec § 8.3's fallback explicitly anticipates
|
|
13
|
+
// this drop ("drop @jimp/webp" — but in practice there's no such package).
|
|
14
|
+
//
|
|
15
|
+
// LAZY-LOAD: this module is dynamically imported only when the feedback
|
|
16
|
+
// command actually has --attach arguments (see commands/feedback.js). Every
|
|
17
|
+
// other CLI command — install, search, publish, etc. — pays zero require
|
|
18
|
+
// cost for jimp.
|
|
19
|
+
|
|
20
|
+
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
21
|
+
|
|
22
|
+
const MAX_DIMENSION = 2000
|
|
23
|
+
const JPEG_QUALITY = 80
|
|
24
|
+
|
|
25
|
+
const get_default = (m) => (m && m.default !== undefined) ? m.default : m
|
|
26
|
+
|
|
27
|
+
let _Jimp = null
|
|
28
|
+
|
|
29
|
+
const get_jimp = () => {
|
|
30
|
+
if (_Jimp) return _Jimp
|
|
31
|
+
const { createJimp } = require('@jimp/core')
|
|
32
|
+
const jpeg = get_default(require('@jimp/js-jpeg'))
|
|
33
|
+
const png = get_default(require('@jimp/js-png'))
|
|
34
|
+
const resize = get_default(require('@jimp/plugin-resize'))
|
|
35
|
+
_Jimp = createJimp({ formats: [jpeg, png], plugins: [resize] })
|
|
36
|
+
return _Jimp
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Returns { buffer, bytes, width, height } — JPEG-encoded, longest side
|
|
40
|
+
// ≤ 2000 px, quality 80. Accepts PNG or JPEG input.
|
|
41
|
+
const compress_image = (file_path) => catch_errors('Image compression failed', async () => {
|
|
42
|
+
const Jimp = get_jimp()
|
|
43
|
+
let img
|
|
44
|
+
try {
|
|
45
|
+
img = await Jimp.read(file_path)
|
|
46
|
+
} catch (err) {
|
|
47
|
+
throw e(`Could not read image at ${file_path} — only PNG and JPEG are supported on the CLI`, [err])
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const { width, height } = img.bitmap
|
|
51
|
+
if (width > MAX_DIMENSION || height > MAX_DIMENSION) {
|
|
52
|
+
// scale proportionally so longest side = MAX_DIMENSION
|
|
53
|
+
if (width >= height) {
|
|
54
|
+
img.resize({ w: MAX_DIMENSION })
|
|
55
|
+
} else {
|
|
56
|
+
img.resize({ h: MAX_DIMENSION })
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const buffer = await img.getBuffer('image/jpeg', { quality: JPEG_QUALITY })
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
buffer,
|
|
64
|
+
bytes: buffer.length,
|
|
65
|
+
width: img.bitmap.width,
|
|
66
|
+
height: img.bitmap.height
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
module.exports = { compress_image, MAX_DIMENSION, JPEG_QUALITY }
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Spec 260524-01 § 12 — Secret scrubbing applied to feedback `client_context`
|
|
2
|
+
// before it leaves the CLI. Must match the rules in web/src/lib/scrub-secrets.js.
|
|
3
|
+
//
|
|
4
|
+
// Rules:
|
|
5
|
+
// 1. Strip OpenAI API keys (sk-...)
|
|
6
|
+
// 2. Strip GitHub tokens (ghp_..., gho_...)
|
|
7
|
+
// 3. Strip Cognito JWT-shaped tokens (eyJ...)
|
|
8
|
+
// 4. Strip values for env-var keys ending in _TOKEN, _KEY, _SECRET, _PASSWORD
|
|
9
|
+
//
|
|
10
|
+
// The replacement is the literal string `<redacted>` so callers can see that
|
|
11
|
+
// a value WAS present and got stripped — vs missing entirely.
|
|
12
|
+
|
|
13
|
+
const REDACTED = '<redacted>'
|
|
14
|
+
|
|
15
|
+
const PATTERNS = [
|
|
16
|
+
/sk-[A-Za-z0-9_-]{20,}/g, // OpenAI keys
|
|
17
|
+
/ghp_[A-Za-z0-9]{30,}/g, // GitHub personal access tokens
|
|
18
|
+
/gho_[A-Za-z0-9]{30,}/g, // GitHub OAuth tokens
|
|
19
|
+
/eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g // JWT shape
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
const SENSITIVE_KEY = /(_TOKEN|_KEY|_SECRET|_PASSWORD|_API_KEY)$/i
|
|
23
|
+
|
|
24
|
+
const scrub_string = (value) => {
|
|
25
|
+
let out = value
|
|
26
|
+
for (const re of PATTERNS) out = out.replace(re, REDACTED)
|
|
27
|
+
return out
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Recursively walk an object, scrubbing string values and redacting values
|
|
31
|
+
// whose key looks sensitive (e.g. `OPENAI_API_KEY`).
|
|
32
|
+
const scrub = (value) => {
|
|
33
|
+
if (value == null) return value
|
|
34
|
+
if (typeof value === 'string') return scrub_string(value)
|
|
35
|
+
if (typeof value !== 'object') return value
|
|
36
|
+
if (Array.isArray(value)) return value.map(scrub)
|
|
37
|
+
|
|
38
|
+
const out = {}
|
|
39
|
+
for (const [key, v] of Object.entries(value)) {
|
|
40
|
+
if (typeof v === 'string' && SENSITIVE_KEY.test(key)) {
|
|
41
|
+
out[key] = REDACTED
|
|
42
|
+
} else {
|
|
43
|
+
out[key] = scrub(v)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return out
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { scrub, scrub_string, REDACTED }
|
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
const fs = require('fs')
|
|
2
2
|
const path = require('path')
|
|
3
3
|
const { error: { catch_errors } } = require('puffy-core')
|
|
4
|
+
const { valid: valid_semver } = require('./semver')
|
|
5
|
+
const { SKILL_JSON, SKILL_TYPES } = require('../constants')
|
|
4
6
|
|
|
5
7
|
const SKIP_ENTRIES = new Set(['.tmp', '.DS_Store', '.install.lock'])
|
|
6
8
|
|
|
9
|
+
// A disk-resident, unclaimed skill is a "draft" when it has a HappySkills-shaped
|
|
10
|
+
// skill.json next to its SKILL.md — i.e. the file `init` already produced.
|
|
11
|
+
// "HappySkills-shaped" means: a non-empty `name`, a valid-semver `version`, and
|
|
12
|
+
// a `type` of "skill" or "kit". This is the minimum surface `release`/`publish`
|
|
13
|
+
// can pick up on a no-lock-entry first publish without an intermediate `convert`.
|
|
14
|
+
const is_happyskills_shaped_manifest = (manifest) => {
|
|
15
|
+
if (!manifest || typeof manifest !== 'object') return false
|
|
16
|
+
if (typeof manifest.name !== 'string' || !manifest.name.trim()) return false
|
|
17
|
+
if (!valid_semver(manifest.version)) return false
|
|
18
|
+
const t = manifest.type
|
|
19
|
+
if (t && t !== SKILL_TYPES.SKILL && t !== SKILL_TYPES.KIT) return false
|
|
20
|
+
return true
|
|
21
|
+
}
|
|
22
|
+
|
|
7
23
|
const parse_frontmatter = (content) => {
|
|
8
24
|
const match = content.match(/^---\n([\s\S]*?)\n---/)
|
|
9
25
|
if (!match) return null
|
|
@@ -44,9 +60,25 @@ const scan_skills_dir = (base_dir) => catch_errors('Failed to scan skills direct
|
|
|
44
60
|
const frontmatter = parse_frontmatter(content)
|
|
45
61
|
if (!frontmatter || !frontmatter.name) continue
|
|
46
62
|
|
|
63
|
+
// Detect whether this skill has a HappySkills-shaped manifest. If yes,
|
|
64
|
+
// callers can classify it as a "draft" (scaffolded by `init`, not yet
|
|
65
|
+
// claimed in the lock) rather than "external" (genuinely foreign).
|
|
66
|
+
let manifest = null
|
|
67
|
+
try {
|
|
68
|
+
const raw = await fs.promises.readFile(path.join(dir, SKILL_JSON), 'utf-8')
|
|
69
|
+
manifest = JSON.parse(raw)
|
|
70
|
+
} catch {
|
|
71
|
+
// No skill.json — genuinely external. manifest stays null.
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const is_draft = is_happyskills_shaped_manifest(manifest)
|
|
75
|
+
|
|
47
76
|
skills.push({
|
|
48
77
|
name: frontmatter.name,
|
|
49
|
-
description: frontmatter.description || ''
|
|
78
|
+
description: frontmatter.description || '',
|
|
79
|
+
is_draft,
|
|
80
|
+
version: is_draft ? manifest.version : null,
|
|
81
|
+
type: is_draft ? (manifest.type || SKILL_TYPES.SKILL) : null
|
|
50
82
|
})
|
|
51
83
|
}
|
|
52
84
|
|
|
@@ -129,4 +161,4 @@ const scan_agent_orphan_skills = (agents, is_global, project_root, known_names)
|
|
|
129
161
|
return [...by_name.values()]
|
|
130
162
|
})
|
|
131
163
|
|
|
132
|
-
module.exports = { scan_skills_dir, scan_agent_orphan_skills, parse_frontmatter }
|
|
164
|
+
module.exports = { scan_skills_dir, scan_agent_orphan_skills, parse_frontmatter, is_happyskills_shaped_manifest }
|