From a032d59e79e5f150b026d39dce4dbe016922220c Mon Sep 17 00:00:00 2001 From: Haider Date: Tue, 15 Sep 2026 10:59:41 +0530 Subject: [PATCH 1/3] feat(workspace): `skill publish ` and a "Publish to workspace" action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publish path from the previous PR had no surface: nothing invoked it, so a locally authored skill still had no route to the workspace, and the CLI still did not say whether one existed. - `altimate-code skill publish ` resolves the skill the way `skill test` does, refuses a built-in (no directory to bundle), and prints one line on success. Every deliberate refusal — not linked, workspace-owned, binary or linked file, empty, too large, name taken elsewhere, uploaded but not attached — is printed as-is, since each already says what to do. - The Skills dialog gains "Publish to workspace" in the per-skill action picker, next to Show / Edit / Test / Remove — where a user who wonders whether publishing is possible will see it. Disabled for built-ins and for skills the workspace sent us. - `describePublish` and `explainPublishError` give both surfaces the same words, and a `skill_published` telemetry event records the outcome. Verified: 538 pass across the workspace + plugin suites and the fork guards, typecheck clean; `skill publish` smoke-run on an unlinked project and on a missing skill prints the intended line for each. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012Q51zFUmPg1WwtS5CrGJE6 --- .../opencode/src/altimate/telemetry/index.ts | 9 +++ .../src/altimate/workspace/skill-publish.ts | 26 +++++++++ packages/opencode/src/cli/cmd/skill.ts | 56 +++++++++++++++++++ .../src/plugin/tui/altimate/skill-ops.tsx | 31 ++++++++++ .../altimate/workspace/skill-publish.test.ts | 36 ++++++++++++ 5 files changed, 158 insertions(+) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index a036aa42b..6ecbd260a 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -677,6 +677,15 @@ export namespace Telemetry { skill_name: string source: "cli" | "tui" } + | { + type: "skill_published" + timestamp: number + session_id: string + skill_name: string + action: "created" | "updated" + file_count: number + source: "cli" | "tui" + } // altimate_change end // altimate_change start — plan refinement telemetry event | { diff --git a/packages/opencode/src/altimate/workspace/skill-publish.ts b/packages/opencode/src/altimate/workspace/skill-publish.ts index 7b7950425..331f26cbc 100644 --- a/packages/opencode/src/altimate/workspace/skill-publish.ts +++ b/packages/opencode/src/altimate/workspace/skill-publish.ts @@ -574,6 +574,32 @@ async function publishSkillUnlocked(input: { return { action: "created", publicId, name: input.name, files: files.length, bytes, datamateId: binding.datamateId } } +/** One line for a surface to show after a publish. Both the CLI and the TUI + * say the same thing, so a user moving between them recognises the outcome. */ +export function describePublish(report: PublishReport): string { + const verb = report.action === "created" ? "Published" : "Updated" + const size = report.bytes >= 1024 ? `${Math.round(report.bytes / 1024)}KB` : `${report.bytes}B` + return `${verb} "${report.name}" in the workspace (${report.files} file${report.files === 1 ? "" : "s"}, ${size}).` +} + +/** The message for an error this module raised on purpose, or null for one it + * did not — a surface shows the former as-is (each already says what to do) + * and wraps the latter as a failure. */ +export function explainPublishError(err: unknown): string | null { + if ( + err instanceof NotLinkedError || + err instanceof ManagedSkillError || + err instanceof BinaryFileError || + err instanceof SymlinkError || + err instanceof EmptyBundleError || + err instanceof BundleTooLargeError || + err instanceof SkillNameConflictError || + err instanceof AttachFailedError + ) + return err.message + return null +} + /** Accepts the documented `{public_id}` and a `{skill: {public_id}}` envelope, so * a compat wrapper on either side does not strand the id — the same tolerance * `skill-sync` applies to the list and detail shapes. */ diff --git a/packages/opencode/src/cli/cmd/skill.ts b/packages/opencode/src/cli/cmd/skill.ts index a658e6955..7ef3426e4 100644 --- a/packages/opencode/src/cli/cmd/skill.ts +++ b/packages/opencode/src/cli/cmd/skill.ts @@ -11,6 +11,7 @@ import { Global } from "@/global" import { detectToolReferences, skillSource, isToolOnPath } from "./skill-helpers" // altimate_change start — telemetry for skill operations import { Telemetry } from "@/altimate/telemetry" +import { describePublish, explainPublishError, publishSkill } from "@/altimate/workspace/skill-publish" // altimate_change end // --------------------------------------------------------------------------- @@ -457,6 +458,60 @@ const SkillTestCommand = cmd({ }, }) +const SkillPublishCommand = cmd({ + command: "publish ", + describe: "publish a skill to the workspace this project is linked to", + builder: (yargs) => + yargs.positional("name", { + type: "string", + describe: "name of the skill to publish", + demandOption: true, + }), + async handler(args) { + const name = args.name as string + const cwd = process.cwd() + await bootstrap(cwd, async () => { + const skill = await Skill.get(name) + if (!skill) { + process.stderr.write(`Skill "${name}" not found. Check .opencode/skills/${name}/SKILL.md exists.` + EOL) + process.exitCode = 1 + return + } + // Built-in skills ship in the binary and have no directory to bundle; + // a skill the workspace sent us is refused by `publishSkill` itself. + if (skill.location.startsWith("builtin:") || !path.isAbsolute(skill.location)) { + process.stderr.write(`"${name}" is a built-in skill and cannot be published.` + EOL) + process.exitCode = 1 + return + } + try { + const report = await publishSkill({ + projectDirectory: Instance.directory, + skillDirectory: path.dirname(skill.location), + name: skill.name, + description: skill.description ?? "", + }) + process.stdout.write(describePublish(report) + EOL) + try { + Telemetry.track({ + type: "skill_published", + timestamp: Date.now(), + session_id: Telemetry.getContext().sessionId || "", + skill_name: skill.name, + action: report.action, + file_count: report.files, + source: "cli", + }) + } catch {} + } catch (err) { + const known = explainPublishError(err) + process.stderr.write((known ?? `Publish failed: ${err instanceof Error ? err.message : String(err)}`) + EOL) + process.exitCode = 1 + } + }) + }, +}) + const SkillShowCommand = cmd({ command: "show ", describe: "display the full content of a skill", @@ -738,6 +793,7 @@ export const SkillCommand = cmd({ .command(SkillListCommand) .command(SkillCreateCommand) .command(SkillTestCommand) + .command(SkillPublishCommand) .command(SkillShowCommand) .command(SkillInstallCommand) .command(SkillRemoveCommand) diff --git a/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx b/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx index e6da8c85d..59a8a297d 100644 --- a/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx +++ b/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx @@ -27,6 +27,7 @@ import type { TuiPlugin, TuiPluginApi, TuiDialogSelectOption } from "@opencode-a import type { BuiltinTuiPlugin } from "@opencode-ai/tui/builtins" import { createMemo, createResource, createSignal, Show } from "solid-js" import { detectToolReferences } from "@/cli/cmd/skill-helpers" +import { describePublish, explainPublishError, isManagedSkill, publishSkill } from "@/altimate/workspace/skill-publish" import { spawn } from "child_process" import os from "os" import path from "path" @@ -503,12 +504,20 @@ function isRemovable(info: SkillInfo): boolean { function openActionPicker(api: TuiPluginApi, info: SkillInfo | undefined, skillName: string, reopen: () => void) { const isBuiltin = !info || info.location.startsWith("builtin:") || !path.isAbsolute(info.location) const removable = !!info && isRemovable(info) + // A skill the workspace sent us is not ours to publish back to it. + const managed = !isBuiltin && isManagedSkill(workdir(api), path.dirname(info!.location)) const actions: TuiDialogSelectOption[] = ( [ { title: "Show details", value: "show", description: "View skill info, tools, and location" }, { title: "Edit", value: "edit", description: "Open SKILL.md in your default editor", disabled: isBuiltin }, { title: "Test", value: "test", description: "Validate the paired CLI tool works" }, + { + title: "Publish to workspace", + value: "publish", + description: "Upload this skill to the linked workspace so your team gets it", + disabled: isBuiltin || managed, + }, { title: "Remove", value: "remove", description: "Delete this skill and its paired tool", disabled: !removable }, ] as TuiDialogSelectOption[] ).filter((a) => !a.disabled) @@ -559,6 +568,28 @@ function openActionPicker(api: TuiPluginApi, info: SkillInfo | undefined, skillN reopen() break } + case "publish": { + if (!info) return + api.ui.toast({ message: `Publishing ${skillName}...`, variant: "info", duration: 120_000 }) + try { + const report = await publishSkill({ + projectDirectory: workdir(api), + skillDirectory: path.dirname(info.location), + name: skillName, + description: info.description ?? "", + }) + api.ui.toast({ message: describePublish(report), variant: "success", duration: 6000 }) + } catch (err) { + const known = explainPublishError(err) + api.ui.toast({ + message: known ?? `Publish failed: ${(err instanceof Error ? err.message : String(err)).slice(0, 150)}`, + variant: known ? "warning" : "error", + duration: 8000, + }) + } + reopen() + break + } case "remove": { if (!info) return try { diff --git a/packages/opencode/test/altimate/workspace/skill-publish.test.ts b/packages/opencode/test/altimate/workspace/skill-publish.test.ts index 46857a155..180bafe55 100644 --- a/packages/opencode/test/altimate/workspace/skill-publish.test.ts +++ b/packages/opencode/test/altimate/workspace/skill-publish.test.ts @@ -44,6 +44,8 @@ const { SkillNameConflictError, SymlinkError, collectBundle, + describePublish, + explainPublishError, isManagedSkill, publishSkill, } = await import("../../../src/altimate/workspace/skill-publish") @@ -550,3 +552,37 @@ describe("attaching to the workspace", () => { expect(requests.filter((r) => r.method === "POST")).toHaveLength(0) }) }) + +describe("what a surface says", () => { + // The CLI and the TUI share these lines so a user moving between them + // recognises the outcome. + test("names the outcome, the skill, and the size", () => { + const line = describePublish({ action: "created", publicId: "p", name: "deploy", files: 3, bytes: 2048, datamateId: 1 }) + expect(line).toContain("Published") + expect(line).toContain('"deploy"') + expect(line).toContain("3 files") + expect(line).toContain("2KB") + expect(describePublish({ action: "updated", publicId: "p", name: "d", files: 1, bytes: 12, datamateId: 1 })).toContain( + "Updated", + ) + expect(describePublish({ action: "updated", publicId: "p", name: "d", files: 1, bytes: 12, datamateId: 1 })).toContain( + "1 file,", + ) + }) + + test("passes a deliberate error through and wraps nothing else", async () => { + // Each typed error already says what to do; an unexpected one must not be + // shown as if it were advice. + const unlinked = mkdtempSync(path.join(SANDBOX, "unlinked-")) + const dir = path.join(unlinked, "skills", "x") + mkdirSync(dir, { recursive: true }) + writeFileSync(path.join(dir, "SKILL.md"), "---\nname: x\n---\n") + const err = await publishSkill({ projectDirectory: unlinked, skillDirectory: dir, name: "x", description: "d" }).catch( + (e) => e, + ) + expect(err).toBeInstanceOf(NotLinkedError) + expect(explainPublishError(err)).toContain("altimate-code link") + expect(explainPublishError(new SymlinkError("references"))).toContain("references") + expect(explainPublishError(new Error("ECONNRESET"))).toBeNull() + }) +}) From 30ba5c3dbe28c7bd40b92a96c00010266e3da68d Mon Sep 17 00:00:00 2001 From: Haider Date: Tue, 15 Sep 2026 11:06:39 +0530 Subject: [PATCH 2/3] fix(workspace): refuse installed built-ins, judge managed skills against the project directory, record TUI publishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `skill publish` classifies built-ins through `skillSource`, which also knows the `~/.altimate/builtin` install; the prefix check let those through as absolute paths. - The TUI action checks the managed snapshot against, and publishes from, `api.state.path.directory` — where the binding and the snapshot live — rather than the git root, which differs in a worktree subdirectory. - A TUI publish records the same `skill_published` event as the CLI, with `source: "tui"`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012Q51zFUmPg1WwtS5CrGJE6 --- packages/opencode/src/cli/cmd/skill.ts | 7 +++--- .../src/plugin/tui/altimate/skill-ops.tsx | 22 ++++++++++++++++--- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/cli/cmd/skill.ts b/packages/opencode/src/cli/cmd/skill.ts index 7ef3426e4..d75e7ca60 100644 --- a/packages/opencode/src/cli/cmd/skill.ts +++ b/packages/opencode/src/cli/cmd/skill.ts @@ -477,9 +477,10 @@ const SkillPublishCommand = cmd({ process.exitCode = 1 return } - // Built-in skills ship in the binary and have no directory to bundle; - // a skill the workspace sent us is refused by `publishSkill` itself. - if (skill.location.startsWith("builtin:") || !path.isAbsolute(skill.location)) { + // Built-in skills ship with altimate-code — embedded, or installed under + // `~/.altimate/builtin` — and are not the user's to publish; a skill the + // workspace sent us is refused by `publishSkill` itself. + if (skillSource(skill.location) === "builtin" || !path.isAbsolute(skill.location)) { process.stderr.write(`"${name}" is a built-in skill and cannot be published.` + EOL) process.exitCode = 1 return diff --git a/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx b/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx index 59a8a297d..cd1c64536 100644 --- a/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx +++ b/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx @@ -28,6 +28,7 @@ import type { BuiltinTuiPlugin } from "@opencode-ai/tui/builtins" import { createMemo, createResource, createSignal, Show } from "solid-js" import { detectToolReferences } from "@/cli/cmd/skill-helpers" import { describePublish, explainPublishError, isManagedSkill, publishSkill } from "@/altimate/workspace/skill-publish" +import { Telemetry } from "@/altimate/telemetry" import { spawn } from "child_process" import os from "os" import path from "path" @@ -504,8 +505,12 @@ function isRemovable(info: SkillInfo): boolean { function openActionPicker(api: TuiPluginApi, info: SkillInfo | undefined, skillName: string, reopen: () => void) { const isBuiltin = !info || info.location.startsWith("builtin:") || !path.isAbsolute(info.location) const removable = !!info && isRemovable(info) - // A skill the workspace sent us is not ours to publish back to it. - const managed = !isBuiltin && isManagedSkill(workdir(api), path.dirname(info!.location)) + // A skill the workspace sent us is not ours to publish back to it. Judged + // against the project directory workspace sync uses — the binding and the + // managed snapshot live under `api.state.path.directory`, not the git root + // `workdir` resolves to, and the two differ in a worktree subdirectory. + const projectDirectory = api.state.path.directory || workdir(api) + const managed = !isBuiltin && isManagedSkill(projectDirectory, path.dirname(info!.location)) const actions: TuiDialogSelectOption[] = ( [ @@ -573,12 +578,23 @@ function openActionPicker(api: TuiPluginApi, info: SkillInfo | undefined, skillN api.ui.toast({ message: `Publishing ${skillName}...`, variant: "info", duration: 120_000 }) try { const report = await publishSkill({ - projectDirectory: workdir(api), + projectDirectory, skillDirectory: path.dirname(info.location), name: skillName, description: info.description ?? "", }) api.ui.toast({ message: describePublish(report), variant: "success", duration: 6000 }) + try { + Telemetry.track({ + type: "skill_published", + timestamp: Date.now(), + session_id: Telemetry.getContext().sessionId || "", + skill_name: skillName, + action: report.action, + file_count: report.files, + source: "tui", + }) + } catch {} } catch (err) { const known = explainPublishError(err) api.ui.toast({ From 7d2de22150f054913051839e4b44f57a9cd98914 Mon Sep 17 00:00:00 2001 From: Haider Date: Thu, 17 Sep 2026 20:58:54 +0530 Subject: [PATCH 3/3] fix(workspace): the surfaces render NotWorkspaceOwnerError as advice, not as a failure Merged from #1280; `explainPublishError` covers the new typed error so the CLI and the TUI both show "link this project to one of yours, or ask the owner" rather than wrapping it as a raw failure. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012Q51zFUmPg1WwtS5CrGJE6 --- packages/opencode/src/altimate/workspace/skill-publish.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/altimate/workspace/skill-publish.ts b/packages/opencode/src/altimate/workspace/skill-publish.ts index 57e571a27..6d530b835 100644 --- a/packages/opencode/src/altimate/workspace/skill-publish.ts +++ b/packages/opencode/src/altimate/workspace/skill-publish.ts @@ -750,6 +750,7 @@ export function explainPublishError(err: unknown): string | null { err instanceof BundleTooLargeError || err instanceof SkillNameConflictError || err instanceof SkillChangedElsewhereError || + err instanceof NotWorkspaceOwnerError || err instanceof AttachFailedError ) return err.message