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
5 changes: 5 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -1145,6 +1145,11 @@ missing), and for every other supported host directory that already exists.
any newly detected supported host that is missing it.
- Local skills: agent-native local skills are not synchronized during startup.
A local skill belongs to the agent skill directory where it was created.
- Shared host directories: supported hosts whose skill directories resolve to
the same location — for example `~/.claude/skills` symlinked to
`~/.agents/skills` — are one host. The skill is installed there once and is
reported under the more specific agent: the universal `~/.agents` host yields
to a detected agent that shares its directory.
- Migration: startup synchronization does not rewrite same-version legacy
symlink targets. Use `oo skills add` for bundled skills and
`oo skills update` for registry skills to replace legacy symlinks explicitly.
Expand Down
4 changes: 4 additions & 0 deletions docs/commands.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -963,6 +963,10 @@ CLI 默认记录受隐私约束的命令使用 telemetry。事件不包含 free-
到且尚未安装它的受支持 Agent。
- 本地 skill:agent-native local skill 不会在启动时同步。本地 skill 归属于创建它
的 Agent skill 目录。
- 共享 Agent 目录:如果多个受支持 Agent 的 skill 目录指向同一个位置(例如
`~/.claude/skills` 软链接到 `~/.agents/skills`),它们会被视为同一个 host:
skill 只安装一次,并归属到更具体的 Agent,即通用 `~/.agents` host 让位于与
它共享目录的已检测 Agent。
- 迁移:启动同步不会改写同版本的历史软链接目标。请用 `oo skills add` 刷新内置
skill,用 `oo skills update` 刷新 registry skill,从而显式替换历史软链接。成功
的 `oo install` / `oo update` 会运行这两个维护步骤。
Expand Down
14 changes: 14 additions & 0 deletions src/application/commands/skills/__tests__/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,20 @@ export async function readBundledSkillSourceContent(
return await readBundledSkillFileContent(file);
}

// Links an existing directory without touching its contents. Windows CI does
// not grant directory symlink privileges; junctions resolve through realpath()
// the same way.
export async function createDirectorySymbolicLinkForTest(
targetPath: string,
linkPath: string,
): Promise<void> {
await symlink(
targetPath,
linkPath,
process.platform === "win32" ? "junction" : "dir",
);
}

