From a78c30d02bb81afc5a36cfa598ed5f6ad7550c34 Mon Sep 17 00:00:00 2001 From: JustYannicc <52761674+JustYannicc@users.noreply.github.com> Date: Sat, 14 Mar 2026 18:15:42 +0100 Subject: [PATCH] feat: add shared skills settings --- apps/server/src/sharedSkills.test.ts | 344 +++++++ apps/server/src/sharedSkills.ts | 939 +++++++++++++++++ apps/server/src/wsServer.ts | 32 + apps/web/src/appSettings.ts | 3 + .../settings/SkillsSettingsPanel.tsx | 569 +++++++++++ apps/web/src/lib/sharedSkillsReactQuery.ts | 119 +++ apps/web/src/routes/_chat.settings.tsx | 941 ++++++++++-------- apps/web/src/wsNativeApi.test.ts | 70 ++ apps/web/src/wsNativeApi.ts | 9 + packages/contracts/src/ipc.ts | 13 + packages/contracts/src/server.ts | 73 ++ packages/contracts/src/ws.ts | 18 +- 12 files changed, 2690 insertions(+), 440 deletions(-) create mode 100644 apps/server/src/sharedSkills.test.ts create mode 100644 apps/server/src/sharedSkills.ts create mode 100644 apps/web/src/components/settings/SkillsSettingsPanel.tsx create mode 100644 apps/web/src/lib/sharedSkillsReactQuery.ts diff --git a/apps/server/src/sharedSkills.test.ts b/apps/server/src/sharedSkills.test.ts new file mode 100644 index 000000000..6b343da9c --- /dev/null +++ b/apps/server/src/sharedSkills.test.ts @@ -0,0 +1,344 @@ +import os from "node:os"; +import path from "node:path"; +import { promises as fs } from "node:fs"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + getSharedSkillsState, + initializeSharedSkills, + setSharedSkillEnabled, + uninstallSharedSkill, +} from "./sharedSkills"; + +const tempDirectories: string[] = []; +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; + +async function makeTempDir(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirectories.push(dir); + return dir; +} + +async function writeSkill(rootPath: string, name: string, description = `${name} description`) { + const skillPath = path.join(rootPath, name); + await fs.mkdir(skillPath, { recursive: true }); + await fs.writeFile( + path.join(skillPath, "SKILL.md"), + `---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n`, + "utf8", + ); + return skillPath; +} + +async function writeSystemSkill( + codexHomePath: string, + name: string, + description = `${name} description`, +) { + const systemRoot = path.join(codexHomePath, "skills", ".system"); + await fs.mkdir(systemRoot, { recursive: true }); + await fs.writeFile(path.join(systemRoot, ".codex-system-skills.marker"), "marker\n", "utf8"); + return writeSkill(systemRoot, name, description); +} + +async function withTempHome( + prefix: string, + callback: (homePath: string) => Promise, +): Promise { + const homePath = await makeTempDir(prefix); + process.env.HOME = homePath; + process.env.USERPROFILE = homePath; + + try { + return await callback(homePath); + } finally { + process.env.HOME = originalHome; + process.env.USERPROFILE = originalUserProfile; + } +} + +async function useIsolatedHome(): Promise { + const homePath = await makeTempDir("t3-home-"); + process.env.HOME = homePath; + process.env.USERPROFILE = homePath; + return homePath; +} + +afterEach(async () => { + process.env.HOME = originalHome; + process.env.USERPROFILE = originalUserProfile; + await Promise.all( + tempDirectories.splice(0).map((dir) => + fs.rm(dir, { + recursive: true, + force: true, + }), + ), + ); +}); + +describe("sharedSkills", () => { + it("initializes shared skills by moving Codex user skills and replacing them with symlinks", async () => { + await useIsolatedHome(); + const codexHomePath = await makeTempDir("t3-codex-home-"); + const sharedSkillsPath = path.join(await makeTempDir("t3-shared-skills-"), "skills"); + + await writeSkill(path.join(codexHomePath, "skills"), "demo"); + + const result = await initializeSharedSkills({ + codexHomePath, + sharedSkillsPath, + }); + + expect(result.isInitialized).toBe(true); + expect(result.skills).toEqual([ + expect.objectContaining({ + name: "demo", + status: "managed", + codexPathExists: true, + sharedPathExists: true, + symlinkedToSharedPath: true, + }), + ]); + + const codexSkillPath = path.join(codexHomePath, "skills", "demo"); + const codexStat = await fs.lstat(codexSkillPath); + expect(codexStat.isSymbolicLink()).toBe(true); + expect(await fs.realpath(codexSkillPath)).toBe( + await fs.realpath(path.join(sharedSkillsPath, "demo")), + ); + await expect(fs.stat(path.join(sharedSkillsPath, "demo", "SKILL.md"))).resolves.toBeDefined(); + }); + + it("surfaces newly discovered skills after initialization until they are explicitly moved", async () => { + await useIsolatedHome(); + const codexHomePath = await makeTempDir("t3-codex-home-"); + const sharedSkillsPath = path.join(await makeTempDir("t3-shared-skills-"), "skills"); + + await initializeSharedSkills({ + codexHomePath, + sharedSkillsPath, + }); + + await writeSkill(path.join(codexHomePath, "skills"), "later"); + + const result = await getSharedSkillsState({ + codexHomePath, + sharedSkillsPath, + }); + + expect(result.skills).toEqual([ + expect.objectContaining({ + name: "later", + status: "needs-migration", + codexPathExists: true, + sharedPathExists: false, + symlinkedToSharedPath: false, + }), + ]); + + const movedState = await initializeSharedSkills({ + codexHomePath, + sharedSkillsPath, + }); + + expect(movedState.skills).toEqual([ + expect.objectContaining({ + name: "later", + status: "managed", + codexPathExists: true, + sharedPathExists: true, + symlinkedToSharedPath: true, + }), + ]); + }); + + it("initializes nested Codex skills such as .system skills", async () => { + await useIsolatedHome(); + const codexHomePath = await makeTempDir("t3-codex-home-"); + const sharedSkillsPath = path.join(await makeTempDir("t3-shared-skills-"), "skills"); + + await writeSystemSkill(codexHomePath, "skill-creator", "system skill"); + + const result = await initializeSharedSkills({ + codexHomePath, + sharedSkillsPath, + }); + + expect(result.skills).toEqual([ + expect.objectContaining({ + name: ".system/skill-creator", + status: "managed", + enabled: true, + codexPathExists: true, + sharedPathExists: true, + symlinkedToSharedPath: true, + }), + ]); + + const codexSkillPath = path.join(codexHomePath, "skills", ".system", "skill-creator"); + expect(await fs.realpath(codexSkillPath)).toBe( + await fs.realpath(path.join(sharedSkillsPath, ".system", "skill-creator")), + ); + await expect( + fs.readFile(path.join(codexHomePath, "skills", ".system", ".codex-system-skills.marker")), + ).resolves.toBeDefined(); + }); + + it("finds and initializes user-installed skills from ~/.agents/skills", async () => { + await withTempHome("t3-home-", async (homePath) => { + const codexHomePath = path.join(homePath, ".codex"); + const sharedSkillsPath = path.join(await makeTempDir("t3-shared-skills-"), "skills"); + + await writeSkill(path.join(homePath, ".agents", "skills"), "agent-browser"); + + const beforeInit = await getSharedSkillsState({ + codexHomePath, + sharedSkillsPath, + }); + + expect(beforeInit.skills).toEqual([ + expect.objectContaining({ + name: "agent-browser", + status: "needs-migration", + enabled: true, + codexPathExists: true, + sharedPathExists: false, + }), + ]); + + const initialized = await initializeSharedSkills({ + codexHomePath, + sharedSkillsPath, + }); + + expect(initialized.skills).toEqual([ + expect.objectContaining({ + name: "agent-browser", + status: "managed", + enabled: true, + codexPathExists: true, + sharedPathExists: true, + symlinkedToSharedPath: true, + }), + ]); + + expect(await fs.realpath(path.join(homePath, ".agents", "skills", "agent-browser"))).toBe( + await fs.realpath(path.join(sharedSkillsPath, "agent-browser")), + ); + }); + }); + + it("surfaces broken harness skill symlinks after initialization", async () => { + await withTempHome("t3-home-", async (homePath) => { + const codexHomePath = path.join(homePath, ".codex"); + const sharedSkillsPath = path.join(await makeTempDir("t3-shared-skills-"), "skills"); + + await initializeSharedSkills({ + codexHomePath, + sharedSkillsPath, + }); + + const agentsSkillsPath = path.join(homePath, ".agents", "skills"); + await fs.mkdir(agentsSkillsPath, { recursive: true }); + await fs.symlink( + path.join(homePath, "missing-skill"), + path.join(agentsSkillsPath, "agent-browser"), + "dir", + ); + + const state = await getSharedSkillsState({ + codexHomePath, + sharedSkillsPath, + }); + + expect(state.skills).toEqual([ + expect.objectContaining({ + name: "agent-browser", + status: "broken-link", + enabled: false, + codexPathExists: true, + sharedPathExists: false, + }), + ]); + expect(state.warnings).toContain( + "Skill 'agent-browser' points to a missing directory and could not be migrated. Restore or reinstall it, then click Move skills.", + ); + }); + }); + + it("keeps disabled skills hidden from Codex across refreshes", async () => { + await useIsolatedHome(); + const codexHomePath = await makeTempDir("t3-codex-home-"); + const sharedSkillsPath = path.join(await makeTempDir("t3-shared-skills-"), "skills"); + + await writeSkill(path.join(codexHomePath, "skills"), "demo"); + await initializeSharedSkills({ + codexHomePath, + sharedSkillsPath, + }); + + const disabledState = await setSharedSkillEnabled({ + codexHomePath, + sharedSkillsPath, + skillName: "demo", + enabled: false, + }); + + expect(disabledState.skills).toEqual([ + expect.objectContaining({ + name: "demo", + enabled: false, + status: "needs-link", + codexPathExists: false, + sharedPathExists: true, + }), + ]); + + await expect(fs.lstat(path.join(codexHomePath, "skills", "demo"))).rejects.toMatchObject({ + code: "ENOENT", + }); + + const refreshedState = await getSharedSkillsState({ + codexHomePath, + sharedSkillsPath, + }); + + expect(refreshedState.skills).toEqual([ + expect.objectContaining({ + name: "demo", + enabled: false, + status: "needs-link", + codexPathExists: false, + }), + ]); + }); + + it("uninstalls a managed shared skill from both locations", async () => { + await useIsolatedHome(); + const codexHomePath = await makeTempDir("t3-codex-home-"); + const sharedSkillsPath = path.join(await makeTempDir("t3-shared-skills-"), "skills"); + + await writeSkill(path.join(codexHomePath, "skills"), "demo"); + await initializeSharedSkills({ + codexHomePath, + sharedSkillsPath, + }); + + const result = await uninstallSharedSkill({ + codexHomePath, + sharedSkillsPath, + skillName: "demo", + }); + + expect(result.skills).toEqual([]); + await expect(fs.lstat(path.join(codexHomePath, "skills", "demo"))).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect(fs.lstat(path.join(sharedSkillsPath, "demo"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); +}); diff --git a/apps/server/src/sharedSkills.ts b/apps/server/src/sharedSkills.ts new file mode 100644 index 000000000..890e4145a --- /dev/null +++ b/apps/server/src/sharedSkills.ts @@ -0,0 +1,939 @@ +import os from "node:os"; +import path from "node:path"; +import { promises as fs, type Dirent } from "node:fs"; + +import type { + SharedSkill, + SharedSkillDetail, + SharedSkillDetailInput, + SharedSkillsConfigInput, + SharedSkillsState, + SharedSkillSetEnabledInput, + SharedSkillUninstallInput, +} from "@t3tools/contracts"; + +const SKILL_MARKDOWN_FILE = "SKILL.md"; +const SKILL_MANIFEST_FILE = "SKILL.json"; +const SHARED_SKILLS_MARKER_FILE = ".t3code-shared-skills.json"; + +interface SharedSkillsMarkerData { + version: number; + initializedAt: string; + codexHomePath: string; + disabledSkillNames: string[]; +} + +interface ResolvedSharedSkillsPaths { + codexHomePath: string; + codexSkillsPath: string; + agentsSkillsPath: string; + sharedSkillsPath: string; + initializationMarkerPath: string; +} + +interface SkillManifestMetadata { + displayName: string | null; + shortDescription: string | null; + iconPath: string | null; + brandColor: string | null; +} + +interface DiskSkillEntry extends SkillManifestMetadata { + name: string; + path: string; + isSymlink: boolean; + hasSkillMarkdown: boolean; + realPath: string | null; + description: string | null; + markdownPath: string; +} + +const sharedSkillsOperationLocks = new Map>(); + +function toSkillName(rootPath: string, entryPath: string): string { + return path.relative(rootPath, entryPath).split(path.sep).join("/"); +} + +function normalizeDisabledSkillNames(values: readonly string[] | undefined): string[] { + return [ + ...new Set((values ?? []).map((value) => value.trim()).filter((value) => value.length)), + ].toSorted((left, right) => left.localeCompare(right)); +} + +function expandHomePath(input: string): string { + if (input === "~") { + return os.homedir(); + } + if (input.startsWith("~/") || input.startsWith("~\\")) { + return path.join(os.homedir(), input.slice(2)); + } + return input; +} + +function resolveConfiguredPath(rawPath: string | null | undefined, fallbackPath: string): string { + const trimmed = rawPath?.trim() ?? ""; + const value = trimmed.length > 0 ? trimmed : fallbackPath; + return path.resolve(expandHomePath(value)); +} + +function resolveSharedSkillsPaths(input: SharedSkillsConfigInput): ResolvedSharedSkillsPaths { + const codexHomePath = resolveConfiguredPath( + input.codexHomePath, + path.join(os.homedir(), ".codex"), + ); + const agentsSkillsPath = path.resolve(os.homedir(), ".agents", "skills"); + const sharedSkillsPath = resolveConfiguredPath( + input.sharedSkillsPath, + path.join(os.homedir(), "Documents", "skills"), + ); + + return { + codexHomePath, + codexSkillsPath: path.join(codexHomePath, "skills"), + agentsSkillsPath, + sharedSkillsPath, + initializationMarkerPath: path.join(sharedSkillsPath, SHARED_SKILLS_MARKER_FILE), + }; +} + +async function pathExists(targetPath: string): Promise { + try { + await fs.lstat(targetPath); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return false; + } + throw error; + } +} + +async function isFile(targetPath: string): Promise { + try { + const stat = await fs.stat(targetPath); + return stat.isFile(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return false; + } + throw error; + } +} + +async function readSkillMarkdown(skillPath: string): Promise { + try { + return await fs.readFile(path.join(skillPath, SKILL_MARKDOWN_FILE), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw error; + } +} + +function extractDescriptionFromFrontmatter(markdown: string): string | null { + const frontmatterMatch = markdown.match(/^---\n([\s\S]*?)\n---/); + if (!frontmatterMatch) { + return null; + } + + const descriptionLine = (frontmatterMatch[1] ?? "") + .split("\n") + .find((line) => line.trimStart().startsWith("description:")); + if (!descriptionLine) { + return null; + } + + const description = descriptionLine.replace(/^.*description:\s*/, "").trim(); + return description.replace(/^["']|["']$/g, "") || null; +} + +function resolveSkillIconPath(skillPath: string, iconPath: unknown): string | null { + if (typeof iconPath !== "string") { + return null; + } + + if (path.isAbsolute(iconPath)) { + return iconPath; + } + + return path.resolve(skillPath, iconPath); +} + +async function readSkillManifest(skillPath: string): Promise { + let contents: string; + try { + contents = await fs.readFile(path.join(skillPath, SKILL_MANIFEST_FILE), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { + displayName: null, + shortDescription: null, + iconPath: null, + brandColor: null, + }; + } + throw error; + } + + try { + const parsed = JSON.parse(contents) as { + interface?: { + brandColor?: unknown; + displayName?: unknown; + iconLarge?: unknown; + iconSmall?: unknown; + shortDescription?: unknown; + }; + }; + const interfaceConfig = parsed.interface; + + return { + displayName: + typeof interfaceConfig?.displayName === "string" ? interfaceConfig.displayName : null, + shortDescription: + typeof interfaceConfig?.shortDescription === "string" + ? interfaceConfig.shortDescription + : null, + iconPath: resolveSkillIconPath( + skillPath, + interfaceConfig?.iconSmall ?? interfaceConfig?.iconLarge, + ), + brandColor: + typeof interfaceConfig?.brandColor === "string" ? interfaceConfig.brandColor : null, + }; + } catch { + return { + displayName: null, + shortDescription: null, + iconPath: null, + brandColor: null, + }; + } +} + +async function readInitializationMarker( + paths: ResolvedSharedSkillsPaths, +): Promise { + let contents: string; + try { + contents = await fs.readFile(paths.initializationMarkerPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw error; + } + + try { + const parsed = JSON.parse(contents) as Partial; + return { + version: typeof parsed.version === "number" ? parsed.version : 1, + initializedAt: + typeof parsed.initializedAt === "string" && parsed.initializedAt.length > 0 + ? parsed.initializedAt + : new Date().toISOString(), + codexHomePath: + typeof parsed.codexHomePath === "string" && parsed.codexHomePath.length > 0 + ? parsed.codexHomePath + : paths.codexHomePath, + disabledSkillNames: normalizeDisabledSkillNames(parsed.disabledSkillNames), + }; + } catch { + return { + version: 1, + initializedAt: new Date().toISOString(), + codexHomePath: paths.codexHomePath, + disabledSkillNames: [], + }; + } +} + +async function writeInitializationMarker( + paths: ResolvedSharedSkillsPaths, + marker: SharedSkillsMarkerData, +): Promise { + await fs.mkdir(path.dirname(paths.initializationMarkerPath), { recursive: true }); + await fs.writeFile( + paths.initializationMarkerPath, + JSON.stringify( + { + ...marker, + disabledSkillNames: normalizeDisabledSkillNames(marker.disabledSkillNames), + }, + null, + 2, + ), + "utf8", + ); +} + +async function withSharedSkillsLock( + paths: ResolvedSharedSkillsPaths, + operation: () => Promise, +): Promise { + const lockKey = paths.initializationMarkerPath; + const previousOperation = sharedSkillsOperationLocks.get(lockKey) ?? Promise.resolve(); + const runOperation = previousOperation.catch(() => undefined).then(operation); + const settledOperation = runOperation.then( + () => undefined, + () => undefined, + ); + + sharedSkillsOperationLocks.set(lockKey, settledOperation); + + try { + return await runOperation; + } finally { + if (sharedSkillsOperationLocks.get(lockKey) === settledOperation) { + sharedSkillsOperationLocks.delete(lockKey); + } + } +} + +async function readSkillEntries(rootPath: string): Promise> { + async function walkDirectory( + currentPath: string, + entries: Map, + visitedDirectories: Set, + ): Promise { + let dirents: Dirent[]; + try { + dirents = await fs.readdir(currentPath, { encoding: "utf8", withFileTypes: true }); + } catch (error) { + const errorCode = (error as NodeJS.ErrnoException).code; + if (errorCode === "ENOENT" || errorCode === "ENOTDIR") { + return; + } + throw error; + } + + await Promise.all( + dirents.map(async (dirent) => { + if (!dirent.isDirectory() && !dirent.isSymbolicLink()) { + return; + } + + const entryPath = path.join(currentPath, dirent.name); + const stat = await fs.lstat(entryPath); + const realPath = await fs.realpath(entryPath).catch(() => null); + const markdownPath = path.join(entryPath, SKILL_MARKDOWN_FILE); + const hasSkillMarkdown = await isFile(markdownPath); + if (stat.isSymbolicLink() && realPath === null) { + const name = toSkillName(rootPath, entryPath); + entries.set(name, { + name, + path: entryPath, + isSymlink: true, + hasSkillMarkdown: false, + realPath: null, + description: null, + markdownPath, + displayName: null, + shortDescription: null, + iconPath: null, + brandColor: null, + }); + return; + } + + if (hasSkillMarkdown) { + const markdown = await readSkillMarkdown(entryPath); + const description = markdown ? extractDescriptionFromFrontmatter(markdown) : null; + const manifest = await readSkillManifest(entryPath); + + entries.set(toSkillName(rootPath, entryPath), { + name: toSkillName(rootPath, entryPath), + path: entryPath, + isSymlink: stat.isSymbolicLink(), + hasSkillMarkdown, + realPath, + description, + markdownPath, + ...manifest, + }); + return; + } + + const traversalKey = await fs.realpath(entryPath).catch(() => path.resolve(entryPath)); + if (visitedDirectories.has(traversalKey)) { + return; + } + + visitedDirectories.add(traversalKey); + await walkDirectory(entryPath, entries, visitedDirectories); + }), + ); + } + + try { + const stat = await fs.stat(rootPath); + if (!stat.isDirectory()) { + return new Map(); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return new Map(); + } + throw error; + } + + const entries = new Map(); + const visitedDirectories = new Set([ + await fs.realpath(rootPath).catch(() => path.resolve(rootPath)), + ]); + await walkDirectory(rootPath, entries, visitedDirectories); + + return entries; +} + +function isCodexVisible(entry: DiskSkillEntry | undefined): boolean { + if (!entry) { + return false; + } + + if (!entry.isSymlink) { + return entry.hasSkillMarkdown; + } + + return entry.hasSkillMarkdown && entry.realPath !== null; +} + +function isSystemSkillName(name: string): boolean { + return name === ".system" || name.startsWith(".system/"); +} + +function skillPathFromName(rootPath: string, skillName: string): string { + return path.join(rootPath, ...skillName.split("/")); +} + +function preferredHarnessSkillPath(paths: ResolvedSharedSkillsPaths, skillName: string): string { + return isSystemSkillName(skillName) + ? skillPathFromName(paths.codexSkillsPath, skillName) + : skillPathFromName(paths.agentsSkillsPath, skillName); +} + +function containingHarnessRoot(paths: ResolvedSharedSkillsPaths, entryPath: string): string | null { + const normalizedEntryPath = path.resolve(entryPath); + const candidateRoots = [paths.codexSkillsPath, paths.agentsSkillsPath].map((rootPath) => + path.resolve(rootPath), + ); + + return ( + candidateRoots.find( + (rootPath) => + normalizedEntryPath === rootPath || + normalizedEntryPath.startsWith(`${rootPath}${path.sep}`), + ) ?? null + ); +} + +async function readHarnessSkillEntries( + paths: ResolvedSharedSkillsPaths, +): Promise> { + const [codexEntries, agentsEntries] = await Promise.all([ + readSkillEntries(paths.codexSkillsPath), + readSkillEntries(paths.agentsSkillsPath), + ]); + + const merged = new Map(codexEntries); + for (const [name, entry] of agentsEntries) { + merged.set(name, entry); + } + + return merged; +} + +function toSharedSkillSummary(input: { + name: string; + paths: ResolvedSharedSkillsPaths; + sharedEntry: DiskSkillEntry | undefined; + codexEntry: DiskSkillEntry | undefined; +}): SharedSkill { + const { codexEntry, name, paths, sharedEntry } = input; + const codexPath = codexEntry?.path ?? preferredHarnessSkillPath(paths, name); + const sharedPath = skillPathFromName(paths.sharedSkillsPath, name); + const symlinkedToSharedPath = + codexEntry !== undefined && + sharedEntry !== undefined && + codexEntry.isSymlink && + codexEntry.realPath !== null && + sharedEntry.realPath !== null && + codexEntry.realPath === sharedEntry.realPath; + + let status: SharedSkill["status"]; + if (codexEntry?.isSymlink && codexEntry.realPath === null) { + status = "broken-link"; + } else if (sharedEntry && codexEntry) { + status = symlinkedToSharedPath ? "managed" : "conflict"; + } else if (sharedEntry) { + status = "needs-link"; + } else { + status = "needs-migration"; + } + + const preferredEntry = sharedEntry ?? codexEntry; + + return { + name, + description: preferredEntry?.description ?? undefined, + displayName: preferredEntry?.displayName ?? undefined, + shortDescription: preferredEntry?.shortDescription ?? undefined, + iconPath: preferredEntry?.iconPath ?? undefined, + brandColor: preferredEntry?.brandColor ?? undefined, + markdownPath: preferredEntry?.markdownPath ?? path.join(sharedPath, SKILL_MARKDOWN_FILE), + enabled: isCodexVisible(codexEntry), + status, + codexPath, + sharedPath, + codexPathExists: codexEntry !== undefined, + sharedPathExists: sharedEntry !== undefined, + symlinkedToSharedPath, + }; +} + +async function buildSharedSkillsState( + paths: ResolvedSharedSkillsPaths, + input: { + isInitialized: boolean; + warnings: string[]; + }, +): Promise { + const [sharedEntries, codexEntries] = await Promise.all([ + readSkillEntries(paths.sharedSkillsPath), + readHarnessSkillEntries(paths), + ]); + + const skillNames = new Set([...sharedEntries.keys(), ...codexEntries.keys()]); + const skills: SharedSkill[] = []; + + for (const name of Array.from(skillNames).toSorted((left, right) => left.localeCompare(right))) { + skills.push( + toSharedSkillSummary({ + name, + paths, + sharedEntry: sharedEntries.get(name), + codexEntry: codexEntries.get(name), + }), + ); + } + + return { + codexHomePath: paths.codexHomePath, + codexSkillsPath: paths.codexSkillsPath, + agentsSkillsPath: paths.agentsSkillsPath, + sharedSkillsPath: paths.sharedSkillsPath, + initializationMarkerPath: paths.initializationMarkerPath, + isInitialized: input.isInitialized, + skills, + warnings: input.warnings, + }; +} + +async function moveDirectory(sourcePath: string, destinationPath: string): Promise { + await fs.mkdir(path.dirname(destinationPath), { recursive: true }); + try { + await fs.rename(sourcePath, destinationPath); + return; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== "EXDEV") { + throw error; + } + } + + await fs.cp(sourcePath, destinationPath, { + recursive: true, + errorOnExist: true, + force: false, + }); + await fs.rm(sourcePath, { recursive: true, force: true }); +} + +async function removePath(targetPath: string): Promise { + await fs.rm(targetPath, { + recursive: true, + force: true, + }); +} + +async function removeEmptyParentDirectories(rootPath: string, leafPath: string): Promise { + let currentPath = path.dirname(leafPath); + const normalizedRootPath = path.resolve(rootPath); + + while (currentPath.startsWith(normalizedRootPath) && currentPath !== normalizedRootPath) { + const remainingEntries = await fs.readdir(currentPath).catch(() => null); + if (remainingEntries === null || remainingEntries.length > 0) { + return; + } + + await fs.rmdir(currentPath).catch(() => undefined); + currentPath = path.dirname(currentPath); + } +} + +async function createDirectorySymlink(targetPath: string, symlinkPath: string): Promise { + await fs.mkdir(path.dirname(symlinkPath), { recursive: true }); + await fs.symlink(targetPath, symlinkPath, process.platform === "win32" ? "junction" : "dir"); +} + +async function reconcileSharedSkills( + paths: ResolvedSharedSkillsPaths, + disabledSkillNames: Set, + options: { + migrateDiscoveredSkills: boolean; + }, +): Promise { + await Promise.all([ + fs.mkdir(paths.codexSkillsPath, { recursive: true }), + fs.mkdir(paths.agentsSkillsPath, { recursive: true }), + fs.mkdir(paths.sharedSkillsPath, { recursive: true }), + ]); + + const warnings: string[] = []; + const [sharedEntries, codexEntries] = await Promise.all([ + readSkillEntries(paths.sharedSkillsPath), + readHarnessSkillEntries(paths), + ]); + + for (const [name, sharedEntry] of sharedEntries) { + const codexEntry = codexEntries.get(name); + if (disabledSkillNames.has(name)) { + if (codexEntry?.isSymlink) { + await removePath(codexEntry.path); + const containingRoot = containingHarnessRoot(paths, codexEntry.path); + if (containingRoot) { + await removeEmptyParentDirectories(containingRoot, codexEntry.path); + } + } else if (codexEntry) { + warnings.push( + `Codex skill '${name}' could not be disabled because a real directory still exists at '${codexEntry.path}'.`, + ); + } + continue; + } + + if (!codexEntry) { + await createDirectorySymlink(sharedEntry.path, preferredHarnessSkillPath(paths, name)); + continue; + } + + if (codexEntry.isSymlink) { + if ( + codexEntry.realPath !== null && + sharedEntry.realPath !== null && + codexEntry.realPath === sharedEntry.realPath + ) { + continue; + } + + await removePath(codexEntry.path); + const containingRoot = containingHarnessRoot(paths, codexEntry.path); + if (containingRoot) { + await removeEmptyParentDirectories(containingRoot, codexEntry.path); + } + await createDirectorySymlink(sharedEntry.path, codexEntry.path); + continue; + } + + warnings.push( + `Codex skill '${name}' was left in place because '${sharedEntry.path}' already exists.`, + ); + } + + for (const [name, codexEntry] of codexEntries) { + if (sharedEntries.has(name)) { + continue; + } + + if (!options.migrateDiscoveredSkills) { + if (codexEntry.isSymlink && (!codexEntry.hasSkillMarkdown || codexEntry.realPath === null)) { + warnings.push( + `Skill '${name}' points to a missing directory and could not be migrated. Restore or reinstall it, then click Move skills.`, + ); + } + continue; + } + + const destinationPath = skillPathFromName(paths.sharedSkillsPath, name); + if (await pathExists(destinationPath)) { + warnings.push( + `Shared skill destination '${destinationPath}' already exists for '${name}', so it was not migrated.`, + ); + continue; + } + + disabledSkillNames.delete(name); + + if (codexEntry.isSymlink) { + if (!codexEntry.hasSkillMarkdown || codexEntry.realPath === null) { + warnings.push( + `Skill '${name}' points to a missing directory and could not be migrated. Restore or reinstall it, then click Recheck skills.`, + ); + continue; + } + + await moveDirectory(codexEntry.realPath, destinationPath); + await removePath(codexEntry.path); + const containingRoot = containingHarnessRoot(paths, codexEntry.path); + if (containingRoot) { + await removeEmptyParentDirectories(containingRoot, codexEntry.path); + } + await createDirectorySymlink(destinationPath, codexEntry.path); + continue; + } + + await moveDirectory(codexEntry.path, destinationPath); + await createDirectorySymlink(destinationPath, codexEntry.path); + } + + return warnings; +} + +async function findSkillEntries(paths: ResolvedSharedSkillsPaths, skillName: string) { + const [sharedEntries, codexEntries] = await Promise.all([ + readSkillEntries(paths.sharedSkillsPath), + readHarnessSkillEntries(paths), + ]); + + return { + codexEntry: codexEntries.get(skillName), + sharedEntry: sharedEntries.get(skillName), + }; +} + +async function ensureSharedSkillMaterialized( + paths: ResolvedSharedSkillsPaths, + skillName: string, +): Promise<{ codexEntry: DiskSkillEntry | undefined; sharedEntry: DiskSkillEntry }> { + const { codexEntry, sharedEntry } = await findSkillEntries(paths, skillName); + if (sharedEntry) { + return { codexEntry, sharedEntry }; + } + + if (!codexEntry || !codexEntry.hasSkillMarkdown) { + throw new Error(`Skill '${skillName}' was not found.`); + } + + const destinationPath = skillPathFromName(paths.sharedSkillsPath, skillName); + if (await pathExists(destinationPath)) { + throw new Error(`Shared skill destination '${destinationPath}' already exists.`); + } + + if (codexEntry.isSymlink) { + if (codexEntry.realPath === null) { + throw new Error(`Skill '${skillName}' is a broken symlink.`); + } + + await moveDirectory(codexEntry.realPath, destinationPath); + await removePath(codexEntry.path); + } else { + await moveDirectory(codexEntry.path, destinationPath); + } + + const refreshedEntries = await findSkillEntries(paths, skillName); + if (!refreshedEntries.sharedEntry) { + throw new Error(`Skill '${skillName}' could not be materialized in the shared directory.`); + } + + return { + codexEntry: refreshedEntries.codexEntry, + sharedEntry: refreshedEntries.sharedEntry, + }; +} + +export async function getSharedSkillsState( + input: SharedSkillsConfigInput, +): Promise { + const paths = resolveSharedSkillsPaths(input); + return withSharedSkillsLock(paths, async () => { + const marker = await readInitializationMarker(paths); + const disabledSkillNames = new Set(marker?.disabledSkillNames ?? []); + const warnings = marker + ? await reconcileSharedSkills(paths, disabledSkillNames, { + migrateDiscoveredSkills: false, + }) + : []; + + if (marker) { + await writeInitializationMarker(paths, { + ...marker, + codexHomePath: paths.codexHomePath, + disabledSkillNames: [...disabledSkillNames], + }); + } + + return buildSharedSkillsState(paths, { isInitialized: marker !== null, warnings }); + }); +} + +export async function initializeSharedSkills( + input: SharedSkillsConfigInput, +): Promise { + const paths = resolveSharedSkillsPaths(input); + return withSharedSkillsLock(paths, async () => { + await fs.mkdir(paths.sharedSkillsPath, { recursive: true }); + const warnings = await reconcileSharedSkills(paths, new Set(), { + migrateDiscoveredSkills: true, + }); + await writeInitializationMarker(paths, { + version: 1, + initializedAt: new Date().toISOString(), + codexHomePath: paths.codexHomePath, + disabledSkillNames: [], + }); + return buildSharedSkillsState(paths, { isInitialized: true, warnings }); + }); +} + +export async function getSharedSkillDetail( + input: SharedSkillDetailInput, +): Promise { + const paths = resolveSharedSkillsPaths(input); + const { codexEntry, sharedEntry } = await findSkillEntries(paths, input.skillName); + const preferredEntry = sharedEntry ?? codexEntry; + if (!preferredEntry?.hasSkillMarkdown) { + throw new Error(`Skill '${input.skillName}' was not found.`); + } + + const markdown = await readSkillMarkdown(preferredEntry.path); + if (markdown === null) { + throw new Error(`Skill '${input.skillName}' could not be read.`); + } + + return { + skill: toSharedSkillSummary({ + name: input.skillName, + paths, + sharedEntry, + codexEntry, + }), + markdown, + }; +} + +export async function setSharedSkillEnabled( + input: SharedSkillSetEnabledInput, +): Promise { + const paths = resolveSharedSkillsPaths(input); + return withSharedSkillsLock(paths, async () => { + const marker = await readInitializationMarker(paths); + if (!marker) { + throw new Error("Initialize shared skills before changing whether a skill is enabled."); + } + + const disabledSkillNames = new Set(marker.disabledSkillNames); + await Promise.all([ + fs.mkdir(paths.codexSkillsPath, { recursive: true }), + fs.mkdir(paths.agentsSkillsPath, { recursive: true }), + fs.mkdir(paths.sharedSkillsPath, { recursive: true }), + ]); + + const { codexEntry, sharedEntry } = await ensureSharedSkillMaterialized(paths, input.skillName); + const sharedSkillPath = sharedEntry.path; + const codexSkillPath = codexEntry?.path ?? preferredHarnessSkillPath(paths, input.skillName); + + if (input.enabled) { + if (codexEntry?.isSymlink) { + if ( + codexEntry.realPath !== null && + sharedEntry?.realPath !== null && + codexEntry.realPath === sharedEntry.realPath + ) { + disabledSkillNames.delete(input.skillName); + await writeInitializationMarker(paths, { + ...marker, + codexHomePath: paths.codexHomePath, + disabledSkillNames: [...disabledSkillNames], + }); + return buildSharedSkillsState(paths, { isInitialized: true, warnings: [] }); + } + + await removePath(codexEntry.path); + const containingRoot = containingHarnessRoot(paths, codexEntry.path); + if (containingRoot) { + await removeEmptyParentDirectories(containingRoot, codexEntry.path); + } + } else if (codexEntry) { + throw new Error( + `Skill '${input.skillName}' could not be enabled because Codex still owns a real directory at '${codexSkillPath}'.`, + ); + } + + await createDirectorySymlink(sharedSkillPath, codexSkillPath); + disabledSkillNames.delete(input.skillName); + await writeInitializationMarker(paths, { + ...marker, + codexHomePath: paths.codexHomePath, + disabledSkillNames: [...disabledSkillNames], + }); + return buildSharedSkillsState(paths, { isInitialized: true, warnings: [] }); + } + + if (codexEntry?.isSymlink) { + await removePath(codexEntry.path); + const containingRoot = containingHarnessRoot(paths, codexEntry.path); + if (containingRoot) { + await removeEmptyParentDirectories(containingRoot, codexEntry.path); + } + } else if (codexEntry) { + throw new Error( + `Skill '${input.skillName}' could not be disabled because Codex still owns a real directory at '${codexSkillPath}'.`, + ); + } + + disabledSkillNames.add(input.skillName); + await writeInitializationMarker(paths, { + ...marker, + codexHomePath: paths.codexHomePath, + disabledSkillNames: [...disabledSkillNames], + }); + return buildSharedSkillsState(paths, { isInitialized: true, warnings: [] }); + }); +} + +export async function uninstallSharedSkill( + input: SharedSkillUninstallInput, +): Promise { + const paths = resolveSharedSkillsPaths(input); + return withSharedSkillsLock(paths, async () => { + const marker = await readInitializationMarker(paths); + const { codexEntry, sharedEntry } = await findSkillEntries(paths, input.skillName); + + if (codexEntry?.isSymlink) { + await removePath(codexEntry.path); + const containingRoot = containingHarnessRoot(paths, codexEntry.path); + if (containingRoot) { + await removeEmptyParentDirectories(containingRoot, codexEntry.path); + } + } else if (codexEntry) { + await removePath(codexEntry.path); + const containingRoot = containingHarnessRoot(paths, codexEntry.path); + if (containingRoot) { + await removeEmptyParentDirectories(containingRoot, codexEntry.path); + } + } + + if (sharedEntry) { + await removePath(sharedEntry.path); + await removeEmptyParentDirectories(paths.sharedSkillsPath, sharedEntry.path); + } + + if (marker) { + const disabledSkillNames = new Set(marker.disabledSkillNames); + disabledSkillNames.delete(input.skillName); + await writeInitializationMarker(paths, { + ...marker, + codexHomePath: paths.codexHomePath, + disabledSkillNames: [...disabledSkillNames], + }); + } + + return buildSharedSkillsState(paths, { + isInitialized: marker !== null, + warnings: [], + }); + }); +} + +export { SHARED_SKILLS_MARKER_FILE }; diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 2e6ac51b7..eee8fa88d 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -78,6 +78,13 @@ import { expandHomePath } from "./os-jank.ts"; import { makeServerPushBus } from "./wsServer/pushBus.ts"; import { makeServerReadiness } from "./wsServer/readiness.ts"; import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; +import { + getSharedSkillDetail, + getSharedSkillsState, + initializeSharedSkills, + setSharedSkillEnabled, + uninstallSharedSkill, +} from "./sharedSkills"; /** * ServerShape - Service API for server lifecycle control. @@ -877,6 +884,31 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< availableEditors, }; + case WS_METHODS.serverGetSharedSkills: { + const body = stripRequestTag(request.body); + return yield* Effect.tryPromise(() => getSharedSkillsState(body)); + } + + case WS_METHODS.serverGetSharedSkillDetail: { + const body = stripRequestTag(request.body); + return yield* Effect.tryPromise(() => getSharedSkillDetail(body)); + } + + case WS_METHODS.serverInitializeSharedSkills: { + const body = stripRequestTag(request.body); + return yield* Effect.tryPromise(() => initializeSharedSkills(body)); + } + + case WS_METHODS.serverSetSharedSkillEnabled: { + const body = stripRequestTag(request.body); + return yield* Effect.tryPromise(() => setSharedSkillEnabled(body)); + } + + case WS_METHODS.serverUninstallSharedSkill: { + const body = stripRequestTag(request.body); + return yield* Effect.tryPromise(() => uninstallSharedSkill(body)); + } + case WS_METHODS.serverUpsertKeybinding: { const body = stripRequestTag(request.body); const keybindingsConfig = yield* keybindingsManager.upsertKeybindingRule(body); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 18e76d2f9..d494df969 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -21,6 +21,9 @@ const AppSettingsSchema = Schema.Struct({ codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe( Schema.withConstructorDefault(() => Option.some("")), ), + sharedSkillsPath: Schema.String.check(Schema.isMaxLength(4096)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), defaultThreadEnvMode: Schema.Literals(["local", "worktree"]).pipe( Schema.withConstructorDefault(() => Option.some("local")), ), diff --git a/apps/web/src/components/settings/SkillsSettingsPanel.tsx b/apps/web/src/components/settings/SkillsSettingsPanel.tsx new file mode 100644 index 000000000..f11e0efde --- /dev/null +++ b/apps/web/src/components/settings/SkillsSettingsPanel.tsx @@ -0,0 +1,569 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { FolderOpenIcon, RefreshCcwIcon } from "lucide-react"; +import { useState } from "react"; + +import { useAppSettings } from "~/appSettings"; +import { Alert, AlertAction, AlertDescription, AlertTitle } from "~/components/ui/alert"; +import { Badge } from "~/components/ui/badge"; +import { Button } from "~/components/ui/button"; +import ChatMarkdown from "~/components/ChatMarkdown"; +import { Input } from "~/components/ui/input"; +import { + Sheet, + SheetDescription, + SheetHeader, + SheetPanel, + SheetPopup, + SheetTitle, +} from "~/components/ui/sheet"; +import { Spinner } from "~/components/ui/spinner"; +import { + initializeSharedSkillsMutationOptions, + sharedSkillDetailQueryOptions, + sharedSkillsQueryOptions, + setSharedSkillEnabledMutationOptions, + uninstallSharedSkillMutationOptions, +} from "~/lib/sharedSkillsReactQuery"; +import { ensureNativeApi } from "~/nativeApi"; +import { openInPreferredEditor } from "~/editorPreferences"; + +const STATUS_LABEL_BY_SKILL_STATE = { + managed: "Managed", + "needs-migration": "Needs migration", + "needs-link": "Disabled in Codex", + conflict: "Needs review", + "broken-link": "Broken link", +} as const; + +interface SkillsSettingsPanelProps { + codexHomePath: string; +} + +function inferHomePath(codexHomePath: string) { + if (codexHomePath.endsWith("/.codex")) { + return codexHomePath.slice(0, -"/.codex".length); + } + + return null; +} + +function toDisplayPath(value: string, codexHomePath: string) { + const homePath = inferHomePath(codexHomePath); + if (!homePath) { + return value; + } + + if (value === homePath) { + return "~"; + } + + if (value.startsWith(`${homePath}/`)) { + return `~/${value.slice(homePath.length + 1)}`; + } + + return value; +} + +function skillDisplayName(skill: { + displayName?: string | null | undefined; + name: string; + shortDescription?: string | null | undefined; + description?: string | null | undefined; +}) { + const fallbackName = skill.name.match(/[^/]+$/)?.[0] ?? skill.name; + return { + name: skill.displayName || fallbackName, + description: skill.shortDescription || skill.description || null, + }; +} + +function skillMonogram(name: string) { + return name + .split(/[\s-_]+/) + .filter((part) => part.length > 0) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase() ?? "") + .join(""); +} + +function SkillTile({ + brandColor, + label, +}: { + brandColor?: string | null | undefined; + label: string; +}) { + const style = brandColor + ? { + backgroundColor: `${brandColor}22`, + borderColor: `${brandColor}44`, + color: brandColor, + } + : undefined; + + return ( +
+ {skillMonogram(label)} +
+ ); +} + +export function SkillsSettingsPanel({ codexHomePath }: SkillsSettingsPanelProps) { + const { settings, updateSettings } = useAppSettings(); + const queryClient = useQueryClient(); + const [isEditingPath, setIsEditingPath] = useState(false); + const [selectedSkillName, setSelectedSkillName] = useState(null); + const [openFolderError, setOpenFolderError] = useState(null); + const config = { + codexHomePath, + sharedSkillsPath: settings.sharedSkillsPath, + }; + + const sharedSkillsQuery = useQuery(sharedSkillsQueryOptions(config, !isEditingPath)); + const selectedSkillQuery = useQuery( + sharedSkillDetailQueryOptions(config, selectedSkillName, selectedSkillName !== null), + ); + + const initializeMutation = useMutation( + initializeSharedSkillsMutationOptions({ + config, + queryClient, + }), + ); + const setEnabledMutation = useMutation({ + ...setSharedSkillEnabledMutationOptions({ + config, + queryClient, + }), + }); + const uninstallMutation = useMutation( + uninstallSharedSkillMutationOptions({ + config, + queryClient, + }), + ); + + const sharedSkillsState = sharedSkillsQuery.data; + const sharedSkillsPath = settings.sharedSkillsPath; + const selectedSkillDetail = selectedSkillQuery.data; + const isInitialized = sharedSkillsState?.isInitialized === true; + const pendingMigrationSkills = + sharedSkillsState?.skills.filter((skill) => skill.status === "needs-migration") ?? []; + const resolvedCodexHomePath = sharedSkillsState?.codexHomePath ?? codexHomePath; + const queryError = + sharedSkillsQuery.error instanceof Error + ? sharedSkillsQuery.error.message + : sharedSkillsQuery.error + ? "Unable to inspect shared skills." + : null; + const initializeError = + initializeMutation.error instanceof Error + ? initializeMutation.error.message + : initializeMutation.error + ? "Unable to sync shared skills." + : null; + const actionError = + (selectedSkillQuery.error instanceof Error && selectedSkillQuery.error.message) || + (setEnabledMutation.error instanceof Error && setEnabledMutation.error.message) || + (uninstallMutation.error instanceof Error && uninstallMutation.error.message) || + null; + + const openFolderPicker = async () => { + const pickedPath = await ensureNativeApi().dialogs.pickFolder(); + if (!pickedPath) { + return; + } + + updateSettings({ sharedSkillsPath: pickedPath }); + }; + + const handleToggleSelectedSkill = async (enabled: boolean) => { + if (!selectedSkillDetail) { + return; + } + + setOpenFolderError(null); + await setEnabledMutation.mutateAsync({ + enabled, + skillName: selectedSkillDetail.skill.name, + }); + }; + + const handleUninstallSelectedSkill = async () => { + if (!selectedSkillDetail) { + return; + } + + const confirmed = await ensureNativeApi().dialogs.confirm( + [ + `Uninstall skill "${skillDisplayName(selectedSkillDetail.skill).name}"?`, + "This permanently removes the shared skill directory.", + ].join("\n"), + ); + if (!confirmed) { + return; + } + + setOpenFolderError(null); + await uninstallMutation.mutateAsync(selectedSkillDetail.skill.name); + setSelectedSkillName((current) => + current === selectedSkillDetail.skill.name ? null : current, + ); + }; + + const handleOpenSharedSkillsFolder = async () => { + try { + setOpenFolderError(null); + if (!sharedSkillsState?.sharedSkillsPath) { + throw new Error("Shared skills folder is not available yet."); + } + + await openInPreferredEditor(ensureNativeApi(), sharedSkillsState.sharedSkillsPath); + } catch (error) { + setOpenFolderError( + error instanceof Error ? error.message : "Unable to open the shared skill folder.", + ); + } + }; + + const userSkillsRoot = sharedSkillsState?.agentsSkillsPath ?? "~/.agents/skills"; + const codexSystemSkillsRoot = `${ + sharedSkillsState?.codexSkillsPath ?? `${codexHomePath || "~/.codex"}/skills` + }/.system`; + + return ( + <> +
+ {!sharedSkillsState?.isInitialized ? ( + + Initialize shared skill sync + +

+ The first run is explicit. Initializing moves user skill folders from{" "} + ~/.agents/skills into the shared directory, then symlinks them back so + harnesses still see their normal paths. +

+

+ Codex system skills still live under CODEX_HOME/skills/.system. + Reopening this tab surfaces newly discovered user skills here so you can move them + into the shared directory explicitly. +

+
+ + + +
+ ) : null} + + {isInitialized && pendingMigrationSkills.length > 0 ? ( + + + {pendingMigrationSkills.length === 1 + ? "New skill found outside the shared directory" + : "New skills found outside the shared directory"} + + + {pendingMigrationSkills.length === 1 + ? "A newly installed skill is still living in a harness root. Move it into the shared directory to keep this setup in sync." + : `${pendingMigrationSkills.length} newly installed skills are still living in harness roots. Move them into the shared directory to keep this setup in sync.`} + + + + + + ) : null} + +
+
+

Shared Skills

+

+ Keep user skills in one shared directory so other harnesses can point at the same + source of truth later while Codex continues reading its normal user and system skill + roots as usual. +

+
+ +
+
+ + +
+ +
+
+

User skills root

+

+ {toDisplayPath(userSkillsRoot, resolvedCodexHomePath)} +

+
+
+

Codex system root

+

+ {toDisplayPath(codexSystemSkillsRoot, resolvedCodexHomePath)} +

+
+
+
+
+ + {queryError ? ( + + Skills inspection failed + {queryError} + + ) : null} + + {initializeError ? ( + + Initialization failed + {initializeError} + + ) : null} + + {openFolderError ? ( + + Folder open failed + {openFolderError} + + ) : null} + + {sharedSkillsState?.warnings.length ? ( + + Manual follow-up needed + + {sharedSkillsState.warnings.map((warning) => ( +

{warning}

+ ))} +
+
+ ) : null} + +
+
+
+

Managed skills

+

+ Skills discovered in the current harness skill roots are tracked here. +

+ {isInitialized ? ( +

+ Use Recheck skills after adding skills in the current source harness + to import and relink them here. +

+ ) : null} +
+
+ + +
+
+ + {sharedSkillsQuery.isLoading ? ( +
+ + Inspecting shared skills... +
+ ) : sharedSkillsState?.skills.length ? ( +
+ {sharedSkillsState.skills.map((skill) => { + const display = skillDisplayName(skill); + return ( + + ); + })} +
+ ) : ( +
+

No skills were found yet.

+
+ )} +
+
+ + { + if (!open) { + setSelectedSkillName(null); + } + }} + open={selectedSkillName !== null} + > + + + {selectedSkillDetail ? ( +
+ +
+
+ {skillDisplayName(selectedSkillDetail.skill).name} + + {selectedSkillDetail.skill.enabled ? "Enabled" : "Disabled"} + +
+ + {skillDisplayName(selectedSkillDetail.skill).description || + "No description available."} + +
+
+ ) : ( +
+ + Loading skill... +
+ )} +
+ + + {actionError ? ( + + Skill action failed + {actionError} + + ) : null} + + {selectedSkillDetail && !isInitialized ? ( + + Initialize before enabling + + Initialize shared skill sync from the banner above before making this skill + visible to Codex. + + + ) : null} + + {selectedSkillDetail ? ( + <> +
+ {isInitialized ? ( + selectedSkillDetail.skill.enabled ? ( + + ) : ( + + ) + ) : ( +
+ Initialize shared sync to enable or disable this skill in Codex. +
+ )} + +
+ +
+
+ +
+
+ + ) : null} +
+
+
+ + ); +} diff --git a/apps/web/src/lib/sharedSkillsReactQuery.ts b/apps/web/src/lib/sharedSkillsReactQuery.ts new file mode 100644 index 000000000..3a166ec20 --- /dev/null +++ b/apps/web/src/lib/sharedSkillsReactQuery.ts @@ -0,0 +1,119 @@ +import type { SharedSkillsConfigInput } from "@t3tools/contracts"; +import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query"; + +import { ensureNativeApi } from "~/nativeApi"; + +export const sharedSkillsQueryKeys = { + all: ["server", "shared-skills"] as const, + detail: (input: SharedSkillsConfigInput) => + ["server", "shared-skills", input.codexHomePath ?? "", input.sharedSkillsPath ?? ""] as const, + skillDetail: (input: SharedSkillsConfigInput, skillName: string | null) => + [ + "server", + "shared-skills", + "detail", + input.codexHomePath ?? "", + input.sharedSkillsPath ?? "", + skillName ?? "", + ] as const, +}; + +export function sharedSkillsQueryOptions(input: SharedSkillsConfigInput, enabled = true) { + return queryOptions({ + queryKey: sharedSkillsQueryKeys.detail(input), + queryFn: async () => { + const api = ensureNativeApi(); + return api.server.getSharedSkills(input); + }, + enabled, + staleTime: 0, + refetchOnMount: "always", + refetchOnWindowFocus: true, + refetchOnReconnect: true, + }); +} + +export function sharedSkillDetailQueryOptions( + input: SharedSkillsConfigInput, + skillName: string | null, + enabled = true, +) { + return queryOptions({ + queryKey: sharedSkillsQueryKeys.skillDetail(input, skillName), + queryFn: async () => { + const api = ensureNativeApi(); + if (!skillName) { + throw new Error("Skill detail is unavailable."); + } + + return api.server.getSharedSkillDetail({ + codexHomePath: input.codexHomePath, + sharedSkillsPath: input.sharedSkillsPath, + skillName, + }); + }, + enabled: enabled && skillName !== null, + staleTime: 0, + refetchOnMount: "always", + }); +} + +export function initializeSharedSkillsMutationOptions(input: { + config: SharedSkillsConfigInput; + queryClient: QueryClient; +}) { + return mutationOptions({ + mutationKey: ["server", "shared-skills", "initialize"] as const, + mutationFn: async () => { + const api = ensureNativeApi(); + return api.server.initializeSharedSkills(input.config); + }, + onSuccess: async () => { + await input.queryClient.invalidateQueries({ queryKey: sharedSkillsQueryKeys.all }); + }, + }); +} + +export function setSharedSkillEnabledMutationOptions(input: { + config: SharedSkillsConfigInput; + queryClient: QueryClient; +}) { + return mutationOptions({ + mutationKey: ["server", "shared-skills", "set-enabled"] as const, + mutationFn: async (variables: { enabled: boolean; skillName: string }) => { + const api = ensureNativeApi(); + return api.server.setSharedSkillEnabled({ + ...input.config, + ...variables, + }); + }, + onSuccess: async (_result, variables) => { + await input.queryClient.invalidateQueries({ queryKey: sharedSkillsQueryKeys.all }); + await input.queryClient.invalidateQueries({ + queryKey: sharedSkillsQueryKeys.skillDetail(input.config, variables.skillName), + }); + }, + }); +} + +export function uninstallSharedSkillMutationOptions(input: { + config: SharedSkillsConfigInput; + queryClient: QueryClient; +}) { + return mutationOptions({ + mutationKey: ["server", "shared-skills", "uninstall"] as const, + mutationFn: async (skillName: string) => { + const api = ensureNativeApi(); + return api.server.uninstallSharedSkill({ + ...input.config, + skillName, + }); + }, + onSuccess: async (_result, skillName) => { + await input.queryClient.invalidateQueries({ queryKey: sharedSkillsQueryKeys.all }); + await input.queryClient.invalidateQueries({ + queryKey: sharedSkillsQueryKeys.skillDetail(input.config, skillName), + }); + }, + }); +} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index b4afcdefa..8d28e9417 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1,4 +1,4 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; import { useCallback, useState } from "react"; import { type ProviderKind } from "@t3tools/contracts"; @@ -19,9 +19,17 @@ import { SelectValue, } from "../components/ui/select"; import { Switch } from "../components/ui/switch"; +import { SkillsSettingsPanel } from "../components/settings/SkillsSettingsPanel"; import { APP_VERSION } from "../branding"; +import { cn } from "~/lib/utils"; import { SidebarInset } from "~/components/ui/sidebar"; +type SettingsTab = "general" | "skills"; + +function parseSettingsSearch(search: Record): { tab?: SettingsTab } { + return search.tab === "skills" ? { tab: "skills" } : {}; +} + const THEME_OPTIONS = [ { value: "system", @@ -93,6 +101,8 @@ function patchCustomModels(provider: ProviderKind, models: string[]) { } function SettingsRouteView() { + const navigate = useNavigate(); + const search = Route.useSearch(); const { theme, setTheme, resolvedTheme } = useTheme(); const { settings, defaults, updateSettings } = useAppSettings(); const serverConfigQuery = useQuery(serverConfigQueryOptions()); @@ -109,6 +119,7 @@ function SettingsRouteView() { const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; + const activeTab = search.tab === "skills" ? "skills" : "general"; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; @@ -219,471 +230,522 @@ function SettingsRouteView() {

-
-
-

Appearance

-

- Choose how T3 Code looks across the app. -

-
- -
-
- {THEME_OPTIONS.map((option) => { - const selected = theme === option.value; - return ( - - ); - })} -
- -

- Active theme: {resolvedTheme} -

- -
-
-

Timestamp format

-

- System default follows your browser or OS time format. 12-hour{" "} - and 24-hour force the hour cycle. +

+ + {activeTab === "general" ? ( + <> +
+
+

Appearance

+

+ Choose how T3 Code looks across the app.

- -
- - {settings.timestampFormat !== defaults.timestampFormat ? ( -
- + +
+
+ {THEME_OPTIONS.map((option) => { + const selected = theme === option.value; + return ( + + ); + })} +
+ +

+ Active theme:{" "} + {resolvedTheme} +

+ +
+
+

Timestamp format

+

+ System default follows your browser or OS time format.{" "} + 12-hour and 24-hour force the hour cycle. +

+
+ +
+ + {settings.timestampFormat !== defaults.timestampFormat ? ( +
+ +
+ ) : null}
- ) : null} -
-
- -
-
-

Codex App Server

-

- These overrides apply to new sessions and let you use a non-default Codex install. -

-
- -
- - - - -
-
-

Binary source

-

- {codexBinaryPath || "PATH"} +

+ +
+
+

Codex App Server

+

+ These overrides apply to new sessions and let you use a non-default Codex + install.

- - - -
- -
-
-

Models

-

- Save additional provider model slugs so they appear in the chat model picker and - `/model` command suggestions. -

-
- -
- {MODEL_PROVIDER_SETTINGS.map((providerSettings) => { - const provider = providerSettings.provider; - const customModels = getCustomModelsForProvider(settings, provider); - const customModelInput = customModelInputByProvider[provider]; - const customModelError = customModelErrorByProvider[provider] ?? null; - return ( -
-
-

- {providerSettings.title} -

-

- {providerSettings.description} + +

+ + + + +
+
+

Binary source

+

+ {codexBinaryPath || "PATH"}

+ +
+
+
+ +
+
+

Models

+

+ Save additional provider model slugs so they appear in the chat model picker + and `/model` command suggestions. +

+
-
-
- - - -
+
+ {MODEL_PROVIDER_SETTINGS.map((providerSettings) => { + const provider = providerSettings.provider; + const customModels = getCustomModelsForProvider(settings, provider); + const customModelInput = customModelInputByProvider[provider]; + const customModelError = customModelErrorByProvider[provider] ?? null; + return ( +
+
+

+ {providerSettings.title} +

+

+ {providerSettings.description} +

+
- {customModelError ? ( -

{customModelError}

- ) : null} +
+
+ -
-
-

Saved custom models: {customModels.length}

- {customModels.length > 0 ? ( +
+ + {customModelError ? ( +

{customModelError}

) : null} -
- {customModels.length > 0 ? (
- {customModels.map((slug) => ( -
- - {slug} - +
+

Saved custom models: {customModels.length}

+ {customModels.length > 0 ? ( + ) : null} +
+ + {customModels.length > 0 ? ( +
+ {customModels.map((slug) => ( +
+ + {slug} + + +
+ ))}
- ))} -
- ) : ( -
- No custom models saved yet. + ) : ( +
+ No custom models saved yet. +
+ )}
- )} +
+ ); + })} +
+
+ +
+
+

Threads

+

+ Choose the default workspace mode for newly created draft threads. +

+
+ +
+
+

Default to New worktree

+

+ New threads start in New worktree mode instead of Local. +

+
+ + updateSettings({ + defaultThreadEnvMode: checked ? "worktree" : "local", + }) + } + aria-label="Default new threads to New worktree mode" + /> +
+ + {settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? ( +
+ +
+ ) : null} +
+ +
+
+

Responses

+

+ Control how assistant output is rendered during a turn. +

+
+ +
+
+

+ Stream assistant messages +

+

+ Show token-by-token output while a response is in progress. +

+
+ + updateSettings({ + enableAssistantStreaming: Boolean(checked), + }) + } + aria-label="Stream assistant messages" + /> +
+ + {settings.enableAssistantStreaming !== defaults.enableAssistantStreaming ? ( +
+ +
+ ) : null} +
+ +
+
+

Keybindings

+

+ Open the persisted keybindings.json file to edit advanced + bindings directly. +

+
+ +
+
+
+

Config file path

+

+ {keybindingsConfigPath ?? "Resolving keybindings path..."} +

+
- ); - })} -
-
- -
-
-

Threads

-

- Choose the default workspace mode for newly created draft threads. -

-
- -
-
-

Default to New worktree

-

- New threads start in New worktree mode instead of Local. -

-
- - updateSettings({ - defaultThreadEnvMode: checked ? "worktree" : "local", - }) - } - aria-label="Default new threads to New worktree mode" - /> -
- - {settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? ( -
- -
- ) : null} -
- -
-
-

Responses

-

- Control how assistant output is rendered during a turn. -

-
- -
-
-

Stream assistant messages

-

- Show token-by-token output while a response is in progress. -

-
- - updateSettings({ - enableAssistantStreaming: Boolean(checked), - }) - } - aria-label="Stream assistant messages" - /> -
- - {settings.enableAssistantStreaming !== defaults.enableAssistantStreaming ? ( -
- -
- ) : null} -
- -
-
-

Keybindings

-

- Open the persisted keybindings.json file to edit advanced bindings - directly. -

-
- -
-
-
-

Config file path

-

- {keybindingsConfigPath ?? "Resolving keybindings path..."} + +

+ Opens in your preferred editor selection.

+ {openKeybindingsError ? ( +

{openKeybindingsError}

+ ) : null} +
+
+ +
+
+

Safety

+

+ Additional guardrails for destructive local actions. +

+
+ +
+
+

Confirm thread deletion

+

+ Ask for confirmation before deleting a thread and its chat history. +

+
+ + updateSettings({ + confirmThreadDelete: Boolean(checked), + }) + } + aria-label="Confirm thread deletion" + /> +
+ + {settings.confirmThreadDelete !== defaults.confirmThreadDelete ? ( +
+ +
+ ) : null} +
+
+
+

About

+

+ Application version and environment information. +

+
+ +
+
+

Version

+

+ Current version of the application. +

+
+ {APP_VERSION}
- - - -

- Opens in your preferred editor selection. -

- {openKeybindingsError ? ( -

{openKeybindingsError}

- ) : null} - -
- -
-
-

Safety

-

- Additional guardrails for destructive local actions. -

-
- -
-
-

Confirm thread deletion

-

- Ask for confirmation before deleting a thread and its chat history. -

-
- - updateSettings({ - confirmThreadDelete: Boolean(checked), - }) - } - aria-label="Confirm thread deletion" - /> -
- - {settings.confirmThreadDelete !== defaults.confirmThreadDelete ? ( -
- -
- ) : null} -
-
-
-

About

-

- Application version and environment information. -

-
- -
-
-

Version

-

- Current version of the application. -

-
- {APP_VERSION} -
-
+ + + ) : ( + + )} @@ -692,5 +754,6 @@ function SettingsRouteView() { } export const Route = createFileRoute("/_chat/settings")({ + validateSearch: parseSettingsSearch, component: SettingsRouteView, }); diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 2323380da..9f3ca5fa6 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -336,6 +336,76 @@ describe("wsNativeApi", () => { }); }); + it("forwards shared skills inspection requests to the server websocket method", async () => { + requestMock.mockResolvedValue({ isInitialized: false, skills: [], warnings: [] }); + const { createWsNativeApi } = await import("./wsNativeApi"); + + const api = createWsNativeApi(); + await api.server.getSharedSkills({ + codexHomePath: "/tmp/.codex", + sharedSkillsPath: "/tmp/Documents/skills", + }); + + expect(requestMock).toHaveBeenCalledWith(WS_METHODS.serverGetSharedSkills, { + codexHomePath: "/tmp/.codex", + sharedSkillsPath: "/tmp/Documents/skills", + }); + }); + + it("forwards shared skills initialization requests to the server websocket method", async () => { + requestMock.mockResolvedValue({ isInitialized: true, skills: [], warnings: [] }); + const { createWsNativeApi } = await import("./wsNativeApi"); + + const api = createWsNativeApi(); + await api.server.initializeSharedSkills({ + codexHomePath: "/tmp/.codex", + sharedSkillsPath: "/tmp/Documents/skills", + }); + + expect(requestMock).toHaveBeenCalledWith(WS_METHODS.serverInitializeSharedSkills, { + codexHomePath: "/tmp/.codex", + sharedSkillsPath: "/tmp/Documents/skills", + }); + }); + + it("forwards shared skill detail requests to the server websocket method", async () => { + requestMock.mockResolvedValue({ skill: { name: "demo" }, markdown: "# Demo" }); + const { createWsNativeApi } = await import("./wsNativeApi"); + + const api = createWsNativeApi(); + await api.server.getSharedSkillDetail({ + codexHomePath: "/tmp/.codex", + sharedSkillsPath: "/tmp/Documents/skills", + skillName: "demo", + }); + + expect(requestMock).toHaveBeenCalledWith(WS_METHODS.serverGetSharedSkillDetail, { + codexHomePath: "/tmp/.codex", + sharedSkillsPath: "/tmp/Documents/skills", + skillName: "demo", + }); + }); + + it("forwards shared skill enable toggles to the server websocket method", async () => { + requestMock.mockResolvedValue({ isInitialized: true, skills: [], warnings: [] }); + const { createWsNativeApi } = await import("./wsNativeApi"); + + const api = createWsNativeApi(); + await api.server.setSharedSkillEnabled({ + codexHomePath: "/tmp/.codex", + sharedSkillsPath: "/tmp/Documents/skills", + skillName: "demo", + enabled: false, + }); + + expect(requestMock).toHaveBeenCalledWith(WS_METHODS.serverSetSharedSkillEnabled, { + codexHomePath: "/tmp/.codex", + sharedSkillsPath: "/tmp/Documents/skills", + skillName: "demo", + enabled: false, + }); + }); + it("forwards context menu metadata to desktop bridge", async () => { const showContextMenu = vi.fn().mockResolvedValue("delete"); Object.defineProperty(getWindowForTest(), "desktopBridge", { diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index ddfffbde6..31c464096 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -159,6 +159,15 @@ export function createWsNativeApi(): NativeApi { }, server: { getConfig: () => transport.request(WS_METHODS.serverGetConfig), + getSharedSkills: (input) => transport.request(WS_METHODS.serverGetSharedSkills, input), + getSharedSkillDetail: (input) => + transport.request(WS_METHODS.serverGetSharedSkillDetail, input), + initializeSharedSkills: (input) => + transport.request(WS_METHODS.serverInitializeSharedSkills, input), + setSharedSkillEnabled: (input) => + transport.request(WS_METHODS.serverSetSharedSkillEnabled, input), + uninstallSharedSkill: (input) => + transport.request(WS_METHODS.serverUninstallSharedSkill, input), upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), }, orchestration: { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b9127fb17..69e1f02a0 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -25,6 +25,14 @@ import type { ProjectWriteFileResult, } from "./project"; import type { ServerConfig } from "./server"; +import type { + SharedSkillDetail, + SharedSkillDetailInput, + SharedSkillsConfigInput, + SharedSkillsState, + SharedSkillSetEnabledInput, + SharedSkillUninstallInput, +} from "./server"; import type { TerminalClearInput, TerminalCloseInput, @@ -158,6 +166,11 @@ export interface NativeApi { }; server: { getConfig: () => Promise; + getSharedSkills: (input: SharedSkillsConfigInput) => Promise; + getSharedSkillDetail: (input: SharedSkillDetailInput) => Promise; + initializeSharedSkills: (input: SharedSkillsConfigInput) => Promise; + setSharedSkillEnabled: (input: SharedSkillSetEnabledInput) => Promise; + uninstallSharedSkill: (input: SharedSkillUninstallInput) => Promise; upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; }; orchestration: { diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 96ea90c1f..d37d106eb 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -55,6 +55,79 @@ export const ServerConfig = Schema.Struct({ }); export type ServerConfig = typeof ServerConfig.Type; +export const SharedSkillsConfigInput = Schema.Struct({ + codexHomePath: Schema.optional(Schema.String), + sharedSkillsPath: Schema.optional(Schema.String), +}); +export type SharedSkillsConfigInput = typeof SharedSkillsConfigInput.Type; + +export const SharedSkillStatus = Schema.Literals([ + "managed", + "needs-migration", + "needs-link", + "conflict", + "broken-link", +]); +export type SharedSkillStatus = typeof SharedSkillStatus.Type; + +export const SharedSkill = Schema.Struct({ + name: TrimmedNonEmptyString, + description: Schema.optional(Schema.String), + displayName: Schema.optional(Schema.String), + shortDescription: Schema.optional(Schema.String), + iconPath: Schema.optional(Schema.String), + brandColor: Schema.optional(Schema.String), + markdownPath: TrimmedNonEmptyString, + enabled: Schema.Boolean, + status: SharedSkillStatus, + codexPath: TrimmedNonEmptyString, + sharedPath: TrimmedNonEmptyString, + codexPathExists: Schema.Boolean, + sharedPathExists: Schema.Boolean, + symlinkedToSharedPath: Schema.Boolean, +}); +export type SharedSkill = typeof SharedSkill.Type; + +export const SharedSkillsState = Schema.Struct({ + codexHomePath: TrimmedNonEmptyString, + codexSkillsPath: TrimmedNonEmptyString, + agentsSkillsPath: TrimmedNonEmptyString, + sharedSkillsPath: TrimmedNonEmptyString, + initializationMarkerPath: TrimmedNonEmptyString, + isInitialized: Schema.Boolean, + skills: Schema.Array(SharedSkill), + warnings: Schema.Array(TrimmedNonEmptyString), +}); +export type SharedSkillsState = typeof SharedSkillsState.Type; + +export const SharedSkillDetailInput = Schema.Struct({ + codexHomePath: Schema.optional(Schema.String), + sharedSkillsPath: Schema.optional(Schema.String), + skillName: TrimmedNonEmptyString, +}); +export type SharedSkillDetailInput = typeof SharedSkillDetailInput.Type; + +export const SharedSkillDetail = Schema.Struct({ + skill: SharedSkill, + markdown: Schema.String, +}); +export type SharedSkillDetail = typeof SharedSkillDetail.Type; + +export const SharedSkillSetEnabledInput = Schema.Struct({ + codexHomePath: Schema.optional(Schema.String), + sharedSkillsPath: Schema.optional(Schema.String), + skillName: TrimmedNonEmptyString, + enabled: Schema.Boolean, +}); +export type SharedSkillSetEnabledInput = typeof SharedSkillSetEnabledInput.Type; + +export const SharedSkillUninstallInput = Schema.Struct({ + codexHomePath: Schema.optional(Schema.String), + sharedSkillsPath: Schema.optional(Schema.String), + skillName: TrimmedNonEmptyString, +}); +export type SharedSkillUninstallInput = typeof SharedSkillUninstallInput.Type; + export const ServerUpsertKeybindingInput = KeybindingRule; export type ServerUpsertKeybindingInput = typeof ServerUpsertKeybindingInput.Type; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index ebb76138b..981dc6394 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -36,7 +36,13 @@ import { import { KeybindingRule } from "./keybindings"; import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project"; import { OpenInEditorInput } from "./editor"; -import { ServerConfigUpdatedPayload } from "./server"; +import { + ServerConfigUpdatedPayload, + SharedSkillDetailInput, + SharedSkillsConfigInput, + SharedSkillSetEnabledInput, + SharedSkillUninstallInput, +} from "./server"; // ── WebSocket RPC Method Names ─────────────────────────────────────── @@ -74,6 +80,11 @@ export const WS_METHODS = { // Server meta serverGetConfig: "server.getConfig", + serverGetSharedSkills: "server.getSharedSkills", + serverGetSharedSkillDetail: "server.getSharedSkillDetail", + serverInitializeSharedSkills: "server.initializeSharedSkills", + serverSetSharedSkillEnabled: "server.setSharedSkillEnabled", + serverUninstallSharedSkill: "server.uninstallSharedSkill", serverUpsertKeybinding: "server.upsertKeybinding", } as const; @@ -138,6 +149,11 @@ const WebSocketRequestBody = Schema.Union([ // Server meta tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})), + tagRequestBody(WS_METHODS.serverGetSharedSkills, SharedSkillsConfigInput), + tagRequestBody(WS_METHODS.serverGetSharedSkillDetail, SharedSkillDetailInput), + tagRequestBody(WS_METHODS.serverInitializeSharedSkills, SharedSkillsConfigInput), + tagRequestBody(WS_METHODS.serverSetSharedSkillEnabled, SharedSkillSetEnabledInput), + tagRequestBody(WS_METHODS.serverUninstallSharedSkill, SharedSkillUninstallInput), tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule), ]);