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..d75e7ca60 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,61 @@ 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 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 + } + 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 +794,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..cd1c64536 100644 --- a/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx +++ b/packages/opencode/src/plugin/tui/altimate/skill-ops.tsx @@ -27,6 +27,8 @@ 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 { Telemetry } from "@/altimate/telemetry" import { spawn } from "child_process" import os from "os" import path from "path" @@ -503,12 +505,24 @@ 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. 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[] = ( [ { 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 +573,39 @@ 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, + 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({ + 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() + }) +})