export async function createSymbolicLinkForTest(
targetPath: string,
linkPath: string,
Expand Down
124 changes: 90 additions & 34 deletions src/application/commands/skills/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import {
parseTelemetryRowPayload,
readTelemetryRowsForTest,
} from "../../telemetry/outbox.ts";
import { readBundledSkillSourceContent, seedRegistrySkill } from "./__tests__/helpers.ts";
import {
createDirectorySymbolicLinkForTest,
readBundledSkillSourceContent,
seedRegistrySkill,
} from "./__tests__/helpers.ts";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import {
canonicalLocalSkillsDirectoryName,
resolveBundledSkillCanonicalDirectoryPath,
Expand Down Expand Up @@ -348,39 +352,7 @@ describe("skills commands", () => {

const result = await sandbox.run(
["skills", "install", "oo@0.0.2"],
{
fetcher: async (input, init) => {
const request = toRequest(input, init);

requests.push(request);

if (request.url.includes("/package-info/")) {
return new Response(JSON.stringify({
packageName: "oo",
version: "0.0.2",
skills: [
{
description: "Run OO workflows",
name: "runtime",
title: "Runtime",
},
],
}));
}

if (request.url.endsWith("/oo/-/meta/oo-0.0.2.tgz")) {
return new Response(await createRegistrySkillArchiveBytes({
"package/package/skills/runtime/SKILL.md": "# Runtime\n",
}));
}

if (isRegistryPackageDownloadCountRequest(request)) {
return new Response(null, { status: 204 });
}

throw new Error(`Unexpected request: ${request.url}`);
},
},
{ fetcher: createRuntimeRegistryFetcher(requests) },
);

expect(result.exitCode).toBe(0);
Expand All @@ -399,6 +371,51 @@ describe("skills commands", () => {
}
});

test("installs a registry skill once when two agent homes share a skills directory", async () => {
const sandbox = await createCliSandbox();
const universalHomeDirectory = resolveManagedSkillAgentHomeDirectory(sandbox.env, "universal");
const claudeHomeDirectory = resolveManagedSkillAgentHomeDirectory(sandbox.env, "claude");
const sharedSkillsDirectoryPath = join(sandbox.env.HOME!, ".shared", "skills");
const skillDirectoryPath = resolveManagedSkillDirectoryPath(
claudeHomeDirectory,
"runtime",
);

try {
await mkdir(sharedSkillsDirectoryPath, { recursive: true });
await mkdir(universalHomeDirectory, { recursive: true });
await mkdir(claudeHomeDirectory, { recursive: true });
await createDirectorySymbolicLinkForTest(
sharedSkillsDirectoryPath,
join(universalHomeDirectory, "skills"),
);
await createDirectorySymbolicLinkForTest(
sharedSkillsDirectoryPath,
join(claudeHomeDirectory, "skills"),
);
await writeAuthFile(sandbox);

const result = await sandbox.run(
["skills", "install", "oo@0.0.2"],
{ fetcher: createRuntimeRegistryFetcher() },
);

// Both homes publish into the same directory: publishing them as two
// hosts copied into the same path concurrently and failed with EEXIST.
expect(result.exitCode).toBe(0);
expect(result.stderr).toBe("");
expect(result.stdout).toBe(
`Skill: runtime\nInstalled skill runtime to ${skillDirectoryPath}.\n`,
);
expect(
await readFile(join(sharedSkillsDirectoryPath, "runtime", "SKILL.md"), "utf8"),
).toContain("# Runtime");
}
finally {
await sandbox.cleanup();
}
});

test("explicit bundled skill install overwrites a managed development-version installation", async () => {
const sandbox = await createCliSandbox();
const universalHomeDirectory = resolveManagedSkillAgentHomeDirectory(sandbox.env, "universal");
Expand Down Expand Up @@ -3820,6 +3837,45 @@ function isRegistryPackageDownloadCountRequest(request: Request): boolean {
&& request.url.endsWith("/download-count");
}

// Serves the `oo@0.0.2` registry package that publishes a single `runtime`
// skill. Pass a request sink to assert the request sequence.
function createRuntimeRegistryFetcher(requests?: Request[]): (
input: string | URL | Request,
init?: RequestInit,
) => Promise<Response> {
return async (input, init) => {
const request = toRequest(input, init);

requests?.push(request);

if (request.url.includes("/package-info/")) {
return new Response(JSON.stringify({
packageName: "oo",
version: "0.0.2",
skills: [
{
description: "Run OO workflows",
name: "runtime",
title: "Runtime",
},
],
}));
}

if (request.url.endsWith("/oo/-/meta/oo-0.0.2.tgz")) {
return new Response(await createRegistrySkillArchiveBytes({
"package/package/skills/runtime/SKILL.md": "# Runtime\n",
}));
}

if (isRegistryPackageDownloadCountRequest(request)) {
return new Response(null, { status: 204 });
}

throw new Error(`Unexpected request: ${request.url}`);
};
}

function createMultiPackageRegistryFetcher(): (
input: string | URL | Request,
init?: RequestInit,
Expand Down
111 changes: 103 additions & 8 deletions src/application/commands/skills/managed-skill-hosts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,33 @@ import { join } from "node:path";
import { describe, expect, test } from "bun:test";

import { createTemporaryDirectory } from "../../../../__tests__/helpers.ts";
import { createDirectorySymbolicLinkForTest } from "./__tests__/helpers.ts";
import { resolveAvailableManagedSkillHosts } from "./managed-skill-hosts.ts";

describe("resolveAvailableManagedSkillHosts", () => {
test("always includes the universal host even when its home directory does not exist", async () => {
const rootDirectory = await createTemporaryDirectory("oo-managed-hosts");
const env = { HOME: rootDirectory, USERPROFILE: rootDirectory };
const hostSandbox = await createManagedSkillHostSandbox();
const rootDirectory = hostSandbox.rootDirectory;

try {
const hosts = await resolveAvailableManagedSkillHosts(env);
const hosts = await resolveAvailableManagedSkillHosts(hostSandbox.env);

expect(hosts.map(host => host.agentName)).toEqual(["universal"]);
expect(hosts[0]!.homeDirectory).toBe(join(rootDirectory, ".agents"));
}
finally {
await rm(rootDirectory, { force: true, recursive: true });
await hostSandbox.cleanup();
}
});

test("includes non-always-provision hosts only when their home directory exists", async () => {
const rootDirectory = await createTemporaryDirectory("oo-managed-hosts");
const env = { HOME: rootDirectory, USERPROFILE: rootDirectory };
const hostSandbox = await createManagedSkillHostSandbox();
const rootDirectory = hostSandbox.rootDirectory;

try {
await mkdir(join(rootDirectory, ".claude"), { recursive: true });

const hosts = await resolveAvailableManagedSkillHosts(env);
const hosts = await resolveAvailableManagedSkillHosts(hostSandbox.env);
const agentNames = hosts.map(host => host.agentName);

// universal is always provisioned; claude is included because its home
Expand All @@ -39,7 +40,101 @@ describe("resolveAvailableManagedSkillHosts", () => {
expect(agentNames).not.toContain("hermes");
}
finally {
await rm(rootDirectory, { force: true, recursive: true });
await hostSandbox.cleanup();
}
});

test("collapses hosts whose skills directories resolve to the same path", async () => {
const hostSandbox = await createManagedSkillHostSandbox();
const rootDirectory = hostSandbox.rootDirectory;

Comment thread
coderabbitai[bot] marked this conversation as resolved.
try {
const sharedSkillsDirectory = join(rootDirectory, ".shared", "skills");

await mkdir(sharedSkillsDirectory, { recursive: true });
await mkdir(join(rootDirectory, ".agents"), { recursive: true });
await mkdir(join(rootDirectory, ".claude"), { recursive: true });
await createDirectorySymbolicLinkForTest(
sharedSkillsDirectory,
join(rootDirectory, ".agents", "skills"),
);
await createDirectorySymbolicLinkForTest(
sharedSkillsDirectory,
join(rootDirectory, ".claude", "skills"),
);

const hosts = await resolveAvailableManagedSkillHosts(hostSandbox.env);

// Both homes publish into the same directory, so they are one host;
// the universal fallback yields to the concrete agent.
expect(hosts.map(host => host.agentName)).toEqual(["claude"]);
expect(hosts[0]!.homeDirectory).toBe(join(rootDirectory, ".claude"));
}
finally {
await hostSandbox.cleanup();
}
});

test("keeps the first concrete agent when two concrete agents share a skills directory", async () => {
const hostSandbox = await createManagedSkillHostSandbox();
const rootDirectory = hostSandbox.rootDirectory;

try {
const claudeSkillsDirectory = join(rootDirectory, ".claude", "skills");

await mkdir(claudeSkillsDirectory, { recursive: true });
await mkdir(join(rootDirectory, ".trae-cn"), { recursive: true });
await createDirectorySymbolicLinkForTest(
claudeSkillsDirectory,
join(rootDirectory, ".trae-cn", "skills"),
);

const hosts = await resolveAvailableManagedSkillHosts(hostSandbox.env);

// The universal host has its own (still missing) skills directory, so
// only the two aliased concrete agents collapse.
expect(hosts.map(host => host.agentName)).toEqual(["universal", "claude"]);
}
finally {
await hostSandbox.cleanup();
}
});

test("collapses hosts whose home directories are aliased before any skills directory exists", async () => {
const hostSandbox = await createManagedSkillHostSandbox();
const rootDirectory = hostSandbox.rootDirectory;

try {
await mkdir(join(rootDirectory, ".agents"), { recursive: true });
await createDirectorySymbolicLinkForTest(
join(rootDirectory, ".agents"),
join(rootDirectory, ".claude"),
);

const hosts = await resolveAvailableManagedSkillHosts(hostSandbox.env);

expect(hosts.map(host => host.agentName)).toEqual(["claude"]);
}
finally {
await hostSandbox.cleanup();
}
});
});

// Every host test needs an empty home directory tree plus the environment that
// points home resolution at it, and must remove the tree afterwards.
async function createManagedSkillHostSandbox(): Promise<{
cleanup: () => Promise<void>;
env: Record<string, string | undefined>;
rootDirectory: string;
}> {
const rootDirectory = await createTemporaryDirectory("oo-managed-hosts");

return {
cleanup: async () => {
await rm(rootDirectory, { force: true, recursive: true });
},
env: { HOME: rootDirectory, USERPROFILE: rootDirectory },
rootDirectory,
};
}
Loading
Loading