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
2 changes: 1 addition & 1 deletion docs/docs/configure/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ altimate-code skill remove my-tool # remove skill + paired tool
altimate-code skill publish my-tool # upload every file in the skill directory; re-run to update
```

`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.
`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.

### TUI

Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/altimate/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -730,7 +730,8 @@ export namespace Telemetry {
skill_name: string
action: "created" | "updated"
file_count: number
source: "cli" | "tui"
// "serve": published from the IDE extension over the serve route
source: "cli" | "tui" | "serve"
}
// altimate_change end
// altimate_change start — plan refinement telemetry event
Expand Down
54 changes: 54 additions & 0 deletions packages/opencode/src/altimate/workspace/publishable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// altimate_change - new file
//
// Which of the skills this project can reach may be published to its workspace, for the serve
// routes the IDE extension calls. The CLI's `skill publish` and the TUI's "Publish to workspace"
// row apply the same rules inline (`skillSource`, `isManagedSkill`, `assertProjectSkill`); the
// refusal wording here matches theirs so a user moving between surfaces reads the same thing.
import path from "path"
import { skillSource } from "@/cli/cmd/skill-helpers"
import { assertProjectSkill, IDE_DELIVERED_MARKER, isManagedSkill } from "./skill-publish"

export type PublishEligibility = "publishable" | "builtin" | "personal" | "workspace" | "outside-project"

/** The boundary a skill must lie within: the worktree, since discovery walks up to it — except for
* a project with no git, whose worktree is the sentinel `/`, which would contain everything. */
export function projectRootFor(directory: string, worktree: string): string {
return worktree !== "/" ? worktree : directory
}

/** Whether a skill at `location` (its `SKILL.md`) may be published from `projectDirectory`. */
export function publishEligibility(location: string, projectDirectory: string, projectRoot: string): PublishEligibility {
if (!path.isAbsolute(location) || skillSource(location) === "builtin") return "builtin"
if (skillSource(location) === "global") return "personal"
const skillDirectory = path.dirname(location)
if (isManagedSkill(projectDirectory, skillDirectory)) return "workspace"
try {
assertProjectSkill(projectRoot, skillDirectory)
} catch {
return "outside-project"
}
return "publishable"
}

/** Why a skill cannot be published, in the words the CLI uses, or null when it can. */
export function explainIneligible(name: string, location: string, eligibility: PublishEligibility): string | null {
switch (eligibility) {
case "publishable":
return null
case "builtin":
return `"${name}" is a built-in skill and cannot be published.`
case "personal":
return (
`"${name}" is a personal skill (${path.dirname(location)}), not one of this project's. ` +
`Copy it into the project's skills directory to publish it.`
)
case "workspace":
return (
`"${name}" is a skill this workspace sent to you, not one you authored. ` +
`Publishing it would send the workspace's own skill back to it. ` +
`If you copied it to make your own, rename it and delete any ${IDE_DELIVERED_MARKER} in its folder.`
)
case "outside-project":
return `"${name}" is not inside this project (${path.dirname(location)}), so it cannot be published from here.`
}
}
19 changes: 16 additions & 3 deletions packages/opencode/src/altimate/workspace/skill-publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,12 @@
// interpret.
import fs from "fs/promises"
import path from "path"
import { realpathSync } from "fs"
import { existsSync, realpathSync } from "fs"
import { Log } from "@/altimate/util/log"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { AltimateApi } from "@/altimate/api/client"
import { isInWorkspaceSnapshot } from "./snapshot-path"
import { ConflictError, ForbiddenError, NotFoundError, WorkspaceApi, altimateRequest } from "./api-client"
import { resolveBinding } from "./state"

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

/** Written by the VS Code / Cursor extension into every skill directory it delivers from the
* workspace (`.claude/skills/altimate-*`, `.agents/skills/altimate-*`). Those roots are also this
* project's own skill-discovery roots, so without the marker a delivered skill would be offered for
* publish — and uploaded back to the workspace that sent it, as a new skill owned by the publisher.
* The name is the extension's (`OWNERSHIP_MARKER` in its `customSkillDelivery.ts`); keep in step. */
export const IDE_DELIVERED_MARKER = ".altimate-managed.json"

