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..ce396d41abb9f 100644 --- a/apps/server/src/skills/SkillsLibrary.test.ts +++ b/apps/server/src/skills/SkillsLibrary.test.ts @@ -15,10 +15,12 @@ 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* ( drivers: ReadonlyArray = [], + configuredRoot?: { readonly directory: string; readonly enabled: boolean }, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -28,35 +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: true, - 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 }) @@ -71,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. */ @@ -92,6 +98,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", () => @@ -306,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", () => @@ -387,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 ebbb531ec2733..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 }); @@ -155,10 +165,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) => @@ -209,24 +226,90 @@ export const make = Effect.gen(function* () { return (yield* exists(path.join(entryPath, "SKILL.md"))) ? "skill" : "other"; }); - 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 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; + 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"); - // 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 }); + roots.push({ root, directory, active: true }); } - 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, 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); + 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. */ @@ -253,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); }; @@ -266,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 }))), @@ -275,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"), @@ -289,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)]); } @@ -316,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) { @@ -324,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]; @@ -375,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); @@ -421,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); @@ -428,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)); @@ -436,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, @@ -495,11 +596,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 +611,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,10 +626,10 @@ export const layer = Layer.effect( yield* Stream.fromSubscription(instanceChanges).pipe( Stream.runForEach(() => Effect.gen(function* () { - const current = instanceIds(yield* registry.listInstances); - const created = [...current].some((id) => !known.has(id)); + const current = instanceRoots(yield* registry.listInstances); + 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 8115114548855..4d826a0f17d69 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 @@ -349,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 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,