From 6e57395bef47d7a0a370836400239a194417ae6b Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:28:06 -0800 Subject: [PATCH 1/2] fix(server): share skills with configured account homes --- .../src/provider/Drivers/ClaudeDriver.test.ts | 2 + .../src/provider/Drivers/ClaudeDriver.ts | 3 + .../src/provider/Drivers/CodexDriver.test.ts | 2 + .../src/provider/Drivers/CodexDriver.ts | 1 + .../provider/Drivers/CodexManagedProvider.ts | 1 + apps/server/src/skills/SkillsLibrary.test.ts | 98 ++++++++++++++++++- apps/server/src/skills/SkillsLibrary.ts | 46 ++++++--- docs/user/composer.md | 4 +- packages/provider-core/src/server/driver.ts | 2 + 9 files changed, 141 insertions(+), 18 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.test.ts b/apps/server/src/provider/Drivers/ClaudeDriver.test.ts index 6cced50eb91df..b42772aab816f 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.test.ts @@ -139,6 +139,7 @@ it.effect( shareProfile: false, }, }); + expect(instance.sharedSkillsDirectory).toBeUndefined(); expect((yield* instance.snapshot.refresh).auth.email).toBe("before@example.test"); expect((yield* instance.snapshot.refresh).auth.email).toBe("before@example.test"); const auth = instance.auth!; @@ -206,6 +207,7 @@ for (const conflict of [false, true]) { expect(yield* fs.readLink(path.join(account, "settings.json"))).toBe( path.join(shared, "settings.json"), ); + expect(instance.sharedSkillsDirectory).toBe(path.join(shared, "skills")); expect(instance.continuationIdentity.continuationKey).toBe(`claude:home:${account}`); expect(instance.auth?.credentialBinding).toEqual({ owner: "provider", key: account }); } diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 8b831a558eb44..c0f33bbad1cd6 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -467,6 +467,9 @@ export const ClaudeDriver: ProviderDriver< return { instanceId, driverKind: DRIVER_KIND, + ...(runtime.overlay?.active === false + ? {} + : { sharedSkillsDirectory: path.join(runtime.overlay?.sharedRoot ?? configDir, "skills") }), continuationIdentity: { ...fallbackContinuationIdentity, continuationKey: continuationGroupKey, diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 51abbfa2f3a8e..7912a1cadd49f 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -229,6 +229,7 @@ it.layer(layerTest)("CodexDriver", (it) => { ), ), ); + expect(instance.sharedSkillsDirectory).toBe(NodePath.join(sharedHome, "skills")); const observedAccount = yield* Deferred.make(); const disconnected = yield* instance.snapshot.streamChanges.pipe( Stream.tap((provider) => @@ -568,6 +569,7 @@ it.layer(layerTest)("CodexDriver", (it) => { environment: [{ name: "CODEX_HOME", value: codexHome, sensitive: false }], config: CodexDriver.defaultConfig(), }); + expect(instance.sharedSkillsDirectory).toBe(NodePath.join(codexHome, "skills")); expect(instance.continuationIdentity.continuationKey).toBe(`codex:home:${codexHome}`); } }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), Effect.scoped), diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 1f1aeb1bf005c..128ab109cd53d 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -598,6 +598,7 @@ export const CodexDriver: ProviderDriver< return { instanceId, driverKind: DRIVER_KIND, + sharedSkillsDirectory: pathService.join(homeLayout.sharedHomePath, "skills"), continuationIdentity, displayName, accentColor, diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index 85da879d3b14f..77c7cf5f06464 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -351,6 +351,7 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu return { instanceId, driverKind: DRIVER, + sharedSkillsDirectory: path.join(runtime.homeLayout.sharedHomePath, "skills"), continuationIdentity, displayName, accentColor, diff --git a/apps/server/src/skills/SkillsLibrary.test.ts b/apps/server/src/skills/SkillsLibrary.test.ts index 4a67fb4c95ead..611873e35bf13 100644 --- a/apps/server/src/skills/SkillsLibrary.test.ts +++ b/apps/server/src/skills/SkillsLibrary.test.ts @@ -19,6 +19,7 @@ import * as SkillsLibrary from "./SkillsLibrary.ts"; const setup = Effect.fn("SkillsLibrary.test.setup")(function* ( drivers: ReadonlyArray = [], + configuredRoot?: { readonly directory: string; readonly enabled: boolean }, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -37,7 +38,8 @@ const setup = Effect.fn("SkillsLibrary.test.setup")(function* ( drivers.map((driverKind) => ({ instanceId: ProviderInstanceId.make(driverKind), driverKind, - enabled: true, + enabled: configuredRoot?.enabled ?? true, + ...(configuredRoot ? { sharedSkillsDirectory: configuredRoot.directory } : {}), displayName: undefined, continuationIdentity: { driverKind, continuationKey: driverKind }, get orchestrationAdapter(): never { @@ -92,6 +94,100 @@ const snapshot = (root: string) => it.layer(NodeServices.layer)("SkillsLibrary", (it) => { describe("apply", () => { + it.effect.skipIf(!symlinksSupported)( + "shares a configured profile root once and restores it when disabled", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const customHome = yield* fs.makeTempDirectoryScoped({ prefix: "t3-custom-skills-" }); + const directory = path.join(customHome, "skills"); + const { service, library, skill, read, link } = yield* setup( + [ProviderDriverKind.make("claudeAgent")], + { directory, enabled: true }, + ); + yield* skill(directory, "custom", "custom instructions"); + yield* skill(library, "shared", "shared instructions"); + yield* service.apply; + yield* service.apply; + expect(yield* read(library, "custom")).toContain("custom instructions"); + expect(yield* read(directory, "shared")).toContain("shared instructions"); + expect(yield* link(directory, "custom")).toBe(path.join(library, "custom")); + yield* service.detach; + expect(yield* link(directory, "custom")).toBeUndefined(); + expect(yield* read(directory, "shared")).toContain("shared instructions"); + yield* service.detach; + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "deduplicates a configured alias of the default profile", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const customHome = yield* fs.makeTempDirectoryScoped({ prefix: "t3-alias-skills-" }); + const directory = path.join(customHome, "skills"); + const { service, library, claude, skill, read } = yield* setup( + [ProviderDriverKind.make("claudeAgent")], + { directory, enabled: true }, + ); + yield* fs.symlink(claude, directory); + yield* skill(library, "shared", "shared instructions"); + yield* service.apply; + expect( + (yield* service.status).linked.filter((entry) => entry.driver === "claudeAgent"), + ).toEqual([{ driver: "claudeAgent", name: "shared" }]); + yield* service.detach; + expect(yield* read(directory, "shared")).toContain("shared instructions"); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "recognizes the library through an alias before its skills directory exists", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const customHome = yield* fs.makeTempDirectoryScoped({ prefix: "t3-library-alias-" }); + const alias = path.join(customHome, "agents"); + const directory = path.join(alias, "skills"); + const { service, library, claude, skill, read } = yield* setup( + [ProviderDriverKind.make("claudeAgent")], + { directory, enabled: true }, + ); + yield* fs.makeDirectory(path.dirname(library), { recursive: true }); + yield* fs.symlink(path.dirname(library), alias); + yield* skill(claude, "custom", "custom instructions"); + yield* service.apply; + expect(yield* read(library, "custom")).toContain("custom instructions"); + yield* service.detach; + expect(yield* read(claude, "custom")).toContain("custom instructions"); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "does not mutate a disabled account's configured profile", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const customHome = yield* fs.makeTempDirectoryScoped({ prefix: "t3-disabled-skills-" }); + const directory = path.join(customHome, "skills"); + const { service, library, skill, link } = yield* setup( + [ProviderDriverKind.make("claudeAgent")], + { directory, enabled: false }, + ); + yield* skill(directory, "custom", "private instructions"); + yield* skill(library, "shared", "shared instructions"); + const before = yield* snapshot(customHome); + yield* service.apply; + yield* service.detach; + expect(yield* snapshot(customHome)).toEqual(before); + expect(yield* link(directory, "custom")).toBeUndefined(); + }), + ); + it.effect.skipIf(!symlinksSupported)( "makes shared skills available to a configured Antigravity with no global home", () => diff --git a/apps/server/src/skills/SkillsLibrary.ts b/apps/server/src/skills/SkillsLibrary.ts index ebbb531ec2733..fd0725a837bcd 100644 --- a/apps/server/src/skills/SkillsLibrary.ts +++ b/apps/server/src/skills/SkillsLibrary.ts @@ -155,10 +155,17 @@ export const make = Effect.gen(function* () { const realPath = (filePath: string) => io("scan", filePath, () => - NodeFSP.realpath(filePath).catch((cause: NodeJS.ErrnoException) => { - if (cause.code === "ENOENT") return filePath; - throw cause; - }), + (async function resolve(candidate: string): Promise { + try { + return await NodeFSP.realpath(candidate); + } catch (cause) { + if ((cause as NodeJS.ErrnoException).code !== "ENOENT") throw cause; + const parent = path.dirname(candidate); + return parent === candidate + ? candidate + : path.join(await resolve(parent), path.basename(candidate)); + } + })(filePath), ); const readNames = (directory: string) => @@ -212,21 +219,27 @@ export const make = Effect.gen(function* () { const resolveRoots = Effect.fn("SkillsLibrary.resolveRoots")(function* () { const libraryPath = path.join(home, ".agents", "skills"); const libraryReal = yield* realPath(libraryPath); - const configuredDrivers = new Set( - (yield* registry.listInstances) - .filter((instance) => instance.enabled) - .map((instance) => instance.driverKind), - ); + const instances = (yield* registry.listInstances).filter((instance) => instance.enabled); + const configuredDrivers = new Set(instances.map((instance) => instance.driverKind)); const roots: Array<{ readonly root: HarnessSkillRoot; readonly directory: string }> = []; for (const root of HARNESS_SKILL_ROOTS) { const harnessHome = yield* root.home(environment, home); if (!configuredDrivers.has(root.driver) && !(yield* exists(harnessHome))) continue; const directory = path.join(harnessHome, "skills"); - // Treating the library itself as a root would delete its copies as duplicates of themselves. - if ((yield* realPath(directory)) === libraryReal) continue; roots.push({ root, directory }); } - return { libraryPath, roots }; + for (const instance of instances) { + const root = HARNESS_SKILL_ROOTS.find((root) => root.driver === instance.driverKind); + if (root !== undefined && instance.sharedSkillsDirectory !== undefined) { + roots.push({ root, directory: instance.sharedSkillsDirectory }); + } + } + const unique = new Map(); + for (const candidate of roots) { + const real = yield* realPath(candidate.directory); + if (real !== libraryReal && !unique.has(real)) unique.set(real, candidate); + } + return { libraryPath, roots: [...unique.values()] }; }); /** Restores a crash left staged; one beside a folder it does not equal is the user's. */ @@ -495,11 +508,12 @@ export const layer = Layer.effect( Effect.map((current) => current.skillsLibraryEnabled), Effect.orElseSucceed(() => false), ); - const instanceIds = ( + const instanceRoots = ( instances: ReadonlyArray<{ instanceId: string; driverKind: ProviderDriverKind; enabled: boolean; + sharedSkillsDirectory?: string; }>, ) => new Set( @@ -509,9 +523,9 @@ export const layer = Layer.effect( instance.enabled && HARNESS_SKILL_ROOTS.some((root) => root.driver === instance.driverKind), ) - .map((instance) => instance.instanceId), + .map((instance) => `${instance.instanceId}\0${instance.sharedSkillsDirectory ?? ""}`), ); - let known = instanceIds(yield* registry.listInstances); + let known = instanceRoots(yield* registry.listInstances); if (enabled) yield* library.apply.pipe(logFailure, Effect.forkScoped); // A detach a restart interrupted has already turned the switch off; finish its staged copies. @@ -524,7 +538,7 @@ export const layer = Layer.effect( yield* Stream.fromSubscription(instanceChanges).pipe( Stream.runForEach(() => Effect.gen(function* () { - const current = instanceIds(yield* registry.listInstances); + const current = instanceRoots(yield* registry.listInstances); const created = [...current].some((id) => !known.has(id)); known = current; if (created && enabled) yield* library.apply.pipe(logFailure); diff --git a/docs/user/composer.md b/docs/user/composer.md index 8115114548855..87c1c9d772bde 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -340,7 +340,9 @@ folder from `~/.claude/skills`, `~/.codex/skills`, and Antigravity's user skill into `~/.agents/skills` and links it back, and links every skill there into Claude's and Antigravity's skill folders. Codex, Cursor, OpenCode, and other agents that read `~/.agents/skills` see the same set. Claude's -account-synced skills and Codex's bundled skills stay where they are. +account-synced skills and Codex's bundled skills stay where they are. Configured Claude and +Codex homes participate too. Claude accounts with **Use your Claude profile** turned off keep +their private skills. A skill found in two places with identical contents is kept once. When the contents differ, nothing is moved for that skill and the setting lists both paths; delete or diff --git a/packages/provider-core/src/server/driver.ts b/packages/provider-core/src/server/driver.ts index 83bd52d61716a..4cd903f9c5377 100644 --- a/packages/provider-core/src/server/driver.ts +++ b/packages/provider-core/src/server/driver.ts @@ -40,6 +40,8 @@ export interface ProviderInstance { readonly displayName: string | undefined; readonly accentColor?: string | undefined; readonly enabled: boolean; + /** Resolved profile skill root eligible for the environment skills library. */ + readonly sharedSkillsDirectory?: string; readonly snapshot: ManagedServerProvider; readonly snapshotForCwd?: ( cwd: string, From 54151bef5466114f658370f6dee124321db5ddd0 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:47:02 -0800 Subject: [PATCH 2/2] fix(server): restore skill links when account homes change --- apps/server/src/skills/SkillsLibrary.test.ts | 150 +++++++++++++++---- apps/server/src/skills/SkillsLibrary.ts | 126 +++++++++++++--- docs/user/composer.md | 5 +- 3 files changed, 230 insertions(+), 51 deletions(-) diff --git a/apps/server/src/skills/SkillsLibrary.test.ts b/apps/server/src/skills/SkillsLibrary.test.ts index 611873e35bf13..ce396d41abb9f 100644 --- a/apps/server/src/skills/SkillsLibrary.test.ts +++ b/apps/server/src/skills/SkillsLibrary.test.ts @@ -15,6 +15,7 @@ import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry. import { discoverAntigravitySkills } from "../provider/Drivers/AntigravitySkills.ts"; import { prepareAntigravityProfile } from "../provider/antigravityAuthSupport.ts"; import * as ServerSettings from "../serverSettings.ts"; +import * as ServerConfig from "../config.ts"; import * as SkillsLibrary from "./SkillsLibrary.ts"; const setup = Effect.fn("SkillsLibrary.test.setup")(function* ( @@ -29,36 +30,39 @@ const setup = Effect.fn("SkillsLibrary.test.setup")(function* ( const library = path.join(home, ".agents", "skills"); yield* fs.makeDirectory(claude, { recursive: true }); yield* fs.makeDirectory(codex, { recursive: true }); - const service = yield* SkillsLibrary.make.pipe( - Effect.provide( - Layer.mergeAll( - ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ - listInstances: Effect.succeed( - drivers.map((driverKind) => ({ - instanceId: ProviderInstanceId.make(driverKind), - driverKind, - enabled: configuredRoot?.enabled ?? true, - ...(configuredRoot ? { sharedSkillsDirectory: configuredRoot.directory } : {}), - displayName: undefined, - continuationIdentity: { driverKind, continuationKey: driverKind }, - get orchestrationAdapter(): never { - throw new Error("Sharing skills must not start a provider session."); - }, - get snapshot(): never { - throw new Error("Sharing skills must not probe a provider."); - }, - get textGeneration(): never { - throw new Error("Sharing skills must not generate text."); - }, - })), - ), - }), + const reopen = (root: typeof configuredRoot, stateHome = path.join(home, "environment")) => + SkillsLibrary.make.pipe( + Effect.provide( + Layer.mergeAll( + ServerSettings.ServerSettingsService.layerTest(), + ServerConfig.layerTest(home, stateHome), + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + listInstances: Effect.succeed( + drivers.map((driverKind) => ({ + instanceId: ProviderInstanceId.make(driverKind), + driverKind, + enabled: root?.enabled ?? true, + ...(root ? { sharedSkillsDirectory: root.directory } : {}), + displayName: undefined, + continuationIdentity: { driverKind, continuationKey: driverKind }, + get orchestrationAdapter(): never { + throw new Error("Sharing skills must not start a provider session."); + }, + get snapshot(): never { + throw new Error("Sharing skills must not probe a provider."); + }, + get textGeneration(): never { + throw new Error("Sharing skills must not generate text."); + }, + })), + ), + }), + ), ), - ), - Effect.provideService(HostProcess.HomeDirectory, home), - Effect.provideService(HostProcess.Environment, { HOME: home }), - ); + Effect.provideService(HostProcess.HomeDirectory, home), + Effect.provideService(HostProcess.Environment, { HOME: home }), + ); + const service = yield* reopen(configuredRoot); const skill = (root: string, name: string, body: string, frontmatterName = name) => fs .makeDirectory(path.join(root, name), { recursive: true }) @@ -73,7 +77,7 @@ const setup = Effect.fn("SkillsLibrary.test.setup")(function* ( const read = (root: string, name: string) => fs.readFileString(path.join(root, name, "SKILL.md")); const link = (root: string, name: string) => fs.readLink(path.join(root, name)).pipe(Effect.orElseSucceed(() => undefined)); - return { fs, path, home, claude, codex, library, service, skill, read, link }; + return { fs, path, home, claude, codex, library, service, reopen, skill, read, link }; }); /** Every entry under `root`, links not followed, with what a write would change. */ @@ -402,6 +406,91 @@ it.layer(NodeServices.layer)("SkillsLibrary", (it) => { }); describe("detach", () => { + it.effect.skipIf(!symlinksSupported)( + "does not use another environment's ownership journal at startup", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const profiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-isolated-skills-" }); + const directory = path.join(profiles, "skills"); + const { service, reopen, home, claude, library, skill, link } = yield* setup( + [ProviderDriverKind.make("claudeAgent")], + { directory, enabled: true }, + ); + yield* skill(library, "shared", "shared instructions"); + yield* service.apply; + const otherEnvironment = yield* reopen(undefined, path.join(home, "other-environment")); + yield* otherEnvironment.finishDetach; + expect(yield* link(directory, "shared")).toBe(path.join(library, "shared")); + expect(yield* link(claude, "shared")).toBe(path.join(library, "shared")); + yield* (yield* reopen(undefined)).finishDetach; + expect(yield* link(directory, "shared")).toBeUndefined(); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "restores former configured homes after reassignment and restart", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const profiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-old-skills-" }); + const directory = path.join(profiles, "old", "skills"); + const replacement = path.join(profiles, "new", "skills"); + const { service, reopen, library, skill, read, link } = yield* setup( + [ProviderDriverKind.make("claudeAgent")], + { directory, enabled: true }, + ); + yield* skill(library, "shared", "shared instructions"); + yield* service.apply; + const changed = yield* reopen({ directory: replacement, enabled: true }); + yield* changed.apply; + expect(yield* link(directory, "shared")).toBeUndefined(); + expect(yield* read(directory, "shared")).toContain("shared instructions"); + yield* skill(directory, "shared", "edited outside the library"); + expect((yield* changed.status).conflicts).toEqual([]); + expect(yield* link(replacement, "shared")).toBe(path.join(library, "shared")); + yield* skill(replacement, ".shared.t3-restore", "unrelated staged folder"); + const restartedWithoutProfile = yield* reopen(undefined); + yield* restartedWithoutProfile.finishDetach; + expect(yield* link(replacement, "shared")).toBe(path.join(library, "shared")); + expect(yield* read(replacement, ".shared.t3-restore")).toContain( + "unrelated staged folder", + ); + yield* fs.remove(path.join(replacement, ".shared.t3-restore"), { recursive: true }); + yield* (yield* reopen(undefined)).finishDetach; + expect(yield* link(replacement, "shared")).toBeUndefined(); + expect(yield* read(replacement, "shared")).toContain("shared instructions"); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "leaves a missing or reassigned former root alone during cleanup", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const profiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-missing-skills-" }); + const directory = path.join(profiles, "skills"); + const { service, reopen, library, skill } = yield* setup( + [ProviderDriverKind.make("claudeAgent")], + { directory, enabled: true }, + ); + yield* skill(library, "shared", "shared instructions"); + yield* service.apply; + yield* fs.remove(directory, { recursive: true }); + yield* (yield* reopen(undefined)).detach; + expect(yield* fs.exists(directory)).toBe(false); + yield* service.apply; + yield* fs.remove(directory, { recursive: true }); + yield* skill(directory, "shared", "new owner"); + const before = yield* snapshot(profiles); + yield* (yield* reopen(undefined)).detach; + expect(yield* snapshot(profiles)).toEqual(before); + }), + ); + it.effect.skipIf(!symlinksSupported)( "restores real folders and removes only links into the library", () => @@ -483,6 +572,7 @@ it.layer(NodeServices.layer)("SkillsLibrary", (it) => { Effect.provide( Layer.mergeAll( ServerSettings.ServerSettingsService.layerTest(), + ServerConfig.layerTest(home, path.join(home, "environment")), Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ listInstances: Effect.succeed([]), subscribeChanges: PubSub.unbounded().pipe(Effect.flatMap(PubSub.subscribe)), diff --git a/apps/server/src/skills/SkillsLibrary.ts b/apps/server/src/skills/SkillsLibrary.ts index fd0725a837bcd..f03d3d1bb4e25 100644 --- a/apps/server/src/skills/SkillsLibrary.ts +++ b/apps/server/src/skills/SkillsLibrary.ts @@ -13,6 +13,7 @@ import { } from "@t3tools/provider-core/server/homeOverlay"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { parseSkillFrontmatter } from "@t3tools/shared/skillFrontmatter"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -26,6 +27,7 @@ import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; +import * as ServerConfig from "../config.ts"; const CODEX_DEFAULTS = Schema.decodeSync(CodexSettings)({}); const CLAUDE = ProviderDriverKind.make("claudeAgent"); @@ -83,6 +85,7 @@ type PendingRestore = }; interface RootScan { + readonly active: boolean; readonly root: HarnessSkillRoot; readonly directory: string; readonly skills: ReadonlyArray; @@ -112,7 +115,7 @@ export class SkillsLibrary extends Context.Service< readonly apply: Effect.Effect; /** Turns each link into the library back into a real folder. */ readonly detach: Effect.Effect; - /** Finishes copies a detach left staged, without detaching anything else. */ + /** Finishes an interrupted detach, including recorded homes no longer configured. */ readonly finishDetach: Effect.Effect; /** Applies or detaches to match `skillsLibraryEnabled`, then reports status. */ readonly refresh: Effect.Effect; @@ -127,6 +130,13 @@ export const make = Effect.gen(function* () { const home = yield* HostProcess.HomeDirectory; const environment = yield* HostProcess.Environment; const lock = yield* Semaphore.make(1); + const { stateDir } = yield* ServerConfig.ServerConfig; + const rootsPath = path.join(stateDir, "skills-library-roots.json"); + const rootsSchema = Schema.fromJsonString( + Schema.Array(Schema.Struct({ driver: ProviderDriverKind, directory: Schema.NonEmptyString })), + ); + const decodeRoots = Schema.decodeUnknownEffect(rootsSchema); + const encodeRoots = Schema.encodeEffect(rootsSchema); const fail = (operation: SkillsLibraryError["operation"], filePath: string) => (cause: unknown) => new SkillsLibraryError({ operation, path: filePath, cause }); @@ -216,24 +226,84 @@ export const make = Effect.gen(function* () { return (yield* exists(path.join(entryPath, "SKILL.md"))) ? "skill" : "other"; }); - const resolveRoots = Effect.fn("SkillsLibrary.resolveRoots")(function* () { + const readRoots = Effect.fn("SkillsLibrary.readRoots")(function* () { + const contents = yield* fileSystem.readFileString(rootsPath).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.succeed("[]"), + ), + Effect.flatMap(decodeRoots), + Effect.mapError(fail("scan", rootsPath)), + ); + const roots: Array<{ readonly root: HarnessSkillRoot; readonly directory: string }> = []; + for (const entry of contents) { + if (!path.isAbsolute(entry.directory)) { + return yield* fail("scan", rootsPath)("A recorded skill root must be absolute."); + } + const root = HARNESS_SKILL_ROOTS.find((root) => root.driver === entry.driver); + if (root !== undefined) roots.push({ root, directory: entry.directory }); + } + return roots; + }); + + const writeRoots = Effect.fn("SkillsLibrary.writeRoots")(function* ( + roots: ReadonlyArray<{ readonly root: HarnessSkillRoot; readonly directory: string }>, + ) { + const entries = new Map< + string, + { readonly driver: ProviderDriverKind; readonly directory: string } + >(); + for (const { root, directory } of roots) { + const real = yield* realPath(directory); + entries.set(real, { driver: root.driver, directory: real }); + } + if (entries.size === 0) { + return yield* fileSystem + .remove(rootsPath, { force: true }) + .pipe(Effect.mapError(fail("detach", rootsPath))); + } + const contents = yield* encodeRoots([...entries.values()]).pipe( + Effect.map((contents) => `${contents}\n`), + Effect.mapError(fail("apply", rootsPath)), + ); + const previous = yield* fileSystem.readFileString(rootsPath).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.void, + ), + Effect.mapError(fail("scan", rootsPath)), + ); + if (previous !== contents) { + yield* writeFileStringAtomically({ filePath: rootsPath, contents }).pipe( + Effect.mapError(fail("apply", rootsPath)), + ); + } + }); + + const resolveRoots = Effect.fn("SkillsLibrary.resolveRoots")(function* (includeRecorded = false) { const libraryPath = path.join(home, ".agents", "skills"); const libraryReal = yield* realPath(libraryPath); const instances = (yield* registry.listInstances).filter((instance) => instance.enabled); const configuredDrivers = new Set(instances.map((instance) => instance.driverKind)); - const roots: Array<{ readonly root: HarnessSkillRoot; readonly directory: string }> = []; + const roots: Array<{ + readonly root: HarnessSkillRoot; + readonly directory: string; + readonly active: boolean; + }> = []; for (const root of HARNESS_SKILL_ROOTS) { const harnessHome = yield* root.home(environment, home); if (!configuredDrivers.has(root.driver) && !(yield* exists(harnessHome))) continue; const directory = path.join(harnessHome, "skills"); - roots.push({ root, directory }); + roots.push({ root, directory, active: true }); } for (const instance of instances) { const root = HARNESS_SKILL_ROOTS.find((root) => root.driver === instance.driverKind); if (root !== undefined && instance.sharedSkillsDirectory !== undefined) { - roots.push({ root, directory: instance.sharedSkillsDirectory }); + roots.push({ root, directory: instance.sharedSkillsDirectory, active: true }); } } + if (includeRecorded) + roots.push(...(yield* readRoots()).map((root) => ({ ...root, active: false }))); const unique = new Map(); for (const candidate of roots) { const real = yield* realPath(candidate.directory); @@ -266,12 +336,14 @@ export const make = Effect.gen(function* () { }); const scan = Effect.fn("SkillsLibrary.scan")(function* () { - const { libraryPath, roots } = yield* resolveRoots(); + const { libraryPath, roots } = yield* resolveRoots(true); const conflicts: Array = []; const conflicted = new Set(); - const conflict = (entry: SkillsLibraryStatus["conflicts"][number]) => { - if (conflicted.has(fold(entry.name))) return; - conflicted.add(fold(entry.name)); + const reported = new Set(); + const conflict = (entry: SkillsLibraryStatus["conflicts"][number], blocks = true) => { + if (blocks) conflicted.add(fold(entry.name)); + if (reported.has(fold(entry.name))) return; + reported.add(fold(entry.name)); conflicts.push(entry); }; @@ -279,7 +351,7 @@ export const make = Effect.gen(function* () { exists(path.join(libraryPath, name, "SKILL.md")), ); const rootScans: Array = []; - for (const { root, directory } of roots) { + for (const { root, directory, active } of roots) { const names = yield* readNames(directory); const kinds = yield* Effect.forEach(visible(names, root.private), (name) => entryKind(directory, name, libraryPath).pipe(Effect.map((kind) => ({ name, kind }))), @@ -288,9 +360,11 @@ export const make = Effect.gen(function* () { kinds.filter((entry) => entry.kind === kind).map((entry) => entry.name); const restores = yield* pendingRestores(directory, names); for (const restore of restores) { - if (restore.finish === "conflict") conflict({ name: restore.name, paths: restore.paths }); + if (restore.finish === "conflict") + conflict({ name: restore.name, paths: restore.paths }, active); } rootScans.push({ + active, root, directory, skills: named("skill"), @@ -302,7 +376,7 @@ export const make = Effect.gen(function* () { const copies = new Map>(); for (const name of librarySkills) copies.set(name, [path.join(libraryPath, name)]); - for (const rootScan of rootScans) { + for (const rootScan of rootScans.filter((root) => root.active)) { for (const name of rootScan.skills) { copies.set(name, [...(copies.get(name) ?? []), path.join(rootScan.directory, name)]); } @@ -329,7 +403,7 @@ export const make = Effect.gen(function* () { if (link !== undefined) conflict({ name, paths: [copy, link], reason: "outsideLink" }); } } - for (const rootScan of rootScans) { + for (const rootScan of rootScans.filter((root) => root.active)) { for (const name of rootScan.otherLinks) { const first = groups.get(fold(name))?.[0]; if (first !== undefined) { @@ -337,7 +411,7 @@ export const make = Effect.gen(function* () { } } } - for (const rootScan of rootScans) { + for (const rootScan of rootScans.filter((root) => root.active)) { for (const name of rootScan.skills) { const parked = discardPath(path, path.join(rootScan.directory, name)); const first = groups.get(fold(name))?.[0]; @@ -388,10 +462,16 @@ export const make = Effect.gen(function* () { const apply = Effect.fn("SkillsLibrary.apply")(function* () { const current = yield* scan(); + const remembered = current.roots.filter((root) => root.active); + for (const root of current.roots) { + if (!root.active && (yield* restore(current.libraryPath, root))) remembered.push(root); + } + // Persist ownership before linking so a restart or account edit cannot hide the way out. + yield* writeRoots(remembered); const linkable = [...current.copies.keys()].filter( (name) => !current.conflicted.has(fold(name)), ); - for (const rootScan of current.roots) { + for (const rootScan of current.roots.filter((root) => root.active)) { // A skill deleted from the library leaves its links dangling; remove them with it. for (const name of rootScan.linked.filter((linked) => !current.copies.has(linked))) { const linkPath = path.join(rootScan.directory, name); @@ -434,6 +514,7 @@ export const make = Effect.gen(function* () { rootScan: RootScan, ) { const { directory } = rootScan; + let unresolved = rootScan.pendingRestores.some((restore) => restore.finish === "conflict"); yield* finishRestores(directory, rootScan.pendingRestores); for (const name of rootScan.linked) { const linkPath = path.join(directory, name); @@ -441,7 +522,10 @@ export const make = Effect.gen(function* () { if (!rootScan.root.readsLibrary && (yield* exists(target))) { const stagedPath = path.join(directory, `.${name}${RESTORE_SUFFIX}`); // Left in place only when scan reported it as the user's; never copy over it. - if (yield* present(stagedPath)) continue; + if (yield* present(stagedPath)) { + unresolved = true; + continue; + } yield* fileSystem.copy(target, stagedPath).pipe(Effect.mapError(fail("detach", target))); yield* io("detach", linkPath, () => NodeFSP.unlink(linkPath)); yield* io("detach", stagedPath, () => NodeFSP.rename(stagedPath, linkPath)); @@ -449,16 +533,20 @@ export const make = Effect.gen(function* () { yield* io("detach", linkPath, () => NodeFSP.unlink(linkPath)); } } + return unresolved; }); const detach = Effect.fn("SkillsLibrary.detach")(function* () { const current = yield* scan(); + const remaining: RootScan[] = []; for (const rootScan of current.roots) { - yield* restore(current.libraryPath, rootScan); + if (yield* restore(current.libraryPath, rootScan)) remaining.push(rootScan); } + yield* writeRoots(remaining); }); const finishDetach = Effect.fn("SkillsLibrary.finishDetach")(function* () { + if (yield* exists(rootsPath)) return yield* detach(); for (const { directory } of (yield* resolveRoots()).roots) { yield* finishRestores( directory, @@ -539,9 +627,9 @@ export const layer = Layer.effect( Stream.runForEach(() => Effect.gen(function* () { const current = instanceRoots(yield* registry.listInstances); - const created = [...current].some((id) => !known.has(id)); + const changed = current.size !== known.size || [...current].some((id) => !known.has(id)); known = current; - if (created && enabled) yield* library.apply.pipe(logFailure); + if (changed && enabled) yield* library.apply.pipe(logFailure); }), ), Effect.forkScoped, diff --git a/docs/user/composer.md b/docs/user/composer.md index 87c1c9d772bde..4d826a0f17d69 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -351,8 +351,9 @@ only in case, and for a skill folder that links to a file outside itself, since would break the link. A skill you add to `~/.agents/skills` by hand reaches Claude at the next refresh, server start, or new account. -Turning it off copies each linked skill back into Claude's and Antigravity's folders and -removes the links. Codex keeps reading `~/.agents/skills` directly. Each environment has +At the next refresh, T3 restores library links in configured homes that are no longer shared. +Homes still used by another account or the default profile stay shared. Turning the library off copies each linked skill back into +Claude's and Antigravity's folders and removes the links, including previously used homes. Codex keeps reading `~/.agents/skills` directly. Each environment has its own library, so turn it on for every machine you want it on. Provider commands must start the message to run. T3 Code commands such as