/** Mirrors the server's own ceilings so an oversized bundle fails locally, with a
* usable message, instead of after a long upload. `MAX_BUNDLE_FILES` and
* `MAX_BUNDLE_BYTES` in `app/service/custom_skills/bundle.py`; a mismatch
Expand Down Expand Up @@ -355,7 +363,8 @@ export async function collectBundle(dir: string): Promise<BundleFile[]> {
return files
}

/** True when this path lives inside the workspace-owned snapshot. */
/** True when this skill came from the workspace: it lives inside the workspace-owned snapshot, or
* the IDE extension delivered it (see {@link IDE_DELIVERED_MARKER}). */
export function isManagedSkill(projectDirectory: string, skillDirectory: string): boolean {
// `path.resolve` is lexical: it normalises `..` and makes the path absolute,
// but it does not follow links. A skill directory that IS a symlink into the
Expand All @@ -374,7 +383,11 @@ export function isManagedSkill(projectDirectory: string, skillDirectory: string)
}
const managed = real(path.resolve(projectDirectory, MANAGED_DIR))
const candidate = real(skillDirectory)
return candidate === managed || candidate.startsWith(managed + path.sep)
if (candidate === managed || candidate.startsWith(managed + path.sep)) return true
// A snapshot above the project directory: discovery reads config directories up to the worktree.
if (isInWorkspaceSnapshot(candidate)) return true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new snapshot check in isManagedSkill matches the .altimate-code/skill/_workspace segment sequence anywhere in the path, unscoped to the current project. publishEligibility (and publishSkill at line 657) therefore classify any skill whose location contains those three segments as "workspace" and refuse to publish it, even when that directory is the user's own nested project inside the worktree rather than the workspace-owned snapshot. Scope the segment match to the project/worktree boundary (e.g. verify the match sits on or above projectDirectory's worktree, matching how discovery walks config dirs up to the worktree) instead of matching globally.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/altimate/workspace/skill-publish.ts, line 388:

<comment>The new snapshot check in `isManagedSkill` matches the `.altimate-code/skill/_workspace` segment sequence anywhere in the path, unscoped to the current project. `publishEligibility` (and `publishSkill` at line 657) therefore classify any skill whose location contains those three segments as "workspace" and refuse to publish it, even when that directory is the user's own nested project inside the worktree rather than the workspace-owned snapshot. Scope the segment match to the project/worktree boundary (e.g. verify the match sits on or above `projectDirectory`'s worktree, matching how discovery walks config dirs up to the worktree) instead of matching globally.</comment>

<file context>
@@ -374,7 +383,11 @@ export function isManagedSkill(projectDirectory: string, skillDirectory: string)
-  return candidate === managed || candidate.startsWith(managed + path.sep)
+  if (candidate === managed || candidate.startsWith(managed + path.sep)) return true
+  // A snapshot above the project directory: discovery reads config directories up to the worktree.
+  if (isInWorkspaceSnapshot(candidate)) return true
+  // A delivered skill sits in the project's own discovery roots, not under the snapshot.
+  return existsSync(path.join(candidate, IDE_DELIVERED_MARKER))
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this. .altimate-code/skill/_workspace is written only by the workspace skill sync, so a path containing it is always a snapshot, never something a user authored. Discovery reaches that directory only through config dirs walked up from the session directory to the worktree, which is why the segment check exists: a session in repo/sub reads repo/.altimate-code/.... A nested project below the session directory isn't a discovery root at all.

// A delivered skill sits in the project's own discovery roots, not under the snapshot.
return existsSync(path.join(candidate, IDE_DELIVERED_MARKER))
}

// ---------------------------------------------------------------------------
Expand Down
37 changes: 37 additions & 0 deletions packages/opencode/src/altimate/workspace/snapshot-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// altimate_change - new file
//
// Whether a path lies inside a workspace skill snapshot (`.altimate-code/skill/_workspace`), judged
// by path segments rather than against one project directory: discovery walks config directories up
// to the worktree, so a session started in `repo/sub` also reads `repo/.altimate-code/...`.
// Dependency-free on purpose — skill discovery imports it, and the workspace modules are heavy.
import path from "path"

const SNAPSHOT_SEGMENTS = [".altimate-code", "skill", "_workspace"]

export function isInWorkspaceSnapshot(location: string): boolean {
const parts = path.resolve(location).split(path.sep)
for (let i = 0; i + SNAPSHOT_SEGMENTS.length <= parts.length; i++) {
if (SNAPSHOT_SEGMENTS.every((segment, j) => parts[i + j] === segment)) return true
}
return false
}

/** Whether `location` lies inside `root`, by path segments. */
export function isWithin(root: string, location: string): boolean {
const rel = path.relative(path.resolve(root), path.resolve(location))
return rel === "" || (!rel.startsWith(".." + path.sep) && rel !== ".." && !path.isAbsolute(rel))
}

/** Whether a skill found in the workspace snapshot must yield to a same-name skill already
* registered at `existingLocation`: only when that one is the user's own, inside the project.
* Built-in (`builtin:` / `<built-in>`), personal and snapshot entries are overridden as before. */
export function snapshotCopyYields(match: string, existingLocation: unknown, projectRoot: string | undefined): boolean {
return (
!!projectRoot &&
typeof existingLocation === "string" &&
isInWorkspaceSnapshot(match) &&
path.isAbsolute(existingLocation) &&
!isInWorkspaceSnapshot(existingLocation) &&
isWithin(projectRoot, existingLocation)
)
}
Loading
Loading