Skip to content

Commit cb84ca9

Browse files
saravmajesticclaude
andcommitted
feat(workspace): publish a project skill to the workspace over serve HTTP
- `GET /altimate/skill/publishable` lists the project skills that can be published; `POST /altimate/skill/publish { name }` runs `publishSkill`, so the IDE extension's pin, the publish ledger and the CLI's wording are shared. Same pilot and origin gate as `/altimate/workspace/*`; engine refusals and ineligible skills answer 422 (409 stays the pilot gate). - The registry is reloaded in-context before each lookup, so a skill written after `serve` started is found, also in a skills directory that did not exist at boot. - `isManagedSkill` also recognises skills the IDE extension delivered (`.altimate-managed.json`) and a snapshot above the project directory. - Discovery: a workspace-synced copy never shadows a project skill of the same name. `.claude`/`.agents` are scanned before the snapshot, so a skill published from there was replaced by its own synced copy. - `skill_published` telemetry gains `source: "serve"`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 7ed6e16 commit cb84ca9

10 files changed

Lines changed: 633 additions & 25 deletions

File tree

‎docs/docs/configure/skills.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ altimate-code skill remove my-tool # remove skill + paired tool
197197
altimate-code skill publish my-tool # upload every file in the skill directory; re-run to update
198198
```
199199

200-
`skill publish` sends the whole skill directory, not just `SKILL.md`, so keep secrets out of it. A built-in filter skips known file and directory names — `.env*`, `.git`, `id_rsa`, `*.pem`, `*.key`, `*.p12`, `.npmrc`/`.netrc`, `credentials.json`, `secrets.*`, `.ssh`/`.aws`, editor swap files — but it matches names only and never scans file contents, so a token inside `config.yaml` or a key named `server.crt` would still be uploaded. Built-in skills, global skills and skills the workspace itself sent you cannot be published.
200+
`skill publish` sends the whole skill directory, not just `SKILL.md`, so keep secrets out of it. A built-in filter skips known file and directory names — `.env*`, `.git`, `id_rsa`, `*.pem`, `*.key`, `*.p12`, `.npmrc`/`.netrc`, `credentials.json`, `secrets.*`, `.ssh`/`.aws`, editor swap files — but it matches names only and never scans file contents, so a token inside `config.yaml` or a key named `server.crt` would still be uploaded. Built-in skills, global skills and skills the workspace itself sent you cannot be published — including the copies the VS Code / Cursor extension delivers into `.claude/skills/` and `.agents/skills/`, which carry an `.altimate-managed.json` marker.
201201

202202
### TUI
203203

‎packages/opencode/src/altimate/telemetry/index.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -730,7 +730,8 @@ export namespace Telemetry {
730730
skill_name: string
731731
action: "created" | "updated"
732732
file_count: number
733-
source: "cli" | "tui"
733+
// "serve": published from the IDE extension over the serve route
734+
source: "cli" | "tui" | "serve"
734735
}
735736
// altimate_change end
736737
// altimate_change start — plan refinement telemetry event
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// altimate_change - new file
2+
//
3+
// Which of the skills this project can reach may be published to its workspace, for the serve
4+
// routes the IDE extension calls. The CLI's `skill publish` and the TUI's "Publish to workspace"
5+
// row apply the same rules inline (`skillSource`, `isManagedSkill`, `assertProjectSkill`); the
6+
// refusal wording here matches theirs so a user moving between surfaces reads the same thing.
7+
import path from "path"
8+
import { skillSource } from "@/cli/cmd/skill-helpers"
9+
import { assertProjectSkill, IDE_DELIVERED_MARKER, isManagedSkill } from "./skill-publish"
10+
11+
export type PublishEligibility = "publishable" | "builtin" | "personal" | "workspace" | "outside-project"
12+
13+
/** The boundary a skill must lie within: the worktree, since discovery walks up to it — except for
14+
* a project with no git, whose worktree is the sentinel `/`, which would contain everything. */
15+
export function projectRootFor(directory: string, worktree: string): string {
16+
return worktree !== "/" ? worktree : directory
17+
}
18+
19+
/** Whether a skill at `location` (its `SKILL.md`) may be published from `projectDirectory`. */
20+
export function publishEligibility(location: string, projectDirectory: string, projectRoot: string): PublishEligibility {
21+
if (!path.isAbsolute(location) || skillSource(location) === "builtin") return "builtin"
22+
if (skillSource(location) === "global") return "personal"
23+
const skillDirectory = path.dirname(location)
24+
if (isManagedSkill(projectDirectory, skillDirectory)) return "workspace"
25+
try {
26+
assertProjectSkill(projectRoot, skillDirectory)
27+
} catch {
28+
return "outside-project"
29+
}
30+
return "publishable"
31+
}
32+
33+
/** Why a skill cannot be published, in the words the CLI uses, or null when it can. */
34+
export function explainIneligible(name: string, location: string, eligibility: PublishEligibility): string | null {
35+
switch (eligibility) {
36+
case "publishable":
37+
return null
38+
case "builtin":
39+
return `"${name}" is a built-in skill and cannot be published.`
40+
case "personal":
41+
return (
42+
`"${name}" is a personal skill (${path.dirname(location)}), not one of this project's. ` +
43+
`Copy it into the project's skills directory to publish it.`
44+
)
45+
case "workspace":
46+
return (
47+
`"${name}" is a skill this workspace sent to you, not one you authored. ` +
48+
`Publishing it would send the workspace's own skill back to it. ` +
49+
`If you copied it to make your own, rename it and delete any ${IDE_DELIVERED_MARKER} in its folder.`
50+
)
51+
case "outside-project":
52+
return `"${name}" is not inside this project (${path.dirname(location)}), so it cannot be published from here.`
53+
}
54+
}

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

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,12 @@
3333
// interpret.
3434
import fs from "fs/promises"
3535
import path from "path"
36-
import { realpathSync } from "fs"
36+
import { existsSync, realpathSync } from "fs"
3737
import { Log } from "@/altimate/util/log"
3838
import { Global } from "@/global"
3939
import { Filesystem } from "@/util/filesystem"
4040
import { AltimateApi } from "@/altimate/api/client"
41+
import { isInWorkspaceSnapshot } from "./snapshot-path"
4142
import { ConflictError, ForbiddenError, NotFoundError, WorkspaceApi, altimateRequest } from "./api-client"
4243
import { resolveBinding } from "./state"
4344

