Skip to content
Merged
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 @@ -716,6 +716,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
120 changes: 111 additions & 9 deletions packages/opencode/src/altimate/workspace/skill-publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,22 @@ export class NotLinkedError extends Error {
}
}

/** The skill directory is not a project skill: it lives outside the project
* (a personal skill under `~/.claude/skills` or the like), or it reaches
* the project only through a symbolic link. Publishing shares a bundle with
* the whole workspace — a personal skill is not the user's to share by
* accident, and a linked root would publish whatever it points at, which
* `isManagedSkill` cannot see if the target is not the managed snapshot. */
export class NotProjectSkillError extends Error {
constructor(readonly skillDirectory: string) {
super(
`"${skillDirectory}" is not a skill of this project — it lives outside the project, or is reached ` +
`through a symbolic link. Only a project's own skills can be published to its workspace.`,
)
this.name = "NotProjectSkillError"
}
}

/** The project is linked to a workspace the caller does not own. Linking
* needs only visibility — a colleague's shared workspace can be linked to —
* but attaching a skill is a write against the workspace and needs
Expand Down Expand Up @@ -577,23 +593,35 @@ async function attachToWorkspace(publicId: string, datamateId: number): Promise<
* `privacy` is left unset: the server defaults to `private`. Publishing should
* attach a skill to a workspace, not disclose it to the whole organisation as a
* side effect of a command whose name says nothing about visibility. */
export async function publishSkill(input: {
export interface PublishInput {
/** Where the workspace binding lives — the directory the session was
* started in. `resolveBinding` is keyed on it. */
projectDirectory: string
/** The boundary a skill must lie within to count as this project's.
* Discovery walks up to the git worktree root, so a skill under
* `repo/.opencode/skills` is the project's even when the session started
* in `repo/models` — and `projectDirectory` alone would refuse it.
* Defaults to `projectDirectory` for a project with no worktree. */
projectRoot?: string
skillDirectory: string
name: string
description: string
}): Promise<PublishReport> {
}

export async function publishSkill(input: PublishInput): Promise<PublishReport> {
return withPublishLock(input.skillDirectory, () => publishSkillUnlocked(input))
}

async function publishSkillUnlocked(input: {
projectDirectory: string
skillDirectory: string
name: string
description: string
}): Promise<PublishReport> {
async function publishSkillUnlocked(input: PublishInput): Promise<PublishReport> {
if (isManagedSkill(input.projectDirectory, input.skillDirectory))
throw new ManagedSkillError(input.skillDirectory)
// The root itself, resolved: `collectBundle` refuses links INSIDE the
// skill, but a root that is a link is followed, and would publish whatever
// it points at. And the resolved root must be inside the project: the
// loader also serves personal skills from under the home directory, which
// are not this workspace's to receive. The REAL path that passed is what
// the walk reads, so a root swapped after the check is not what uploads.
const skillRoot = assertProjectSkill(input.projectRoot ?? input.projectDirectory, input.skillDirectory)

// Before the bundle is even read. An unlinked project has nowhere to attach
// to, and uploading first would create the orphan this module exists to
Expand All @@ -614,7 +642,7 @@ async function publishSkillUnlocked(input: {
// uploaded until the attach is known to be possible.
await assertOwnsWorkspace(binding.datamateId, binding.datamateName, scope.userId)

const files = await collectBundle(input.skillDirectory)
const files = await collectBundle(skillRoot)
if (files.length === 0) throw new EmptyBundleError()
const bytes = files.reduce((n, f) => n + Buffer.byteLength(f.content, "utf8"), 0)

Expand Down Expand Up @@ -721,6 +749,35 @@ 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 NotProjectSkillError ||
err instanceof BinaryFileError ||
err instanceof SymlinkError ||
err instanceof EmptyBundleError ||
err instanceof BundleTooLargeError ||
err instanceof SkillNameConflictError ||
err instanceof SkillChangedElsewhereError ||
err instanceof NotWorkspaceOwnerError ||
Comment thread
sahrizvi marked this conversation as resolved.
err instanceof AttachFailedError
)
return err.message
return null
}

/** Which of the update path's conflicts this is. The server distinguishes them
* in its detail; this module's typed errors then carry advice that fits.
* Anything unrecognised keeps the server's own words rather than being
Expand All @@ -732,6 +789,51 @@ function updateConflict(err: ConflictError, skillName: string): Error {
return err
}

/** The skill's real directory, once it has passed. Exported for its test:
* the boundary rule has to hold for every caller, and a root of `/` — the
* sentinel a project with no git carries — is not a boundary at all. */
export function assertProjectSkill(projectRoot: string, skillDirectory: string): string {
// The boundary is the root's REAL path, resolved once and used for both
// checks below. A filesystem root would contain everything: both callers
// substitute the session directory for the `/` sentinel, and this refuses
// it in case one forgets, since the failure mode is publishing anything
// on the machine. Judged after resolving — a root that is a symbolic link
// to `/` is `/` for the containment comparison, so it must be refused on
// the same value that comparison uses.
let root: string
try {
root = realpathSync(projectRoot)
} catch {
root = path.resolve(projectRoot)
}
if (root === path.parse(root).root) throw new NotProjectSkillError(skillDirectory)
const lexical = path.resolve(skillDirectory)
let real: string
try {
real = realpathSync(lexical)
} catch {
// Absent: `collectBundle` fails on it in a moment with a better message.
return lexical
}
// "The root is a link" is judged on the LAST component only: the parent's
// real path plus the skill's own name must equal the skill's real path.
// Comparing the whole path to its lexical form would call every skill on
// macOS a link, since `/var` and `/tmp` are links to `/private/...`.
let parentReal: string
try {
parentReal = realpathSync(path.dirname(lexical))
} catch {
return lexical
}
if (real !== path.join(parentReal, path.basename(lexical))) throw new NotProjectSkillError(skillDirectory)
const rel = path.relative(root, real)
// Parent traversal exactly, not any name that begins with two dots: a
// skill directory literally named `..foo` is inside the project.
if (rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel))
throw new NotProjectSkillError(skillDirectory)
return real
}

/** Refuse before upload when the bound workspace is not the caller's. Read
* from the same list the picker uses, which carries each workspace's owner. A
* list that omits the owner (an older server) cannot answer, and the attach
Expand Down
12 changes: 10 additions & 2 deletions packages/opencode/src/cli/cmd/skill-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,19 +61,27 @@ export function skillSource(location: string): string {
if (location.startsWith("builtin:")) return "builtin"
const home = Global.Path.home
// Builtin skills shipped with altimate-code
if (location.startsWith(path.join(home, ".altimate", "builtin"))) return "builtin"
if (isInside(location, path.join(home, ".altimate", "builtin"))) return "builtin"
// Global user skills (~/.claude/skills/, ~/.agents/skills/, ~/.config/altimate-code/skills/)
const globalDirs = [
path.join(home, ".claude", "skills"),
path.join(home, ".agents", "skills"),
path.join(home, ".altimate-code", "skills"),
path.join(Global.Path.config, "skills"),
]
if (globalDirs.some((dir) => location.startsWith(dir))) return "global"
if (globalDirs.some((dir) => isInside(location, dir))) return "global"
// Everything else is project-level
return "project"
}

/** Path containment by segments, not by string prefix: `~/.claude/skills-archive/x`
* is not inside `~/.claude/skills`, and a raw `startsWith` said it was — which
* refused a project skill as personal. */
function isInside(location: string, dir: string): boolean {
const rel = path.relative(dir, location)
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))
}

