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.
Files changed (49) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/package.json +1 -1
  3. package/src/api/profiles.js +76 -0
  4. package/src/commands/agents.js +4 -1
  5. package/src/commands/check.js +4 -1
  6. package/src/commands/convert.js +6 -4
  7. package/src/commands/disable.js +4 -1
  8. package/src/commands/enable.js +4 -1
  9. package/src/commands/install.js +12 -3
  10. package/src/commands/list.js +4 -1
  11. package/src/commands/profile.js +350 -0
  12. package/src/commands/profile.test.js +80 -0
  13. package/src/commands/publish.js +3 -5
  14. package/src/commands/pull.js +7 -7
  15. package/src/commands/reconcile.js +4 -2
  16. package/src/commands/release.js +4 -1
  17. package/src/commands/schema.js +5 -2
  18. package/src/commands/skills-config.js +419 -55
  19. package/src/commands/snapshot.js +4 -1
  20. package/src/commands/update.js +4 -1
  21. package/src/commands/validate.js +5 -2
  22. package/src/constants/next_step_actions.js +3 -1
  23. package/src/constants.js +1 -0
  24. package/src/engine/installer.js +8 -4
  25. package/src/engine/uninstaller.js +2 -3
  26. package/src/integration/schema.test.js +2 -2
  27. package/src/integration/skills-config.test.js +432 -1
  28. package/src/lock/reader.js +28 -2
  29. package/src/lock/writer.guard.test.js +66 -0
  30. package/src/lock/writer.js +54 -2
  31. package/src/lock/writer.test.js +114 -0
  32. package/src/merge/detector.js +19 -11
  33. package/src/merge/rebase.js +2 -3
  34. package/src/schema/envelope_validator.js +1 -1
  35. package/src/skills_config/configure.js +6 -10
  36. package/src/skills_config/configure.test.js +120 -0
  37. package/src/skills_config/reader.js +30 -13
  38. package/src/skills_config/resolve.js +14 -6
  39. package/src/skills_config/schema_validator.js +245 -0
  40. package/src/skills_config/schema_validator.test.js +153 -0
  41. package/src/skills_config/skills_config.test.js +126 -1
  42. package/src/skills_config/types.js +59 -0
  43. package/src/skills_config/validate.js +344 -0
  44. package/src/skills_config/validate.test.js +214 -0
  45. package/src/skills_config/writer.js +67 -18
  46. package/src/utils/file_lock.js +93 -0
  47. package/src/utils/file_lock.test.js +72 -0
  48. package/src/validation/skill_json_rules.js +34 -4
  49. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "1.20.1",
3
+ "version": "1.21.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)",
@@ -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
+ }
@@ -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
 
@@ -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
 
@@ -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 { write_lock, update_lock_skills } = require('../lock/writer')
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 new_skills = update_lock_skills(lock_data, updates)
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,
@@ -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 = []
@@ -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 = []
@@ -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,
@@ -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 }