Skip to content

Commit 5cb64ce

Browse files
sahrizviclaude
andcommitted
fix(workspace): the TUI decides "built-in" the way the CLI does
Ralph's F1 on #1313, which Kilo and Codex traced independently. The picker's predicate knew `builtin:` and non-absolute paths only; on any postinstall'd machine the loader prefers the filesystem copy under `~/.altimate/builtin` and registers it by ABSOLUTE path, so every shipped built-in was publishable from the TUI — and one published to a workspace syncs back as a managed skill that overrides the shipped one for every linked member, frozen at that version. `isBuiltinLocation` is the CLI's line (`skillSource`), and a test pins the three-predicate trace for an installed built-in: CLI true, TUI true, managed false. Also: the picker's publish case is one call to `publishFromPicker`, which returns the toast to show — the switch was at cognitive 37 with the try-inside-try inline; and `explainPublishError`'s test asserts the `NotWorkspaceOwnerError` wording it renders as advice. Verified: 628 pass across the workspace, plugin and fork-guard suites, typecheck clean; the prefix-only predicate fails the new test. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_012Q51zFUmPg1WwtS5CrGJE6
1 parent 88af60f commit 5cb64ce

3 files changed

Lines changed: 87 additions & 29 deletions

File tree

‎packages/opencode/src/plugin/tui/altimate/skill-ops.tsx‎

Lines changed: 49 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
import type { TuiPlugin, TuiPluginApi, TuiDialogSelectOption } from "@opencode-ai/plugin/tui"
2727
import type { BuiltinTuiPlugin } from "@opencode-ai/tui/builtins"
2828
import { createMemo, createResource, createSignal, Show } from "solid-js"
29-
import { detectToolReferences } from "@/cli/cmd/skill-helpers"
29+
import { detectToolReferences, skillSource } from "@/cli/cmd/skill-helpers"
3030
import { describePublish, explainPublishError, isManagedSkill, publishSkill } from "@/altimate/workspace/skill-publish"
3131
import { Telemetry } from "@/altimate/telemetry"
3232
import { spawn } from "child_process"
@@ -502,8 +502,54 @@ function isRemovable(info: SkillInfo): boolean {
502502
return gitCheck.exitCode !== 0 // only removable if NOT git-tracked
503503
}
504504

