feat(workspace): publish a locally-authored skill to the linked workspace
Issue for this PR
Closes #1271
Branched off main, independent of #1278 / #1279 — publishing shares no code
with the /workspace operations.
Type of change
- New feature
What does this PR do?
Adds the upload half of skill-sync.ts, which only ever pulls. A skill authored
locally had no route to the workspace, and nothing in the CLI said so.
Note the original issue was wrong and has been corrected. It claimed this
needed a workspace API that does not exist. It does exist — create, update, write
bundle file, delete, and attach are all there. The missing half was entirely
client-side, which makes this much smaller than first scoped.
Shaped so agents and commands can ride the same path later: a workspace skill is a
named bundle of files, and nothing here is skill-specific except the endpoint it
posts to. collectBundle and the binary guard take a directory, not a skill.
Three rules, each a real bug if skipped:
-
Refuse non-UTF-8 files, naming the path. The wire format is
{path, content}with content as a string — the server does
content.encode("utf-8")inbound and returns a decoded string outbound. A
bundle carrying a PNG cannot round-trip: the declared byte size stops matching
after the re-encode andskill-syncskips the whole skill, logging a warning
nobody sees. Caught at publish it is one clear local error. Uncaught, the
upload succeeds and the skill silently vanishes from every other machine,
days later, with nothing tying the symptom to the cause. Decoding is strict
(fatal: true); the default substitutes U+FFFD and would hand back a "valid"
string that reassembles into a different file. -
Never publish from the managed snapshot.
.altimate-code/skill/_workspace
holds skills the workspace sent us, under the same{skill,skills}/**glob as
the user's own — deliberately, since that is how they load. A publish walking
"every skill in this project" would send the workspace's own skills back to it. -
Remember the server's id, so a second publish updates. Names are unique per
creator, so a blind re-create answers 409 rather than duplicating — turning an
ordinary second publish into an error the user has to interpret.
Two decisions I made rather than block on — both worth a reviewer disagreeing
with:
- The id lives in a local ledger, not
SKILL.mdfrontmatter. Frontmatter is
committed, so the id would travel with the skill: a colleague cloning the repo
and publishing would update the original author's bundle rather than create
their own. It would also put a server identifier in a hand-edited file and show
up in every diff. privacyis left unset, so the server'sprivatedefault applies.
Publishing should attach a skill to a workspace, not disclose it org-wide as a
side effect of a command whose name says nothing about visibility.
Attaching to the workspace (added in review)
Creating a skill and attaching it to a workspace are two server calls, and the first
version of this PR only made the first. A created-but-unattached skill shows up in no
workspace — the CLI and the web UI both list workspace skills by workspace id — so from the
user's side "publish" did nothing visible. That is the workspaces UAT report this PR exists
to close, and as first written it would have reproduced it.
Publish now resolves the linked workspace before uploading (refusing an unlinked project
with NotLinkedError, since uploading first would create the orphan), then attaches via
PUT /skills/{id}/datamates. That endpoint replaces the whole set, so the current
attachments are read and merged rather than overwritten. Attachment happens on the update
path too, and on create it runs after the id is recorded so a failed attach is retried by the
next publish instead of creating a duplicate.
Not in this PR
publishSkill has no caller yet. This PR adds the module and its tests; wiring it to a
/workspace action or a skill publish subcommand is a follow-up. Until then the feature is
not discoverable from the CLI.
Planned shape for skill bundles (follow-up)
A CLI-created skill is currently two things in two places: SKILL.md in
.opencode/skills/<name>/ and its paired tool in .opencode/tools/<name>, found by bare
name because that directory is on the agent's PATH. Publishing bundles the skill folder
only, so the tool does not travel — anyone who pulls the skill gets instructions that
reference a command their machine does not have. It fails quietly at the moment of use.
The agreed direction is self-contained skills, converging on the format upstream and the
SaaS already use (a folder with SKILL.md as entry point):
skill createscaffolds the tool inside the skill folder
(.opencode/skills/<name>/tools/<name>), so what is pushed is what is pulled.SKILL.mdreferences it by path —{skill_dir}/tools/<name>— and the loader substitutes
the skill's real directory on inject. Path-based, notPATH-based, because there is no
"skill invocation" boundary at runtime: a per-skilltools/dir onPATHwould shadow that
command name for every call in the session, not just the skill's own.- Pull marks
tools/*executable on write. The server stores no mode bit, so this is a
client-side convention; bundles are text-only (no binaries), so these are scripts. skill test/skill removelook in both layouts; old-layout skills keep working
indefinitely and publish warns when a referenced tool will not travel.
Decided with the product owner: workspace skills may carry runnable scripts.
Untouched by any of this: core tools on ALTIMATE_BIN_DIR, user tools in
.altimate-code/tools/ and .opencode/tools/, and every existing skill on disk.
How did you verify your code works?
11 new tests, 443 across test/altimate/workspace. Typecheck clean; the one lint
finding in the new source was a cast of on-disk JSON, replaced with a real shape
check so a corrupt row costs its own skill a re-create instead of a PATCH against
a garbage id.
Mutation-checked: 8 mutations, 8 killed — non-fatal decoding, dropping the
managed-snapshot guard, prefix-matching without the separator, always creating,
swallowing the 409, not re-creating after a 404, not recording the id, and
defaulting privacy to public each fail a test.
Screenshots / recordings
No UI in this PR — see below.
Checklist
- I have tested my changes locally
- I have not included unrelated changes in this PR
Known gaps
- No user surface yet. This is the publish path and its tests; nothing invokes
it. A command belongs in a follow-up, and I did not want to bundle a UX decision
into a PR that is otherwise mechanical. - Not exercised end-to-end. Unlike #1278, this has not been run against a live
backend — worth doing before it leaves draft, particularly the 409 path, since
that depends on the server's per-creator name uniqueness behaving as read. kindgeneralisation is client-shaped only. The endpoint is skills-specific, so
agents and commands would still need a server-side bundle kind to ride this path.
🤖 Generated with Claude Code
Summary by cubic
Closes #1271. Adds the client-side publish path for locally authored skills, changing workspace skill sync from pull-only to create or update plus attachment to the linked workspace; without attachment, uploaded skills remained invisible in workspace UIs, while existing workspace attachments are preserved.
Safety and recovery
- Resolves the workspace binding and caller identity before uploading, rejecting unlinked projects with
NotLinkedErrorand workspaces the caller does not own withNotWorkspaceOwnerError. - Rejects non-UTF-8 files, empty or oversized bundles (server ceiling: 10MB / 100 files), and symlinks; skips junk files with case-folded names —
.env,.envrc,.git(directory or worktree file), editor backups — so secrets never leave the machine. - Excludes the managed workspace snapshot and detects skill directories that link into it.
- Uses a 120-second upload timeout instead of the shared 15-second request budget.
- Stores published ids in an atomic, account-scoped local ledger keyed by the skill directory's real path and user id (not the API key, so rotation still finds the id), serialized against concurrent publishes so each skill is created once. Legacy rows are found by scanning for the directory under the account — a rotated key's digest cannot be recomputed — and the server confirms ownership before trusting one.
- Retries failed attachments, recreates skills after 404 responses, handles 403 ids by creating a scoped copy, and replaces the whole bundle on update so deleted local files are removed.
- Reports duplicate names and mid-upload edits as typed conflicts, so each gets advice that fits.
- Leaves
privacyunset so the server's private default applies.
Verification
- Adds 38 tests covering collection, validation, attachment, account scoping, concurrency, conflicts, and recovery.
- No command invokes the publish path yet, and live backend validation remains outstanding.
Summary by CodeRabbit
-
New Features
- Publish workspace skills to the Altimate server.
- Create, update, or recreate previously published skills.
- Attach published skills to the associated workspace while preserving existing attachments.
- Validate supported text files, bundle size, file counts, empty folders, and symbolic links before publishing.
- Provide clear errors for binary files, managed or unlinked projects, and naming conflicts.
-
Bug Fixes
- Preserve published skill associations across accounts and concurrent publishing operations.
- Retain skill identifiers when workspace attachment fails so publishing can be retried.
-
Tests
- Added comprehensive coverage for validation, publishing, recovery, attachments, and concurrency.