Skip to content

Commit 5a02c4c

Browse files
saravmajesticclaude
andcommitted
Merge main into the account-scoped workspace cache
One conflict, in `src/skill/index.ts`: #1371 added a workspace-snapshot precedence rule to `add`, and this branch added an account gate to `discoverSkills`. The two are orthogonal — the gate withholds a foreign match before `loadSkills` ever reaches `add` — so both sides are kept. Resolved by removing this branch's duplicate rather than stacking it. #1371 introduced `altimate/workspace/snapshot-path.ts`, which already answers "is this path inside a managed snapshot" by path segments and is already imported here. This branch had its own copy of that literal as a substring constant. `snapshotProjectOf` now lives in that module beside its sibling and returns the project rather than a boolean; `isInWorkspaceSnapshot` is expressed in terms of it, so the segment walk exists once. Segment matching also answers the root-project case (`/.altimate-code/...`) directly, which the substring form needed a special case for. `bun test test/skill/` 1001 pass / 0 fail. `test/altimate/` and `test/server/` carry only the pre-existing HttpApi and `flushPendingSyncs` timeouts. Typecheck clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tneq4MLNoRLsX7htV6fGZC
2 parents 2291a2e + 8fe2e0c commit 5a02c4c

11 files changed

Lines changed: 799 additions & 57 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: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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+
/** The project a path belongs to, if the path lies inside that project's managed snapshot; `null`
12+
* otherwise.
13+
*
14+
* A project opened AT the filesystem root puts the snapshot's first segment at index 0, which is a
15+
* project of `/` and not "no project" — the difference decides whether discovery gates the file or
16+
* serves it, so it is spelled out rather than left to a truthiness test. (review) */
17+
export function snapshotProjectOf(location: string): string | null {
18+
const parts = path.resolve(location).split(path.sep)
19+
for (let i = 0; i + SNAPSHOT_SEGMENTS.length <= parts.length; i++) {
20+
if (SNAPSHOT_SEGMENTS.every((segment, j) => parts[i + j] === segment))
21+
return parts.slice(0, i).join(path.sep) || path.sep
22+
}
23+
return null
24+
}
25+
26+
export function isInWorkspaceSnapshot(location: string): boolean {
27+
return snapshotProjectOf(location) !== null
28+
}
29+
30+
/** Whether `location` lies inside `root`, by path segments. */
31+
export function isWithin(root: string, location: string): boolean {
32+
const rel = path.relative(path.resolve(root), path.resolve(location))
33+
return rel === "" || (!rel.startsWith(".." + path.sep) && rel !== ".." && !path.isAbsolute(rel))
34+
}
35+
36+
/** Whether a skill found in the workspace snapshot must yield to a same-name skill already
37+
* registered at `existingLocation`: only when that one is the user's own, inside the project.
38+
* Built-in (`builtin:` / `<built-in>`), personal and snapshot entries are overridden as before. */
39+
export function snapshotCopyYields(match: string, existingLocation: unknown, projectRoot: string | undefined): boolean {
40+
return (
41+
!!projectRoot &&
42+
typeof existingLocation === "string" &&
43+
isInWorkspaceSnapshot(match) &&
44+
path.isAbsolute(existingLocation) &&
45+
!isInWorkspaceSnapshot(existingLocation) &&
46+
isWithin(projectRoot, existingLocation)
47+
)
48+
}

0 commit comments

Comments
 (0)