@@ -49,6 +50,13 @@ const SKILLS_BASE = "/skills"
4950
* process-global store — into every caller that only wants to publish. */
5051
const MANAGED_DIR = path.join(".altimate-code", "skill", "_workspace")
5152

53+
/** Written by the VS Code / Cursor extension into every skill directory it delivers from the
54+
* workspace (`.claude/skills/altimate-*`, `.agents/skills/altimate-*`). Those roots are also this
55+
* project's own skill-discovery roots, so without the marker a delivered skill would be offered for
56+
* publish — and uploaded back to the workspace that sent it, as a new skill owned by the publisher.
57+
* The name is the extension's (`OWNERSHIP_MARKER` in its `customSkillDelivery.ts`); keep in step. */
58+
export const IDE_DELIVERED_MARKER = ".altimate-managed.json"
59+
5260
/** Mirrors the server's own ceilings so an oversized bundle fails locally, with a
5361
* usable message, instead of after a long upload. `MAX_BUNDLE_FILES` and
5462
* `MAX_BUNDLE_BYTES` in `app/service/custom_skills/bundle.py`; a mismatch
@@ -355,7 +363,8 @@ export async function collectBundle(dir: string): Promise<BundleFile[]> {
355363
return files
356364
}
357365

358-
/** True when this path lives inside the workspace-owned snapshot. */
366+
/** True when this skill came from the workspace: it lives inside the workspace-owned snapshot, or
367+
* the IDE extension delivered it (see {@link IDE_DELIVERED_MARKER}). */
359368
export function isManagedSkill(projectDirectory: string, skillDirectory: string): boolean {
360369
// `path.resolve` is lexical: it normalises `..` and makes the path absolute,
361370
// but it does not follow links. A skill directory that IS a symlink into the
@@ -374,7 +383,11 @@ export function isManagedSkill(projectDirectory: string, skillDirectory: string)
374383
}
375384
const managed = real(path.resolve(projectDirectory, MANAGED_DIR))
376385
const candidate = real(skillDirectory)
377-
return candidate === managed || candidate.startsWith(managed + path.sep)
386+
if (candidate === managed || candidate.startsWith(managed + path.sep)) return true
387+
// A snapshot above the project directory: discovery reads config directories up to the worktree.
388+
if (isInWorkspaceSnapshot(candidate)) return true
389+
// A delivered skill sits in the project's own discovery roots, not under the snapshot.
390+
return existsSync(path.join(candidate, IDE_DELIVERED_MARKER))
378391
}
379392