505+
/** Built-in the way the CLI decides it: embedded (`builtin:`), or installed
506+
* under `~/.altimate/builtin`, which the loader prefers when present and
507+
* registers by ABSOLUTE path. A prefix check alone let every shipped
508+
* built-in through as publishable on a normal install — and a built-in
509+
* published to a workspace syncs back as a managed skill that overrides the
510+
* shipped one for every linked member, frozen at that version. */
511+
export function isBuiltinLocation(location: string | undefined): boolean {
512+
return !location || skillSource(location) === "builtin" || !path.isAbsolute(location)
513+
}
514+
515+
/** The publish half of the action picker, as one call returning the toast to
516+
* show. Kept out of the picker's `onSelect` so that switch stays readable. */
517+
export async function publishFromPicker(
518+
info: SkillInfo,
519+
skillName: string,
520+
projectDirectory: string,
521+
): Promise<{ message: string; variant: "success" | "warning" | "error"; duration: number }> {
522+
try {
523+
const report = await publishSkill({
524+
projectDirectory,
525+
skillDirectory: path.dirname(info.location),
526+
name: skillName,
527+
description: info.description ?? "",
528+
})
529+
try {
530+
Telemetry.track({
531+
type: "skill_published",
532+
timestamp: Date.now(),
533+
session_id: Telemetry.getContext().sessionId || "",
534+
skill_name: skillName,
535+
action: report.action,
536+
file_count: report.files,
537+
source: "tui",
538+
})
539+
} catch {}
540+
return { message: describePublish(report), variant: "success", duration: 6000 }
541+
} catch (err) {
542+
const known = explainPublishError(err)
543+
return {
544+
message: known ?? `Publish failed: ${(err instanceof Error ? err.message : String(err)).slice(0, 150)}`,
545+
variant: known ? "warning" : "error",
546+
duration: 8000,
547+
}
548+
}
549+
}
550+
505551
function openActionPicker(api: TuiPluginApi, info: SkillInfo | undefined, skillName: string, reopen: () => void) {
506-
const isBuiltin = !info || info.location.startsWith("builtin:") || !path.isAbsolute(info.location)
552+
const isBuiltin = isBuiltinLocation(info?.location)
507553
const removable = !!info && isRemovable(info)
508554
// A skill the workspace sent us is not ours to publish back to it. Judged
509555
// against the project directory workspace sync uses — the binding and the
@@ -576,33 +622,7 @@ function openActionPicker(api: TuiPluginApi, info: SkillInfo | undefined, skillN
576622
case "publish": {
577623
if (!info) return
578624
api.ui.toast({ message: `Publishing ${skillName}...`, variant: "info", duration: 120_000 })
579-
try {
580-
const report = await publishSkill({
581-
projectDirectory,
582-
skillDirectory: path.dirname(info.location),
583-
name: skillName,
584-
description: info.description ?? "",
585-
})
586-
api.ui.toast({ message: describePublish(report), variant: "success", duration: 6000 })
587-
try {
588-
Telemetry.track({
589-
type: "skill_published",
590-
timestamp: Date.now(),
591-
session_id: Telemetry.getContext().sessionId || "",
592-
skill_name: skillName,
593-
action: report.action,
594-
file_count: report.files,
595-
source: "tui",
596-
})
597-
} catch {}
598-
} catch (err) {
599-
const known = explainPublishError(err)
600-
api.ui.toast({
601-
message: known ?? `Publish failed: ${(err instanceof Error ? err.message : String(err)).slice(0, 150)}`,
602-
variant: known ? "warning" : "error",
603-
duration: 8000,
604-
})
605-
}
625+
api.ui.toast(await publishFromPicker(info, skillName, projectDirectory))
606626
reopen()
607627
break
608628
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// altimate_change - new file
2+
//
3+
// The Skills dialog's action picker must decide "built-in" the way the CLI
4+
// does. It decided by prefix alone — `builtin:` or a non-absolute path — and
5+
// on any postinstall'd machine the loader prefers the filesystem copy under
6+
// `~/.altimate/builtin`, registered by ABSOLUTE path. So every shipped
7+
// built-in was publishable from the TUI, and one published to a workspace
8+
// syncs back as a managed skill that overrides the shipped one for every
9+
// linked member, frozen at that version. (Ralph, review of #1313; Kilo and
10+
// Codex found the same trace independently.)
11+
import { describe, expect, test } from "bun:test"
12+
import path from "node:path"
13+
import { Global } from "../../../src/global"
14+
import { skillSource } from "../../../src/cli/cmd/skill-helpers"
15+
import { isBuiltinLocation } from "../../../src/plugin/tui/altimate/skill-ops"
16+
import { isManagedSkill } from "../../../src/altimate/workspace/skill-publish"
17+
18+
describe("the action picker's notion of built-in", () => {
19+
test("agrees with the CLI's for a filesystem-installed built-in", () => {
20+
const installed = path.join(Global.Path.home, ".altimate", "builtin", "x", "SKILL.md")
21+
// The three predicates Ralph traced: CLI true, TUI true, managed false.
22+
expect(skillSource(installed)).toBe("builtin")
23+
expect(isBuiltinLocation(installed)).toBe(true)
24+
expect(isManagedSkill("/some/project", path.dirname(installed))).toBe(false)
25+
})
26+
27+
test("still treats the embedded and the non-absolute forms as built-in", () => {
28+
expect(isBuiltinLocation("builtin:x/SKILL.md")).toBe(true)
29+
expect(isBuiltinLocation("relative/x/SKILL.md")).toBe(true)
30+
expect(isBuiltinLocation(undefined)).toBe(true)
31+
})
32+
33+
test("does not call a project skill built-in", () => {
34+
expect(isBuiltinLocation("/some/project/.opencode/skills/deploy/SKILL.md")).toBe(false)
35+
})
36+
})

‎packages/opencode/test/altimate/workspace/skill-publish.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -836,6 +836,8 @@ describe("what a surface says", () => {
836836
expect(err).toBeInstanceOf(NotLinkedError)
837837
expect(explainPublishError(err)).toContain("altimate-code link")
838838
expect(explainPublishError(new SymlinkError("references"))).toContain("references")
839+
// Advice, not a failure: the surfaces show this one as-is.
840+
expect(explainPublishError(new NotWorkspaceOwnerError("ws"))).toContain("Skills can only be published")
839841
expect(explainPublishError(new Error("ECONNRESET"))).toBeNull()
840842
})
841843
})

0 commit comments

Comments
 (0)