happyskills 1.20.1 → 1.21.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 +17 -0
- package/package.json +1 -1
- package/src/api/profiles.js +76 -0
- package/src/commands/agents.js +4 -1
- package/src/commands/check.js +4 -1
- package/src/commands/convert.js +6 -4
- package/src/commands/disable.js +4 -1
- package/src/commands/enable.js +4 -1
- package/src/commands/install.js +12 -3
- package/src/commands/list.js +4 -1
- package/src/commands/profile.js +350 -0
- package/src/commands/profile.test.js +80 -0
- package/src/commands/publish.js +3 -5
- package/src/commands/pull.js +7 -7
- package/src/commands/reconcile.js +4 -2
- package/src/commands/release.js +4 -1
- package/src/commands/schema.js +5 -2
- package/src/commands/skills-config.js +419 -55
- package/src/commands/snapshot.js +4 -1
- package/src/commands/update.js +4 -1
- package/src/commands/validate.js +5 -2
- package/src/constants/next_step_actions.js +3 -1
- package/src/constants.js +1 -0
- package/src/engine/installer.js +8 -4
- package/src/engine/uninstaller.js +2 -3
- package/src/integration/schema.test.js +2 -2
- package/src/integration/skills-config.test.js +432 -1
- package/src/lock/reader.js +28 -2
- package/src/lock/writer.guard.test.js +66 -0
- package/src/lock/writer.js +54 -2
- package/src/lock/writer.test.js +114 -0
- package/src/merge/detector.js +19 -11
- package/src/merge/rebase.js +2 -3
- package/src/schema/envelope_validator.js +1 -1
- package/src/skills_config/configure.js +6 -10
- package/src/skills_config/configure.test.js +120 -0
- package/src/skills_config/reader.js +30 -13
- package/src/skills_config/resolve.js +14 -6
- package/src/skills_config/schema_validator.js +245 -0
- package/src/skills_config/schema_validator.test.js +153 -0
- package/src/skills_config/skills_config.test.js +126 -1
- package/src/skills_config/types.js +59 -0
- package/src/skills_config/validate.js +344 -0
- package/src/skills_config/validate.test.js +214 -0
- package/src/skills_config/writer.js +67 -18
- package/src/utils/file_lock.js +93 -0
- package/src/utils/file_lock.test.js +72 -0
- package/src/validation/skill_json_rules.js +34 -4
- package/src/validation/skill_json_rules.test.js +156 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.21.0] - 2026-07-13
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Add the flat `happyskills profile` command (spec 260710-01, full parity with the web profile editor — no UI-only flows). View or edit a workspace's public `/w/<slug>/` page: `--tagline`, `--location`, `--website`, the six social-link flags (`--github` … `--mastodon`, each an https URL, `""` clears), the markdown bio (`--readme` / `--readme-file`), pinned skills by NAME (`--pin "a,b"` — replaces the set; names are resolved to ids against the workspace's public skills), the expertise-map `--hide-map`/`--show-map` toggle, and the `--public`/`--private` visibility toggle — a refusal for zero public skills surfaces the API's `publish_first` routing `next_step` (`route_to_skill: happyskills-publish`). `--avatar <path>` uploads a JPEG/PNG/WebP (magic-byte sniffed, 8 MB cap) through initiate → Content-Type-signed S3 PUT → complete; `--crop x,y,size` (normalized 0..1) crops the upload, or alone re-crops the RETAINED original with no re-upload. With no positional the command targets your personal workspace. Ships a full `schema` entry, so `happyskills schema --json` exposes the whole surface.
|
|
15
|
+
- Add `publish_first` (kind `routing`) to the closed `next_step.action` enum, byte-identical with the API mirror. Bumped `ENVELOPE_SCHEMA_VERSION` to `1.2.0` (additive: one new next_step action — same precedent as 1.1.0's `complete_manual_setup`).
|
|
16
|
+
- Support **structured config values**: a `skill.json` `config` field's `type` may now be `object` or `array`, not just a scalar — so a skill can declare a setting that has no scalar form (a theme, a named palette library). An `env` var stays scalar-only (a `.env` value is always a string; a structured type there is now a validation error). A field authored by the skill's own UI rather than by a human declares **`prompt: false`** and is skipped at install; an object/array field is never prompted regardless, since there is no sensible terminal prompt for "a map of named palettes". An unprompted field still applies its author default and still raises the fail-loud warning when it is `required` with no default.
|
|
17
|
+
- Add **`happyskills skills-config set` / `unset`** — the deterministic writer, which the subsystem had been missing (only a reader shipped, so a skill that needed to persist its own config had no path but hand-editing JSON). Writes one key atomically and key-scoped, preserving every other skill's block, the skill's other keys, and `envFile`. `--value <scalar>` for a scalar field; **`--json-value <json>`** for an object/array (`--json-value -` reads it from stdin, for a value too large to shell-quote). `--root <dir>` writes to a directory that is not a HappySkills project, creating the file if absent; **`--global`** writes to `~/.agents/skills-config.json` — the right home for a user-level preference that should follow the user across every project. `set` **refuses** a key the skill declared `secret: true` (`FORBIDDEN_FIELD`) — that file is committed, and a secret in it is a leak.
|
|
18
|
+
- Add **`happyskills skills-config validate`** — a deterministic checker for the consumer file. Because `skills-config.json` stays hand-editable by design, an LLM can and eventually will break it; this makes a hand-edit *recoverable*. Every result says **where** (the field path; for a syntax error the line, column, and the offending source line with a caret) and **how to fix it** (an imperative `fix`), naming the actual mistake — trailing comma, single quotes, unquoted key, comment, unclosed bracket, empty file — rather than emitting a generic "invalid JSON". Also catches a **secret sitting in the committed file** as a hard error (without echoing the value), an `envFile` that is absolute or not gitignored, a value contradicting its declared type, and an undeclared field.
|
|
19
|
+
- Add an **opt-in, author-declared `schema`** on a config field (a hand-rolled JSON-Schema subset — no new dependency): `type`, `enum`, `const`, `properties`, `required`, `additionalProperties`, `patternProperties`, `items`, `minItems`, `maxItems`, `pattern`, `minLength`, `maxLength`, `minimum`, `maximum`. When declared, `skills-config set` **refuses** a violating value and `validate` reports **every** violation with the exact addressable path (`palettes.Acme.palette[2]`) and a fix — so an agent repairs the value and re-runs until it converges, instead of discovering the problem at runtime. When absent, content opacity stands: the value is stored verbatim and never inspected. This does not put a contract on our side of the wire — the schema ships *inside* the skill and versions *with* it, so it cannot drift from the skill's real contract. The author's own schema is validated at authoring time by `happyskills validate` (an uncompilable regex, an unknown keyword, or a keyword applied to the wrong type — a `minLength` on an array, which would silently validate nothing — is an error before publish, never at the consumer's).
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- **Close a lost-update race on `skills-config.json` and `skills-lock.json`.** An atomic replace (write-temp + rename) protects a *reader* from a torn file but does nothing about two concurrent *read-modify-write* cycles: the second writer computes its next state from a pre-race snapshot and atomically replaces the file, silently erasing the first write. Both report success; one is gone. `skills-lock.json` was the worse case — `installer.js` read the lock and wrote it back **after a full network download**, a multi-second window, so two concurrent `happyskills install` runs could leave a skill on disk but absent from the lock. New `utils/file_lock.js` (a `mkdir`-based exclusive lock, stale-breaking and timeout-bounded) plus `lock/writer.js`'s new **`mutate_lock`**, which lets the caller do its slow work unlocked and then **re-reads the file inside the critical section** — so the lock is never held across a network fetch. All ten lock-write sites across seven files migrated; a static guard test fails the build if any production file calls or imports the raw `write_lock` again.
|
|
24
|
+
- **Distinguish ABSENT from CORRUPT when reading `skills-config.json` and `skills-lock.json`.** Both readers returned `null` for a file that existed but did not parse, making it indistinguishable from a missing one. For config, `skills-config get` therefore reported `ok: true` with an empty config and zero warnings — so an agent concluded nothing had ever been set and could overwrite the values the broken file still held. For the lock it was worse: writers merged their update into `null`, producing a map containing only their own entry, and replaced the whole file — **one unparseable byte and the next `install` erased every other skill from the lock.** Both now stat the path first (absent → `null`; present-but-unparseable → throw `VALIDATION_FAILED` with a `fix_validation_errors` next_step pointing at `skills-config validate`), the corrupt file is never overwritten, and the sixteen lock callers that silently discarded the error now propagate it.
|
|
25
|
+
- **Stop discarding the typed error when re-throwing a puffy-core chain.** `throw errors[0]` surfaces the generic *wrapper*, not the `CliError` deeper in the array where the `error.code` and `next_step` live — so writing to a corrupt config returned a bare `INTERNAL_ERROR` ("Failed to run the locked section for …") with no recovery action. Re-thrown with `wrap_errors` so the typed cause survives.
|
|
26
|
+
|
|
10
27
|
## [1.20.1] - 2026-07-09
|
|
11
28
|
|
|
12
29
|
### Changed
|
package/package.json
CHANGED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// API bindings for the /w/ workspace profile surface (spec 260710-01
|
|
2
|
+
// § II.4.3). Thin wrappers over the shared client — every response is the
|
|
3
|
+
// canonical six-key response envelope; the client unwraps `data`.
|
|
4
|
+
|
|
5
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
6
|
+
const client = require('./client')
|
|
7
|
+
|
|
8
|
+
const enc = encodeURIComponent
|
|
9
|
+
|
|
10
|
+
const get_profile = (slug) => catch_errors(`Failed to load profile ${slug}`, async () => {
|
|
11
|
+
const [errors, data] = await client.get(`/workspaces/${enc(slug)}/profile?limit=100`)
|
|
12
|
+
if (errors) throw errors[errors.length - 1]
|
|
13
|
+
return data
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const patch_profile = (slug, fields) => catch_errors(`Failed to update profile ${slug}`, async () => {
|
|
17
|
+
const [errors, data] = await client.patch(`/workspaces/${enc(slug)}/profile`, fields)
|
|
18
|
+
if (errors) throw errors[errors.length - 1]
|
|
19
|
+
return data
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const set_visibility = (slug, is_public) => catch_errors(`Failed to set profile visibility for ${slug}`, async () => {
|
|
23
|
+
const [errors, data] = await client.put(`/workspaces/${enc(slug)}/profile:visibility`, { is_public })
|
|
24
|
+
if (errors) {
|
|
25
|
+
const err = errors[errors.length - 1]
|
|
26
|
+
// The publish-first precondition (§ II.4.3): surface the API's routing
|
|
27
|
+
// next_step instead of the generic fix_validation_errors default.
|
|
28
|
+
if (err && err.code === 'VALIDATION_FAILED' && err.details && err.details.public_skills === 0) {
|
|
29
|
+
err.next_step = {
|
|
30
|
+
kind: 'routing',
|
|
31
|
+
action: 'publish_first',
|
|
32
|
+
instructions: 'Publish at least one public skill, then re-run this visibility toggle. The public profile is the payoff for a public body of work.',
|
|
33
|
+
context: { commands: ['npx happyskills release --json'] },
|
|
34
|
+
route_to_skill: 'happyskills-publish',
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
throw err
|
|
38
|
+
}
|
|
39
|
+
return data
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const avatar_initiate = (slug, bytes, content_type) => catch_errors('Failed to initiate the avatar upload', async () => {
|
|
43
|
+
const [errors, data] = await client.post(`/workspaces/${enc(slug)}/avatar:initiate`, { bytes, content_type })
|
|
44
|
+
if (errors) throw errors[errors.length - 1]
|
|
45
|
+
return data
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const avatar_complete = (slug, upload_id, crop) => catch_errors('Failed to complete the avatar upload', async () => {
|
|
49
|
+
const [errors, data] = await client.post(`/workspaces/${enc(slug)}/avatar:complete`, { upload_id, crop: crop || null })
|
|
50
|
+
if (errors) throw errors[errors.length - 1]
|
|
51
|
+
return data
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const recrop_avatar = (slug, crop) => catch_errors('Failed to re-crop the avatar', async () => {
|
|
55
|
+
const [errors, data] = await client.patch(`/workspaces/${enc(slug)}/profile:crop`, { crop })
|
|
56
|
+
if (errors) throw errors[errors.length - 1]
|
|
57
|
+
return data
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// The caller's app username — resolves the default workspace when the
|
|
61
|
+
// positional is omitted (`happyskills profile` = your own profile).
|
|
62
|
+
const own_username = () => catch_errors('Failed to resolve your username', async () => {
|
|
63
|
+
const [errors, data] = await client.get('/me/bootstrap')
|
|
64
|
+
if (errors) throw errors[errors.length - 1]
|
|
65
|
+
return data && data.user && data.user.username
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
module.exports = {
|
|
69
|
+
get_profile,
|
|
70
|
+
patch_profile,
|
|
71
|
+
set_visibility,
|
|
72
|
+
avatar_initiate,
|
|
73
|
+
avatar_complete,
|
|
74
|
+
recrop_avatar,
|
|
75
|
+
own_username,
|
|
76
|
+
}
|
package/src/commands/agents.js
CHANGED
|
@@ -148,7 +148,10 @@ const _add = async (raw_args, args) => {
|
|
|
148
148
|
const is_json = args.flags.json || false
|
|
149
149
|
const base_dir = skills_dir(is_global, project_root)
|
|
150
150
|
|
|
151
|
-
const [, lock_data] = await read_lock(lock_root(is_global, project_root))
|
|
151
|
+
const [lock_data_err, lock_data] = await read_lock(lock_root(is_global, project_root))
|
|
152
|
+
// A corrupt lock must not be read as "nothing installed" — that lie would have us
|
|
153
|
+
// report an empty project, or reinstall over a lock we simply failed to parse.
|
|
154
|
+
if (lock_data_err) throw e('Failed to read the lock file', lock_data_err)
|
|
152
155
|
|
|
153
156
|
const per_agent = []
|
|
154
157
|
|
package/src/commands/check.js
CHANGED
|
@@ -30,7 +30,10 @@ const run = (args) => catch_errors('Check failed', async () => {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
const project_root = find_project_root()
|
|
33
|
-
const [, lock_data] = await read_lock(project_root)
|
|
33
|
+
const [lock_data_err, lock_data] = await read_lock(project_root)
|
|
34
|
+
// A corrupt lock must not be read as "nothing installed" — that lie would have us
|
|
35
|
+
// report an empty project, or reinstall over a lock we simply failed to parse.
|
|
36
|
+
if (lock_data_err) throw e('Failed to read the lock file', lock_data_err)
|
|
34
37
|
const skills = get_all_locked_skills(lock_data)
|
|
35
38
|
const entries = Object.entries(skills)
|
|
36
39
|
|
package/src/commands/convert.js
CHANGED
|
@@ -9,7 +9,7 @@ const { parse_frontmatter } = require('../utils/skill_scanner')
|
|
|
9
9
|
const { read_manifest } = require('../manifest/reader')
|
|
10
10
|
const { write_manifest } = require('../manifest/writer')
|
|
11
11
|
const { read_lock } = require('../lock/reader')
|
|
12
|
-
const {
|
|
12
|
+
const { mutate_lock, update_lock_skills } = require('../lock/writer')
|
|
13
13
|
const { hash_directory } = require('../lock/integrity')
|
|
14
14
|
const { verify_lock_disk_consistency } = require('../lock/verify')
|
|
15
15
|
const { skills_dir, find_project_root, lock_root } = require('../config/paths')
|
|
@@ -183,7 +183,10 @@ const run = (args) => catch_errors('Convert failed', async () => {
|
|
|
183
183
|
|
|
184
184
|
pub_spinner.update('Updating lock file...')
|
|
185
185
|
const lock_dir = lock_root(is_global, project_root)
|
|
186
|
-
const [, lock_data] = await read_lock(lock_dir)
|
|
186
|
+
const [lock_data_err, lock_data] = await read_lock(lock_dir)
|
|
187
|
+
// A corrupt lock must not be read as "nothing installed" — that lie would have us
|
|
188
|
+
// report an empty project, or reinstall over a lock we simply failed to parse.
|
|
189
|
+
if (lock_data_err) throw e('Failed to read the lock file', lock_data_err)
|
|
187
190
|
const [, integrity] = await hash_directory(skill_dir)
|
|
188
191
|
|
|
189
192
|
const full_name = `${workspace.slug}/${skill_name}`
|
|
@@ -198,8 +201,7 @@ const run = (args) => catch_errors('Convert failed', async () => {
|
|
|
198
201
|
}
|
|
199
202
|
}
|
|
200
203
|
|
|
201
|
-
const
|
|
202
|
-
const [lock_err] = await write_lock(lock_dir, new_skills)
|
|
204
|
+
const [lock_err] = await mutate_lock(lock_dir, (current) => update_lock_skills(current, updates))
|
|
203
205
|
if (lock_err) { pub_spinner.fail('Failed to write lock file'); throw e('Lock write failed', lock_err) }
|
|
204
206
|
|
|
205
207
|
// Post-write verification — convert wrote both skill.json and the lock,
|
package/src/commands/disable.js
CHANGED
|
@@ -61,7 +61,10 @@ const run = (args) => catch_errors('Disable failed', async () => {
|
|
|
61
61
|
if (agents_err) throw e('Agent resolution failed', agents_err)
|
|
62
62
|
const { agents } = agents_result
|
|
63
63
|
|
|
64
|
-
const [, lock_data] = await read_lock(lock_root(is_global, project_root))
|
|
64
|
+
const [lock_data_err, lock_data] = await read_lock(lock_root(is_global, project_root))
|
|
65
|
+
// A corrupt lock must not be read as "nothing installed" — that lie would have us
|
|
66
|
+
// report an empty project, or reinstall over a lock we simply failed to parse.
|
|
67
|
+
if (lock_data_err) throw e('Failed to read the lock file', lock_data_err)
|
|
65
68
|
const locked_skills = get_all_locked_skills(lock_data)
|
|
66
69
|
|
|
67
70
|
const results = []
|
package/src/commands/enable.js
CHANGED
|
@@ -61,7 +61,10 @@ const run = (args) => catch_errors('Enable failed', async () => {
|
|
|
61
61
|
if (agents_err) throw e('Agent resolution failed', agents_err)
|
|
62
62
|
const { agents } = agents_result
|
|
63
63
|
|
|
64
|
-
const [, lock_data] = await read_lock(lock_root(is_global, project_root))
|
|
64
|
+
const [lock_data_err, lock_data] = await read_lock(lock_root(is_global, project_root))
|
|
65
|
+
// A corrupt lock must not be read as "nothing installed" — that lie would have us
|
|
66
|
+
// report an empty project, or reinstall over a lock we simply failed to parse.
|
|
67
|
+
if (lock_data_err) throw e('Failed to read the lock file', lock_data_err)
|
|
65
68
|
const locked_skills = get_all_locked_skills(lock_data)
|
|
66
69
|
|
|
67
70
|
const results = []
|
package/src/commands/install.js
CHANGED
|
@@ -64,7 +64,10 @@ const run = (args) => catch_errors('Install failed', async () => {
|
|
|
64
64
|
if (skills_raw.length === 0) {
|
|
65
65
|
const [manifest_err, manifest] = await read_manifest()
|
|
66
66
|
if (manifest_err) {
|
|
67
|
-
const [, lock_data] = await read_lock(base_options.project_root)
|
|
67
|
+
const [lock_data_err, lock_data] = await read_lock(base_options.project_root)
|
|
68
|
+
// A corrupt lock must not be read as "nothing installed" — that lie would have us
|
|
69
|
+
// report an empty project, or reinstall over a lock we simply failed to parse.
|
|
70
|
+
if (lock_data_err) throw e('Failed to read the lock file', lock_data_err)
|
|
68
71
|
if (lock_data && lock_data.skills && Object.keys(lock_data.skills).length > 0) {
|
|
69
72
|
const [errors, result] = await install_from_lock(lock_data, base_options)
|
|
70
73
|
if (errors) throw e('Install from lock failed', errors)
|
|
@@ -131,7 +134,10 @@ const run = (args) => catch_errors('Install failed', async () => {
|
|
|
131
134
|
// refuse before contacting the registry. Spec § 8.5 + envelope
|
|
132
135
|
// integration test (install_fresh.test.js).
|
|
133
136
|
if (dir_present) {
|
|
134
|
-
const [, lock_data_pre] = await read_lock(base_options.project_root)
|
|
137
|
+
const [lock_data_pre_err, lock_data_pre] = await read_lock(base_options.project_root)
|
|
138
|
+
// A corrupt lock must not be read as "nothing installed" — that lie would have us
|
|
139
|
+
// report an empty project, or reinstall over a lock we simply failed to parse.
|
|
140
|
+
if (lock_data_pre_err) throw e('Failed to read the lock file', lock_data_pre_err)
|
|
135
141
|
let has_local_edits = false
|
|
136
142
|
if (lock_data_pre) {
|
|
137
143
|
const all = get_all_locked_skills(lock_data_pre)
|
|
@@ -179,7 +185,10 @@ const run = (args) => catch_errors('Install failed', async () => {
|
|
|
179
185
|
|
|
180
186
|
// (3) Snapshot before wiping. Always — so a failed install is reversible.
|
|
181
187
|
if (dir_present) {
|
|
182
|
-
const [, lock_data_pre] = await read_lock(base_options.project_root)
|
|
188
|
+
const [lock_data_pre_err, lock_data_pre] = await read_lock(base_options.project_root)
|
|
189
|
+
// A corrupt lock must not be read as "nothing installed" — that lie would have us
|
|
190
|
+
// report an empty project, or reinstall over a lock we simply failed to parse.
|
|
191
|
+
if (lock_data_pre_err) throw e('Failed to read the lock file', lock_data_pre_err)
|
|
183
192
|
const [snap_err, snap] = await snapshot_storage.create({
|
|
184
193
|
skill_dir,
|
|
185
194
|
workspace: owner,
|
package/src/commands/list.js
CHANGED
|
@@ -48,7 +48,10 @@ const scope_label = (is_global) => (is_global ? 'Global' : 'Local')
|
|
|
48
48
|
const gather_scope = (is_global, project_root, agents_flag) => catch_errors('Gather scope failed', async () => {
|
|
49
49
|
const base_dir = skills_dir(is_global, project_root)
|
|
50
50
|
|
|
51
|
-
const [, lock_data] = await read_lock(lock_root(is_global, project_root))
|
|
51
|
+
const [lock_data_err, lock_data] = await read_lock(lock_root(is_global, project_root))
|
|
52
|
+
// A corrupt lock must not be read as "nothing installed" — that lie would have us
|
|
53
|
+
// report an empty project, or reinstall over a lock we simply failed to parse.
|
|
54
|
+
if (lock_data_err) throw e('Failed to read the lock file', lock_data_err)
|
|
52
55
|
const skills = get_all_locked_skills(lock_data)
|
|
53
56
|
const managed_entries = Object.entries(skills)
|
|
54
57
|
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// happyskills profile — view and edit a workspace's public /w/ profile page
|
|
2
|
+
// (spec 260710-01, Open Decision 4 = FULL CLI PARITY: every profile field, the
|
|
3
|
+
// visibility toggle, and the avatar are agent-reachable — no UI-only flows).
|
|
4
|
+
//
|
|
5
|
+
// FLAT command by design (one file-named verb, flags for everything — never a
|
|
6
|
+
// nested noun/verb family). One invocation may combine several edits; they run
|
|
7
|
+
// in a deterministic order: field PATCH → visibility PUT → avatar/crop → final
|
|
8
|
+
// GET, and the emitted envelope carries the final profile state.
|
|
9
|
+
|
|
10
|
+
const fs = require('fs')
|
|
11
|
+
const path = require('path')
|
|
12
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
13
|
+
const profiles_api = require('../api/profiles')
|
|
14
|
+
const { require_token } = require('../auth/token_store')
|
|
15
|
+
const { print_help, print_json, print_success, print_label, print_info } = require('../ui/output')
|
|
16
|
+
const { exit_with_error, UsageError } = require('../utils/errors')
|
|
17
|
+
const { EXIT_CODES } = require('../constants')
|
|
18
|
+
|
|
19
|
+
const SOCIAL_KEYS = ['github', 'twitter', 'linkedin', 'youtube', 'bluesky', 'mastodon']
|
|
20
|
+
const MAX_AVATAR_BYTES = 8 * 1000 * 1000
|
|
21
|
+
|
|
22
|
+
const HELP_TEXT = `Usage: happyskills profile [workspace] [options]
|
|
23
|
+
|
|
24
|
+
View or edit a workspace's public profile page (happyskills.ai/w/<workspace>/).
|
|
25
|
+
With no options, shows the profile. With no [workspace], uses your personal
|
|
26
|
+
workspace. Every edit the web UI can make is available here.
|
|
27
|
+
|
|
28
|
+
Arguments:
|
|
29
|
+
workspace Workspace slug (defaults to your personal workspace)
|
|
30
|
+
|
|
31
|
+
Profile fields (pass "" to clear a field):
|
|
32
|
+
--tagline <text> One-line tagline (max 160 chars)
|
|
33
|
+
--location <text> Location (max 120 chars)
|
|
34
|
+
--website <url> Website (https:// URL)
|
|
35
|
+
--github <url> Social links — each an https:// URL
|
|
36
|
+
--twitter <url> (also: --linkedin, --youtube, --bluesky, --mastodon)
|
|
37
|
+
--readme <markdown> The About section, inline markdown
|
|
38
|
+
--readme-file <path> The About section, read from a markdown file
|
|
39
|
+
--pin <names> Comma-separated skill names to pin (REPLACES the pinned
|
|
40
|
+
set; "" unpins everything)
|
|
41
|
+
--hide-map Hide the auto-generated expertise map from the public page
|
|
42
|
+
--show-map Show it again
|
|
43
|
+
|
|
44
|
+
Visibility:
|
|
45
|
+
--public Make the profile public (requires >= 1 public skill).
|
|
46
|
+
Public means indexed and in the sitemap.
|
|
47
|
+
--private Make it private again
|
|
48
|
+
|
|
49
|
+
Avatar:
|
|
50
|
+
--avatar <path> Upload a JPEG/PNG/WebP image (max 8 MB). The centre
|
|
51
|
+
square is shown by default; the ORIGINAL is kept so you
|
|
52
|
+
can re-crop later without re-uploading.
|
|
53
|
+
--crop <x,y,size> Crop square, normalized 0..1 (e.g. 0.1,0.05,0.8).
|
|
54
|
+
With --avatar: applied to the new upload.
|
|
55
|
+
Alone: re-crops the existing avatar from the kept original.
|
|
56
|
+
|
|
57
|
+
Other:
|
|
58
|
+
--json Output as JSON (the canonical envelope)
|
|
59
|
+
|
|
60
|
+
Examples:
|
|
61
|
+
happyskills profile
|
|
62
|
+
happyskills profile nicolasdao --json
|
|
63
|
+
happyskills profile --tagline "Ships agent skills" --location "Sydney"
|
|
64
|
+
happyskills profile --avatar ./me.jpg --crop 0.1,0,0.8
|
|
65
|
+
happyskills profile --public
|
|
66
|
+
happyskills profile --pin "update-doc,pg-guard"`
|
|
67
|
+
|
|
68
|
+
// ── pure helpers (exported for tests) ───────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
const sniff_content_type = (buf) => {
|
|
71
|
+
if (!buf || buf.length < 12) return null
|
|
72
|
+
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg'
|
|
73
|
+
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return 'image/png'
|
|
74
|
+
if (buf.slice(0, 4).toString('latin1') === 'RIFF' && buf.slice(8, 12).toString('latin1') === 'WEBP') return 'image/webp'
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const parse_crop = (raw) => {
|
|
79
|
+
const parts = String(raw || '').split(',').map(s => Number(s.trim()))
|
|
80
|
+
if (parts.length !== 3 || parts.some(n => !Number.isFinite(n))) {
|
|
81
|
+
throw new UsageError('--crop must be three numbers "x,y,size" in 0..1 (e.g. --crop 0.1,0.05,0.8).')
|
|
82
|
+
}
|
|
83
|
+
const [x, y, size] = parts
|
|
84
|
+
if (x < 0 || y < 0 || size <= 0 || size > 1 || x + size > 1.0001 || y + size > 1.0001) {
|
|
85
|
+
throw new UsageError('--crop values must satisfy 0 <= x, 0 <= y, 0 < size <= 1, x+size <= 1, y+size <= 1.')
|
|
86
|
+
}
|
|
87
|
+
return { x, y, size }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Collect the PATCHable fields from flags. `undefined` = untouched; '' = clear.
|
|
91
|
+
const collect_field_edits = (flags) => {
|
|
92
|
+
const fields = {}
|
|
93
|
+
const str_flag = (name) => {
|
|
94
|
+
const v = flags[name]
|
|
95
|
+
if (v === undefined) return undefined
|
|
96
|
+
if (v === true) throw new UsageError(`--${name} needs a value (use --${name} "" to clear it).`)
|
|
97
|
+
return String(v)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const tagline = str_flag('tagline')
|
|
101
|
+
if (tagline !== undefined) fields.tagline = tagline || null
|
|
102
|
+
const location = str_flag('location')
|
|
103
|
+
if (location !== undefined) fields.location = location || null
|
|
104
|
+
const website = str_flag('website')
|
|
105
|
+
if (website !== undefined) fields.website_url = website || null
|
|
106
|
+
|
|
107
|
+
const socials = {}
|
|
108
|
+
let socials_touched = false
|
|
109
|
+
for (const key of SOCIAL_KEYS) {
|
|
110
|
+
const v = str_flag(key)
|
|
111
|
+
if (v === undefined) continue
|
|
112
|
+
socials_touched = true
|
|
113
|
+
socials[key] = v || ''
|
|
114
|
+
}
|
|
115
|
+
if (socials_touched) fields.social_links = socials
|
|
116
|
+
|
|
117
|
+
if (flags['hide-map'] && flags['show-map']) throw new UsageError('--hide-map and --show-map are mutually exclusive.')
|
|
118
|
+
if (flags['hide-map']) fields.expertise_map_hidden = true
|
|
119
|
+
if (flags['show-map']) fields.expertise_map_hidden = false
|
|
120
|
+
|
|
121
|
+
const readme_inline = str_flag('readme')
|
|
122
|
+
const readme_file = str_flag('readme-file')
|
|
123
|
+
if (readme_inline !== undefined && readme_file !== undefined) {
|
|
124
|
+
throw new UsageError('Pass --readme OR --readme-file, not both.')
|
|
125
|
+
}
|
|
126
|
+
if (readme_inline !== undefined) fields.profile_readme = readme_inline || null
|
|
127
|
+
if (readme_file !== undefined) fields.__readme_file = readme_file // resolved by the caller (fs)
|
|
128
|
+
|
|
129
|
+
const pin = str_flag('pin')
|
|
130
|
+
if (pin !== undefined) fields.__pin_names = pin.trim() ? pin.split(',').map(s => s.trim()).filter(Boolean) : []
|
|
131
|
+
|
|
132
|
+
return fields
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── the command ─────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
const upload_avatar = async (slug, avatar_path, crop) => {
|
|
138
|
+
const abs = path.resolve(avatar_path)
|
|
139
|
+
if (!fs.existsSync(abs)) throw new UsageError(`Avatar file not found: ${avatar_path}`)
|
|
140
|
+
const buf = fs.readFileSync(abs)
|
|
141
|
+
if (buf.length > MAX_AVATAR_BYTES) {
|
|
142
|
+
throw new UsageError(`The image is too large (max ${Math.round(MAX_AVATAR_BYTES / 1000 / 1000)} MB). Resize it and try again.`)
|
|
143
|
+
}
|
|
144
|
+
const content_type = sniff_content_type(buf)
|
|
145
|
+
if (!content_type) {
|
|
146
|
+
throw new UsageError('This image format is not supported yet. Save it as JPEG, PNG, or WebP and try again.')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const [init_err, init] = await profiles_api.avatar_initiate(slug, buf.length, content_type)
|
|
150
|
+
if (init_err) throw init_err[init_err.length - 1]
|
|
151
|
+
|
|
152
|
+
// Raw PUT to the presigned URL — the Content-Type MUST match the signed one.
|
|
153
|
+
const res = await fetch(init.upload_url, {
|
|
154
|
+
method: 'PUT',
|
|
155
|
+
headers: { 'Content-Type': init.required_content_type || content_type },
|
|
156
|
+
body: buf,
|
|
157
|
+
})
|
|
158
|
+
if (!res.ok) throw new Error(`The image upload failed (S3 ${res.status}). Please retry.`)
|
|
159
|
+
|
|
160
|
+
const [complete_err, completed] = await profiles_api.avatar_complete(slug, init.upload_id, crop)
|
|
161
|
+
if (complete_err) throw complete_err[complete_err.length - 1]
|
|
162
|
+
return completed
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const resolve_pin_ids = async (slug, names) => {
|
|
166
|
+
if (names.length === 0) return []
|
|
167
|
+
const [err, profile] = await profiles_api.get_profile(slug)
|
|
168
|
+
if (err) throw err[err.length - 1]
|
|
169
|
+
const by_name = new Map((profile.skills || []).map(s => [s.name, s.id]))
|
|
170
|
+
const missing = names.filter(n => !by_name.has(n))
|
|
171
|
+
if (missing.length > 0) {
|
|
172
|
+
throw new UsageError(`Cannot pin ${missing.join(', ')} — not among this workspace's public skills. Pins take PUBLIC skill names.`)
|
|
173
|
+
}
|
|
174
|
+
return names.map(n => by_name.get(n))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const render_text = (profile) => {
|
|
178
|
+
const ws = profile.workspace
|
|
179
|
+
const t = profile.totals
|
|
180
|
+
print_label('Workspace', `${ws.display_name} (@${ws.slug})`)
|
|
181
|
+
print_label('Profile', ws.is_public ? `PUBLIC — https://happyskills.ai/w/${ws.slug}/` : 'private (only editors can see it)')
|
|
182
|
+
if (ws.tagline) print_label('Tagline', ws.tagline)
|
|
183
|
+
if (ws.location) print_label('Location', ws.location)
|
|
184
|
+
if (ws.website_url) print_label('Website', ws.website_url)
|
|
185
|
+
const socials = Object.entries(ws.social_links || {})
|
|
186
|
+
if (socials.length) print_label('Links', socials.map(([k, v]) => `${k}: ${v}`).join(' '))
|
|
187
|
+
print_label('Adoption', `${t.installs} installs · ${t.stars} stars${t.forks ? ` · ${t.forks} forks` : ''}`)
|
|
188
|
+
print_label('Skills', `${t.skills.original} authored${t.skills.claimed ? ` · ${t.skills.claimed} claimed` : ''}${t.skills.community_listed ? ` · ${t.skills.community_listed} community-listed` : ''}`)
|
|
189
|
+
if (ws.avatar) print_label('Avatar', 'set')
|
|
190
|
+
if (profile.expertise_map) {
|
|
191
|
+
const spine = profile.expertise_map.platform_family
|
|
192
|
+
.map(e => `${e.value} (${e.skills} skills, ${e.installs} installs)`)
|
|
193
|
+
.join(', ')
|
|
194
|
+
print_label('Expertise', spine || '(not enough scored skills yet)')
|
|
195
|
+
}
|
|
196
|
+
if ((ws.pinned || []).length) {
|
|
197
|
+
const pinned_names = (profile.skills || []).filter(s => s.pinned).map(s => s.name)
|
|
198
|
+
print_label('Pinned', pinned_names.join(', '))
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const run = (args) => catch_errors('Profile command failed', async () => {
|
|
203
|
+
if (args.flags._show_help) {
|
|
204
|
+
print_help(HELP_TEXT)
|
|
205
|
+
return process.exit(EXIT_CODES.SUCCESS)
|
|
206
|
+
}
|
|
207
|
+
const flags = args.flags
|
|
208
|
+
|
|
209
|
+
if (flags.public && flags.private) throw new UsageError('--public and --private are mutually exclusive.')
|
|
210
|
+
|
|
211
|
+
const fields = collect_field_edits(flags)
|
|
212
|
+
const wants_visibility = !!flags.public || !!flags.private
|
|
213
|
+
const wants_avatar = flags.avatar !== undefined
|
|
214
|
+
const wants_recrop = flags.crop !== undefined && !wants_avatar
|
|
215
|
+
const crop = flags.crop !== undefined ? parse_crop(flags.crop) : null
|
|
216
|
+
const is_edit = Object.keys(fields).length > 0 || wants_visibility || wants_avatar || wants_recrop
|
|
217
|
+
|
|
218
|
+
// Reads of a PUBLIC profile work anonymously; anything else needs a session.
|
|
219
|
+
if (is_edit || !args._[0]) await require_token()
|
|
220
|
+
|
|
221
|
+
let slug = args._[0]
|
|
222
|
+
if (!slug) {
|
|
223
|
+
const [me_err, username] = await profiles_api.own_username()
|
|
224
|
+
if (me_err) throw me_err[me_err.length - 1]
|
|
225
|
+
if (!username) throw new Error('Could not resolve your personal workspace — pass the workspace slug explicitly.')
|
|
226
|
+
slug = username
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const performed = []
|
|
230
|
+
|
|
231
|
+
// 1. Field edits (one PATCH).
|
|
232
|
+
if (fields.__readme_file !== undefined) {
|
|
233
|
+
const readme_path = path.resolve(fields.__readme_file)
|
|
234
|
+
if (!fs.existsSync(readme_path)) throw new UsageError(`README file not found: ${fields.__readme_file}`)
|
|
235
|
+
fields.profile_readme = fs.readFileSync(readme_path, 'utf8') || null
|
|
236
|
+
delete fields.__readme_file
|
|
237
|
+
}
|
|
238
|
+
if (fields.__pin_names !== undefined) {
|
|
239
|
+
fields.pinned_repo_ids = await resolve_pin_ids(slug, fields.__pin_names)
|
|
240
|
+
delete fields.__pin_names
|
|
241
|
+
}
|
|
242
|
+
if (Object.keys(fields).length > 0) {
|
|
243
|
+
const [patch_err] = await profiles_api.patch_profile(slug, fields)
|
|
244
|
+
if (patch_err) throw patch_err[patch_err.length - 1]
|
|
245
|
+
performed.push('fields_updated')
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 2. Visibility (one guarded PUT — the publish-first precondition surfaces
|
|
249
|
+
// as a structured error with a publish_first next_step).
|
|
250
|
+
if (wants_visibility) {
|
|
251
|
+
const [vis_err, vis] = await profiles_api.set_visibility(slug, !!flags.public)
|
|
252
|
+
if (vis_err) throw vis_err[vis_err.length - 1]
|
|
253
|
+
performed.push(vis.is_public ? 'made_public' : 'made_private')
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 3. Avatar upload / re-crop (async server-side processing → 202).
|
|
257
|
+
if (wants_avatar) {
|
|
258
|
+
await upload_avatar(slug, String(flags.avatar), crop)
|
|
259
|
+
performed.push('avatar_processing')
|
|
260
|
+
} else if (wants_recrop) {
|
|
261
|
+
const [crop_err] = await profiles_api.recrop_avatar(slug, crop)
|
|
262
|
+
if (crop_err) throw crop_err[crop_err.length - 1]
|
|
263
|
+
performed.push('avatar_recrop_processing')
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// 4. Final state (also the plain view when nothing was edited).
|
|
267
|
+
const [get_err, profile] = await profiles_api.get_profile(slug)
|
|
268
|
+
if (get_err) throw get_err[get_err.length - 1]
|
|
269
|
+
|
|
270
|
+
if (flags.json) {
|
|
271
|
+
print_json({
|
|
272
|
+
data: {
|
|
273
|
+
...profile,
|
|
274
|
+
...(performed.length ? { performed } : {}),
|
|
275
|
+
public_url: profile.workspace.is_public ? `https://happyskills.ai/w/${profile.workspace.slug}/` : null,
|
|
276
|
+
},
|
|
277
|
+
})
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
render_text(profile)
|
|
282
|
+
if (performed.includes('avatar_processing') || performed.includes('avatar_recrop_processing')) {
|
|
283
|
+
print_info('Your photo is processing — it appears on the profile in a few seconds. The original is kept so you can re-crop later (`--crop x,y,size`) without re-uploading.')
|
|
284
|
+
}
|
|
285
|
+
if (performed.includes('made_public')) {
|
|
286
|
+
print_success(`Your profile is live: https://happyskills.ai/w/${profile.workspace.slug}/`)
|
|
287
|
+
} else if (performed.length) {
|
|
288
|
+
print_success('Profile updated.')
|
|
289
|
+
}
|
|
290
|
+
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
291
|
+
|
|
292
|
+
const schema = {
|
|
293
|
+
name: 'profile',
|
|
294
|
+
audience: 'consumer',
|
|
295
|
+
purpose: 'View and edit a workspace public profile page (/w/<slug>/): tagline, location, links, bio, pinned skills, expertise-map visibility, the public toggle, and the avatar. Full parity with the web editor.',
|
|
296
|
+
mutation: true,
|
|
297
|
+
interactive_in_text_mode: false,
|
|
298
|
+
input: {
|
|
299
|
+
positional: [
|
|
300
|
+
{ name: 'workspace', required: false, type: 'string', pattern: '<slug>', description: 'Defaults to your personal workspace' },
|
|
301
|
+
],
|
|
302
|
+
flags: [
|
|
303
|
+
{ name: 'tagline', type: 'string' },
|
|
304
|
+
{ name: 'location', type: 'string' },
|
|
305
|
+
{ name: 'website', type: 'string', pattern: 'https://…' },
|
|
306
|
+
{ name: 'github', type: 'string', pattern: 'https://…' },
|
|
307
|
+
{ name: 'twitter', type: 'string', pattern: 'https://…' },
|
|
308
|
+
{ name: 'linkedin', type: 'string', pattern: 'https://…' },
|
|
309
|
+
{ name: 'youtube', type: 'string', pattern: 'https://…' },
|
|
310
|
+
{ name: 'bluesky', type: 'string', pattern: 'https://…' },
|
|
311
|
+
{ name: 'mastodon', type: 'string', pattern: 'https://…' },
|
|
312
|
+
{ name: 'readme', type: 'string', description: 'About section markdown, inline' },
|
|
313
|
+
{ name: 'readme-file', type: 'string', description: 'About section markdown, from a file' },
|
|
314
|
+
{ name: 'pin', type: 'string', description: 'Comma-separated PUBLIC skill names; replaces the pinned set' },
|
|
315
|
+
{ name: 'hide-map', type: 'boolean', default: false },
|
|
316
|
+
{ name: 'show-map', type: 'boolean', default: false },
|
|
317
|
+
{ name: 'public', type: 'boolean', default: false, description: 'Requires >= 1 public skill; public ⇒ indexed + in the sitemap' },
|
|
318
|
+
{ name: 'private', type: 'boolean', default: false },
|
|
319
|
+
{ name: 'avatar', type: 'string', description: 'Path to a JPEG/PNG/WebP image (max 8 MB); centre-square crop by default' },
|
|
320
|
+
{ name: 'crop', type: 'string', pattern: 'x,y,size (normalized 0..1)', description: 'With --avatar: crop the upload. Alone: re-crop the kept original.' },
|
|
321
|
+
{ name: 'json', type: 'boolean', default: false },
|
|
322
|
+
],
|
|
323
|
+
},
|
|
324
|
+
output: {
|
|
325
|
+
data_shape: {
|
|
326
|
+
workspace: 'object — slug, display_name, is_public, tagline, location, website_url, social_links, profile_readme, avatar, pinned',
|
|
327
|
+
totals: 'object — installs, stars, forks, skills{original,claimed,community_listed}',
|
|
328
|
+
skills: 'array — public skills with adoption counts and listing_status',
|
|
329
|
+
expertise_map: 'object|null — platform-vetted, evidence-carrying; null when hidden or below the 3-skill floor',
|
|
330
|
+
timeline: 'array — {date, count} over ORIGINAL skills only',
|
|
331
|
+
performed: 'array<string> — edits applied this invocation (present only after an edit)',
|
|
332
|
+
public_url: 'string|null',
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
errors: [
|
|
336
|
+
{ code: 'USAGE_ERROR', next_step: { kind: 'routing', action: 'discover_schema' } },
|
|
337
|
+
{ code: 'AUTH_REQUIRED', next_step: { kind: 'recovery', action: 'login' } },
|
|
338
|
+
{ code: 'VALIDATION_FAILED', next_step: { kind: 'routing', action: 'publish_first' }, when: 'toggling --public with zero public skills' },
|
|
339
|
+
{ code: 'NOT_FOUND', when: 'workspace missing, or the profile is private and you are not an editor' },
|
|
340
|
+
],
|
|
341
|
+
examples: [
|
|
342
|
+
'happyskills profile',
|
|
343
|
+
'happyskills profile nicolasdao --json',
|
|
344
|
+
'happyskills profile --tagline "Ships agent skills" --location Sydney',
|
|
345
|
+
'happyskills profile --avatar ./me.jpg --crop 0.1,0,0.8',
|
|
346
|
+
'happyskills profile --public',
|
|
347
|
+
],
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
module.exports = { run, schema, collect_field_edits, parse_crop, sniff_content_type }
|