diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index b126028f813..ef0ebc7fae0 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -199,6 +199,7 @@ export const ClaudeDriver: ProviderDriver = { displayName, accentColor, enabled, + environment: processEnv, snapshot, adapter, textGeneration, diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 441edda479f..60c1645b2c1 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -196,6 +196,7 @@ export const CodexDriver: ProviderDriver = { displayName, accentColor, enabled, + environment: processEnv, snapshot, adapter, textGeneration, diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index ba532864c45..77d13a69c78 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -169,6 +169,7 @@ export const CursorDriver: ProviderDriver = { displayName, accentColor, enabled, + environment: processEnv, snapshot, adapter, textGeneration, diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index ab01439ffd3..309654c25d3 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -145,6 +145,7 @@ export const GrokDriver: ProviderDriver = { displayName, accentColor, enabled, + environment: processEnv, snapshot, adapter, textGeneration, diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 2edf7d73623..54ebf9be890 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -374,6 +374,7 @@ export const OpenCodeDriver: ProviderDriver displayName, accentColor, enabled, + environment: processEnv, snapshot, adapter, textGeneration, diff --git a/apps/server/src/provider/Layers/OpenCodeExternalSessionSync.test.ts b/apps/server/src/provider/Layers/OpenCodeExternalSessionSync.test.ts index 05d9a38b72f..3e26e7bbe9d 100644 --- a/apps/server/src/provider/Layers/OpenCodeExternalSessionSync.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeExternalSessionSync.test.ts @@ -104,6 +104,7 @@ function makeFakeProviderInstance(adapter: OpenCodeAdapterShape): ProviderInstan }, displayName: undefined, enabled: true, + environment: {}, snapshot: {} as ProviderInstance["snapshot"], adapter, textGeneration: {} as ProviderInstance["textGeneration"], diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index b6a35b638d8..59690882519 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -115,6 +115,7 @@ const makeFakeInstance = ( }, displayName: undefined, enabled: true, + environment: {}, snapshot: { maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ provider: driverKind, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index f7154d31d5c..422bf8205d1 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -609,6 +609,38 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ]); }); + it("does not preserve missing user skills without a recent install record", () => { + const installedSkill = { + name: "ucsd-branding", + path: "/tmp/t3code-skills/ucsd-branding/SKILL.md", + enabled: true, + scope: "user", + } as const; + const previousProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [installedSkill], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-04-14T00:01:00.000Z", + skills: [], + } satisfies ServerProvider; + + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, refreshedProvider).skills, + [], + ); + }); + it.effect("does not run provider probes during layer construction", () => Effect.gen(function* () { const codexDriver = ProviderDriverKind.make("codex"); @@ -637,6 +669,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }, displayName: undefined, enabled: true, + environment: {}, snapshot: { maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ provider: codexDriver, @@ -680,6 +713,161 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }), ); + it.effect("records installed provider skills in the cached snapshot", () => + Effect.gen(function* () { + const codexInstanceId = ProviderInstanceId.make("codex"); + const codexDriver = ProviderDriverKind.make("codex"); + const staleDisabledSkillPath = "/tmp/t3code-skills/stale-disabled-skill/SKILL.md"; + const disabledSkillPath = "/tmp/t3code-skills/disabled-skill/SKILL.md"; + const installedSkillPath = "/tmp/t3code-skills/ucsd-branding/SKILL.md"; + const initialProvider = { + instanceId: codexInstanceId, + driver: codexDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [ + { + name: "disabled-skill", + path: staleDisabledSkillPath, + enabled: false, + scope: "user", + }, + ], + } satisfies ServerProvider; + const instance = { + instanceId: codexInstanceId, + driverKind: codexDriver, + continuationIdentity: { + driverKind: codexDriver, + continuationKey: "codex:instance:codex", + }, + displayName: undefined, + enabled: true, + environment: {}, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: codexDriver, + packageName: null, + }), + getSnapshot: Effect.succeed(initialProvider), + refresh: Effect.succeed(initialProvider), + streamChanges: Stream.empty, + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const instanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { + getInstance: (instanceId) => + Effect.succeed(instanceId === codexInstanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistryLive.pipe( + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-skill-install-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + + const reenabledProviders = yield* registry.recordInstalledProviderSkill({ + instanceId: codexInstanceId, + skillName: "disabled-skill", + skillPath: disabledSkillPath, + }); + assert.deepStrictEqual(reenabledProviders[0]?.skills, [ + { + name: "disabled-skill", + path: disabledSkillPath, + enabled: true, + scope: "user", + }, + ]); + + const updatedProviders = yield* registry.recordInstalledProviderSkill({ + instanceId: codexInstanceId, + skillName: "ucsd-branding", + skillPath: installedSkillPath, + }); + assert.deepStrictEqual( + updatedProviders[0]?.skills.map((skill) => skill.name), + ["disabled-skill", "ucsd-branding"], + ); + assert.deepStrictEqual(yield* registry.getProviders, updatedProviders); + + const staleRefreshProviders = yield* registry.refreshInstance(codexInstanceId); + assert.deepStrictEqual(staleRefreshProviders[0]?.skills, [ + { + name: "disabled-skill", + path: disabledSkillPath, + enabled: true, + scope: "user", + }, + { + name: "ucsd-branding", + path: installedSkillPath, + enabled: true, + scope: "user", + }, + ]); + + const secondStaleRefreshProviders = yield* registry.refreshInstance(codexInstanceId); + assert.deepStrictEqual(secondStaleRefreshProviders[0]?.skills, [ + { + name: "disabled-skill", + path: disabledSkillPath, + enabled: true, + scope: "user", + }, + { + name: "ucsd-branding", + path: installedSkillPath, + enabled: true, + scope: "user", + }, + ]); + + const removedProviders = yield* registry.recordRemovedProviderSkill({ + instanceId: codexInstanceId, + skillPath: installedSkillPath, + }); + assert.deepStrictEqual( + removedProviders[0]?.skills.map((skill) => skill.name), + ["disabled-skill"], + ); + assert.deepStrictEqual(yield* registry.getProviders, removedProviders); + + const removedDisabledProviders = yield* registry.recordRemovedProviderSkill({ + instanceId: codexInstanceId, + skillPath: disabledSkillPath, + }); + assert.deepStrictEqual(removedDisabledProviders[0]?.skills, []); + + const staleRemovalRefreshProviders = yield* registry.refreshInstance(codexInstanceId); + assert.deepStrictEqual(staleRemovalRefreshProviders[0]?.skills, []); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it("persists merged provider snapshots for the providers that were refreshed", () => { const previousProviders = [ { @@ -789,6 +977,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }, displayName: undefined, enabled: true, + environment: {}, snapshot: { maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ provider: cursorDriver, @@ -883,6 +1072,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }, displayName: undefined, enabled: true, + environment: {}, snapshot: { maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ provider: codexDriver, @@ -972,6 +1162,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }, displayName: undefined, enabled: true, + environment: {}, snapshot: { maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ provider: provider.driver, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 2df63e53830..4eb0fe8e898 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -101,6 +101,57 @@ const mergeProviderModels = ( return [...mergedModels, ...previousModels.filter((model) => !nextSlugs.has(model.slug))]; }; +const isSameProviderSkill = ( + left: ServerProvider["skills"][number], + right: ServerProvider["skills"][number], +): boolean => left.path === right.path || left.name === right.name; + +const sortProviderSkills = ( + skills: ReadonlyArray, +): ReadonlyArray => + [...skills].toSorted((left, right) => left.name.localeCompare(right.name)); + +const applySkillInstallOverlay = ( + provider: ServerProvider, + installedSkills: ReadonlyArray, +): ServerProvider => { + if (installedSkills.length === 0) { + return provider; + } + + let changed = false; + const skills = provider.skills.map((skill) => { + const installedSkill = installedSkills.find((candidate) => + isSameProviderSkill(skill, candidate), + ); + if (!installedSkill) { + return skill; + } + changed = true; + return { + ...skill, + name: installedSkill.name, + path: installedSkill.path, + enabled: true, + scope: skill.scope ?? installedSkill.scope, + }; + }); + const missingInstalledSkills = installedSkills.filter( + (installedSkill) => + !provider.skills.some((skill) => isSameProviderSkill(skill, installedSkill)), + ); + if (missingInstalledSkills.length > 0) { + changed = true; + } + + return changed + ? { + ...provider, + skills: sortProviderSkills([...skills, ...missingInstalledSkills]), + } + : provider; +}; + export const mergeProviderSnapshot = ( previousProvider: ServerProvider | undefined, nextProvider: ServerProvider, @@ -270,6 +321,12 @@ export const ProviderRegistryLive = Layer.effect( const maintenanceActionStatesRef = yield* Ref.make< ReadonlyMap >(new Map()); + const recentlyInstalledSkillsRef = yield* Ref.make< + ReadonlyMap> + >(new Map()); + const recentlyRemovedSkillsRef = yield* Ref.make< + ReadonlyMap> + >(new Map()); // Live-source registry — the dynamic counterpart to the boot-time // `bootSources`. Keyed by `instanceId`; the stored `ProviderInstance` @@ -322,12 +379,174 @@ export const ProviderRegistryLive = Layer.effect( }; }); + const rememberRecentlyInstalledSkill = (input: { + readonly instanceId: ProviderInstanceId; + readonly skill: ServerProvider["skills"][number]; + }) => + Ref.update(recentlyInstalledSkillsRef, (previous) => { + const next = new Map(previous); + const providerSkills = new Map(next.get(input.instanceId) ?? new Map()); + for (const [path, skill] of providerSkills) { + if (skill.name === input.skill.name) { + providerSkills.delete(path); + } + } + providerSkills.set(input.skill.path, input.skill); + next.set(input.instanceId, providerSkills); + return next; + }); + + const forgetRecentlyInstalledSkill = (input: { + readonly instanceId: ProviderInstanceId; + readonly skillPath: string; + }) => + Ref.update(recentlyInstalledSkillsRef, (previous) => { + const providerSkills = previous.get(input.instanceId); + if (!providerSkills?.has(input.skillPath)) { + return previous; + } + const nextProviderSkills = new Map(providerSkills); + nextProviderSkills.delete(input.skillPath); + const next = new Map(previous); + if (nextProviderSkills.size === 0) { + next.delete(input.instanceId); + } else { + next.set(input.instanceId, nextProviderSkills); + } + return next; + }); + + const rememberRecentlyRemovedSkill = (input: { + readonly instanceId: ProviderInstanceId; + readonly skill: ServerProvider["skills"][number]; + }) => + Ref.update(recentlyRemovedSkillsRef, (previous) => { + const next = new Map(previous); + const providerSkills = new Map(next.get(input.instanceId) ?? new Map()); + for (const [path, skill] of providerSkills) { + if (skill.name === input.skill.name) { + providerSkills.delete(path); + } + } + providerSkills.set(input.skill.path, input.skill); + next.set(input.instanceId, providerSkills); + return next; + }); + + const forgetRecentlyRemovedSkill = (input: { + readonly instanceId: ProviderInstanceId; + readonly skillPath: string; + readonly skillName?: string; + }) => + Ref.update(recentlyRemovedSkillsRef, (previous) => { + const providerSkills = previous.get(input.instanceId); + if ( + !providerSkills?.has(input.skillPath) && + ![...(providerSkills?.values() ?? [])].some((skill) => skill.name === input.skillName) + ) { + return previous; + } + const nextProviderSkills = new Map(providerSkills); + for (const [path, skill] of nextProviderSkills) { + if (path === input.skillPath || skill.name === input.skillName) { + nextProviderSkills.delete(path); + } + } + const next = new Map(previous); + if (nextProviderSkills.size === 0) { + next.delete(input.instanceId); + } else { + next.set(input.instanceId, nextProviderSkills); + } + return next; + }); + + const applyRecentlyInstalledSkills = Effect.fn("applyRecentlyInstalledSkills")(function* ( + provider: ServerProvider, + ) { + const recentlyInstalledSkills = yield* Ref.get(recentlyInstalledSkillsRef); + const installedSkills = [ + ...(recentlyInstalledSkills.get(provider.instanceId)?.values() ?? []), + ]; + const observedInstalledSkills = installedSkills.filter((installedSkill) => + provider.skills.some( + (skill) => skill.path === installedSkill.path && skill.enabled === true, + ), + ); + if (observedInstalledSkills.length > 0) { + yield* Ref.update(recentlyInstalledSkillsRef, (previous) => { + const providerSkills = previous.get(provider.instanceId); + if (!providerSkills) { + return previous; + } + const nextProviderSkills = new Map(providerSkills); + for (const skill of observedInstalledSkills) { + nextProviderSkills.delete(skill.path); + } + const next = new Map(previous); + if (nextProviderSkills.size === 0) { + next.delete(provider.instanceId); + } else { + next.set(provider.instanceId, nextProviderSkills); + } + return next; + }); + } + return applySkillInstallOverlay(provider, installedSkills); + }); + + const applyRecentlyRemovedSkills = Effect.fn("applyRecentlyRemovedSkills")(function* ( + provider: ServerProvider, + ) { + const recentlyRemovedSkills = yield* Ref.get(recentlyRemovedSkillsRef); + const removedSkills = [...(recentlyRemovedSkills.get(provider.instanceId)?.values() ?? [])]; + if (removedSkills.length === 0) { + return provider; + } + + const observedRemovedSkills = removedSkills.filter( + (removedSkill) => + !provider.skills.some((skill) => isSameProviderSkill(skill, removedSkill)), + ); + if (observedRemovedSkills.length > 0) { + yield* Ref.update(recentlyRemovedSkillsRef, (previous) => { + const providerSkills = previous.get(provider.instanceId); + if (!providerSkills) { + return previous; + } + const nextProviderSkills = new Map(providerSkills); + for (const skill of observedRemovedSkills) { + nextProviderSkills.delete(skill.path); + } + const next = new Map(previous); + if (nextProviderSkills.size === 0) { + next.delete(provider.instanceId); + } else { + next.set(provider.instanceId, nextProviderSkills); + } + return next; + }); + } + + const nextSkills = provider.skills.filter( + (skill) => !removedSkills.some((removedSkill) => isSameProviderSkill(skill, removedSkill)), + ); + return nextSkills.length === provider.skills.length + ? provider + : { + ...provider, + skills: nextSkills, + }; + }); + const upsertProviders = Effect.fn("upsertProviders")(function* ( nextProviders: ReadonlyArray, options?: { readonly publish?: boolean; readonly persist?: boolean; readonly replace?: boolean; + readonly applyRecentInstalls?: boolean; + readonly applyRecentRemovals?: boolean; }, ) { const nextProvidersWithUpdateState = yield* Effect.forEach( @@ -337,6 +556,18 @@ export const ProviderRegistryLive = Layer.effect( concurrency: "unbounded", }, ); + const nextProvidersWithRecentInstalls = + options?.applyRecentInstalls === false + ? nextProvidersWithUpdateState + : yield* Effect.forEach(nextProvidersWithUpdateState, applyRecentlyInstalledSkills, { + concurrency: "unbounded", + }); + const nextProvidersWithRecentRemovals = + options?.applyRecentRemovals === false + ? nextProvidersWithRecentInstalls + : yield* Effect.forEach(nextProvidersWithRecentInstalls, applyRecentlyRemovedSkills, { + concurrency: "unbounded", + }); const [previousProviders, providers, providersToPersist] = yield* Ref.modify( providersRef, (previousProviders) => { @@ -345,7 +576,7 @@ export const ProviderRegistryLive = Layer.effect( ); const updatedKeys = new Set(); - for (const provider of nextProvidersWithUpdateState) { + for (const provider of nextProvidersWithRecentRemovals) { const key = snapshotInstanceKey(provider); updatedKeys.add(key); mergedProviders.set( @@ -427,6 +658,98 @@ export const ProviderRegistryLive = Layer.effect( }, ); + const recordInstalledProviderSkill = Effect.fn("recordInstalledProviderSkill")( + function* (input: { + readonly instanceId: ProviderInstanceId; + readonly skillName: string; + readonly skillPath: string; + }) { + const existingProviders = yield* Ref.get(providersRef); + const matchingProvider = existingProviders.find( + (candidate) => candidate.instanceId === input.instanceId, + ); + if (!matchingProvider) { + return existingProviders; + } + + const nextSkill: ServerProvider["skills"][number] = { + name: input.skillName, + path: input.skillPath, + enabled: true, + scope: "user", + }; + const matchedExistingSkill = matchingProvider.skills.some( + (skill) => skill.path === input.skillPath || skill.name === input.skillName, + ); + const nextProvider: ServerProvider = { + ...matchingProvider, + skills: (matchedExistingSkill + ? matchingProvider.skills.map((skill) => + skill.path === input.skillPath || skill.name === input.skillName + ? { + ...skill, + name: input.skillName, + path: input.skillPath, + enabled: true, + scope: skill.scope ?? "user", + } + : skill, + ) + : [...matchingProvider.skills, nextSkill] + ).toSorted((left, right) => left.name.localeCompare(right.name)), + }; + + yield* forgetRecentlyRemovedSkill({ + instanceId: input.instanceId, + skillPath: input.skillPath, + skillName: input.skillName, + }); + const updatedProviders = yield* upsertProviders([nextProvider], { + applyRecentInstalls: false, + }); + yield* rememberRecentlyInstalledSkill({ + instanceId: input.instanceId, + skill: nextSkill, + }); + return updatedProviders; + }, + ); + + const recordRemovedProviderSkill = Effect.fn("recordRemovedProviderSkill")(function* (input: { + readonly instanceId: ProviderInstanceId; + readonly skillPath: string; + }) { + const existingProviders = yield* Ref.get(providersRef); + const matchingProvider = existingProviders.find( + (candidate) => candidate.instanceId === input.instanceId, + ); + if (!matchingProvider) { + return existingProviders; + } + const removedSkill = matchingProvider.skills.find((skill) => skill.path === input.skillPath); + yield* forgetRecentlyInstalledSkill({ + instanceId: input.instanceId, + skillPath: input.skillPath, + }); + if (removedSkill) { + yield* rememberRecentlyRemovedSkill({ + instanceId: input.instanceId, + skill: removedSkill, + }); + } + + const nextProvider: ServerProvider = { + ...matchingProvider, + skills: matchingProvider.skills.filter((skill) => skill.path !== input.skillPath), + }; + + return yield* upsertProviders([nextProvider], { + replace: true, + applyRecentInstalls: false, + applyRecentRemovals: false, + }); + }); + const refreshOneSource = Effect.fn("refreshOneSource")(function* ( providerSource: ProviderSnapshotSource, ) { @@ -688,6 +1011,8 @@ export const ProviderRegistryLive = Layer.effect( refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), refreshInstance: (instanceId: ProviderInstanceId) => refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), + recordInstalledProviderSkill, + recordRemovedProviderSkill, getProviderMaintenanceCapabilitiesForInstance, setProviderMaintenanceActionState, get streamChanges() { diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index 3a57f374de4..249e9e4d77b 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -68,6 +68,7 @@ export interface ProviderInstance { readonly displayName: string | undefined; readonly accentColor?: string | undefined; readonly enabled: boolean; + readonly environment: NodeJS.ProcessEnv; readonly snapshot: ServerProviderShape; readonly adapter: ProviderAdapterShape; readonly textGeneration: TextGenerationShape; diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts index b7426b30338..e2a66a2845a 100644 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -48,6 +48,28 @@ export interface ProviderRegistryShape { instanceId: ProviderInstanceId, ) => Effect.Effect>; + /** + * Reflect a newly installed user skill in the cached provider snapshot. + * This is used after catalog/link installs update on-disk skill config, + * where an immediate driver refresh can still report the pre-install + * snapshot until the provider process observes its config change. + */ + readonly recordInstalledProviderSkill: (input: { + readonly instanceId: ProviderInstanceId; + readonly skillName: string; + readonly skillPath: string; + }) => Effect.Effect>; + + /** + * Reflect an explicitly removed user skill in the cached provider snapshot. + * This clears the local cache before the follow-up refresh, so refresh merging + * can preserve installed user skills without reintroducing a trashed folder. + */ + readonly recordRemovedProviderSkill: (input: { + readonly instanceId: ProviderInstanceId; + readonly skillPath: string; + }) => Effect.Effect>; + /** * Resolve the maintenance capabilities owned by one live provider instance. * Falls back to manual-only capabilities when the instance is not live. diff --git a/apps/server/src/provider/installProviderSkill.test.ts b/apps/server/src/provider/installProviderSkill.test.ts index 0a4c3f3024d..8fa33c114c0 100644 --- a/apps/server/src/provider/installProviderSkill.test.ts +++ b/apps/server/src/provider/installProviderSkill.test.ts @@ -3,6 +3,7 @@ import { assert, it } from "@effect/vitest"; import { ProviderDriverKind, ProviderInstanceId, + ServerProviderSkillBundle, ServerProviderSkillCatalog, type ServerProvider, type ServerProviderSkill, @@ -37,6 +38,7 @@ const encodeTestOpenCodeConfig = Schema.encodeSync(TestOpenCodeConfigJson); const decodeTestOpenCodeConfig = Schema.decodeUnknownSync(TestOpenCodeConfigJson); const encodeMalformedOpenCodeConfig = Schema.encodeSync(MalformedOpenCodeConfigJson); const encodeTestSkillCatalog = Schema.encodeSync(fromJsonStringPretty(ServerProviderSkillCatalog)); +const encodeTestSkillBundle = Schema.encodeSync(fromJsonStringPretty(ServerProviderSkillBundle)); const unusedGitLayer = Layer.mock(VcsProcess.VcsProcess)({ run: () => Effect.die("Git should not be used in this installer test"), @@ -61,7 +63,7 @@ function vcsOutput(stdout = ""): VcsProcess.VcsProcessOutput { }; } -function makeProvider(skill: ServerProviderSkill): ServerProvider { +function makeProviderWithSkills(skills: ReadonlyArray): ServerProvider { return { instanceId: OPENCODE_INSTANCE_ID, driver: ProviderDriverKind.make("opencode"), @@ -73,10 +75,14 @@ function makeProvider(skill: ServerProviderSkill): ServerProvider { checkedAt: "2026-06-19T00:00:00.000Z", models: [], slashCommands: [], - skills: [skill], + skills, }; } +function makeProvider(skill: ServerProviderSkill): ServerProvider { + return makeProviderWithSkills([skill]); +} + function installTestSkillRoot() { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -138,6 +144,377 @@ it.layer(NodeServices.layer)("installProviderSkill", (it) => { }), ); + it.effect( + "refreshes an existing matching catalog skill folder that the provider did not report", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { root, provider } = yield* installTestSkillRoot(); + const existingDirectory = path.join(root, "skills", "tritonai-feedback"); + const existingPath = path.join(existingDirectory, "SKILL.md"); + const existingContent = + "---\nname: tritonai-feedback\ndescription: Existing catalog skill\n---\n\nAlready here."; + + yield* fs.makeDirectory(existingDirectory, { recursive: true }); + yield* fs.writeFileString(existingPath, existingContent); + + const installed = yield* installProviderSkill({ + providers: [provider], + request: { + instanceId: OPENCODE_INSTANCE_ID, + source: { + type: "catalog", + catalogEntryId: "tritonai-feedback", + }, + }, + environment: { + T3CODE_SKILL_CATALOG_URL: "https://skills.test/catalog.json", + }, + }).pipe( + Effect.provide( + Layer.mergeAll( + makeHttpLayer(() => new Response("missing", { status: 500 })), + unusedGitLayer, + ), + ), + ); + + assert.equal(installed.skillName, "tritonai-feedback"); + assert.equal(installed.skillPath, existingPath); + assert.notEqual(yield* fs.readFileString(existingPath), existingContent); + assert.match(yield* fs.readFileString(existingPath), /# TritonAI Feedback/u); + }), + ); + + it.effect("refreshes an existing linked skill folder with fetched bundle files", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { root, provider } = yield* installTestSkillRoot(); + const existingDirectory = path.join(root, "skills", "linked-skill"); + const existingPath = path.join(existingDirectory, "SKILL.md"); + const freshSkill = "---\nname: linked-skill\ndescription: Fresh linked skill\n---\n\nFresh."; + const bundle = encodeTestSkillBundle({ + version: 1, + skillId: "linked-skill", + files: [ + { + path: "SKILL.md", + content: freshSkill, + }, + { + path: "references/guide.md", + content: "fresh guide", + }, + ], + }); + + yield* fs.makeDirectory(existingDirectory, { recursive: true }); + yield* fs.writeFileString( + existingPath, + "---\nname: linked-skill\ndescription: Stale linked skill\n---\n\nStale.", + ); + yield* fs.makeDirectory(path.join(existingDirectory, "references"), { recursive: true }); + yield* fs.writeFileString(path.join(existingDirectory, "references", "stale.md"), "stale"); + + const installed = yield* installProviderSkill({ + providers: [provider], + request: { + instanceId: OPENCODE_INSTANCE_ID, + source: { + type: "url", + url: "https://skills.test/linked-skill.json", + }, + }, + }).pipe( + Effect.provide( + Layer.mergeAll( + makeHttpLayer( + () => new Response(bundle, { headers: { "content-type": "application/json" } }), + ), + unusedGitLayer, + ), + ), + ); + + assert.equal(installed.skillName, "linked-skill"); + assert.equal(installed.skillPath, existingPath); + assert.equal(yield* fs.readFileString(existingPath), freshSkill); + assert.equal( + yield* fs.readFileString(path.join(existingDirectory, "references", "guide.md")), + "fresh guide", + ); + assert.equal(yield* fs.exists(path.join(existingDirectory, "references", "stale.md")), false); + }), + ); + + it.effect("registers refreshed UCSD catalog skill folders in OpenCode config", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "t3code-install-provider-skill-adopt-ucsd-", + }); + const ucsdRoot = path.join(root, ".agents", "ucsd"); + const existingDirectory = path.join(ucsdRoot, "skills", "existing-skill"); + const adoptedDirectory = path.join(ucsdRoot, "skills", "tritonai-feedback"); + const adoptedPath = path.join(adoptedDirectory, "SKILL.md"); + const configPath = path.join(ucsdRoot, "config", "opencode", "opencode.json"); + + yield* fs.makeDirectory(existingDirectory, { recursive: true }); + yield* fs.writeFileString( + path.join(existingDirectory, "SKILL.md"), + "---\nname: existing-skill\ndescription: Existing skill\n---\n", + ); + yield* fs.makeDirectory(adoptedDirectory, { recursive: true }); + yield* fs.writeFileString( + adoptedPath, + "---\nname: tritonai-feedback\ndescription: Existing catalog skill\n---\n\nAlready here.", + ); + yield* fs.makeDirectory(path.dirname(configPath), { recursive: true }); + yield* fs.writeFileString( + configPath, + encodeTestOpenCodeConfig({ skills: { paths: [existingDirectory] } }), + ); + + const installed = yield* installProviderSkill({ + providers: [ + makeProvider({ + name: "existing-skill", + path: path.join(existingDirectory, "SKILL.md"), + enabled: true, + scope: "user", + }), + ], + request: { + instanceId: OPENCODE_INSTANCE_ID, + source: { + type: "catalog", + catalogEntryId: "tritonai-feedback", + }, + }, + environment: { + OPENCODE_CONFIG: configPath, + T3CODE_SKILL_CATALOG_URL: "https://skills.test/catalog.json", + }, + }).pipe( + Effect.provide( + Layer.mergeAll( + makeHttpLayer(() => new Response("missing", { status: 500 })), + unusedGitLayer, + ), + ), + ); + + const config = decodeTestOpenCodeConfig(yield* fs.readFileString(configPath)); + assert.equal(installed.skillPath, adoptedPath); + assert.deepStrictEqual(config.skills.paths, [existingDirectory, adoptedDirectory]); + }), + ); + + it.effect("restores an existing UCSD skill folder when refresh registration fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "t3code-install-provider-skill-rollback-", + }); + const ucsdRoot = path.join(root, ".agents", "ucsd"); + const existingDirectory = path.join(ucsdRoot, "skills", "linked-skill"); + const existingPath = path.join(existingDirectory, "SKILL.md"); + const configPath = path.join(ucsdRoot, "config", "opencode", "opencode.json"); + const existingContent = + "---\nname: linked-skill\ndescription: Existing linked skill\n---\n\nKeep this."; + const bundle = encodeTestSkillBundle({ + version: 1, + skillId: "linked-skill", + files: [ + { + path: "SKILL.md", + content: "---\nname: linked-skill\ndescription: Fresh linked skill\n---\n\nFresh.", + }, + { + path: "references/fresh.md", + content: "fresh reference", + }, + ], + }); + + yield* fs.makeDirectory(existingDirectory, { recursive: true }); + yield* fs.writeFileString(existingPath, existingContent); + yield* fs.makeDirectory(path.dirname(configPath), { recursive: true }); + yield* fs.writeFileString( + configPath, + encodeMalformedOpenCodeConfig({ skills: { paths: "not-array" } }), + ); + + const error = yield* installProviderSkill({ + providers: [ + makeProvider({ + name: "linked-skill", + path: existingPath, + enabled: true, + scope: "user", + }), + ], + request: { + instanceId: OPENCODE_INSTANCE_ID, + source: { + type: "url", + url: "https://skills.test/linked-skill.json", + }, + }, + }).pipe( + Effect.provide( + Layer.mergeAll( + makeHttpLayer( + () => new Response(bundle, { headers: { "content-type": "application/json" } }), + ), + unusedGitLayer, + ), + ), + Effect.flip, + ); + + assert.match(error.message, /OpenCode skill config/iu); + assert.equal(yield* fs.readFileString(existingPath), existingContent); + assert.equal(yield* fs.exists(path.join(existingDirectory, "references", "fresh.md")), false); + }), + ); + + it.effect("installs catalog skills into the UCSD config root when no skills are reported", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "t3code-install-provider-skill-empty-provider-", + }); + const ucsdRoot = path.join(root, ".agents", "ucsd"); + const configPath = path.join(ucsdRoot, "config", "opencode", "opencode.json"); + + yield* fs.makeDirectory(path.dirname(configPath), { recursive: true }); + yield* fs.writeFileString(configPath, encodeTestOpenCodeConfig({ skills: { paths: [] } })); + + const installed = yield* installProviderSkill({ + providers: [makeProviderWithSkills([])], + request: { + instanceId: OPENCODE_INSTANCE_ID, + source: { + type: "catalog", + catalogEntryId: "tritonai-feedback", + }, + }, + environment: { + HOME: root, + OPENCODE_CONFIG: configPath, + T3CODE_SKILL_CATALOG_URL: "https://skills.test/catalog.json", + }, + }).pipe( + Effect.provide( + Layer.mergeAll( + makeHttpLayer(() => new Response("missing", { status: 500 })), + unusedGitLayer, + ), + ), + ); + + const expectedDirectory = path.join(ucsdRoot, "skills", "tritonai-feedback"); + const expectedPath = path.join(expectedDirectory, "SKILL.md"); + const config = decodeTestOpenCodeConfig(yield* fs.readFileString(configPath)); + + assert.equal(installed.skillName, "tritonai-feedback"); + assert.equal(installed.skillPath, expectedPath); + assert.equal(yield* fs.exists(expectedPath), true); + assert.deepStrictEqual(config.skills.paths, [expectedDirectory]); + }), + ); + + it.effect("does not fall back to HOME when OPENCODE_CONFIG is not UCSD-shaped", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "t3code-install-provider-skill-invalid-config-", + }); + const homeUcsdRoot = path.join(root, ".agents", "ucsd"); + const homeConfigPath = path.join(homeUcsdRoot, "config", "opencode", "opencode.json"); + const invalidConfigPath = path.join(root, "not-ucsd", "opencode.json"); + + yield* fs.makeDirectory(path.dirname(homeConfigPath), { recursive: true }); + yield* fs.writeFileString( + homeConfigPath, + encodeTestOpenCodeConfig({ skills: { paths: [] } }), + ); + + const error = yield* installProviderSkill({ + providers: [makeProviderWithSkills([])], + request: { + instanceId: OPENCODE_INSTANCE_ID, + source: { + type: "catalog", + catalogEntryId: "tritonai-feedback", + }, + }, + environment: { + HOME: root, + OPENCODE_CONFIG: invalidConfigPath, + T3CODE_SKILL_CATALOG_URL: "https://skills.test/catalog.json", + }, + }).pipe( + Effect.provide( + Layer.mergeAll( + makeHttpLayer(() => new Response("missing", { status: 500 })), + unusedGitLayer, + ), + ), + Effect.flip, + ); + + assert.match(error.message, /OPENCODE_CONFIG must point to a UCSD OpenCode config/iu); + assert.equal(yield* fs.exists(path.join(homeUcsdRoot, "skills", "tritonai-feedback")), false); + }), + ); + + it.effect("rejects an existing skill folder with a different frontmatter name", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { root, provider } = yield* installTestSkillRoot(); + const existingDirectory = path.join(root, "skills", "tritonai-feedback"); + + yield* fs.makeDirectory(existingDirectory, { recursive: true }); + yield* fs.writeFileString( + path.join(existingDirectory, "SKILL.md"), + "---\nname: other-skill\ndescription: Wrong skill\n---\n", + ); + + const error = yield* installProviderSkill({ + providers: [provider], + request: { + instanceId: OPENCODE_INSTANCE_ID, + source: { + type: "catalog", + catalogEntryId: "tritonai-feedback", + }, + }, + environment: { + T3CODE_SKILL_CATALOG_URL: "https://skills.test/catalog.json", + }, + }).pipe( + Effect.provide( + Layer.mergeAll( + makeHttpLayer(() => new Response("missing", { status: 500 })), + unusedGitLayer, + ), + ), + Effect.flip, + ); + + assert.match(error.message, /already contains skill 'other-skill'/iu); + }), + ); + it.effect("rejects a catalog bundle that does not match the selected entry", () => Effect.gen(function* () { const { provider } = yield* installTestSkillRoot(); @@ -630,4 +1007,27 @@ it("mergeInstalledProviderSkill appends the installed skill to the target provid mergedAgain[0]?.skills.map((skill) => skill.name), ["existing-skill", "linked-skill"], ); + + const reenabled = mergeInstalledProviderSkill({ + providers: [ + makeProvider({ + name: "linked-skill", + path: "/tmp/skills/stale-linked-skill/SKILL.md", + enabled: false, + scope: "user", + }), + ], + instanceId: OPENCODE_INSTANCE_ID, + skillName: "linked-skill", + skillPath: "/tmp/skills/linked-skill/SKILL.md", + }); + + assert.deepStrictEqual(reenabled[0]?.skills, [ + { + name: "linked-skill", + path: "/tmp/skills/linked-skill/SKILL.md", + enabled: true, + scope: "user", + }, + ]); }); diff --git a/apps/server/src/provider/installProviderSkill.ts b/apps/server/src/provider/installProviderSkill.ts index 0f5fa5d9ed5..4423ec634d1 100644 --- a/apps/server/src/provider/installProviderSkill.ts +++ b/apps/server/src/provider/installProviderSkill.ts @@ -20,6 +20,7 @@ import * as Schema from "effect/Schema"; import * as SchemaIssue from "effect/SchemaIssue"; import * as Stream from "effect/Stream"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import * as NodeOS from "node:os"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import { @@ -768,7 +769,8 @@ function providerSkillsDirectoryRank(root: string): number { function resolveProviderSkillsDirectory(input: { readonly providers: ReadonlyArray; readonly instanceId: ProviderInstanceId; -}): Effect.Effect { + readonly environment?: NodeJS.ProcessEnv; +}): Effect.Effect { return Effect.gen(function* () { const pathService = yield* Path.Path; const provider = input.providers.find((candidate) => candidate.instanceId === input.instanceId); @@ -799,18 +801,91 @@ function resolveProviderSkillsDirectory(input: { ); const selected = candidates[0]; if (!selected) { + const defaultDirectory = yield* resolveDefaultOpenCodeSkillsDirectory( + input.environment !== undefined ? { environment: input.environment } : {}, + ); + if (defaultDirectory) { + return defaultDirectory; + } return yield* installError( - "No provider-reported skills directory is available. Install or enable one OpenCode skill first so TritonAI Code can pick the target folder.", + "No provider-reported skills directory is available, and no UCSD OpenCode config was found to select a default skills folder.", ); } return selected; }); } +function resolveUcsdConfigSkillsDirectory( + configPath: string, +): Effect.Effect { + return Effect.gen(function* () { + const pathService = yield* Path.Path; + const normalized = pathService.resolve(configPath); + const opencodeDirectory = pathService.dirname(normalized); + const configDirectory = pathService.dirname(opencodeDirectory); + const ucsdDirectory = pathService.dirname(configDirectory); + if ( + pathService.basename(normalized) !== "opencode.json" || + pathService.basename(opencodeDirectory) !== "opencode" || + pathService.basename(configDirectory) !== "config" || + pathService.basename(ucsdDirectory) !== "ucsd" + ) { + return null; + } + return pathService.join(ucsdDirectory, "skills"); + }); +} + +function resolveDefaultOpenCodeSkillsDirectory(input: { + readonly environment?: NodeJS.ProcessEnv; +}): Effect.Effect< + string | null, + ServerProviderSkillInstallError, + FileSystem.FileSystem | Path.Path +> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const environment = input.environment ?? process.env; + + const configuredPath = environment.OPENCODE_CONFIG?.trim(); + if (configuredPath) { + const configPath = pathService.resolve(configuredPath); + const skillsDirectory = yield* resolveUcsdConfigSkillsDirectory(configPath); + if (!skillsDirectory) { + return yield* installError( + `OPENCODE_CONFIG must point to a UCSD OpenCode config at 'ucsd/config/opencode/opencode.json'. Received ${configPath}.`, + ); + } + + const exists = yield* fs + .exists(configPath) + .pipe(Effect.mapError((cause) => installError(`Failed to inspect ${configPath}.`, cause))); + if (!exists) { + return yield* installError(`Configured OpenCode config was not found at ${configPath}.`); + } + return skillsDirectory; + } + + const home = environment.HOME?.trim() || NodeOS.homedir(); + if (!home) { + return null; + } + + const ucsdDirectory = pathService.join(home, ".agents", "ucsd"); + const configPath = pathService.join(ucsdDirectory, "config", "opencode", "opencode.json"); + const exists = yield* fs + .exists(configPath) + .pipe(Effect.mapError((cause) => installError(`Failed to inspect ${configPath}.`, cause))); + return exists ? pathService.join(ucsdDirectory, "skills") : null; + }); +} + function installSkillBundle(input: { readonly providers: ReadonlyArray; readonly instanceId: ProviderInstanceId; readonly bundle: ServerProviderSkillBundleData; + readonly environment?: NodeJS.ProcessEnv; }): Effect.Effect< { readonly skillName: string; readonly skillPath: string }, ServerProviderSkillInstallError, @@ -830,6 +905,7 @@ function installSkillBundle(input: { const skillsDirectory = yield* resolveProviderSkillsDirectory({ providers: input.providers, instanceId: input.instanceId, + ...(input.environment !== undefined ? { environment: input.environment } : {}), }); const skillDirectory = pathService.join(skillsDirectory, skillName); const skillPath = pathService.join(skillDirectory, "SKILL.md"); @@ -840,9 +916,31 @@ function installSkillBundle(input: { Effect.mapError((cause) => installError(`Failed to inspect ${skillDirectory}.`, cause)), ); if (skillDirectoryExists) { - return yield* installError( - `Skill '${skillName}' is already installed at ${skillDirectory}. Remove it before reinstalling.`, - ); + const existingEntrypoint = yield* fs + .exists(skillPath) + .pipe(Effect.mapError((cause) => installError(`Failed to inspect ${skillPath}.`, cause))); + if (!existingEntrypoint) { + return yield* installError( + `Skill '${skillName}' cannot be installed because ${skillDirectory} already exists without a SKILL.md file.`, + ); + } + + const existingContent = yield* fs + .readFileString(skillPath) + .pipe(Effect.mapError((cause) => installError(`Failed to read ${skillPath}.`, cause))); + const existingFrontmatter = yield* extractFrontmatter(existingContent); + if (existingFrontmatter.name !== skillName) { + return yield* installError( + `Skill '${skillName}' cannot be installed because ${skillDirectory} already contains skill '${existingFrontmatter.name}'.`, + ); + } + + yield* refreshExistingSkillBundleFiles({ + files: bundle.files, + skillName, + skillDirectory, + }); + return { skillName, skillPath }; } yield* fs @@ -850,26 +948,11 @@ function installSkillBundle(input: { .pipe(Effect.mapError((cause) => installError(`Failed to create ${skillDirectory}.`, cause))); yield* Effect.gen(function* () { - for (const file of bundle.files) { - const normalizedPath = yield* validateBundlePath(file.path); - const targetPath = pathService.join(skillDirectory, normalizedPath); - yield* fs - .makeDirectory(pathService.dirname(targetPath), { recursive: true }) - .pipe( - Effect.mapError((cause) => - installError(`Failed to create directory for ${targetPath}.`, cause), - ), - ); - yield* fs - .writeFileString(targetPath, file.content) - .pipe(Effect.mapError((cause) => installError(`Failed to write ${targetPath}.`, cause))); - } - - yield* registerUcsdOpenCodeSkillPath(skillDirectory).pipe( - Effect.mapError((cause) => - installError(`Failed to register '${skillName}' in OpenCode skill config.`, cause), - ), - ); + yield* writeSkillBundleFiles({ + files: bundle.files, + skillDirectory, + }); + yield* registerInstalledSkillDirectory({ skillName, skillDirectory }); }).pipe( Effect.catch((error) => fs @@ -882,6 +965,103 @@ function installSkillBundle(input: { }); } +function writeSkillBundleFiles(input: { + readonly files: ReadonlyArray; + readonly skillDirectory: string; +}): Effect.Effect { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + + for (const file of input.files) { + const normalizedPath = yield* validateBundlePath(file.path); + const targetPath = pathService.join(input.skillDirectory, normalizedPath); + yield* fs + .makeDirectory(pathService.dirname(targetPath), { recursive: true }) + .pipe( + Effect.mapError((cause) => + installError(`Failed to create directory for ${targetPath}.`, cause), + ), + ); + yield* fs + .writeFileString(targetPath, file.content) + .pipe(Effect.mapError((cause) => installError(`Failed to write ${targetPath}.`, cause))); + } + }); +} + +function refreshExistingSkillBundleFiles(input: { + readonly files: ReadonlyArray; + readonly skillName: string; + readonly skillDirectory: string; +}): Effect.Effect { + return Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const parentDirectory = pathService.dirname(input.skillDirectory); + const backupRoot = yield* fs + .makeTempDirectoryScoped({ + directory: parentDirectory, + prefix: `${pathService.basename(input.skillDirectory)}.backup.`, + }) + .pipe( + Effect.mapError((cause) => + installError(`Failed to prepare backup for ${input.skillDirectory}.`, cause), + ), + ); + const backupDirectory = pathService.join(backupRoot, "skill"); + yield* fs + .copy(input.skillDirectory, backupDirectory) + .pipe( + Effect.mapError((cause) => + installError(`Failed to back up ${input.skillDirectory}.`, cause), + ), + ); + + yield* Effect.gen(function* () { + yield* fs + .remove(input.skillDirectory, { recursive: true, force: true }) + .pipe( + Effect.mapError((cause) => + installError(`Failed to clear ${input.skillDirectory}.`, cause), + ), + ); + yield* writeSkillBundleFiles({ + files: input.files, + skillDirectory: input.skillDirectory, + }); + yield* registerInstalledSkillDirectory({ + skillName: input.skillName, + skillDirectory: input.skillDirectory, + }); + }).pipe( + Effect.catch((error) => + fs + .remove(input.skillDirectory, { recursive: true, force: true }) + .pipe( + Effect.ignore, + Effect.andThen(fs.copy(backupDirectory, input.skillDirectory, { overwrite: true })), + Effect.ignore, + Effect.andThen(Effect.fail(error)), + ), + ), + ); + }), + ); +} + +function registerInstalledSkillDirectory(input: { + readonly skillName: string; + readonly skillDirectory: string; +}): Effect.Effect { + return registerUcsdOpenCodeSkillPath(input.skillDirectory).pipe( + Effect.mapError((cause) => + installError(`Failed to register '${input.skillName}' in OpenCode skill config.`, cause), + ), + ); +} + export function mergeInstalledProviderSkill(input: { readonly providers: ReadonlyArray; readonly instanceId: ProviderInstanceId; @@ -896,10 +1076,6 @@ export function mergeInstalledProviderSkill(input: { const alreadyPresent = provider.skills.some( (skill) => skill.path === input.skillPath || skill.name === input.skillName, ); - if (alreadyPresent) { - return provider; - } - const installedSkill: ServerProviderSkill = { name: input.skillName, path: input.skillPath, @@ -909,9 +1085,20 @@ export function mergeInstalledProviderSkill(input: { return { ...provider, - skills: [...provider.skills, installedSkill].toSorted((left, right) => - left.name.localeCompare(right.name), - ), + skills: (alreadyPresent + ? provider.skills.map((skill) => + skill.path === input.skillPath || skill.name === input.skillName + ? { + ...skill, + name: input.skillName, + path: input.skillPath, + enabled: true, + scope: skill.scope ?? "user", + } + : skill, + ) + : [...provider.skills, installedSkill] + ).toSorted((left, right) => left.name.localeCompare(right.name)), }; }); } @@ -938,5 +1125,6 @@ export const installProviderSkill = Effect.fn("installProviderSkill")(function* providers: input.providers, instanceId: request.instanceId, bundle, + ...(input.environment !== undefined ? { environment: input.environment } : {}), }); }); diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 5ffb69cd5f7..9ee8d478e04 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -181,6 +181,8 @@ function makeRegistry( getProviders: Ref.get(providersRef), refresh: () => Ref.get(providersRef), refreshInstance: () => Ref.get(providersRef), + recordInstalledProviderSkill: () => Ref.get(providersRef), + recordRemovedProviderSkill: () => Ref.get(providersRef), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(lifecycleFor(provider)), setProviderMaintenanceActionState, diff --git a/apps/server/src/provider/testUtils/providerRegistryMock.ts b/apps/server/src/provider/testUtils/providerRegistryMock.ts index 36598b05900..bd764c3719f 100644 --- a/apps/server/src/provider/testUtils/providerRegistryMock.ts +++ b/apps/server/src/provider/testUtils/providerRegistryMock.ts @@ -11,6 +11,8 @@ export const makeProviderRegistryMock = ( getProviders: Effect.succeed(providers), refresh: () => Effect.succeed(providers), refreshInstance: () => Effect.succeed(providers), + recordInstalledProviderSkill: () => Effect.succeed(providers), + recordRemovedProviderSkill: () => Effect.succeed(providers), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null })), setProviderMaintenanceActionState: () => Effect.succeed(providers), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 90d9a424cbf..929cebc856d 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -94,6 +94,10 @@ import { ProviderRegistry, type ProviderRegistryShape, } from "./provider/Services/ProviderRegistry.ts"; +import { + ProviderInstanceRegistry, + type ProviderInstanceRegistryShape, +} from "./provider/Services/ProviderInstanceRegistry.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./serverLifecycleEvents.ts"; import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts"; @@ -346,6 +350,7 @@ const buildAppUnderTest = (options?: { layers?: { keybindings?: Partial; providerRegistry?: Partial; + providerInstanceRegistry?: Partial; serverSettings?: Partial; externalLauncher?: Partial; vcsDriver?: Partial; @@ -556,18 +561,32 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(ProviderRegistry)({ - getProviders: Effect.succeed([]), - refresh: () => Effect.succeed([]), - refreshInstance: () => Effect.succeed([]), - getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }), + Layer.mergeAll( + Layer.mock(ProviderRegistry)({ + getProviders: Effect.succeed([]), + refresh: () => Effect.succeed([]), + refreshInstance: () => Effect.succeed([]), + recordInstalledProviderSkill: () => Effect.succeed([]), + recordRemovedProviderSkill: () => Effect.succeed([]), + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }), + ), + setProviderMaintenanceActionState: () => Effect.succeed([]), + streamChanges: Stream.empty, + ...options?.layers?.providerRegistry, + }), + Layer.mock(ProviderInstanceRegistry)({ + getInstance: () => Effect.succeed(undefined), + listInstances: Effect.succeed([]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), ), - setProviderMaintenanceActionState: () => Effect.succeed([]), - streamChanges: Stream.empty, - ...options?.layers?.providerRegistry, - }), + ...options?.layers?.providerInstanceRegistry, + }), + ), ), Layer.provide( Layer.mock(ServerSettingsService)({ diff --git a/apps/server/src/textGeneration/TextGeneration.test.ts b/apps/server/src/textGeneration/TextGeneration.test.ts index f186d934e52..f5af37a3bc5 100644 --- a/apps/server/src/textGeneration/TextGeneration.test.ts +++ b/apps/server/src/textGeneration/TextGeneration.test.ts @@ -36,6 +36,7 @@ const makeStubInstance = ( }, displayName: undefined, enabled: true, + environment: {}, snapshot: {} as ProviderInstance["snapshot"], adapter: {} as ProviderInstance["adapter"], textGeneration, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6a415a7e584..f89b1522ef7 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -73,16 +73,13 @@ import { observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; +import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import { removeProviderSkillFolder, resolveProviderSkillRemovalTarget, } from "./provider/removeProviderSkill.ts"; -import { - installProviderSkill, - listProviderSkillCatalog, - mergeInstalledProviderSkill, -} from "./provider/installProviderSkill.ts"; +import { installProviderSkill, listProviderSkillCatalog } from "./provider/installProviderSkill.ts"; import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; @@ -304,6 +301,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => const previewManager = yield* PreviewManager.PreviewManager; const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry; + const providerInstanceRegistry = yield* ProviderInstanceRegistry; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; const config = yield* ServerConfig; const lifecycleEvents = yield* ServerLifecycleEvents; @@ -1080,9 +1078,15 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => WS_METHODS.serverInstallProviderSkill, Effect.gen(function* () { const providers = yield* providerRegistry.getProviders; + const providerInstance = yield* providerInstanceRegistry.getInstance( + input.instanceId, + ); const installed = yield* installProviderSkill({ providers, request: input, + ...(providerInstance?.environment !== undefined + ? { environment: providerInstance.environment } + : {}), }); yield* serverSettings .updateProviderSkillPreference({ @@ -1097,15 +1101,14 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => }), ), ); - const refreshedProviders = yield* providerRegistry.refreshInstance(input.instanceId); + const updatedProviders = yield* providerRegistry.recordInstalledProviderSkill({ + instanceId: input.instanceId, + skillName: installed.skillName, + skillPath: installed.skillPath, + }); return { ...installed, - providers: mergeInstalledProviderSkill({ - providers: refreshedProviders, - instanceId: input.instanceId, - skillName: installed.skillName, - skillPath: installed.skillPath, - }), + providers: updatedProviders, }; }), { @@ -1135,6 +1138,10 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => }), ), ); + yield* providerRegistry.recordRemovedProviderSkill({ + instanceId: input.instanceId, + skillPath: input.skillPath, + }); const refreshedProviders = yield* providerRegistry.refreshInstance(input.instanceId); return { providers: refreshedProviders }; }), diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 5adda02de05..6298cb39dd5 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -3580,7 +3580,7 @@ describe("ChatView timeline estimator parity (full app)", () => { it("keeps custom provider instance ids when bootstrapping a local draft thread", async () => { setDraftThreadWithoutWorktree(); - const openRouterInstanceId = ProviderInstanceId.make("claude_openrouter"); + const openRouterInstanceId = ProviderInstanceId.make("opencode_openrouter"); const openRouterSelection = createModelSelection(openRouterInstanceId, "openai/gpt-5.5"); useComposerDraftStore.getState().setModelSelection(THREAD_REF, openRouterSelection); @@ -3593,18 +3593,18 @@ describe("ChatView timeline estimator parity (full app)", () => { providers: [ ...nextFixture.serverConfig.providers, { - driver: ProviderDriverKind.make("claudeAgent"), - instanceId: ProviderInstanceId.make("claudeAgent"), + driver: ProviderDriverKind.make("opencode"), + instanceId: ProviderInstanceId.make("opencode"), enabled: true, installed: true, - version: "2.1.117", + version: "1.2.3", status: "ready", auth: { status: "authenticated" }, checkedAt: NOW_ISO, models: [ { - slug: "claude-opus-4-7", - name: "Claude Opus 4.7", + slug: "github-copilot/claude-opus-4.8", + name: "Claude Opus 4.8", isCustom: false, capabilities: createModelCapabilities({ optionDescriptors: [] }), }, @@ -3613,19 +3613,19 @@ describe("ChatView timeline estimator parity (full app)", () => { skills: [], }, { - driver: ProviderDriverKind.make("claudeAgent"), + driver: ProviderDriverKind.make("opencode"), instanceId: openRouterInstanceId, - displayName: "Claude OpenRouter", + displayName: "OpenRouter", enabled: true, installed: true, - version: "2.1.117", + version: "1.2.3", status: "ready", auth: { status: "authenticated" }, checkedAt: NOW_ISO, models: [ { - slug: "claude-opus-4-7", - name: "Claude Opus 4.7", + slug: "github-copilot/claude-opus-4.8", + name: "Claude Opus 4.8", isCustom: false, capabilities: createModelCapabilities({ optionDescriptors: [] }), }, @@ -3639,8 +3639,8 @@ describe("ChatView timeline estimator parity (full app)", () => { providerInstances: { ...nextFixture.serverConfig.settings.providerInstances, [openRouterInstanceId]: { - driver: ProviderDriverKind.make("claudeAgent"), - displayName: "Claude OpenRouter", + driver: ProviderDriverKind.make("opencode"), + displayName: "OpenRouter", config: { customModels: ["openai/gpt-5.5"] }, }, }, @@ -3658,6 +3658,11 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { + await waitForServerConfigToApply(); + useComposerDraftStore.getState().setModelSelection(THREAD_REF, openRouterSelection); + await vi.waitFor(() => { + expect(findComposerProviderModelPicker()?.textContent).toContain("openai/gpt-5.5"); + }); useComposerDraftStore.getState().setPrompt(THREAD_REF, "Hello there"); await waitForLayout(); @@ -7257,7 +7262,7 @@ describe("ChatView timeline estimator parity (full app)", () => { project.id === PROJECT_ID ? Object.assign({}, project, { defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), + instanceId: ProviderInstanceId.make("opencode"), model: "gpt-5.4", }, }) @@ -7266,7 +7271,10 @@ describe("ChatView timeline estimator parity (full app)", () => { threads: snapshot.threads.map((thread) => thread.id === THREAD_ID ? Object.assign({}, thread, { - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + modelSelection: { + instanceId: ProviderInstanceId.make("opencode"), + model: "gpt-5.4", + }, }) : thread, ), @@ -7317,6 +7325,8 @@ describe("ChatView timeline estimator parity (full app)", () => { providers: [ { ...nextFixture.serverConfig.providers[0]!, + driver: ProviderDriverKind.make("opencode"), + instanceId: ProviderInstanceId.make("opencode"), models: [ { slug: "gpt-5.1-codex-max", @@ -7415,23 +7425,32 @@ describe("ChatView timeline estimator parity (full app)", () => { if (!provider) { throw new Error("Expected default provider in test fixture."); } - ( - provider as { - skills: ServerConfig["providers"][number]["skills"]; - } - ).skills = [ - { - name: "agent-browser", - displayName: "Agent Browser", - description: "Open pages, click around, and inspect web apps.", - path: "/Users/test/.agents/skills/agent-browser/SKILL.md", - enabled: true, - }, - ]; + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + providers: [ + { + ...provider, + driver: ProviderDriverKind.make("opencode"), + instanceId: ProviderInstanceId.make("opencode"), + skills: [ + { + name: "agent-browser", + displayName: "Agent Browser", + description: "Open pages, click around, and inspect web apps.", + path: "/Users/test/.agents/skills/agent-browser/SKILL.md", + enabled: true, + }, + ], + }, + ...nextFixture.serverConfig.providers.slice(1), + ], + }; }, }); try { + await waitForServerConfigToApply(); + await waitForComposerEditor(); useComposerDraftStore.getState().setPrompt(THREAD_REF, "use the $agent-browser "); await waitForComposerText("use the $agent-browser ");