380393
// ---------------------------------------------------------------------------
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// altimate_change - new file
2+
//
3+
// Whether a path lies inside a workspace skill snapshot (`.altimate-code/skill/_workspace`), judged
4+
// by path segments rather than against one project directory: discovery walks config directories up
5+
// to the worktree, so a session started in `repo/sub` also reads `repo/.altimate-code/...`.
6+
// Dependency-free on purpose — skill discovery imports it, and the workspace modules are heavy.
7+
import path from "path"
8+
9+
const SNAPSHOT_SEGMENTS = [".altimate-code", "skill", "_workspace"]
10+
11+
export function isInWorkspaceSnapshot(location: string): boolean {
12+
const parts = path.resolve(location).split(path.sep)
13+
for (let i = 0; i + SNAPSHOT_SEGMENTS.length <= parts.length; i++) {
14+
if (SNAPSHOT_SEGMENTS.every((segment, j) => parts[i + j] === segment)) return true
15+
}
16+
return false
17+
}

‎packages/opencode/src/server/server.ts‎

Lines changed: 158 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,59 @@ export namespace Server {
112112
}
113113
return undefined
114114
}
115+
/** The skill registry this instance serves, reloaded so a skill written since it loaded is found
116+
* — also one in a skills directory that did not exist at boot. Same in-context path as
117+
* `refreshSkillRegistry` in session/prompt.ts: the facade's invalidate keeps the stale root list
118+
* `Config.directories()` cached. The CLI's `skill publish` reads this registry too.
119+
*
120+
* Not free: `Config.Service.invalidate()` drops the config cache for every instance and the
121+
* refresh re-pulls any `skills.urls`. Acceptable for a user-triggered command, and the only
122+
* invalidation the service offers. A config file mid-edit (invalid) fails this call — and would
123+
* fail the session's next config read the same way. */
124+
async function reloadSkills() {
125+
const [{ Effect }, { Config }, { Skill: Registry }] = await Promise.all([
126+
import("effect"),
127+
import("../config/config"),
128+
import("../skill"),
129+
])
130+
await AppRuntime.runPromise(
131+
Effect.gen(function* () {
132+
const config = yield* Config.Service
133+
const skill = yield* Registry.Service
134+
yield* config.invalidate()
135+
yield* skill.refresh()
136+
}),
137+
)
138+
return Registry
139+
}
140+
141+
/** A request body that must be absent or a JSON object: the parsed object, or the response
142+
* to send instead. A failed read keeps the routes' `{ ok: false, error }` 500 contract. */
143+
async function readJsonObject(
144+
read: () => Promise<string>,
145+
route: string,
146+
): Promise<{ body: Record<string, unknown> } | { failure: { status: 400 | 500; body: { ok: false; error: string } } }> {
147+
let text: string
148+
try {
149+
text = await read()
150+
} catch (err) {
151+
const error = err instanceof Error ? err.message : String(err)
152+
log.error(`${route}: could not read the request body`, { error })
153+
return { failure: { status: 500, body: { ok: false, error } } }
154+
}
155+
if (!text.trim()) return { body: {} }
156+
let body: unknown
157+
try {
158+
body = JSON.parse(text)
159+
} catch {
160+
return { failure: { status: 400, body: { ok: false, error: "Request body is not valid JSON." } } }
161+
}
162+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
163+
return { failure: { status: 400, body: { ok: false, error: "Request body must be a JSON object." } } }
164+
}
165+
return { body: body as Record<string, unknown> }
166+
}
167+
115168
export function sameOrigin(origin: string, host: string | undefined): boolean {
116169
try {
117170
return !!host && new URL(origin).host === host
@@ -1008,26 +1061,10 @@ export namespace Server {
10081061
// An absent or empty body is a session-less refresh; anything else must be well formed.
10091062
// Falling back to "no session" on bad input would silently widen the operation to
10101063
// resetting every session's memory overlay.
1011-
let text: string
1012-
try {
1013-
text = await c.req.text()
1014-
} catch (err) {
1015-
const error = err instanceof Error ? err.message : String(err)
1016-
log.error("workspace refresh: could not read the request body", { error })
1017-
return c.json({ ok: false, error }, 500)
1018-
}
1019-
let body: unknown = {}
1020-
if (text.trim()) {
1021-
try {
1022-
body = JSON.parse(text)
1023-
} catch {
1024-
return c.json({ ok: false, error: "Request body is not valid JSON." }, 400)
1025-
}
1026-
}
1027-
if (body === null || typeof body !== "object" || Array.isArray(body)) {
1028-
return c.json({ ok: false, error: "Request body must be a JSON object." }, 400)
1029-
}
1030-
const raw = (body as Record<string, unknown>).sessionID
1064+
const read = await readJsonObject(() => c.req.text(), "workspace refresh")
1065+
if ("failure" in read) return c.json(read.failure.body, read.failure.status)
1066+
const body = read.body
1067+
const raw = body.sessionID
10311068
if (raw !== undefined && (typeof raw !== "string" || !raw)) {
10321069
return c.json({ ok: false, error: "sessionID must be a non-empty string." }, 400)
10331070
}
@@ -1080,6 +1117,107 @@ export namespace Server {
10801117
}
10811118
})
10821119
// altimate_change end
1120+
// altimate_change start — GET /altimate/skill/publishable, POST /altimate/skill/publish
1121+
// The CLI's `skill publish <name>` for the IDE extension. Only `serve` holds the extension's
1122+
// pin, so publishing here targets the workspace selected in the panel — through the same
1123+
// engine, ledger and error wording as the CLI and TUI.
1124+
.get("/altimate/skill/publishable", async (c) => {
1125+
const refused = workspaceRouteRefusal(c.req.header("origin"), c.req.header("host"))
1126+
if (refused) return c.json(refused.body, refused.status)
1127+
try {
1128+
const { projectRootFor, publishEligibility } = await import("../altimate/workspace/publishable")
1129+
const registry = await reloadSkills()
1130+
const root = projectRootFor(Instance.directory, Instance.worktree)
1131+
const skills = (await registry.all())
1132+
.filter((skill) => publishEligibility(skill.location, Instance.directory, root) === "publishable")
1133+
// `description` is optional in frontmatter; sent as "" so every entry has the same shape.
1134+
.map((skill) => ({ name: skill.name, description: skill.description ?? "", location: skill.location }))
1135+
.sort((a, b) => a.name.localeCompare(b.name))
1136+
return c.json({ ok: true as const, skills })
1137+
} catch (err) {
1138+
const error = err instanceof Error ? err.message : String(err)
1139+
log.error("skill publishable: failed", { error })
1140+
return c.json({ ok: false, error }, 500)
1141+
}
1142+
})
1143+
.post("/altimate/skill/publish", async (c) => {
1144+
const refused = workspaceRouteRefusal(c.req.header("origin"), c.req.header("host"))
1145+
if (refused) return c.json(refused.body, refused.status)
1146+
const read = await readJsonObject(() => c.req.text(), "skill publish")
1147+
if ("failure" in read) return c.json(read.failure.body, read.failure.status)
1148+
const name = read.body.name
1149+
if (typeof name !== "string" || !name.trim()) {
1150+
return c.json({ ok: false, error: "name must be a non-empty string." }, 400)
1151+
}
1152+
try {
1153+
const { explainIneligible, projectRootFor, publishEligibility } = await import(
1154+
"../altimate/workspace/publishable"
1155+
)
1156+
const { describePublish, explainPublishError, publishSkill } = await import(
1157+
"../altimate/workspace/skill-publish"
1158+
)
1159+
const registry = await reloadSkills()
1160+
const skill = await registry.get(name.trim())
1161+
if (!skill) {
1162+
// Lookup is exact; a name that differs only in case is almost always what was meant.
1163+
const wanted = name.trim().toLowerCase()
1164+
const near = (await registry.all()).find((s) => s.name.toLowerCase() === wanted)
1165+
return c.json(
1166+
{
1167+
ok: false,
1168+
error: `Skill "${name.trim()}" not found in this project.${near ? ` Did you mean "${near.name}"?` : ""}`,
1169+
},
1170+
404,
1171+
)
1172+
}
1173+
const root = projectRootFor(Instance.directory, Instance.worktree)
1174+
const ineligible = explainIneligible(
1175+
skill.name,
1176+
skill.location,
1177+
publishEligibility(skill.location, Instance.directory, root),
1178+
)
1179+
if (ineligible) {
1180+
log.info("skill publish: refused", { skill: skill.name, reason: ineligible })
1181+
return c.json({ ok: false, error: ineligible }, 422)
1182+
}
1183+
try {
1184+
const report = await publishSkill({
1185+
projectDirectory: Instance.directory,
1186+
projectRoot: root,
1187+
skillDirectory: nodePath.dirname(skill.location),
1188+
name: skill.name,
1189+
description: skill.description ?? "",
1190+
})
1191+
try {
1192+
const { Telemetry } = await import("../altimate/telemetry")
1193+
Telemetry.track({
1194+
type: "skill_published",
1195+
timestamp: Date.now(),
1196+
session_id: Telemetry.getContext().sessionId || "",
1197+
skill_name: skill.name,
1198+
action: report.action,
1199+
file_count: report.files,
1200+
source: "serve",
1201+
})
1202+
} catch {}
1203+
return c.json({ ok: true as const, report, message: describePublish(report) })
1204+
} catch (err) {
1205+
// A refusal the engine raised on purpose already says what to do; 422, since 409 is
1206+
// the pilot gate's. Anything else is a failure, reported as the engine gave it.
1207+
const known = explainPublishError(err)
1208+
if (known) {
1209+
log.info("skill publish: refused by the engine", { skill: skill.name, reason: known })
1210+
return c.json({ ok: false, error: known }, 422)
1211+
}
1212+
throw err
1213+
}
1214+
} catch (err) {
1215+
const error = err instanceof Error ? err.message : String(err)
1216+
log.error("skill publish: failed", { error })
1217+
return c.json({ ok: false, error }, 500)
1218+
}
1219+
})
1220+
// altimate_change end
10831221
.all("/*", async (c) => {
10841222
const path = c.req.path
10851223

‎packages/opencode/src/skill/index.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
22
// altimate_change start — makeRuntime for the restored Promise wrapper (see bottom of file)
33
import { makeRuntime } from "@/effect/run-service"
44
// altimate_change end
5+
// altimate_change start — workspace snapshot precedence in `add`
6+
import { isInWorkspaceSnapshot } from "@/altimate/workspace/snapshot-path"
7+
// altimate_change end
58
import path from "path"
69
import { pathToFileURL } from "url"
710
import { Effect, Layer, Context, Schema } from "effect"
@@ -149,6 +152,20 @@ const add = Effect.fnUntraced(function* (state: State, match: string, events: Ev
149152
if (!isSkillFrontmatter(md.data)) return
150153

151154
if (state.skills[md.data.name]) {
155+
// altimate_change start — a workspace-synced copy never shadows a project skill of the same name.
156+
// The snapshot is scanned after `.claude`/`.agents`, so a skill the user wrote there and then
157+
// published was replaced by the workspace's copy of it on the next sync: the agent read the
158+
// stale copy, and publishing an update was refused as "a skill this workspace sent to you".
159+
// A snapshot scanned first is still overridden by the project skill found later, as before.
160+
if (isInWorkspaceSnapshot(match) && !isInWorkspaceSnapshot(state.skills[md.data.name].location)) {
161+
yield* Effect.logWarning("workspace skill shadowed by a project skill of the same name", {
162+
name: md.data.name,
163+
project: state.skills[md.data.name].location,
164+
workspace: match,
165+
})
166+
return
167+
}
168+
// altimate_change end
152169
yield* Effect.logWarning("duplicate skill name", {
153170
name: md.data.name,
154171
existing: state.skills[md.data.name].location,

0 commit comments

Comments
 (0)