/** Check if a tool is available on the current PATH (including .altimate-code/tools/ and .opencode/tools/). */
export async function isToolOnPath(toolName: string, cwd: string): Promise<boolean> {
// Check project tools/ in both cwd and worktree (they may differ in monorepos)
Expand Down
72 changes: 72 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,76 @@ 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. Run \`altimate-code skill list\` to see the skills this project can reach.` + 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 personal
// skill under the home directory is the user's, but not this project's:
// publishing shares it with the whole workspace, which is not what
// keeping it in `~/.claude/skills` says. A skill the workspace sent us
// is refused by `publishSkill` itself, as is a symlinked root.
const source = skillSource(skill.location)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (source === "builtin" || !path.isAbsolute(skill.location)) {
process.stderr.write(`"${name}" is a built-in skill and cannot be published.` + EOL)
process.exitCode = 1
return
}
if (source === "global") {
Comment thread
sahrizvi marked this conversation as resolved.
process.stderr.write(
`"${name}" is a personal skill (${path.dirname(skill.location)}), not one of this project's. ` +
`Copy it into the project's skills directory to publish it.` + EOL,
)
process.exitCode = 1
return
}
try {
const report = await publishSkill({
projectDirectory: Instance.directory,
// Discovery walks up to the worktree; so must the boundary, or a
// skill under the repository root is refused from a subdirectory.
projectRoot: Instance.worktree !== "/" ? Instance.worktree : 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 +809,7 @@ export const SkillCommand = cmd({
.command(SkillListCommand)
.command(SkillCreateCommand)
.command(SkillTestCommand)
.command(SkillPublishCommand)
.command(SkillShowCommand)
.command(SkillInstallCommand)
.command(SkillRemoveCommand)
Expand Down
Loading
Loading