Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/opencode/src/altimate/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
| {
Expand Down
26 changes: 26 additions & 0 deletions packages/opencode/src/altimate/workspace/skill-publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
57 changes: 57 additions & 0 deletions packages/opencode/src/cli/cmd/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -457,6 +458,61 @@ const SkillTestCommand = cmd({
},
})

const SkillPublishCommand = cmd({
command: "publish <name>",
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 <name>",
describe: "display the full content of a skill",
Expand Down Expand Up @@ -738,6 +794,7 @@ export const SkillCommand = cmd({
.command(SkillListCommand)
.command(SkillCreateCommand)
.command(SkillTestCommand)
.command(SkillPublishCommand)
.command(SkillShowCommand)
.command(SkillInstallCommand)
.command(SkillRemoveCommand)
Expand Down
47 changes: 47 additions & 0 deletions packages/opencode/src/plugin/tui/altimate/skill-ops.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<string>[] = (
[
{ 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<string>[]
).filter((a) => !a.disabled)
Expand Down Expand Up @@ -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 })
Comment thread
sahrizvi marked this conversation as resolved.
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 {
Expand Down
36 changes: 36 additions & 0 deletions packages/opencode/test/altimate/workspace/skill-publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ const {
SkillNameConflictError,
SymlinkError,
collectBundle,
describePublish,
explainPublishError,
isManagedSkill,
publishSkill,
} = await import("../../../src/altimate/workspace/skill-publish")
Expand Down Expand Up @@ -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()
})
})
Loading