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
-
-
-
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),
]);