diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx
index ec4b2565e174..34138a9ae4fc 100644
--- a/apps/mobile/src/Stack.tsx
+++ b/apps/mobile/src/Stack.tsx
@@ -100,6 +100,11 @@ import {
ScheduledTaskBranchPickerRouteScreen,
} from "./features/settings/ScheduledTaskPickerScreens";
import { ScheduledTaskEditorProvider } from "./features/settings/scheduled-task-editor";
+import { SettingsInstructionRouteScreen } from "./features/settings/skills/SettingsInstructionRouteScreen";
+import { SettingsSkillRouteScreen } from "./features/settings/skills/SettingsSkillRouteScreen";
+import { SettingsSkillsRouteScreen } from "./features/settings/skills/SettingsSkillsRouteScreen";
+import { SettingsSkillUseInRouteScreen } from "./features/settings/skills/SettingsSkillUseInRouteScreen";
+import { SkillsSettingsProvider } from "./features/settings/skills/skills-settings";
import { SettingsKeyboardRouteScreen } from "./features/settings/SettingsKeyboardRouteScreen";
import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
import {
@@ -238,6 +243,25 @@ const SettingsContentStack = createV5SheetStackNavigator({
linking: "agent-behavior",
options: { title: "Agent behavior" },
}),
+ SettingsSkills: createNativeStackScreen({
+ screen: SettingsSkillsRouteScreen,
+ linking: "skills",
+ options: { title: "Skills" },
+ }),
+ // The open skill, file and Use in… screens read the list the Skills screen loaded, so they
+ // have no `linking:` path of their own.
+ SettingsSkill: createNativeStackScreen({
+ screen: SettingsSkillRouteScreen,
+ options: { title: "Skill" },
+ }),
+ SettingsSkillUseIn: createNativeStackScreen({
+ screen: SettingsSkillUseInRouteScreen,
+ options: { title: "Use in…" },
+ }),
+ SettingsInstruction: createNativeStackScreen({
+ screen: SettingsInstructionRouteScreen,
+ options: { title: "Instructions" },
+ }),
SettingsProviderAccounts: createNativeStackScreen({
screen: SettingsProviderAccountsRouteScreen,
linking: "provider-accounts",
@@ -418,7 +442,9 @@ const SettingsSheetStack = createV5SheetStackNavigator({
linking: "",
layout: ({ children }) => (
- {children}
+
+ {children}
+
),
}),
diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx
index 122ad1658e27..40171691cf58 100644
--- a/apps/mobile/src/components/AppSymbol.tsx
+++ b/apps/mobile/src/components/AppSymbol.tsx
@@ -25,6 +25,7 @@ import IconArrowsDiagonal2 from "@tabler/icons-react-native/IconArrowsDiagonal2"
import IconArrowsMinimize from "@tabler/icons-react-native/IconArrowsMinimize";
import IconBellRinging from "@tabler/icons-react-native/IconBellRinging";
import IconBolt from "@tabler/icons-react-native/IconBolt";
+import IconBook from "@tabler/icons-react-native/IconBook";
import IconBox from "@tabler/icons-react-native/IconBox";
import IconBrain from "@tabler/icons-react-native/IconBrain";
import IconCamera from "@tabler/icons-react-native/IconCamera";
@@ -145,6 +146,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
"bell.badge": IconBellRinging,
"bolt.circle": IconBolt,
"bolt.horizontal.circle": IconBolt,
+ book: IconBook,
brain: IconBrain,
camera: IconCamera,
"chart.bar.xaxis": IconChartBar,
@@ -213,6 +215,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
"sidebar.left": IconLayoutSidebar,
"sidebar.right": IconLayoutSidebarRight,
"slider.horizontal.3": IconAdjustmentsHorizontal,
+ sparkles: IconSparkles,
"square.and.pencil": IconEdit,
"square.on.square": IconCopy,
"square.grid.2x2": IconApps,
diff --git a/apps/mobile/src/features/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx
index dcd29d71d5c9..f4cbf4724f6f 100644
--- a/apps/mobile/src/features/files/FileMarkdownPreview.tsx
+++ b/apps/mobile/src/features/files/FileMarkdownPreview.tsx
@@ -37,7 +37,9 @@ interface MarkdownPreviewStyles {
readonly nativeTextStyle: NativeMarkdownTextStyle;
}
-function useMarkdownPreviewStyles(renderImage?: MarkdownImageRenderer): MarkdownPreviewStyles {
+export function useMarkdownPreviewStyles(
+ renderImage?: MarkdownImageRenderer,
+): MarkdownPreviewStyles {
const { appearance } = useAppearancePreferences();
const markdownFontSizes = useMemo(
() => resolveMarkdownFontSizes(appearance.baseFontSize),
diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
index 1f474a9d56b8..1c67933fa0b5 100644
--- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
@@ -196,6 +196,12 @@ function SettingsIndexSections() {
target="SettingsEnvironmentSourceControl"
disabled={noServerTargets}
/>
+
;
+
+/** An instruction file, read-only, with the agents that read it. */
+export function SettingsInstructionRouteScreen({ route }: Props) {
+ const insets = useSafeAreaInsets();
+ const settings = useSkillsSettings();
+ const { instructions, instructionsCtx } = settings;
+ const row = useMemo(
+ () =>
+ instructions ? findInstructionRow(instructions, instructionsCtx, route.params.id) : null,
+ [instructions, instructionsCtx, route.params.id],
+ );
+ const environmentId = settings.environment?.environmentId ?? null;
+ return (
+
+
+ {settings.notice ? (
+
+ ) : null}
+ {row && instructions && environmentId !== null ? (
+
+ ) : (
+
+ This file isn't there any more.
+
+ )}
+
+
+ );
+}
+
+type LoadState =
+ | { readonly status: "loading" }
+ | { readonly status: "ready"; readonly result: InstructionReadResult }
+ | { readonly status: "error" };
+
+function InstructionDetail(props: {
+ readonly row: InstructionRow;
+ readonly data: InstructionData;
+ readonly environmentId: EnvironmentId;
+ readonly cwd: string | null;
+}) {
+ const { row, data, environmentId, cwd } = props;
+ const { entry } = row;
+ const settings = useSkillsSettings();
+ const ctx = settings.instructionsCtx;
+ const locked = settings.busy || !settings.canChangeInstructions;
+ const readInstruction = useAtomCommand(serverEnvironment.readInstruction, {
+ reportFailure: false,
+ });
+ const [load, setLoad] = useState({ status: "loading" });
+ const id = entry.id;
+ const chips = useMemo(() => instructionChips(entry, ctx, data), [entry, ctx, data]);
+
+ const read = useCallback(
+ (): Promise =>
+ readInstruction({ environmentId, input: { id, ...(cwd ? { cwd } : {}) } }).then(
+ (result) =>
+ result._tag === "Success"
+ ? { status: "ready", result: result.value }
+ : { status: "error" },
+ () => ({ status: "error" }),
+ ),
+ [readInstruction, environmentId, id, cwd],
+ );
+ useEffect(() => {
+ let cancelled = false;
+ void read().then((next) => {
+ if (!cancelled) setLoad(next);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [read]);
+
+ return (
+ <>
+ {row.headingNote !== "" ? (
+ {row.headingNote}
+ ) : null}
+
+ {entry.exists && chips.length > 0 ? (
+
+ {chips.map((chip, index) => (
+ 0}
+ onToggle={() => {
+ if (chip.plan) settings.runInstructionPlan(chip.plan);
+ }}
+ />
+ ))}
+
+ ) : null}
+
+
+
+ {load.status === "loading" ? (
+
+ ) : load.status === "error" ? (
+
+ The file couldn't be read.
+ {
+ setLoad({ status: "loading" });
+ void read().then(setLoad);
+ }}
+ />
+
+ ) : load.result.tooLarge ? (
+
+ This file is too large to show here.
+
+ ) : (load.result.contents ?? "") === "" ? (
+ This file is empty.
+ ) : (
+
+ )}
+
+
+
+
+ copyTextWithHaptic(entry.path, { target: "instruction path" })}
+ />
+
+ >
+ );
+}
diff --git a/apps/mobile/src/features/settings/skills/SettingsSkillRouteScreen.tsx b/apps/mobile/src/features/settings/skills/SettingsSkillRouteScreen.tsx
new file mode 100644
index 000000000000..b7c73af772a6
--- /dev/null
+++ b/apps/mobile/src/features/settings/skills/SettingsSkillRouteScreen.tsx
@@ -0,0 +1,227 @@
+import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
+import {
+ attention,
+ hasAccess,
+ planDelete,
+ planToggle,
+ planTurnOnAll,
+ scriptFiles,
+ skillBody,
+ switchBlocker,
+ type Skill,
+} from "@t3tools/client-runtime/skills";
+import type { EnvironmentId, SkillGetResult } from "@t3tools/contracts";
+import { useCallback, useEffect, useState } from "react";
+import { ActivityIndicator, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+import { AppText as Text } from "../../../components/AppText";
+import { ScreenScrollView as ScrollView } from "../../../components/ScreenScrollView";
+import { copyTextWithHaptic } from "../../../lib/copyTextWithHaptic";
+import { serverEnvironment } from "../../../state/server";
+import { useAtomCommand } from "../../../state/use-atom-command";
+import { SettingsActionRow } from "../components/SettingsActionRow";
+import { SettingsRow } from "../components/SettingsRow";
+import { SettingsScreen } from "../components/SettingsScreen";
+import { SettingsSection } from "../components/SettingsSection";
+import {
+ SkillAgentSwitchRow,
+ SkillsMarkdown,
+ SkillsNotice,
+ SkillsPillButton,
+} from "./skills-components";
+import { useSkillsSettings } from "./skills-settings";
+
+type Props = StaticScreenProps<{ readonly id: string }>;
+
+export function SettingsSkillRouteScreen({ route }: Props) {
+ const insets = useSafeAreaInsets();
+ const settings = useSkillsSettings();
+ const skill = settings.skills?.skills.find((entry) => entry.id === route.params.id);
+ const environmentId = settings.environment?.environmentId ?? null;
+ return (
+
+
+ {settings.notice ? (
+
+ ) : null}
+ {skill && environmentId !== null ? (
+
+ ) : (
+
+ This skill isn't there any more.
+
+ )}
+
+
+ );
+}
+
+type DetailState =
+ | { readonly status: "loading" }
+ | { readonly status: "ready"; readonly result: SkillGetResult }
+ | { readonly status: "error" };
+
+function SkillDetail(props: {
+ readonly skill: Skill;
+ readonly environmentId: EnvironmentId;
+ readonly cwd: string | null;
+}) {
+ const { skill, environmentId, cwd } = props;
+ const navigation = useNavigation();
+ const settings = useSkillsSettings();
+ const ctx = settings.skillsCtx;
+ const locked = settings.busy || !settings.canChangeSkills;
+ const readSkill = useAtomCommand(serverEnvironment.getSkill, { reportFailure: false });
+ const [detail, setDetail] = useState({ status: "loading" });
+ const { scope, name, home } = skill;
+
+ const read = useCallback(
+ (): Promise =>
+ readSkill({ environmentId, input: { scope, name, home, ...(cwd ? { cwd } : {}) } }).then(
+ (result) =>
+ result._tag === "Success" && result.value.home
+ ? { status: "ready", result: result.value }
+ : { status: "error" },
+ () => ({ status: "error" }),
+ ),
+ [readSkill, environmentId, scope, name, home, cwd],
+ );
+ useEffect(() => {
+ let cancelled = false;
+ void read().then((next) => {
+ if (!cancelled) setDetail(next);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [read]);
+
+ // The list only holds a short preview; the open skill shows the whole description.
+ const description =
+ (detail.status === "ready" ? detail.result.description : "") || skill.description;
+ const folder = detail.status === "ready" ? detail.result.home : null;
+ const scripts = detail.status === "ready" ? scriptFiles(detail.result.files) : [];
+ const warning = attention(skill, ctx);
+ const turnOnAll = planTurnOnAll([skill], ctx);
+ const del = planDelete([skill], ctx);
+ // A deleted skill is gone, so its screen closes once the delete went through.
+ const backToList = (done: boolean) => {
+ if (done) navigation.dispatch(StackActions.popTo("SettingsSkills"));
+ };
+
+ return (
+ <>
+
+ {description || "No description yet."}
+
+ {skill.scope === "global" ? "Global" : "This project"}
+
+ {warning && warning.kind !== "missing" ? (
+ {warning.detail}
+ ) : null}
+ {scripts.length > 0 ? (
+ Includes scripts
+ ) : null}
+
+
+
+ {ctx.installed.length === 0 ? (
+ No agents are installed.
+ ) : (
+ ctx.installed.map((agent, index) => (
+ 0}
+ onToggle={() => {
+ const plan = planToggle(skill, agent, ctx);
+ if (plan) settings.runSkillPlan(plan);
+ }}
+ />
+ ))
+ )}
+
+
+
+
+ navigation.navigate("SettingsSheet", {
+ screen: "SettingsContent",
+ params: { screen: "SettingsSkillUseIn", params: { id: skill.id } },
+ })
+ }
+ />
+ {turnOnAll ? (
+ settings.runSkillPlan(turnOnAll)}
+ />
+ ) : null}
+ {folder ? (
+ copyTextWithHaptic(folder, { target: "skill path" })}
+ />
+ ) : null}
+ {del ? (
+ settings.runSkillPlan(del, backToList)}
+ />
+ ) : null}
+
+
+
+
+ {detail.status === "loading" ? (
+
+ ) : detail.status === "error" ? (
+
+
+ The skill's files couldn't be read.
+
+ {
+ setDetail({ status: "loading" });
+ void read().then(setDetail);
+ }}
+ />
+
+ ) : detail.result.contents === null ? (
+
+ SKILL.md is missing or too large to show here.
+
+ ) : (
+
+ )}
+
+
+ >
+ );
+}
diff --git a/apps/mobile/src/features/settings/skills/SettingsSkillUseInRouteScreen.tsx b/apps/mobile/src/features/settings/skills/SettingsSkillUseInRouteScreen.tsx
new file mode 100644
index 000000000000..cce6b97339d5
--- /dev/null
+++ b/apps/mobile/src/features/settings/skills/SettingsSkillUseInRouteScreen.tsx
@@ -0,0 +1,170 @@
+import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
+import {
+ placeTarget,
+ planPlace,
+ startingPlacement,
+ type PlaceChoice,
+ type ProjectOption,
+ type Skill,
+} from "@t3tools/client-runtime/skills";
+import { useMemo, useState } from "react";
+import { Pressable } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+import { SymbolView } from "../../../components/AppSymbol";
+import { AppText as Text } from "../../../components/AppText";
+import { ScreenScrollView as ScrollView } from "../../../components/ScreenScrollView";
+import { cn } from "../../../lib/cn";
+import { SettingsActionRow } from "../components/SettingsActionRow";
+import { SettingsScreen } from "../components/SettingsScreen";
+import { SettingsSection } from "../components/SettingsSection";
+import { useSkillsSettings } from "./skills-settings";
+
+type Props = StaticScreenProps<{ readonly id: string }>;
+
+/**
+ * Where a skill is used: in the picked project only, in every project, or in a few projects.
+ * Applying asks before it changes anything.
+ */
+export function SettingsSkillUseInRouteScreen({ route }: Props) {
+ const insets = useSafeAreaInsets();
+ const settings = useSkillsSettings();
+ const skill = settings.skills?.skills.find((entry) => entry.id === route.params.id);
+ return (
+
+
+ {skill ? (
+
+ ) : (
+
+ This skill isn't there any more.
+
+ )}
+
+
+ );
+}
+
+function UseInForm(props: {
+ readonly skill: Skill;
+ readonly picked: ProjectOption | null;
+ readonly projects: readonly ProjectOption[];
+}) {
+ const navigation = useNavigation();
+ const settings = useSkillsSettings();
+ const skills = useMemo(() => [props.skill], [props.skill]);
+ const start = useMemo(() => startingPlacement(skills, props.picked), [skills, props.picked]);
+ const [choice, setChoice] = useState(start.choice);
+ const [ticked, setTicked] = useState>(() => new Set(start.ticked));
+ const target = placeTarget(choice, props.picked, props.projects, ticked);
+ // Nothing to apply while the skill is placed that way already.
+ const plan = target ? planPlace(skills, target) : null;
+ const locked = settings.busy || !settings.canChangeSkills;
+ const choices: ReadonlyArray<{ readonly value: PlaceChoice; readonly label: string }> = [
+ ...(props.picked ? [{ value: "project" as const, label: "This project only" }] : []),
+ { value: "global", label: "Globally" },
+ { value: "projects", label: "Only these projects" },
+ ];
+
+ return (
+ <>
+
+ {choices.map((entry, index) => (
+ 0}
+ onPress={() => setChoice(entry.value)}
+ />
+ ))}
+
+
+ {choice === "projects" ? (
+
+ {props.projects.length === 0 ? (
+ No projects here yet.
+ ) : (
+ props.projects.map((project, index) => (
+ 0}
+ onPress={() =>
+ setTicked((current) => {
+ const next = new Set(current);
+ if (next.has(project.cwd)) next.delete(project.cwd);
+ else next.add(project.cwd);
+ return next;
+ })
+ }
+ />
+ ))
+ )}
+
+ ) : null}
+
+
+ {
+ if (!plan) return;
+ // The skill has a new home once it moved, so the list is where to look for it.
+ settings.runSkillPlan(plan, (done) => {
+ if (done) navigation.dispatch(StackActions.popTo("SettingsSkills"));
+ });
+ }}
+ />
+
+ >
+ );
+}
+
+function CheckRow(props: {
+ readonly role: "radio" | "checkbox";
+ readonly label: string;
+ readonly checked: boolean;
+ readonly separated: boolean;
+ readonly onPress: () => void;
+}) {
+ return (
+
+
+ {props.label}
+
+ {props.checked ? (
+
+ ) : null}
+
+ );
+}
diff --git a/apps/mobile/src/features/settings/skills/SettingsSkillsRouteScreen.tsx b/apps/mobile/src/features/settings/skills/SettingsSkillsRouteScreen.tsx
new file mode 100644
index 000000000000..41f51c713576
--- /dev/null
+++ b/apps/mobile/src/features/settings/skills/SettingsSkillsRouteScreen.tsx
@@ -0,0 +1,744 @@
+import { useNavigation } from "@react-navigation/native";
+import {
+ GROUP_PREVIEW,
+ attention,
+ availability,
+ availabilityNote,
+ groupAvailability,
+ groupBySource,
+ listSwitchOn,
+ matchesQuery,
+ planFix,
+ planListSwitch,
+ planRowSwitch,
+ projectsBadge,
+ rowSwitchOn,
+ unreadableNote,
+ type Skill,
+ type SkillGroup,
+ type SkillPlan,
+ type SkillsContext,
+} from "@t3tools/client-runtime/skills";
+import {
+ CLAUDE_OPTIONS,
+ instructionAttentionCount,
+ instructionItems,
+ instructionUnreadableNote,
+ usage,
+ usageNote,
+ type ClaudeRow,
+ type InstructionItem,
+ type InstructionPlan,
+ type InstructionRow,
+ type NestedFile,
+} from "@t3tools/client-runtime/skills/instructions";
+import type { ClaudeInstructionValue } from "@t3tools/contracts";
+import { memo, useCallback, useMemo, useState } from "react";
+import { ActivityIndicator, Pressable, RefreshControl, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+import { SymbolView } from "../../../components/AppSymbol";
+import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText";
+import { ControlPillMenu } from "../../../components/ControlPill";
+import { ScreenScrollView as ScrollView } from "../../../components/ScreenScrollView";
+import { ThemedSwitch } from "../../../components/ThemedSwitch";
+import { cn } from "../../../lib/cn";
+import {
+ AndroidSettingsEnvironmentFilter,
+ SettingsEnvironmentFilterHeader,
+} from "../components/SettingsEnvironmentFilterHeader";
+import { SettingsScreen } from "../components/SettingsScreen";
+import { SettingsSection } from "../components/SettingsSection";
+import { SkillAgents, SkillsNotice, SkillsPillButton, SkillsWarning } from "./skills-components";
+import { useLoadSkillsSettings, useSkillsSettings } from "./skills-settings";
+import { withoutMissingFiles } from "./skills-list.logic";
+
+/** Opens a skill or an instruction file in its own screen. */
+function useOpen() {
+ const navigation = useNavigation();
+ return useMemo(
+ () => ({
+ skill: (id: string) =>
+ navigation.navigate("SettingsSheet", {
+ screen: "SettingsContent",
+ params: { screen: "SettingsSkill", params: { id } },
+ }),
+ instruction: (id: string) =>
+ navigation.navigate("SettingsSheet", {
+ screen: "SettingsContent",
+ params: { screen: "SettingsInstruction", params: { id } },
+ }),
+ }),
+ [navigation],
+ );
+}
+
+export function SettingsSkillsRouteScreen() {
+ useLoadSkillsSettings();
+ const insets = useSafeAreaInsets();
+ const settings = useSkillsSettings();
+ const { environment, environments } = settings;
+
+ return (
+ <>
+
+ }>
+
+ ) : undefined
+ }
+ >
+ {environments.length > 1 ? (
+
+ {environments.map((entry) => (
+ settings.chooseEnvironment(entry.environmentId)}
+ />
+ ))}
+
+ ) : null}
+ {environment ? (
+
+ ) : (
+
+ {settings.missingProject
+ ? "This project isn't on a connected environment."
+ : "Connect an environment to see its skills."}
+
+ )}
+
+
+ >
+ );
+}
+
+function SkillsList() {
+ const settings = useSkillsSettings();
+ const open = useOpen();
+ const [query, setQuery] = useState("");
+ const [onlyAttention, setOnlyAttention] = useState(false);
+ const { skills: data, instructions, skillsCtx: ctx, instructionsCtx } = settings;
+ // Skills that come with an agent are listed apart on web and desktop, and only there.
+ const skills = useMemo(
+ () => data?.skills.filter((skill) => skill.provided === undefined) ?? null,
+ [data],
+ );
+ const needle = query.trim().toLowerCase();
+ const locked = settings.busy || !settings.canChangeSkills;
+ const instructionsLocked = settings.busy || !settings.canChangeInstructions;
+
+ const attentionIds = useMemo(
+ () =>
+ new Set(
+ (skills ?? []).filter((skill) => attention(skill, ctx) !== null).map((skill) => skill.id),
+ ),
+ [skills, ctx],
+ );
+ const attentionTotal =
+ attentionIds.size +
+ (instructions ? instructionAttentionCount(instructions, instructionsCtx) : 0);
+ const narrow = useCallback(
+ (scope: Skill["scope"]) =>
+ (skills ?? []).filter(
+ (skill) =>
+ skill.scope === scope &&
+ (!onlyAttention || attentionIds.has(skill.id)) &&
+ matchesQuery(skill, needle),
+ ),
+ [skills, onlyAttention, attentionIds, needle],
+ );
+ const visibleProject = useMemo(() => narrow("project"), [narrow]);
+ const visibleGlobal = useMemo(() => narrow("global"), [narrow]);
+ const instructionItemsShown = useMemo(
+ () =>
+ instructions
+ ? withoutMissingFiles(
+ instructionItems(instructions, instructionsCtx, { needle, onlyAttention }),
+ )
+ : [],
+ [instructions, instructionsCtx, needle, onlyAttention],
+ );
+ const countIn = (scope: Skill["scope"]) =>
+ (skills ?? []).filter((skill) => skill.scope === scope).length;
+ const emptyText = (total: number, none: string) =>
+ total === 0
+ ? none
+ : onlyAttention && !needle
+ ? "Nothing needs attention here."
+ : "No matching skills.";
+ const loading = skills === null && settings.skillsError === null;
+
+ return (
+ <>
+ {settings.notice ? (
+
+ ) : null}
+ {settings.skillsError ? (
+
+ ) : null}
+
+
+
+ {/* The count depends on the list, so it waits for it instead of showing a false zero. */}
+ {skills !== null ? (
+
+ setOnlyAttention((value) => !value)}
+ />
+
+ ) : null}
+
+
+ {loading ? : null}
+
+ {settings.instructionsError ? (
+
+ ) : null}
+ {instructions && instructions.unreadable.length > 0 ? (
+
+ ) : null}
+ {instructions && instructionItemsShown.length > 0 ? (
+
+ {instructionItemsShown.map((item, index) => (
+ 0}
+ ctx={instructionsCtx}
+ locked={instructionsLocked}
+ onOpen={open.instruction}
+ onPlan={settings.runInstructionPlan}
+ onClaudeChange={settings.chooseClaude}
+ />
+ ))}
+
+ ) : null}
+
+ {data && data.unreadable.length > 0 ? (
+
+ ) : null}
+ {skills !== null && settings.cwd !== null ? (
+
+ ) : null}
+ {skills !== null ? (
+
+ ) : null}
+ >
+ );
+}
+
+const itemKey = (item: InstructionItem) => {
+ switch (item.kind) {
+ case "group":
+ return `group:${item.group}`;
+ case "file":
+ return item.row.id;
+ case "subfolders":
+ return "subfolders";
+ case "claude":
+ return `claude:${item.row.instanceId}`;
+ }
+};
+
+// -- Skills -------------------------------------------------------------------------------------
+
+function SkillSection(props: {
+ readonly title: string;
+ /** The skills that match the search and filter. */
+ readonly visible: readonly Skill[];
+ readonly ctx: SkillsContext;
+ readonly emptyText: string;
+ /** List the skills without groups, as a search does. */
+ readonly flat: boolean;
+ readonly showFix: boolean;
+ /** A change is being made, or the session can't make one. */
+ readonly locked: boolean;
+ readonly onPlan: (plan: SkillPlan) => void;
+ readonly onOpen: (id: string) => void;
+}) {
+ const { visible, ctx, flat } = props;
+ const on = useMemo(() => listSwitchOn(visible, ctx), [visible, ctx]);
+ const { groups, loose } = useMemo(
+ () => (flat ? { groups: [], loose: visible } : groupBySource(visible)),
+ [visible, flat],
+ );
+ return (
+
+ {
+ const plan = planListSwitch(visible, ctx);
+ if (plan) props.onPlan(plan);
+ }}
+ />
+
+ }
+ >
+ {visible.length === 0 ? (
+ {props.emptyText}
+ ) : (
+ <>
+ {groups.map((group, index) => (
+ 0}
+ ctx={ctx}
+ showFix={props.showFix}
+ locked={props.locked}
+ onPlan={props.onPlan}
+ onOpen={props.onOpen}
+ />
+ ))}
+ {loose.map((skill, index) => (
+ 0}
+ ctx={ctx}
+ showFix={props.showFix}
+ locked={props.locked}
+ onPlan={props.onPlan}
+ onOpen={props.onOpen}
+ />
+ ))}
+ >
+ )}
+
+ );
+}
+
+const SkillRow = memo(function SkillRow(props: {
+ readonly skill: Skill;
+ readonly ctx: SkillsContext;
+ /** The row sits under a group's row, so it is indented. */
+ readonly nested?: boolean;
+ readonly separated: boolean;
+ /** Offer the one-tap fix for a skill some agent lacks; only the Needs attention list does. */
+ readonly showFix: boolean;
+ readonly locked: boolean;
+ readonly onPlan: (plan: SkillPlan) => void;
+ readonly onOpen: (id: string) => void;
+}) {
+ const { skill, ctx } = props;
+ const derived = useMemo(() => {
+ const warning = attention(skill, ctx);
+ return {
+ conflict: warning?.kind === "conflict",
+ fix: props.showFix && warning?.kind === "missing" ? planFix(skill, ctx) : null,
+ availability: availability(skill, ctx),
+ on: rowSwitchOn(skill, ctx),
+ projects: projectsBadge(skill),
+ };
+ }, [skill, ctx, props.showFix]);
+ const { fix } = derived;
+ return (
+
+ props.onOpen(skill.id)}
+ >
+
+
+ {skill.name}
+
+
+ {skill.description || "No description yet."}
+
+ {derived.conflict || derived.projects ? (
+
+ {derived.conflict ? (
+ Conflict
+ ) : null}
+ {derived.projects ? (
+ {derived.projects}
+ ) : null}
+
+ ) : null}
+
+
+ {
+ const plan = planRowSwitch(skill, ctx);
+ if (plan) props.onPlan(plan);
+ }}
+ />
+
+ {fix ? (
+
+ props.onPlan(fix.plan)}
+ />
+
+ ) : null}
+
+ );
+});
+
+/** A group's row and its skills: the first few, and a row that reveals the rest. */
+const SkillGroupRows = memo(function SkillGroupRows(props: {
+ readonly group: SkillGroup;
+ readonly separated: boolean;
+ readonly ctx: SkillsContext;
+ readonly showFix: boolean;
+ readonly locked: boolean;
+ readonly onPlan: (plan: SkillPlan) => void;
+ readonly onOpen: (id: string) => void;
+}) {
+ const { group, ctx } = props;
+ const [open, setOpen] = useState(true);
+ const [showAll, setShowAll] = useState(false);
+ const derived = useMemo(
+ () => ({
+ on: listSwitchOn(group.skills, ctx),
+ availability: groupAvailability(group.skills, ctx),
+ }),
+ [group.skills, ctx],
+ );
+ const shown = showAll ? group.skills : group.skills.slice(0, GROUP_PREVIEW);
+ const hidden = group.skills.length - shown.length;
+ return (
+ <>
+ setOpen((value) => !value)}
+ >
+
+
+ From {group.source}
+
+ {group.skills.length}
+
+
+ {
+ const plan = planListSwitch(group.skills, ctx);
+ if (plan) props.onPlan(plan);
+ }}
+ />
+
+ {open
+ ? shown.map((skill) => (
+
+ ))
+ : null}
+ {open && group.skills.length > GROUP_PREVIEW ? (
+ setShowAll((value) => !value)}
+ >
+
+ {hidden > 0 ? `${hidden} more` : "Show fewer"}
+
+
+ ) : null}
+ >
+ );
+});
+
+// -- Instructions -------------------------------------------------------------------------------
+
+function InstructionItemView(props: {
+ readonly item: InstructionItem;
+ readonly separated: boolean;
+ readonly ctx: SkillsContext;
+ readonly locked: boolean;
+ readonly onOpen: (id: string) => void;
+ readonly onPlan: (plan: InstructionPlan) => void;
+ readonly onClaudeChange: (row: ClaudeRow, value: ClaudeInstructionValue) => void;
+}) {
+ const { item } = props;
+ switch (item.kind) {
+ case "group":
+ return (
+
+ {item.label}
+
+ );
+ case "file":
+ return (
+
+ );
+ case "subfolders":
+ return ;
+ case "claude":
+ return (
+ props.onClaudeChange(item.row, value)}
+ />
+ );
+ }
+}
+
+/** One file, which opens on a tap. Its second line is only a problem, with its fix under it. */
+const InstructionFileRow = memo(function InstructionFileRow(props: {
+ readonly row: InstructionRow;
+ readonly ctx: SkillsContext;
+ readonly locked: boolean;
+ readonly onOpen: (id: string) => void;
+ readonly onPlan: (plan: InstructionPlan) => void;
+}) {
+ const { row, ctx } = props;
+ const used = useMemo(() => usage(row.entry, ctx), [row.entry, ctx]);
+ const fix = row.attention?.fix ?? null;
+ return (
+
+ props.onOpen(row.id)}
+ >
+
+
+ {row.title}
+
+ {row.attention ? (
+ {row.attention.detail}
+ ) : null}
+
+
+
+
+ {fix ? (
+
+ props.onPlan(fix.plan)}
+ />
+
+ ) : null}
+
+ );
+});
+
+/** The AGENTS.md and CLAUDE.md files below the project's top folder, folded into one row. */
+function SubfoldersRow(props: {
+ readonly files: readonly NestedFile[];
+ readonly onOpen: (id: string) => void;
+}) {
+ const [open, setOpen] = useState(false);
+ return (
+
+ setOpen((value) => !value)}
+ >
+
+ In subfolders
+
+ {props.files.length}
+
+
+
+ {open
+ ? props.files.map((file) => (
+ props.onOpen(file.id)}
+ >
+
+ {file.folder || "Top folder"}
+
+ {file.file}
+
+ ))
+ : null}
+
+ );
+}
+
+/** Claude's "Project instructions" choice, which applies in every project. */
+function ClaudeChoiceRow(props: {
+ readonly row: ClaudeRow;
+ readonly locked: boolean;
+ readonly onChange: (value: ClaudeInstructionValue) => void;
+}) {
+ const { row } = props;
+ const { control } = row;
+ return (
+
+
+ {row.title}
+ {row.note !== null ? (
+ {row.note}
+ ) : null}
+
+ {control.kind === "text" ? (
+ {control.text}
+ ) : props.locked || control.disabled ? (
+
+ ) : (
+ ({
+ id: option.value,
+ title: option.label,
+ ...(option.hint ? { subtitle: option.hint } : {}),
+ state: option.value === control.value ? ("on" as const) : ("off" as const),
+ }))}
+ onPressAction={({ nativeEvent }) => {
+ const picked = CLAUDE_OPTIONS.find((option) => option.value === nativeEvent.event);
+ if (picked) props.onChange(picked.value);
+ }}
+ >
+
+
+ )}
+
+ );
+}
+
+function ClaudeChoicePill(props: {
+ readonly label: string;
+ readonly title: string;
+ readonly disabled: boolean;
+}) {
+ return (
+
+
+ {props.label}
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/settings/skills/skills-components.tsx b/apps/mobile/src/features/settings/skills/skills-components.tsx
new file mode 100644
index 000000000000..bb51db3ffdd7
--- /dev/null
+++ b/apps/mobile/src/features/settings/skills/skills-components.tsx
@@ -0,0 +1,195 @@
+import { shouldShowInstanceBadge } from "@t3tools/client-runtime/state/provider-instance-display";
+import type { SkillAgent } from "@t3tools/client-runtime/skills";
+import { Pressable, Text as NativeText, View } from "react-native";
+import { Markdown, type CustomRenderers } from "react-native-nitro-markdown";
+
+import { SymbolView } from "../../../components/AppSymbol";
+import { AppText as Text } from "../../../components/AppText";
+import { ProviderInstanceIcon } from "../../../components/ProviderIcon";
+import { ThemedSwitch } from "../../../components/ThemedSwitch";
+import { cn } from "../../../lib/cn";
+import { useUniwindTheme } from "../../../lib/useUniwindTheme";
+import { useMarkdownPreviewStyles } from "../../files/FileMarkdownPreview";
+
+/** An agent's icon. Instances that share a driver carry a badge, so they can be told apart. */
+export function SkillAgentIcon(props: {
+ readonly agent: SkillAgent;
+ /** Every agent on the page, to know whether this one shares its driver with another. */
+ readonly agents: readonly SkillAgent[];
+ readonly size?: number;
+}) {
+ const surface = String(useUniwindTheme()["--color-grouped-card"]);
+ return (
+
+ );
+}
+
+/**
+ * Who has a skill or file on: one mark when every installed agent does, otherwise just the agents
+ * that do, and nothing when none does.
+ */
+export function SkillAgents(props: {
+ readonly value: { readonly everyone: boolean; readonly agents: readonly SkillAgent[] };
+ readonly agents: readonly SkillAgent[];
+ /** What a screen reader says, such as "Available to all your agents". */
+ readonly label: string;
+}) {
+ if (!props.value.everyone && props.value.agents.length === 0) return null;
+ return (
+
+ {props.value.everyone ? (
+
+ ) : (
+ props.value.agents.map((agent) => (
+
+ ))
+ )}
+
+ );
+}
+
+/** A small rounded button for a fix or a filter beside a row. */
+export function SkillsPillButton(props: {
+ readonly label: string;
+ readonly selected?: boolean;
+ readonly disabled?: boolean;
+ readonly onPress: () => void;
+}) {
+ return (
+
+
+ {props.label}
+
+
+ );
+}
+
+/** One agent and its own switch. A switch that can't be flipped says why under the name. */
+export function SkillAgentSwitchRow(props: {
+ readonly agent: SkillAgent;
+ readonly agents: readonly SkillAgent[];
+ readonly on: boolean;
+ /** Why the switch can't be flipped, or null when it can. */
+ readonly blocker: string | null;
+ /** Nothing can be switched now, such as while a change is being made. */
+ readonly disabled: boolean;
+ readonly separated: boolean;
+ readonly onToggle: () => void;
+}) {
+ return (
+
+
+
+
+ {props.agent.displayName}
+
+ {props.blocker ? (
+ {props.blocker}
+ ) : null}
+
+
+
+ );
+}
+
+/** The line a change leaves behind, until it is dismissed or the next change replaces it. */
+export function SkillsNotice(props: { readonly text: string; readonly onDismiss: () => void }) {
+ return (
+
+ {props.text}
+
+
+
+
+ );
+}
+
+/** A warning line, such as a folder that couldn't be read. */
+export function SkillsWarning(props: {
+ readonly text: string;
+ readonly actionLabel?: string;
+ readonly onAction?: () => void;
+}) {
+ return (
+
+ {props.text}
+ {props.actionLabel && props.onAction ? (
+
+ ) : null}
+
+ );
+}
+
+const PLAIN_RENDERERS: CustomRenderers = {
+ link: ({ children }) => (
+ {children}
+ ),
+ image: ({ node }) => {node.alt ?? ""},
+};
+
+/**
+ * A SKILL.md or an instruction file, read-only. Links and images stay plain text, so reading one
+ * never opens a page or fetches anything.
+ */
+export function SkillsMarkdown(props: { readonly text: string }) {
+ const styles = useMarkdownPreviewStyles();
+ return (
+
+ {props.text}
+
+ );
+}
diff --git a/apps/mobile/src/features/settings/skills/skills-list.logic.test.ts b/apps/mobile/src/features/settings/skills/skills-list.logic.test.ts
new file mode 100644
index 000000000000..d61b17336092
--- /dev/null
+++ b/apps/mobile/src/features/settings/skills/skills-list.logic.test.ts
@@ -0,0 +1,117 @@
+import { describe, expect, it } from "vite-plus/test";
+import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts";
+import type { InstructionEntry } from "@t3tools/contracts";
+import { ingestInstructions, instructionItems } from "@t3tools/client-runtime/skills/instructions";
+
+import { withoutMissingFiles } from "./skills-list.logic";
+
+const claude = {
+ instanceId: ProviderInstanceId.make("claudeAgent"),
+ driverKind: ProviderDriverKind.make("claudeAgent"),
+ displayName: "Claude",
+};
+const codex = {
+ instanceId: ProviderInstanceId.make("codex"),
+ driverKind: ProviderDriverKind.make("codex"),
+ displayName: "Codex",
+};
+const ctx = { installed: [claude, codex] };
+
+function entry(id: string, over: Partial): InstructionEntry {
+ return {
+ id,
+ scope: "project",
+ kind: "shared",
+ path: `/home/user/acme-web/${id}`,
+ exists: true,
+ size: 120,
+ readOnly: false,
+ access: [claude, codex].map((agent) => ({
+ instanceId: agent.instanceId,
+ driver: agent.driverKind,
+ state: "direct" as const,
+ })),
+ ...over,
+ };
+}
+
+const projectAgents = (exists: boolean) =>
+ entry("project:shared:AGENTS.md", { relativePath: "AGENTS.md", exists });
+const localClaude = (exists: boolean) =>
+ entry("project:claudeLocal:CLAUDE.local.md", {
+ kind: "claudeLocal",
+ relativePath: "CLAUDE.local.md",
+ exists,
+ });
+const globalAgents = (exists: boolean) =>
+ entry("global:shared", { scope: "global", path: "/home/user/.agents/AGENTS.md", exists });
+const nested = entry("project:nested:apps/web/AGENTS.md", {
+ kind: "nested",
+ relativePath: "apps/web/AGENTS.md",
+});
+
+/** The items' kinds and titles, in order, after the phone leaves out what it can't open. */
+function shown(entries: InstructionEntry[], withClaude = false) {
+ const data = ingestInstructions({
+ entries,
+ claude: withClaude
+ ? [
+ {
+ instanceId: claude.instanceId,
+ value: "claude-md-or-agents-md",
+ explicit: false,
+ supported: true,
+ version: "2.1.291",
+ },
+ ]
+ : [],
+ sharedPath: "/home/user/.agents/AGENTS.md",
+ unreadable: [],
+ });
+ return withoutMissingFiles(instructionItems(data, ctx, { needle: "", onlyAttention: false })).map(
+ (item) =>
+ item.kind === "group"
+ ? `# ${item.label}`
+ : item.kind === "file"
+ ? item.row.title
+ : item.kind === "subfolders"
+ ? "In subfolders"
+ : item.row.title,
+ );
+}
+
+describe("withoutMissingFiles", () => {
+ it.each([
+ {
+ name: "keeps every file that exists",
+ entries: [projectAgents(true), localClaude(true), globalAgents(true)],
+ expected: ["# Project", "AGENTS.md", "CLAUDE.local.md", "# Global", "AGENTS.md"],
+ },
+ {
+ name: "drops files that aren't there yet",
+ entries: [projectAgents(true), localClaude(false), globalAgents(true)],
+ expected: ["# Project", "AGENTS.md", "# Global", "AGENTS.md"],
+ },
+ {
+ name: "drops a heading left with nothing under it",
+ entries: [projectAgents(false), localClaude(false), globalAgents(true)],
+ expected: ["# Global", "AGENTS.md"],
+ },
+ {
+ name: "keeps a heading over subfolder files",
+ entries: [projectAgents(false), nested, globalAgents(false)],
+ expected: ["# Project", "In subfolders"],
+ },
+ {
+ name: "shows nothing when no file exists",
+ entries: [projectAgents(false), localClaude(false), globalAgents(false)],
+ expected: [],
+ },
+ ])("$name", ({ entries, expected }) => {
+ expect(shown(entries)).toEqual(expected);
+ });
+
+ it("keeps the Global heading over Claude's choice when the Global file isn't there", () => {
+ expect(shown([globalAgents(false)], true)).toEqual(["# Global", "Claude reads AGENTS.md"]);
+ });
+});
diff --git a/apps/mobile/src/features/settings/skills/skills-list.logic.ts b/apps/mobile/src/features/settings/skills/skills-list.logic.ts
new file mode 100644
index 000000000000..76a2af58da5e
--- /dev/null
+++ b/apps/mobile/src/features/settings/skills/skills-list.logic.ts
@@ -0,0 +1,14 @@
+import type { InstructionItem } from "@t3tools/client-runtime/skills/instructions";
+
+/**
+ * The Instructions items a phone shows. It only reads files, so one that doesn't exist yet has
+ * nothing to open and is left out, along with a heading left with nothing under it.
+ */
+export function withoutMissingFiles(items: readonly InstructionItem[]): InstructionItem[] {
+ const kept = items.filter((item) => item.kind !== "file" || !item.row.missing);
+ return kept.filter((item, index) => {
+ if (item.kind !== "group") return true;
+ const next = kept[index + 1];
+ return next !== undefined && next.kind !== "group";
+ });
+}
diff --git a/apps/mobile/src/features/settings/skills/skills-settings.tsx b/apps/mobile/src/features/settings/skills/skills-settings.tsx
new file mode 100644
index 000000000000..bd8ddf5d0339
--- /dev/null
+++ b/apps/mobile/src/features/settings/skills/skills-settings.tsx
@@ -0,0 +1,533 @@
+import { useAtomValue } from "@effect/atom-react";
+import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
+import {
+ describeResult,
+ ingestSkills,
+ installedAgents,
+ sendInBatches,
+ skillsToCheckWithGit,
+ withGitNote,
+ type PlanConfirmation,
+ type ProjectOption,
+ type SkillPlan,
+ type SkillsContext,
+} from "@t3tools/client-runtime/skills";
+import {
+ CHANGE_FAILED,
+ claudeChange,
+ describeAgentsResult,
+ describeChange,
+ failureText,
+ ingestInstructions,
+ instructionErrorReason,
+ instructionsToCheckWithGit,
+ withInstructionGitNote,
+ type ClaudeRow,
+ type InstructionData,
+ type InstructionPlan,
+} from "@t3tools/client-runtime/skills/instructions";
+import type {
+ ClaudeInstructionValue,
+ EnvironmentId,
+ ProviderInstanceId,
+ ServerProvider,
+} from "@t3tools/contracts";
+import {
+ createContext,
+ use,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import { Alert, Platform } from "react-native";
+
+import { showConfirmDialog } from "../../../components/ConfirmDialogHost";
+import { useProjects } from "../../../state/entities";
+import { serverEnvironment } from "../../../state/server";
+import { useAtomCommand } from "../../../state/use-atom-command";
+import { useSettingsEnvironmentFilter, type SettingsTarget } from "../settings-environment-filter";
+import { settingsTargetsForProject } from "../settings-environment-filter.logic";
+
+const SKILLS_LOAD_ERROR = "Couldn't read this environment's skill folders.";
+const INSTRUCTIONS_LOAD_ERROR = "Couldn't read this environment's instruction files.";
+const SKILLS_CHANGE_ERROR = "Couldn't change the skills here.";
+const NO_PROVIDERS: readonly ServerProvider[] = [];
+
+type SkillsData = ReturnType;
+
+/** What the pages read, for one environment and project. A new scope starts empty. */
+type Loaded = {
+ readonly key: string;
+ readonly skills: SkillsData | null;
+ readonly skillsError: string | null;
+ readonly instructions: InstructionData | null;
+ readonly instructionsError: string | null;
+};
+
+const scopeKey = (environmentId: EnvironmentId | null, cwd: string | null) =>
+ `${environmentId ?? ""}\0${cwd ?? ""}`;
+
+/** Asks before a change, the way the platform's own alerts do. */
+function confirmPlan(confirmation: PlanConfirmation, onConfirm: () => void) {
+ const message = [confirmation.body, confirmation.notes.join("\n")]
+ .filter((part) => part !== "")
+ .join("\n\n");
+ if (Platform.OS === "ios") {
+ Alert.alert(confirmation.title, message || undefined, [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: confirmation.confirm,
+ style: confirmation.destructive ? "destructive" : "default",
+ onPress: onConfirm,
+ },
+ ]);
+ return;
+ }
+ showConfirmDialog({
+ title: confirmation.title,
+ ...(message ? { message } : {}),
+ confirmText: confirmation.confirm,
+ destructive: confirmation.destructive,
+ onConfirm,
+ });
+}
+
+function useSkillsSettingsState() {
+ const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter();
+ const allProjects = useProjects();
+ const group =
+ selectedProjectKey === null
+ ? null
+ : projectGroups.find((entry) => entry.key === selectedProjectKey);
+ // Skills live in one environment, so a project narrows the choice to the ones it is on.
+ const environments = settingsTargetsForProject(selectedTargets, group);
+ const [chosenId, setChosenId] = useState(null);
+ const environment: SettingsTarget | null =
+ environments.find((entry) => entry.environmentId === chosenId) ?? environments[0] ?? null;
+ const environmentId = environment?.environmentId ?? null;
+ const member = group?.members.find(
+ (entry) => entry.project.environmentId === environmentId,
+ )?.project;
+ const cwd = member?.workspaceRoot ?? null;
+ const projectLabel = group?.label ?? null;
+ const providers = environment?.serverConfig.providers ?? NO_PROVIDERS;
+ const key = scopeKey(environmentId, cwd);
+
+ const listSkills = useAtomCommand(serverEnvironment.listSkills, { reportFailure: false });
+ const enableSkills = useAtomCommand(serverEnvironment.enableSkills, { reportFailure: false });
+ const disableSkills = useAtomCommand(serverEnvironment.disableSkills, { reportFailure: false });
+ const placeSkills = useAtomCommand(serverEnvironment.placeSkills, { reportFailure: false });
+ const deleteSkills = useAtomCommand(serverEnvironment.deleteSkills, { reportFailure: false });
+ const skillsTracked = useAtomCommand(serverEnvironment.skillsTracked, { reportFailure: false });
+ const listInstructions = useAtomCommand(serverEnvironment.listInstructions, {
+ reportFailure: false,
+ });
+ const enableInstruction = useAtomCommand(serverEnvironment.enableInstruction, {
+ reportFailure: false,
+ });
+ const disableInstruction = useAtomCommand(serverEnvironment.disableInstruction, {
+ reportFailure: false,
+ });
+ const setClaudeInstructionFiles = useAtomCommand(serverEnvironment.setClaudeInstructionFiles, {
+ reportFailure: false,
+ });
+ const shareInstruction = useAtomCommand(serverEnvironment.shareInstruction, {
+ reportFailure: false,
+ });
+ const adoptInstruction = useAtomCommand(serverEnvironment.adoptInstruction, {
+ reportFailure: false,
+ });
+ const deleteInstruction = useAtomCommand(serverEnvironment.deleteInstruction, {
+ reportFailure: false,
+ });
+ const instructionsTracked = useAtomCommand(serverEnvironment.instructionsTracked, {
+ reportFailure: false,
+ });
+
+ // Reading needs no grant; each change needs its command's.
+ const canChangeSkills = [
+ useAtomValue(serverEnvironment.enableSkills.permissionAtom(environmentId)),
+ useAtomValue(serverEnvironment.disableSkills.permissionAtom(environmentId)),
+ useAtomValue(serverEnvironment.placeSkills.permissionAtom(environmentId)),
+ useAtomValue(serverEnvironment.deleteSkills.permissionAtom(environmentId)),
+ ].every(Boolean);
+ const canChangeInstructions = [
+ useAtomValue(serverEnvironment.enableInstruction.permissionAtom(environmentId)),
+ useAtomValue(serverEnvironment.disableInstruction.permissionAtom(environmentId)),
+ useAtomValue(serverEnvironment.setClaudeInstructionFiles.permissionAtom(environmentId)),
+ useAtomValue(serverEnvironment.shareInstruction.permissionAtom(environmentId)),
+ useAtomValue(serverEnvironment.adoptInstruction.permissionAtom(environmentId)),
+ useAtomValue(serverEnvironment.deleteInstruction.permissionAtom(environmentId)),
+ ].every(Boolean);
+
+ const [loaded, setLoaded] = useState(null);
+ const current = loaded?.key === key ? loaded : null;
+ /** A change is being made and the files read again; nothing else can start meanwhile. */
+ const [busy, setBusy] = useState(false);
+ const [notice, setNotice] = useState(null);
+ const [refreshing, setRefreshing] = useState(false);
+ // Answers for a scope that is no longer shown are dropped.
+ const keyRef = useRef(key);
+ useEffect(() => {
+ keyRef.current = key;
+ }, [key]);
+
+ const patch = useCallback(
+ (forKey: string, next: Partial>) =>
+ setLoaded((previous) => {
+ if (keyRef.current !== forKey) return previous;
+ const base: Loaded =
+ previous?.key === forKey
+ ? previous
+ : {
+ key: forKey,
+ skills: null,
+ skillsError: null,
+ instructions: null,
+ instructionsError: null,
+ };
+ return { ...base, ...next };
+ }),
+ [],
+ );
+
+ const scoped = useMemo(() => (cwd ? { cwd } : {}), [cwd]);
+
+ // The server reads a fixed list of folders each time; no agent is asked to rescan.
+ const reloadSkills = useCallback(async () => {
+ if (environmentId === null) return;
+ const forKey = key;
+ try {
+ const result = await listSkills({ environmentId, input: scoped });
+ if (result._tag === "Success") {
+ patch(forKey, { skills: ingestSkills(result.value), skillsError: null });
+ } else {
+ patch(forKey, { skillsError: SKILLS_LOAD_ERROR });
+ }
+ } catch {
+ patch(forKey, { skillsError: SKILLS_LOAD_ERROR });
+ }
+ }, [environmentId, key, listSkills, patch, scoped]);
+
+ const reloadInstructions = useCallback(async () => {
+ if (environmentId === null) return;
+ const forKey = key;
+ try {
+ const result = await listInstructions({ environmentId, input: scoped });
+ if (result._tag === "Success") {
+ patch(forKey, {
+ instructions: ingestInstructions(result.value),
+ instructionsError: null,
+ });
+ } else {
+ patch(forKey, { instructionsError: INSTRUCTIONS_LOAD_ERROR });
+ }
+ } catch {
+ patch(forKey, { instructionsError: INSTRUCTIONS_LOAD_ERROR });
+ }
+ }, [environmentId, key, listInstructions, patch, scoped]);
+
+ const reload = useCallback(
+ () => Promise.all([reloadSkills(), reloadInstructions()]).then(() => undefined),
+ [reloadSkills, reloadInstructions],
+ );
+
+ const dismissNotice = useCallback(() => setNotice(null), []);
+
+ const refresh = useCallback(() => {
+ setRefreshing(true);
+ void reload().finally(() => setRefreshing(false));
+ }, [reload]);
+
+ const skills = current?.skills ?? null;
+ const instructions = current?.instructions ?? null;
+ const skillsInstalled = useMemo(
+ () => (skills ? installedAgents(providers, skills.known) : []),
+ [skills, providers],
+ );
+ const skillsCtx = useMemo(
+ () => ({ installed: skillsInstalled }),
+ [skillsInstalled],
+ );
+ const instructionsInstalled = useMemo(
+ () => (instructions ? installedAgents(providers, instructions.known) : []),
+ [instructions, providers],
+ );
+ const instructionsCtx = useMemo(
+ () => ({ installed: instructionsInstalled }),
+ [instructionsInstalled],
+ );
+
+ // The projects "Use in…" can name: the ones registered in this environment.
+ const places = useMemo(() => {
+ const projects = allProjects
+ .filter((entry) => entry.environmentId === environmentId)
+ .map((entry): ProjectOption => ({ cwd: entry.workspaceRoot, label: entry.title }))
+ .sort((a, b) => a.label.localeCompare(b.label));
+ const picked =
+ cwd === null
+ ? null
+ : (projects.find((entry) => entry.cwd === cwd) ?? { cwd, label: projectLabel ?? cwd });
+ return { picked, projects };
+ }, [allProjects, environmentId, cwd, projectLabel]);
+
+ /**
+ * Asks the server to make the change, then reads the folders again: the pages show what is on
+ * disk, never what the change was expected to do.
+ */
+ const applySkills = async (plan: SkillPlan) => {
+ if (environmentId === null) return false;
+ setBusy(true);
+ let done = false;
+ const { change } = plan;
+ const base = { environmentId } as const;
+ try {
+ // The server takes a few hundred skills at a time, so a big change goes in batches.
+ const { outcomes, failed } = await sendInBatches(change.skills, async (skills) => {
+ const result =
+ change.kind === "enable"
+ ? await enableSkills({ ...base, input: { ...scoped, skills, agents: change.agents } })
+ : change.kind === "disable"
+ ? await disableSkills({
+ ...base,
+ input: { ...scoped, skills, agents: change.agents },
+ })
+ : change.kind === "place"
+ ? await placeSkills({ ...base, input: { ...scoped, skills, to: change.to } })
+ : await deleteSkills({ ...base, input: { ...scoped, skills } });
+ return result._tag === "Success" ? result.value.outcomes : null;
+ });
+ done = !failed;
+ // What was done before a batch failed is still told.
+ setNotice(
+ !failed
+ ? describeResult(change, outcomes, skillsCtx)
+ : outcomes.length === 0
+ ? SKILLS_CHANGE_ERROR
+ : `${describeResult(change, outcomes, skillsCtx)} ${SKILLS_CHANGE_ERROR}`,
+ );
+ } catch {
+ setNotice(SKILLS_CHANGE_ERROR);
+ }
+ await reloadSkills();
+ setBusy(false);
+ return done;
+ };
+
+ /**
+ * A plan that needs confirming asks first; any other goes ahead. For a placement or delete in a
+ * project, git is asked before the question, so it can say when git can undo the change. A
+ * failed check leaves that line out. `onApplied` hears whether the change went through, so an
+ * open skill can close once it is moved or deleted.
+ */
+ const runSkillPlan = (plan: SkillPlan, onApplied?: (done: boolean) => void) => {
+ const go = () =>
+ void applySkills(plan).then((done) => {
+ onApplied?.(done);
+ });
+ const confirmation = plan.confirmation;
+ if (!confirmation) {
+ go();
+ return;
+ }
+ const skills = cwd ? skillsToCheckWithGit(plan) : null;
+ if (!cwd || !skills || environmentId === null) {
+ confirmPlan(confirmation, go);
+ return;
+ }
+ void (async () => {
+ let asked = plan;
+ try {
+ const result = await skillsTracked({ environmentId, input: { cwd, skills } });
+ if (result._tag === "Success") asked = withGitNote(plan, result.value.tracked);
+ } catch {
+ // No answer, no promise.
+ }
+ confirmPlan(asked.confirmation ?? confirmation, go);
+ })();
+ };
+
+ /** Asks the server for an instruction change and says what came of it. */
+ const runInstruction = async (plan: InstructionPlan): Promise => {
+ if (environmentId === null) return CHANGE_FAILED;
+ const { change } = plan;
+ const ctx = instructionsCtx;
+ const base = { environmentId } as const;
+ const failed = (result: Parameters[0]) =>
+ failureText(instructionErrorReason(squashAtomCommandFailure(result)));
+ const setClaude = async (
+ instances: readonly ProviderInstanceId[],
+ value: ClaudeInstructionValue | null,
+ ) => {
+ for (const instanceId of instances) {
+ const result = await setClaudeInstructionFiles({ ...base, input: { instanceId, value } });
+ if (result._tag !== "Success") return result;
+ }
+ return null;
+ };
+ switch (change.kind) {
+ case "setClaude": {
+ const result = await setClaude(change.instances, change.value);
+ return result ? failed(result) : describeChange(change, ctx);
+ }
+ case "enable":
+ case "disable": {
+ const input = { ...scoped, id: change.id, agents: change.agents };
+ const result =
+ change.kind === "enable"
+ ? await enableInstruction({ ...base, input })
+ : await disableInstruction({ ...base, input });
+ return result._tag === "Success"
+ ? describeAgentsResult(change.kind, result.value.results, ctx)
+ : failed(result);
+ }
+ case "adopt": {
+ for (const id of change.ids) {
+ const result = await adoptInstruction({ ...base, input: { id } });
+ if (result._tag !== "Success") return failed(result);
+ }
+ return describeChange(change, ctx);
+ }
+ case "share": {
+ // Sharing renames or merges a file in a project, so it needs the picked project.
+ if (!cwd) return CHANGE_FAILED;
+ const shared = await shareInstruction({
+ ...base,
+ input: { cwd, id: change.id, merge: change.merge },
+ });
+ if (shared._tag !== "Success") return failed(shared);
+ // The file is changed; Claude's setting follows, and a failure there is said after it.
+ const claude = await setClaude(change.claude, "claude-md-and-agents-md");
+ if (claude) {
+ const lead = describeChange({ ...change, claude: [] }, ctx);
+ return `${lead} ${failed(claude)}`;
+ }
+ return describeChange(change, ctx);
+ }
+ case "delete": {
+ const result = await deleteInstruction({ ...base, input: { ...scoped, id: change.id } });
+ return result._tag === "Success" ? describeChange(change, ctx) : failed(result);
+ }
+ }
+ };
+
+ /** Makes the change, says what came of it and reads the files again. */
+ const applyInstruction = async (plan: InstructionPlan) => {
+ setBusy(true);
+ try {
+ setNotice(await runInstruction(plan));
+ } catch {
+ setNotice(CHANGE_FAILED);
+ }
+ await reloadInstructions();
+ setBusy(false);
+ };
+
+ /** Like `runSkillPlan`, for instruction files. */
+ const runInstructionPlan = (plan: InstructionPlan) => {
+ const go = () => void applyInstruction(plan);
+ const confirmation = plan.confirmation;
+ if (!confirmation) {
+ go();
+ return;
+ }
+ const ids = cwd ? instructionsToCheckWithGit(plan) : null;
+ if (!cwd || !ids || environmentId === null) {
+ confirmPlan(confirmation, go);
+ return;
+ }
+ void (async () => {
+ let asked = plan;
+ try {
+ const result = await instructionsTracked({ environmentId, input: { cwd, ids } });
+ if (result._tag === "Success") asked = withInstructionGitNote(plan, result.value.tracked);
+ } catch {
+ // No answer, no promise.
+ }
+ confirmPlan(asked.confirmation ?? confirmation, go);
+ })();
+ };
+
+ /** Claude's choice is a setting, not a file, so it goes ahead without asking. */
+ const chooseClaude = (row: ClaudeRow, value: ClaudeInstructionValue) => {
+ const change = claudeChange(row.choice, value);
+ if (change) void applyInstruction({ change });
+ };
+
+ // Rows are memoized, so they get functions that keep their identity and call the latest version.
+ const latest = useRef({ runSkillPlan, runInstructionPlan, chooseClaude });
+ useEffect(() => {
+ latest.current = { runSkillPlan, runInstructionPlan, chooseClaude };
+ });
+ const actions = useMemo(
+ () => ({
+ runSkillPlan: (plan: SkillPlan, onApplied?: (done: boolean) => void) =>
+ latest.current.runSkillPlan(plan, onApplied),
+ runInstructionPlan: (plan: InstructionPlan) => latest.current.runInstructionPlan(plan),
+ chooseClaude: (row: ClaudeRow, value: ClaudeInstructionValue) =>
+ latest.current.chooseClaude(row, value),
+ }),
+ [],
+ );
+
+ return {
+ ...actions,
+ environments,
+ environment,
+ chooseEnvironment: setChosenId,
+ /** A project is picked above the page, but it isn't on any of the picked environments. */
+ missingProject: selectedProjectKey !== null && environments.length === 0,
+ cwd,
+ projectLabel,
+ places,
+ skills,
+ skillsError: current?.skillsError ?? null,
+ instructions,
+ instructionsError: current?.instructionsError ?? null,
+ skillsCtx,
+ instructionsCtx,
+ canChangeSkills,
+ canChangeInstructions,
+ busy,
+ refreshing,
+ notice,
+ dismissNotice,
+ reload,
+ refresh,
+ };
+}
+
+type SkillsSettings = ReturnType;
+
+const SkillsSettingsContext = createContext(null);
+
+/**
+ * The Skills page and the skill, file and Use in… pages it opens read one list and make one
+ * change at a time, so they share this state while Settings is open.
+ */
+export function SkillsSettingsProvider(props: { readonly children: ReactNode }) {
+ const value = useSkillsSettingsState();
+ return {props.children};
+}
+
+export function useSkillsSettings() {
+ const value = use(SkillsSettingsContext);
+ if (value === null) throw new Error("Skills settings provider is missing.");
+ return value;
+}
+
+/**
+ * Reads the lists when the page opens and again whenever its environment or project changes. What
+ * the last change said is cleared once the page closes.
+ */
+export function useLoadSkillsSettings() {
+ // `reload` changes only with the environment and project it reads.
+ const { reload, dismissNotice } = useSkillsSettings();
+ useEffect(() => {
+ void reload();
+ }, [reload]);
+ useEffect(() => dismissNotice, [dismissNotice]);
+}
diff --git a/apps/server/package.json b/apps/server/package.json
index 3dd02fcdcb5e..57b3d2f0b206 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -53,9 +53,11 @@
"@t3tools/source-control-gitlab": "workspace:*",
"effect": "catalog:",
"jose": "catalog:",
+ "jsonc-parser": "3.3.1",
"node-pty": "^1.2.0-beta.15",
"playwright-core": "1.60.0",
"proper-lockfile": "4.1.2",
+ "smol-toml": "1.8.0",
"stream-chain": "^4.2.5",
"stream-json": "3.6.0",
"yaml": "catalog:",
diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts
index d46609edce77..b93a3996c6d9 100644
--- a/apps/server/src/auth/RpcAuthorization.test.ts
+++ b/apps/server/src/auth/RpcAuthorization.test.ts
@@ -2,6 +2,7 @@ import {
AuthEnvironmentMaintainScope,
AuthDiagnosticsReadScope,
AuthFilesystemReadScope,
+ AuthFilesystemWriteScope,
AuthProvidersManageScope,
AuthSettingsWriteScope,
DEFAULT_SERVER_SETTINGS,
@@ -60,6 +61,54 @@ describe("RPC authorization scopes", () => {
}
});
+ it("reads skill folders, SKILL.md text, git tracking and skill sources under the filesystem read scope", () => {
+ for (const method of [
+ WS_METHODS.serverListSkills,
+ WS_METHODS.serverGetSkill,
+ WS_METHODS.serverSkillsTracked,
+ WS_METHODS.serverCheckSkillUpdates,
+ WS_METHODS.serverGetSkillChanges,
+ ]) {
+ expect(requiredScopeForRpcMethod(method)).toBe(AuthFilesystemReadScope);
+ }
+ });
+
+ it("changes and updates skills, which writes links, settings files and folders, under filesystem write", () => {
+ for (const method of [
+ WS_METHODS.serverEnableSkills,
+ WS_METHODS.serverDisableSkills,
+ WS_METHODS.serverPlaceSkills,
+ WS_METHODS.serverDeleteSkills,
+ WS_METHODS.serverCreateSkill,
+ WS_METHODS.serverShareSkills,
+ WS_METHODS.serverUpdateSkill,
+ ]) {
+ expect(requiredScopeForRpcMethod(method)).toBe(AuthFilesystemWriteScope);
+ }
+ });
+
+ it("reads and changes instruction files under the filesystem scopes", () => {
+ for (const method of [
+ WS_METHODS.serverListInstructions,
+ WS_METHODS.serverReadInstruction,
+ WS_METHODS.serverInstructionsTracked,
+ ]) {
+ expect(requiredScopeForRpcMethod(method)).toBe(AuthFilesystemReadScope);
+ }
+ for (const method of [
+ WS_METHODS.serverWriteInstruction,
+ WS_METHODS.serverEnableInstruction,
+ WS_METHODS.serverDisableInstruction,
+ WS_METHODS.serverSetClaudeInstructionFiles,
+ WS_METHODS.serverShareInstruction,
+ WS_METHODS.serverAdoptInstruction,
+ WS_METHODS.serverDeleteInstruction,
+ WS_METHODS.serverMoveInstruction,
+ ]) {
+ expect(requiredScopeForRpcMethod(method)).toBe(AuthFilesystemWriteScope);
+ }
+ });
+
it("allows relay status reads without granting relay installation access", () => {
expect(requiredScopeForRpcMethod(WS_METHODS.cloudGetRelayClientStatus)).toBe(
AuthRelayReadScope,
diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts
index 511fd77cd147..0d84d3f73d53 100644
--- a/apps/server/src/auth/RpcAuthorization.ts
+++ b/apps/server/src/auth/RpcAuthorization.ts
@@ -60,6 +60,21 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.serverProbe]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope,
[WS_METHODS.serverRefreshProviders]: AuthOrchestrationReadScope,
+ // A skill's listing and SKILL.md text are file contents, so they take the scope the other file
+ // reads take, not the orchestration read scope that thread readers hold.
+ [WS_METHODS.serverListSkills]: AuthFilesystemReadScope,
+ [WS_METHODS.serverGetSkill]: AuthFilesystemReadScope,
+ // `git ls-files` in the project's folder.
+ [WS_METHODS.serverSkillsTracked]: AuthFilesystemReadScope,
+ // Comparing skills with their sources reads the skills' files and the sources the user already
+ // installed from; updating one writes its folder and the skills CLI's lock.
+ [WS_METHODS.serverCheckSkillUpdates]: AuthFilesystemReadScope,
+ [WS_METHODS.serverGetSkillChanges]: AuthFilesystemReadScope,
+ [WS_METHODS.serverUpdateSkill]: AuthFilesystemWriteScope,
+ // Instruction files are read like any other file of the machine.
+ [WS_METHODS.serverListInstructions]: AuthFilesystemReadScope,
+ [WS_METHODS.serverReadInstruction]: AuthFilesystemReadScope,
+ [WS_METHODS.serverInstructionsTracked]: AuthFilesystemReadScope,
[WS_METHODS.serverUpdateProvider]: AuthProvidersManageScope,
[WS_METHODS.providerAuthStart]: AuthProvidersManageScope,
[WS_METHODS.providerConsumeResetCredit]: AuthProvidersManageScope,
diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts
index 45f4ae96a133..3db1abe6da2f 100644
--- a/apps/server/src/git/GitManager.test.ts
+++ b/apps/server/src/git/GitManager.test.ts
@@ -23,6 +23,8 @@ import * as Stream from "effect/Stream";
import { TestClock } from "effect/testing";
import { ChildProcessSpawner } from "effect/process";
import { expect } from "vite-plus/test";
+import * as HostProcess from "@t3tools/shared/HostProcess";
+import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
import type {
ChangeRequest,
GitActionProgressEvent,
@@ -289,6 +291,39 @@ function createBareRemote(): Effect.Effect<
});
}
+/**
+ * A repository whose project uses `db-migrations` from the skill library under `home` and `solo`
+ * from a folder of its own, linked into `.agents/skills` and, for the library skill, also into
+ * `.claude/skills`. The project is the repository's `subfolder` when given, else the repository
+ * itself. The links are untracked, so a worktree has the same files but none of them.
+ */
+function repoWithLibrarySkill(subfolder = "") {
+ return Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const home = yield* makeTempDir("t3code-skill-home-");
+ const root = yield* makeTempDir("t3code-git-manager-");
+ yield* initRepo(root);
+ const cwd = NodePath.join(root, subfolder);
+ yield* fileSystem.makeDirectory(cwd, { recursive: true });
+ const entry = NodePath.join(home, ".agents/skill-library/db-migrations");
+ yield* fileSystem.makeDirectory(entry, { recursive: true });
+ yield* fileSystem.writeFileString(
+ NodePath.join(entry, "SKILL.md"),
+ "---\nname: db-migrations\n---\n",
+ );
+ yield* fileSystem.makeDirectory(NodePath.join(cwd, "elsewhere/solo"), { recursive: true });
+ for (const folder of [".agents/skills", ".claude/skills"]) {
+ yield* fileSystem.makeDirectory(NodePath.join(cwd, folder), { recursive: true });
+ yield* fileSystem.symlink(entry, NodePath.join(cwd, folder, "db-migrations"));
+ }
+ yield* fileSystem.symlink(
+ NodePath.join(cwd, "elsewhere/solo"),
+ NodePath.join(cwd, ".agents/skills/solo"),
+ );
+ return { home, root, cwd, entry };
+ });
+}
+
function configureRemote(
cwd: string,
remoteName: string,
@@ -4895,6 +4930,156 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
}),
);
+ it.effect.skipIf(!symlinksSupported)(
+ "links a project's library skills into a new worktree, and leaves its other links behind",
+ () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const { home, cwd, entry } = yield* repoWithLibrarySkill();
+ const { manager } = yield* makeManager();
+ const worktree = NodePath.join(yield* makeTempDir("t3code-git-worktrees-"), "feature");
+
+ const created = yield* manager
+ .createWorktree({
+ cwd,
+ path: worktree,
+ refName: "main",
+ newRefName: "feature/library-links",
+ })
+ .pipe(Effect.provideService(HostProcess.HomeDirectory, home));
+
+ expect(created.worktree.path).toBe(worktree);
+ for (const folder of [".agents/skills", ".claude/skills"]) {
+ expect(yield* fileSystem.readLink(NodePath.join(worktree, folder, "db-migrations"))).toBe(
+ entry,
+ );
+ }
+ // A link to something other than the library is the project's own business.
+ expect(yield* fileSystem.exists(NodePath.join(worktree, ".agents/skills/solo"))).toBe(
+ false,
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "links a project's library skills at its own folder of the worktree when it is a folder in its repository",
+ () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const { home, cwd, entry } = yield* repoWithLibrarySkill("apps/site");
+ const { manager } = yield* makeManager();
+ const worktree = NodePath.join(yield* makeTempDir("t3code-git-worktrees-"), "feature");
+
+ yield* manager
+ .createWorktree({
+ cwd,
+ path: worktree,
+ refName: "main",
+ newRefName: "feature/site-links",
+ })
+ .pipe(Effect.provideService(HostProcess.HomeDirectory, home));
+
+ expect(
+ yield* fileSystem.readLink(
+ NodePath.join(worktree, "apps/site/.agents/skills/db-migrations"),
+ ),
+ ).toBe(entry);
+ // The worktree's root is not the project, so nothing lands there.
+ expect(yield* fileSystem.exists(NodePath.join(worktree, ".agents"))).toBe(false);
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "links a project's library skills into the worktree of a pull request thread",
+ () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const { home, cwd, entry } = yield* repoWithLibrarySkill();
+ const remoteDir = yield* createBareRemote();
+ yield* runGit(cwd, ["remote", "add", "origin", remoteDir]);
+ yield* runGit(cwd, ["push", "-u", "origin", "main"]);
+ yield* runGit(cwd, ["checkout", "-b", "feature/pr-links"]);
+ yield* fileSystem.writeFileString(NodePath.join(cwd, "pr.txt"), "pr\n");
+ yield* runGit(cwd, ["add", "pr.txt"]);
+ yield* runGit(cwd, ["commit", "-m", "PR branch"]);
+ yield* runGit(cwd, ["push", "-u", "origin", "feature/pr-links"]);
+ yield* runGit(cwd, ["push", "origin", "HEAD:refs/pull/78/head"]);
+ yield* runGit(cwd, ["checkout", "main"]);
+ const { manager } = yield* makeManager({
+ ghScenario: {
+ pullRequest: {
+ number: 78,
+ title: "Library links PR",
+ url: "https://github.com/pingdotgg/codething-mvp/pull/78",
+ baseRefName: "main",
+ headRefName: "feature/pr-links",
+ state: "open",
+ },
+ },
+ });
+
+ const result = yield* preparePullRequestThread(manager, {
+ cwd,
+ reference: "78",
+ mode: "worktree",
+ }).pipe(Effect.provideService(HostProcess.HomeDirectory, home));
+
+ expect(result.worktreePath).not.toBeNull();
+ expect(
+ yield* fileSystem.readLink(
+ NodePath.join(result.worktreePath as string, ".agents/skills/db-migrations"),
+ ),
+ ).toBe(entry);
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "makes the worktree all the same when the library links can't be made",
+ () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const { home, cwd } = yield* repoWithLibrarySkill();
+ // On this branch `.agents` is a file, so no folder can be made under it.
+ yield* runGit(cwd, ["checkout", "-q", "-b", "agents-file"]);
+ yield* fileSystem.remove(NodePath.join(cwd, ".agents"), { recursive: true });
+ yield* fileSystem.writeFileString(NodePath.join(cwd, ".agents"), "not a folder\n");
+ yield* runGit(cwd, ["add", "-A"]);
+ yield* runGit(cwd, ["commit", "-q", "-m", "agents is a file"]);
+ yield* runGit(cwd, ["checkout", "-q", "main"]);
+ yield* fileSystem.makeDirectory(NodePath.join(cwd, ".agents/skills"), { recursive: true });
+ yield* fileSystem.symlink(
+ NodePath.join(home, ".agents/skill-library/db-migrations"),
+ NodePath.join(cwd, ".agents/skills/db-migrations"),
+ );
+ const { manager } = yield* makeManager();
+ const worktree = NodePath.join(yield* makeTempDir("t3code-git-worktrees-"), "feature");
+ const warnings: string[] = [];
+ const logger = Logger.make(({ message }) => {
+ warnings.push(String(message));
+ });
+
+ const created = yield* manager
+ .createWorktree({
+ cwd,
+ path: worktree,
+ refName: "agents-file",
+ newRefName: "feature/no-links",
+ })
+ .pipe(
+ Effect.provideService(HostProcess.HomeDirectory, home),
+ Effect.provideService(Logger.CurrentLoggers, new Set([logger])),
+ );
+
+ expect(created.worktree.path).toBe(worktree);
+ expect(
+ warnings.filter((line) => line.includes("could not link library skills")).length,
+ ).toBe(1);
+ expect(yield* fileSystem.readFileString(NodePath.join(worktree, ".agents"))).toBe(
+ "not a folder\n",
+ );
+ }),
+ );
+
it.effect("prepares pull request threads in worktree mode on the PR head branch", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts
index 8ca0b6b976a9..8f7ad38d2cc7 100644
--- a/apps/server/src/git/GitManager.ts
+++ b/apps/server/src/git/GitManager.ts
@@ -82,6 +82,7 @@ import type { GitManagerServiceError } from "@t3tools/contracts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
+import { restoreLibraryLinks } from "../skills/SkillLibrary.ts";
import type { ChangeRequest } from "@t3tools/contracts";
export interface GitActionProgressReporter {
@@ -746,6 +747,27 @@ export const make = Effect.gen(function* () {
Effect.map((settings) => settings.worktreesDirectory),
Effect.orElseSucceed(() => ""),
);
+ /**
+ * A project's links to its library skills sit outside git, so a new checkout has none until
+ * they are made. A failure is logged and goes no further: the checkout is made either way.
+ */
+ const linkLibrarySkills = (project: string, worktree: string) =>
+ gitCore
+ .execute({
+ operation: "GitManager.linkLibrarySkills",
+ cwd: project,
+ args: ["rev-parse", "--show-prefix"],
+ allowNonZeroExit: true,
+ })
+ .pipe(
+ // The project is a folder inside the repository when this isn't empty, and the worktree
+ // has it at the same path under its own root.
+ Effect.map((result) => (result.exitCode === 0 ? result.stdout.trim() : "")),
+ Effect.orElseSucceed(() => ""),
+ Effect.flatMap((prefix) => restoreLibraryLinks({ project, worktree, prefix })),
+ Effect.provideService(FileSystem.FileSystem, fileSystem),
+ Effect.provideService(Path.Path, path),
+ );
const createWorktree: GitManager["Service"]["createWorktree"] = Effect.fn(
"GitManager.createWorktree",
)(function* (input, options) {
@@ -757,7 +779,13 @@ export const make = Effect.gen(function* () {
Effect.orElseSucceed(() => null),
);
const worktreesDirectory = yield* readWorktreesDirectory;
- return yield* gitCore.createWorktree(input, { worktreesDirectory, ...options, submodules });
+ const created = yield* gitCore.createWorktree(input, {
+ worktreesDirectory,
+ ...options,
+ submodules,
+ });
+ yield* linkLibrarySkills(input.cwd, created.worktree.path);
+ return created;
});
const readRepositoryInstructions = (cwd: string, fileName: string) =>
@@ -2655,6 +2683,7 @@ export const make = Effect.gen(function* () {
),
},
);
+ yield* linkLibrarySkills(input.cwd, worktree.worktree.path);
yield* ensureExistingWorktreeUpstream(worktree.worktree.path);
yield* maybeRunSetupScript(worktree.worktree.path);
diff --git a/apps/server/src/instructions/AgentInstructionFiles.test.ts b/apps/server/src/instructions/AgentInstructionFiles.test.ts
new file mode 100644
index 000000000000..437651d35028
--- /dev/null
+++ b/apps/server/src/instructions/AgentInstructionFiles.test.ts
@@ -0,0 +1,86 @@
+import { ProviderDriverKind } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+import { AGENT_SKILL_FOLDERS } from "@t3tools/provider-core/server/AgentSkillFolders";
+
+import {
+ AGENT_INSTRUCTION_FILES,
+ claudeManagedInstructionPath,
+ instructionRulesFor,
+ projectInstructionFile,
+} from "./AgentInstructionFiles.ts";
+
+const driver = ProviderDriverKind.make;
+
+describe("AGENT_INSTRUCTION_FILES", () => {
+ it("covers every agent that has skill folders, once each", () => {
+ const agents = AGENT_INSTRUCTION_FILES.map((rules) => rules.agent);
+ expect(new Set(agents).size).toBe(agents.length);
+ expect(new Set(agents)).toEqual(new Set(AGENT_SKILL_FOLDERS.map((table) => table.agent)));
+ });
+
+ it.each(AGENT_INSTRUCTION_FILES.map((rules) => [rules.agent, rules] as const))(
+ "%s describes files it can actually use",
+ (_agent, rules) => {
+ for (const list of [
+ rules.project?.files.map((entry) => entry.name) ?? [],
+ rules.home?.files ?? [],
+ ]) {
+ expect(new Set(list).size).toBe(list.length);
+ }
+ if (rules.home !== null) {
+ // The shared file has to be one the agent reads from its home folder.
+ expect(rules.home.files).toContain(rules.home.shared.file);
+ // Only an agent that can import a file is joined by an import line.
+ expect(rules.home.shared.join === "import").toBe(rules.imports);
+ }
+ },
+ );
+
+ it("limits Claude's setting to the files the setting governs", () => {
+ const governed = instructionRulesFor(driver("claudeAgent"))?.project?.files.filter(
+ (entry) => entry.governedBy !== undefined,
+ );
+ expect(governed?.map((entry) => entry.name)).toEqual(["AGENTS.md", ".claude/AGENTS.md"]);
+ for (const rules of AGENT_INSTRUCTION_FILES) {
+ if (rules.agent === driver("claudeAgent")) continue;
+ expect(rules.project?.files.some((entry) => entry.governedBy !== undefined)).toBe(false);
+ }
+ });
+});
+
+describe("instructionRulesFor", () => {
+ it("finds a known agent and not an unknown one", () => {
+ expect(instructionRulesFor(driver("codex"))?.agent).toBe(driver("codex"));
+ expect(instructionRulesFor(driver("ollama"))).toBeUndefined();
+ });
+});
+
+describe("projectInstructionFile", () => {
+ it("returns the rule for a name the agent reads", () => {
+ expect(projectInstructionFile(driver("claudeAgent"), "AGENTS.md")).toEqual({
+ name: "AGENTS.md",
+ governedBy: "claudeProjectInstructions",
+ });
+ expect(projectInstructionFile(driver("claudeAgent"), "CLAUDE.md")).toEqual({
+ name: "CLAUDE.md",
+ });
+ expect(projectInstructionFile(driver("opencode"), "CLAUDE.md")).toEqual({ name: "CLAUDE.md" });
+ });
+
+ it("returns nothing for a name the agent doesn't read", () => {
+ expect(projectInstructionFile(driver("codex"), "CLAUDE.md")).toBeUndefined();
+ expect(projectInstructionFile(driver("cursor"), "CLAUDE.local.md")).toBeUndefined();
+ expect(projectInstructionFile(driver("ollama"), "AGENTS.md")).toBeUndefined();
+ });
+});
+
+describe("claudeManagedInstructionPath", () => {
+ it.each([
+ ["darwin", "/Library/Application Support/ClaudeCode/CLAUDE.md"],
+ ["linux", "/etc/claude-code/CLAUDE.md"],
+ ["win32", "C:\\Program Files\\ClaudeCode\\CLAUDE.md"],
+ ["freebsd", undefined],
+ ] as const)("%s", (platform, expected) => {
+ expect(claudeManagedInstructionPath(platform)).toBe(expected);
+ });
+});
diff --git a/apps/server/src/instructions/AgentInstructionFiles.ts b/apps/server/src/instructions/AgentInstructionFiles.ts
new file mode 100644
index 000000000000..2d0fdc6ddcab
--- /dev/null
+++ b/apps/server/src/instructions/AgentInstructionFiles.ts
@@ -0,0 +1,367 @@
+/**
+ * AgentInstructionFiles - the instruction files (AGENTS.md, CLAUDE.md and friends) each agent
+ * reads, and where.
+ *
+ * One table for the Instructions section of the Skills page, in the same spirit as
+ * `AgentSkillFolders`. Data only: nothing here touches the disk. Paths are relative to a project
+ * folder (`project`) or to the user's home directory (`home`). Each file list is in the order the
+ * agent prefers it, and `selection` says what the agent does when several of them exist.
+ *
+ * Only whole instruction files are modelled. Rule folders (`.claude/rules`, `.cursor/rules`,
+ * `.grok/rules`, `.agents/rules`) and files an agent is told to load through its own config
+ * (OpenCode `instructions`, Codex `project_doc_fallback_filenames`) are out of scope.
+ *
+ * A level that can't be confirmed from the agent's documentation or source is `null`, which
+ * means "no icon, no claim". Reasons for the levels left out:
+ * - Cursor, home: User Rules live in the app's Customize -> Rules settings, not in a file.
+ * - Antigravity, home: the agent reads `~/.gemini/AGENTS.md`, but T3 Code starts it with
+ * `GEMINI_HOME` pointing at a private profile and links only the skill folders back
+ * (`linkAntigravityUserSkills` in `antigravityAuthSupport.ts`), so the user's file is never seen.
+ * - Everyone but Claude, managed: none of the others documents a managed instruction file.
+ *
+ * Sources, per agent:
+ * - Claude: https://code.claude.com/docs/en/memory ("Choose where to put CLAUDE.md files",
+ * "Import additional files", "AGENTS.md", "Choose which instruction files load"). `CLAUDE.md`
+ * and `CLAUDE.local.md` load from the working folder and every folder above it, subfolders when
+ * Claude works in them. `AGENTS.md` and `.claude/AGENTS.md` load only as the "Project
+ * instructions" setting says (see `ClaudeInstructionSetting.ts`, Claude Code 2.1.277 or later).
+ * The user file is `/CLAUDE.md`; the config dir is `CLAUDE_CONFIG_DIR` or a T3 Code
+ * instance's `homePath`, resolved as `SkillCatalog` does. The managed file is per OS.
+ * - Codex: https://learn.chatgpt.com/docs/agent-configuration/agents-md (also served at
+ * https://developers.openai.com/codex/guides/agents-md) and, in
+ * https://github.com/openai/codex/tree/fac5d0ba91: `core/src/agents_md.rs` (per folder from the
+ * project root, the nearest ancestor with a `.git` marker, down to the working folder, the
+ * first of `AGENTS.override.md` and `AGENTS.md` wins; with no root only the working folder;
+ * nothing is read in an untrusted project; 32 KiB `project_doc_max_bytes` across all files),
+ * `codex-home/src/instructions/mod.rs` (the home folder's first non-empty file of
+ * `AGENTS.override.md` and `AGENTS.md`) and `utils/home-dir/src/lib.rs` (`CODEX_HOME`).
+ * - OpenCode: https://opencode.ai/docs/rules/ and, in
+ * https://github.com/anomalyco/opencode/tree/a697115b20, `packages/opencode/src/session/instruction.ts`
+ * (`AGENTS.md` in every folder from the working folder up to the worktree root; `CLAUDE.md`, and
+ * the deprecated `CONTEXT.md`, only when no `AGENTS.md` is found anywhere on that walk; a file in
+ * a subfolder is added when the agent reads a file there; the home folder's `AGENTS.md`, else
+ * `~/.claude/CLAUDE.md`, which `OPENCODE_DISABLE_CLAUDE_CODE` and
+ * `OPENCODE_DISABLE_CLAUDE_CODE_PROMPT` turn off together with the project `CLAUDE.md`).
+ * `packages/core/src/global.ts` puts the home folder at `OPENCODE_CONFIG_DIR`, else
+ * `$XDG_CONFIG_HOME/opencode`, else `~/.config/opencode`. Its docs say it doesn't parse file
+ * references in AGENTS.md.
+ * - Pi: https://github.com/earendil-works/pi/blob/43d3763991/packages/coding-agent/docs/configuration.md
+ * ("Agent directory", "Context files"), `docs/environment-variables.md` (`PI_CODING_AGENT_DIR`)
+ * and `src/core/resource-loader.ts` (`loadContextFileFromDir`, `loadProjectContextFiles`): the
+ * first existing of five names in the agent directory, then in the working folder and every
+ * folder above it, all the way up. T3 Code's adapter leaves context loading on
+ * (`PiAdapterV2.ts`).
+ * - Cursor: https://cursor.com/docs/context/rules ("AGENTS.md": the project root and subfolders,
+ * nested files add to their parents') and https://cursor.com/docs/sdk/typescript (the SDK's
+ * workspace scan reads `AGENTS.md`; T3 Code's adapter loads the `project` and `user` setting
+ * sources). The Cursor CLI also reads a root `CLAUDE.md` (https://cursor.com/docs/cli/using),
+ * but the SDK docs don't say so, so it isn't claimed.
+ * - Grok: https://docs.x.ai/build/features/project-rules.md (every folder from the repo root down
+ * to the working folder, or only the working folder outside git; deeper files win) and
+ * https://docs.x.ai/build/settings/reference.md (`GROK_HOME`); in
+ * https://github.com/xai-org/grok-build/tree/2bdd1d6a63, `crates/codegen/xai-grok-config/src/compat.rs`
+ * (`INSTRUCTION_FILENAMES`) and `crates/codegen/xai-grok-agent/src/prompt/agents_md.rs` (every
+ * existing name loads; gitignored files are skipped; nothing is read in an untrusted folder;
+ * the home roots are `$GROK_HOME`, `~/.claude` and `~/.cursor`, the last two through the
+ * Claude and Cursor compatibility scanners that are on by default).
+ * - Antigravity: https://antigravity.google/docs/rules ("Directory-scoped rules", "Global rules",
+ * "Managing rules in Antigravity CLI"): `AGENTS.md` and `GEMINI.md`, also under `.agents/`,
+ * in the workspace root and any subfolder, found by walking up from each file the agent reads
+ * or edits; every one that exists loads. Imports there are `@[label](path)`, not Claude's.
+ *
+ * Claude's `@path` import is the only one that inlines a file. Codex, OpenCode, Pi and Grok don't
+ * document any; Cursor's `@file` mention only lets the agent read the file; Antigravity's
+ * `@[label](path)` is a different syntax. So `imports` is true for Claude alone.
+ *
+ * @module AgentInstructionFiles
+ */
+import { ProviderDriverKind } from "@t3tools/contracts";
+
+/** What an agent does when several of its instruction files exist side by side. */
+export type InstructionSelection =
+ /** Every file that exists loads. */
+ | "all"
+ /** Only the first existing file of each folder loads. */
+ | "first-per-folder"
+ /**
+ * The first name that exists anywhere on the search loads, in every folder that has it; later
+ * names are fallbacks for when no folder has an earlier one.
+ */
+ | "first-name";
+
+/** How far above the working folder an agent looks for project files. */
+export type InstructionParents =
+ /** Not above the project's top folder. */
+ | "none"
+ /** Up to the root of the repository the working folder is in. */
+ | "repo-root"
+ /** Up to the top of the file system. */
+ | "filesystem-root";
+
+export interface ProjectInstructionFile {
+ /** Relative to a folder, so `.claude/CLAUDE.md` is a name too. */
+ readonly name: string;
+ /** Claude reads this one only as its "Project instructions" setting allows. */
+ readonly governedBy?: "claudeProjectInstructions";
+}
+
+export interface ProjectInstructionRules {
+ /** In the order the agent prefers them. */
+ readonly files: readonly ProjectInstructionFile[];
+ readonly selection: InstructionSelection;
+ readonly parents: InstructionParents;
+ /**
+ * Whether a file in a subfolder is used when the agent works there (`on-demand`), or only the
+ * folders on the way up are read (`none`).
+ */
+ readonly subfolders: "none" | "on-demand";
+ /** Environment variables that switch off the files after the first name in `files`. */
+ readonly fallbackDisabledByEnv?: readonly string[];
+ /** The agent skips files git ignores, which `CLAUDE.local.md` is meant to be. */
+ readonly skipsGitIgnored?: boolean;
+}
+
+/** A folder of another agent that this one reads too, at a fixed place under the home directory. */
+export interface AlsoReadFolder {
+ /** Relative to the home directory. */
+ readonly folder: string;
+ readonly files: readonly string[];
+ /** `no-own-file`: only when the agent's own home folder has none of its files. */
+ readonly when: "always" | "no-own-file";
+ /** Environment variables that switch this fallback off. */
+ readonly disabledByEnv?: readonly string[];
+}
+
+export interface HomeInstructionRules {
+ /** The agent's own folder under the home directory, by default. */
+ readonly folder: string;
+ /** In the order the agent prefers them. */
+ readonly files: readonly string[];
+ readonly selection: Exclude;
+ /** A file with no text is passed over, so the next name in `files` is the one that loads. */
+ readonly skipsEmpty?: boolean;
+ /** The variable that names the folder itself. */
+ readonly folderEnv?: string;
+ /** The default folder sits under `$XDG_CONFIG_HOME` when that is set, else under `~/.config`. */
+ readonly xdgConfigHome?: boolean;
+ /** A T3 Code provider instance's `homePath` setting moves the folder, ahead of `folderEnv`. */
+ readonly instanceHomePath?: boolean;
+ /**
+ * How the shared all-projects file reaches the agent: `link` is a symlink at `file` in the
+ * folder, `import` is a line that imports it as the first line of `file`.
+ */
+ readonly shared: { readonly file: string; readonly join: "link" | "import" };
+ readonly alsoReads?: readonly AlsoReadFolder[];
+}
+
+/** Where an organization puts Claude's managed CLAUDE.md. WSL counts as `linux`. */
+export interface ManagedInstructionPaths {
+ readonly darwin: string;
+ readonly linux: string;
+ readonly win32: string;
+}
+
+export interface AgentInstructionRules {
+ readonly agent: ProviderDriverKind;
+ /** `null` when no project file is confirmed. */
+ readonly project: ProjectInstructionRules | null;
+ /** `null` when no file in the user's home is confirmed. */
+ readonly home: HomeInstructionRules | null;
+ readonly managed: ManagedInstructionPaths | null;
+ /** Whether a file can pull in another with Claude's `@path` line. */
+ readonly imports: boolean;
+}
+
+const file = (name: string): ProjectInstructionFile => ({ name });
+const governed = (name: string): ProjectInstructionFile => ({
+ name,
+ governedBy: "claudeProjectInstructions",
+});
+
+/** The names Grok reads in a folder, in the order of its `INSTRUCTION_FILENAMES`. */
+const GROK_FILE_NAMES = [
+ "Agents.md",
+ "Claude.md",
+ "CLAUDE.md",
+ "CLAUDE.local.md",
+ "AGENT.md",
+ "AGENTS.md",
+] as const;
+
+/** The variables that turn off OpenCode's reading of Claude's files, its project `CLAUDE.md` too. */
+const OPENCODE_CLAUDE_COMPAT_ENV = [
+ "OPENCODE_DISABLE_CLAUDE_CODE",
+ "OPENCODE_DISABLE_CLAUDE_CODE_PROMPT",
+] as const;
+
+export const AGENT_INSTRUCTION_FILES: ReadonlyArray = [
+ {
+ agent: ProviderDriverKind.make("claudeAgent"),
+ project: {
+ files: [
+ file("CLAUDE.md"),
+ file(".claude/CLAUDE.md"),
+ file("CLAUDE.local.md"),
+ governed("AGENTS.md"),
+ governed(".claude/AGENTS.md"),
+ ],
+ selection: "all",
+ parents: "filesystem-root",
+ subfolders: "on-demand",
+ },
+ home: {
+ folder: ".claude",
+ files: ["CLAUDE.md"],
+ selection: "all",
+ folderEnv: "CLAUDE_CONFIG_DIR",
+ instanceHomePath: true,
+ shared: { file: "CLAUDE.md", join: "import" },
+ },
+ managed: {
+ darwin: "/Library/Application Support/ClaudeCode/CLAUDE.md",
+ linux: "/etc/claude-code/CLAUDE.md",
+ win32: "C:\\Program Files\\ClaudeCode\\CLAUDE.md",
+ },
+ imports: true,
+ },
+ {
+ agent: ProviderDriverKind.make("codex"),
+ project: {
+ files: [file("AGENTS.override.md"), file("AGENTS.md")],
+ selection: "first-per-folder",
+ parents: "repo-root",
+ subfolders: "none",
+ },
+ home: {
+ folder: ".codex",
+ files: ["AGENTS.override.md", "AGENTS.md"],
+ selection: "first-per-folder",
+ skipsEmpty: true,
+ folderEnv: "CODEX_HOME",
+ instanceHomePath: true,
+ shared: { file: "AGENTS.md", join: "link" },
+ },
+ managed: null,
+ imports: false,
+ },
+ {
+ agent: ProviderDriverKind.make("opencode"),
+ project: {
+ files: [file("AGENTS.md"), file("CLAUDE.md")],
+ selection: "first-name",
+ parents: "repo-root",
+ subfolders: "on-demand",
+ fallbackDisabledByEnv: OPENCODE_CLAUDE_COMPAT_ENV,
+ },
+ home: {
+ folder: ".config/opencode",
+ files: ["AGENTS.md"],
+ selection: "first-per-folder",
+ folderEnv: "OPENCODE_CONFIG_DIR",
+ xdgConfigHome: true,
+ shared: { file: "AGENTS.md", join: "link" },
+ alsoReads: [
+ {
+ folder: ".claude",
+ files: ["CLAUDE.md"],
+ when: "no-own-file",
+ disabledByEnv: OPENCODE_CLAUDE_COMPAT_ENV,
+ },
+ ],
+ },
+ managed: null,
+ imports: false,
+ },
+ {
+ agent: ProviderDriverKind.make("pi"),
+ project: {
+ files: [
+ file("AGENTS.override.md"),
+ file("AGENTS.md"),
+ file("AGENTS.MD"),
+ file("CLAUDE.md"),
+ file("CLAUDE.MD"),
+ ],
+ selection: "first-per-folder",
+ parents: "filesystem-root",
+ subfolders: "none",
+ },
+ home: {
+ folder: ".pi/agent",
+ files: ["AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"],
+ selection: "first-per-folder",
+ folderEnv: "PI_CODING_AGENT_DIR",
+ shared: { file: "AGENTS.md", join: "link" },
+ },
+ managed: null,
+ imports: false,
+ },
+ {
+ agent: ProviderDriverKind.make("cursor"),
+ project: {
+ files: [file("AGENTS.md")],
+ selection: "all",
+ parents: "none",
+ subfolders: "on-demand",
+ },
+ home: null,
+ managed: null,
+ imports: false,
+ },
+ {
+ agent: ProviderDriverKind.make("grok"),
+ project: {
+ files: [...GROK_FILE_NAMES, ".claude/CLAUDE.md", ".claude/CLAUDE.local.md"].map(file),
+ selection: "all",
+ parents: "repo-root",
+ subfolders: "on-demand",
+ skipsGitIgnored: true,
+ },
+ home: {
+ folder: ".grok",
+ files: [...GROK_FILE_NAMES],
+ selection: "all",
+ folderEnv: "GROK_HOME",
+ shared: { file: "AGENTS.md", join: "link" },
+ alsoReads: [
+ { folder: ".claude", files: [...GROK_FILE_NAMES], when: "always" },
+ { folder: ".cursor", files: [...GROK_FILE_NAMES], when: "always" },
+ ],
+ },
+ managed: null,
+ imports: false,
+ },
+ {
+ agent: ProviderDriverKind.make("antigravity"),
+ project: {
+ files: ["AGENTS.md", "GEMINI.md", ".agents/AGENTS.md", ".agents/GEMINI.md"].map(file),
+ selection: "all",
+ parents: "none",
+ subfolders: "on-demand",
+ },
+ home: null,
+ managed: null,
+ imports: false,
+ },
+];
+
+/** What one agent reads, or `undefined` for an agent the table doesn't know. */
+export const instructionRulesFor = (agent: ProviderDriverKind): AgentInstructionRules | undefined =>
+ AGENT_INSTRUCTION_FILES.find((entry) => entry.agent === agent);
+
+/** The agent's rule for a project file name, or `undefined` when it doesn't read that name. */
+export const projectInstructionFile = (
+ agent: ProviderDriverKind,
+ name: string,
+): ProjectInstructionFile | undefined =>
+ instructionRulesFor(agent)?.project?.files.find((entry) => entry.name === name);
+
+/** Claude's managed CLAUDE.md on a platform, or `undefined` on one the docs don't list. */
+export const claudeManagedInstructionPath = (platform: NodeJS.Platform): string | undefined => {
+ const managed = instructionRulesFor(ProviderDriverKind.make("claudeAgent"))?.managed;
+ if (managed === null || managed === undefined) return undefined;
+ if (platform === "darwin") return managed.darwin;
+ if (platform === "linux") return managed.linux;
+ if (platform === "win32") return managed.win32;
+ return undefined;
+};
diff --git a/apps/server/src/instructions/ClaudeInstructionSetting.test.ts b/apps/server/src/instructions/ClaudeInstructionSetting.test.ts
new file mode 100644
index 000000000000..dcc2b31f484e
--- /dev/null
+++ b/apps/server/src/instructions/ClaudeInstructionSetting.test.ts
@@ -0,0 +1,462 @@
+import * as NodePath from "@effect/platform-node/NodePath";
+import { describe, expect, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import * as Path from "effect/Path";
+
+import {
+ addAgentsMdImport,
+ agentsMdImportLine,
+ claudeInstructionChanges,
+ hasAgentsMdImport,
+ parseSettingsJson,
+ readClaudeInstructionSetting,
+ removeAgentsMdImport,
+ supportsAgentsMd,
+} from "./ClaudeInstructionSetting.ts";
+import { editJsoncText } from "../skills/JsoncSettings.ts";
+
+const NEW_ID = "cc-plugin-agents-md@builtin";
+const LEGACY_ID = "agents-md@builtin";
+
+const entry = (value: unknown, extra: Record = {}) => ({
+ options: { instructionFiles: value, ...extra },
+});
+
+describe("settings.json text", () => {
+ it.each(["", "{", "null", "[]", "3", '"text"'])("is not a settings object: %j", (text) => {
+ expect(parseSettingsJson(text)).toBeUndefined();
+ });
+
+ it("parses an object, comments and trailing commas included", () => {
+ expect(parseSettingsJson('{"theme":"dark","list":[1,2]}')).toEqual({
+ theme: "dark",
+ list: [1, 2],
+ });
+ expect(parseSettingsJson('{\n // a note\n "theme": "dark",\n}')).toEqual({ theme: "dark" });
+ });
+});
+
+describe("readClaudeInstructionSetting", () => {
+ const cases: Array<[string, unknown, string, boolean]> = [
+ ["no settings", undefined, "claude-md-or-agents-md", false],
+ ["not an object", [], "claude-md-or-agents-md", false],
+ ["empty object", {}, "claude-md-or-agents-md", false],
+ ["pluginConfigs of the wrong type", { pluginConfigs: "x" }, "claude-md-or-agents-md", false],
+ ["current id", { pluginConfigs: { [NEW_ID]: entry("claude-md") } }, "claude-md", true],
+ [
+ "legacy id only",
+ { pluginConfigs: { [LEGACY_ID]: entry("claude-md-and-agents-md") } },
+ "claude-md-and-agents-md",
+ true,
+ ],
+ [
+ "both ids, current wins",
+ { pluginConfigs: { [NEW_ID]: entry("managed-only"), [LEGACY_ID]: entry("claude-md") } },
+ "managed-only",
+ true,
+ ],
+ [
+ "unknown value in the current id, legacy is valid",
+ { pluginConfigs: { [NEW_ID]: entry("nonsense"), [LEGACY_ID]: entry("claude-md") } },
+ "claude-md",
+ true,
+ ],
+ [
+ "unknown value only",
+ { pluginConfigs: { [NEW_ID]: entry("nonsense") } },
+ "claude-md-or-agents-md",
+ false,
+ ],
+ [
+ "value of the wrong type",
+ { pluginConfigs: { [NEW_ID]: entry(3) } },
+ "claude-md-or-agents-md",
+ false,
+ ],
+ ];
+
+ it.each(cases)("%s", (_name, settings, value, explicit) => {
+ expect(readClaudeInstructionSetting(settings)).toEqual({ value, explicit });
+ });
+});
+
+/** The settings after the shared editor makes the changes, `undefined` when it refuses. */
+const withClaudeInstructionSetting = (
+ settings: Record,
+ value: Parameters[1],
+) => {
+ const text = JSON.stringify(settings, null, 2);
+ const edited = editJsoncText(text, claudeInstructionChanges(settings, value));
+ return edited === undefined ? undefined : parseSettingsJson(edited);
+};
+
+describe("claudeInstructionChanges", () => {
+ it("creates the nested objects in empty settings", () => {
+ expect(withClaudeInstructionSetting({}, "claude-md-and-agents-md")).toEqual({
+ pluginConfigs: { [NEW_ID]: entry("claude-md-and-agents-md") },
+ });
+ });
+
+ it("keeps other keys at every level, and their order", () => {
+ const settings = {
+ theme: "dark",
+ pluginConfigs: {
+ "other@marketplace": { enabled: true },
+ [NEW_ID]: { enabled: true, options: { other: 1, instructionFiles: "claude-md" } },
+ },
+ hooks: {},
+ };
+ const updated = withClaudeInstructionSetting(settings, "managed-only");
+ expect(updated).toEqual({
+ theme: "dark",
+ pluginConfigs: {
+ "other@marketplace": { enabled: true },
+ [NEW_ID]: { enabled: true, options: { other: 1, instructionFiles: "managed-only" } },
+ },
+ hooks: {},
+ });
+ expect(Object.keys(updated ?? {})).toEqual(["theme", "pluginConfigs", "hooks"]);
+ });
+
+ it("keeps the comments of a file it edits", () => {
+ const text = '{\n // my theme\n "theme": "dark"\n}\n';
+ const edited = editJsoncText(
+ text,
+ claudeInstructionChanges(parseSettingsJson(text) ?? {}, "claude-md"),
+ );
+ expect(edited).toContain("// my theme");
+ expect(parseSettingsJson(edited ?? "")).toEqual({
+ theme: "dark",
+ pluginConfigs: { [NEW_ID]: entry("claude-md") },
+ });
+ });
+
+ it("updates a legacy entry that has a value, and leaves one that has none", () => {
+ expect(
+ withClaudeInstructionSetting(
+ { pluginConfigs: { [LEGACY_ID]: entry("claude-md") } },
+ "claude-md-and-agents-md",
+ ),
+ ).toEqual({
+ pluginConfigs: {
+ [LEGACY_ID]: entry("claude-md-and-agents-md"),
+ [NEW_ID]: entry("claude-md-and-agents-md"),
+ },
+ });
+ const withoutValue = { pluginConfigs: { [LEGACY_ID]: { options: { other: true } } } };
+ expect(withClaudeInstructionSetting(withoutValue, "claude-md")).toEqual({
+ pluginConfigs: { [LEGACY_ID]: { options: { other: true } }, [NEW_ID]: entry("claude-md") },
+ });
+ });
+
+ it("removes the entry, then every object that it leaves empty", () => {
+ expect(
+ withClaudeInstructionSetting({ pluginConfigs: { [NEW_ID]: entry("claude-md") } }, null),
+ ).toEqual({});
+ expect(
+ withClaudeInstructionSetting(
+ { theme: "dark", pluginConfigs: { [NEW_ID]: entry("claude-md") } },
+ null,
+ ),
+ ).toEqual({ theme: "dark" });
+ });
+
+ it("stops cleaning up at the first object that still has something in it", () => {
+ expect(
+ withClaudeInstructionSetting(
+ { pluginConfigs: { [NEW_ID]: entry("claude-md", { other: 1 }) } },
+ null,
+ ),
+ ).toEqual({ pluginConfigs: { [NEW_ID]: { options: { other: 1 } } } });
+ expect(
+ withClaudeInstructionSetting(
+ { pluginConfigs: { [NEW_ID]: { enabled: true, ...entry("claude-md") } } },
+ null,
+ ),
+ ).toEqual({ pluginConfigs: { [NEW_ID]: { enabled: true } } });
+ expect(
+ withClaudeInstructionSetting(
+ { pluginConfigs: { "other@marketplace": {}, [NEW_ID]: entry("claude-md") } },
+ null,
+ ),
+ ).toEqual({ pluginConfigs: { "other@marketplace": {} } });
+ });
+
+ it("removes a legacy entry together with the current one", () => {
+ const updated = withClaudeInstructionSetting(
+ {
+ pluginConfigs: { [NEW_ID]: entry("claude-md"), [LEGACY_ID]: entry("claude-md") },
+ theme: "dark",
+ },
+ null,
+ );
+ expect(updated).toEqual({ theme: "dark" });
+ expect(readClaudeInstructionSetting(updated)).toEqual({
+ value: "claude-md-or-agents-md",
+ explicit: false,
+ });
+ });
+
+ it("has nothing to change when there is nothing to remove", () => {
+ expect(claudeInstructionChanges({ pluginConfigs: {}, theme: "dark" }, null)).toEqual([]);
+ });
+
+ it("refuses to overwrite a value that isn't an object, and leaves it alone on removal", () => {
+ for (const settings of [
+ { pluginConfigs: "x" },
+ { pluginConfigs: [] },
+ { pluginConfigs: { [NEW_ID]: true } },
+ { pluginConfigs: { [NEW_ID]: { options: "x" } } },
+ ]) {
+ expect(withClaudeInstructionSetting(settings, "claude-md")).toBeUndefined();
+ expect(withClaudeInstructionSetting(settings, null)).toEqual(settings);
+ }
+ });
+
+ it("reads back what it writes", () => {
+ const written = withClaudeInstructionSetting({}, "claude-md");
+ expect(readClaudeInstructionSetting(written)).toEqual({ value: "claude-md", explicit: true });
+ });
+});
+
+describe("supportsAgentsMd", () => {
+ const cases: Array<[string | null | undefined, boolean]> = [
+ ["2.1.277", true],
+ ["2.1.276", false],
+ ["2.1.291", true],
+ ["2.2.0", true],
+ ["3.0.0", true],
+ ["2.0.999", false],
+ ["1.9.9", false],
+ ["v2.1.277", true],
+ [" 2.1.291 ", true],
+ ["2.1.291 (Claude Code)", true],
+ ["2.1.277+build.5", true],
+ ["2.1.277-beta.1", false],
+ ["2.1.278-beta.1", true],
+ ["2.1", false],
+ ["2", false],
+ ["", false],
+ [" ", false],
+ ["latest", false],
+ ["2.1.x", false],
+ ["(Claude Code)", false],
+ [null, false],
+ [undefined, false],
+ ];
+
+ it.each(cases)("%j", (version, expected) => {
+ expect(supportsAgentsMd(version)).toBe(expected);
+ });
+});
+
+/** The two kinds of AGENTS.md that a CLAUDE.md imports, on the platform the layer provides. */
+const targets = Effect.gen(function* () {
+ const path = yield* Path.Path;
+ return {
+ /** The shared file in the home directory, imported from Claude's own CLAUDE.md. */
+ shared: {
+ path,
+ agentsMdPath: "/home/user/.agents/AGENTS.md",
+ claudeMdDirectory: "/home/user/.claude",
+ homeDirectory: "/home/user",
+ },
+ /** A project's AGENTS.md, imported from the CLAUDE.md next to it. */
+ project: {
+ path,
+ agentsMdPath: "/work/acme-web/AGENTS.md",
+ claudeMdDirectory: "/work/acme-web",
+ homeDirectory: "/home/user",
+ },
+ sharedWithSpace: {
+ path,
+ agentsMdPath: "/home/user/My Notes/AGENTS.md",
+ claudeMdDirectory: "/home/user/.claude",
+ homeDirectory: "/home/user",
+ },
+ };
+});
+
+type TargetName = "shared" | "project" | "sharedWithSpace";
+
+it.layer(NodePath.layerPosix, { excludeTestServices: true })("AGENTS.md imports", (it) => {
+ describe("agentsMdImportLine", () => {
+ const cases: Array<[TargetName | { agentsMdPath: string }, string]> = [
+ ["shared", "@~/.agents/AGENTS.md"],
+ ["project", "@/work/acme-web/AGENTS.md"],
+ ["sharedWithSpace", "@~/My\\ Notes/AGENTS.md"],
+ [{ agentsMdPath: "/home/username/AGENTS.md" }, "@/home/username/AGENTS.md"],
+ [{ agentsMdPath: "/home/user/..cache/AGENTS.md" }, "@~/..cache/AGENTS.md"],
+ ];
+
+ it.effect.each(cases)("%#", ([which, line]) =>
+ Effect.gen(function* () {
+ const all = yield* targets;
+ const target =
+ typeof which === "string"
+ ? all[which]
+ : { ...all.shared, agentsMdPath: which.agentsMdPath };
+ expect(agentsMdImportLine(target)).toBe(line);
+ }),
+ );
+ });
+
+ describe("hasAgentsMdImport", () => {
+ const cases: Array<[string, TargetName, string, boolean]> = [
+ ["home form", "shared", "@~/.agents/AGENTS.md", true],
+ ["absolute", "shared", "@/home/user/.agents/AGENTS.md", true],
+ ["relative with dots", "shared", "@../.agents/AGENTS.md", true],
+ ["same folder", "project", "@AGENTS.md", true],
+ ["same folder with ./", "project", "@./AGENTS.md", true],
+ ["roundabout", "project", "@../acme-web/AGENTS.md", true],
+ ["indented and padded", "project", " @AGENTS.md ", true],
+ ["escaped space", "sharedWithSpace", "@~/My\\ Notes/AGENTS.md", true],
+ ["another file", "project", "@docs/AGENTS.md", false],
+ ["relative to the wrong folder", "shared", "@AGENTS.md", false],
+ ["another home", "shared", "@~/other/AGENTS.md", false],
+ ["unescaped space", "sharedWithSpace", "@~/My Notes/AGENTS.md", false],
+ ["mentioned in a sentence", "project", "See @AGENTS.md for more", false],
+ ["text after the path", "project", "@AGENTS.md please", false],
+ ["not at the start of the line", "project", "- @AGENTS.md", false],
+ ["quoted", "project", '@"AGENTS.md"', false],
+ ["bare at sign", "project", "@", false],
+ ["no at sign", "project", "AGENTS.md", false],
+ ];
+
+ it.effect.each(cases)("%s", ([, which, text, expected]) =>
+ Effect.gen(function* () {
+ expect(hasAgentsMdImport(text, (yield* targets)[which])).toBe(expected);
+ }),
+ );
+
+ it.effect("finds the import anywhere in the text, not only on the first line", () =>
+ Effect.gen(function* () {
+ const { project } = yield* targets;
+ expect(hasAgentsMdImport("# Notes\n\n@AGENTS.md\nmore\n", project)).toBe(true);
+ }),
+ );
+
+ it.effect("ignores lines inside fenced code blocks", () =>
+ Effect.gen(function* () {
+ const { project } = yield* targets;
+ expect(hasAgentsMdImport("```\n@AGENTS.md\n```\n", project)).toBe(false);
+ expect(hasAgentsMdImport("~~~md\n@AGENTS.md\n~~~\n", project)).toBe(false);
+ expect(hasAgentsMdImport("````\n```\n@AGENTS.md\n```\n````\n", project)).toBe(false);
+ expect(hasAgentsMdImport("```\ncode\n```\n@AGENTS.md\n", project)).toBe(true);
+ }),
+ );
+
+ it.effect("treats an unclosed fence as running to the end of the text", () =>
+ Effect.gen(function* () {
+ const { project } = yield* targets;
+ expect(hasAgentsMdImport("```\n@AGENTS.md\n", project)).toBe(false);
+ }),
+ );
+ });
+
+ describe("addAgentsMdImport", () => {
+ const line = "@~/.agents/AGENTS.md";
+ const cases: Array<[string, string, string]> = [
+ ["empty text", "", `${line}\n`],
+ ["one line, no line ending", "# Notes", `${line}\n# Notes`],
+ ["text with a trailing newline", "# Notes\n", `${line}\n# Notes\n`],
+ ["CRLF text", "# Notes\r\nmore\r\n", `${line}\r\n# Notes\r\nmore\r\n`],
+ ["leading blank lines", "\n\n# Notes\n", `${line}\n\n\n# Notes\n`],
+ ["leading blank CRLF lines", "\r\n# Notes\r\n", `${line}\r\n\r\n# Notes\r\n`],
+ ["other @ lines", "@README.md\n@docs/guide.md\n", `${line}\n@README.md\n@docs/guide.md\n`],
+ ["a byte order mark", "\uFEFF# Notes\n", `\uFEFF${line}\n# Notes\n`],
+ ];
+
+ it.effect.each(cases)("%s", ([, text, expected]) =>
+ Effect.gen(function* () {
+ const { shared } = yield* targets;
+ expect(addAgentsMdImport(text, shared)).toBe(expected);
+ }),
+ );
+
+ it.effect("leaves text that already imports the file as it is, wherever the import is", () =>
+ Effect.gen(function* () {
+ const { shared } = yield* targets;
+ for (const text of [
+ `${line}\n# Notes\n`,
+ `# Notes\n${line}\n`,
+ "@../.agents/AGENTS.md\n",
+ `\n${line}`,
+ ]) {
+ expect(addAgentsMdImport(text, shared)).toBe(text);
+ }
+ }),
+ );
+
+ it.effect("adds the import when the only mention is inside a code block", () =>
+ Effect.gen(function* () {
+ const { shared } = yield* targets;
+ expect(addAgentsMdImport("```\n@~/.agents/AGENTS.md\n```\n", shared)).toBe(
+ `${line}\n\`\`\`\n@~/.agents/AGENTS.md\n\`\`\`\n`,
+ );
+ }),
+ );
+ });
+
+ describe("removeAgentsMdImport", () => {
+ const line = "@~/.agents/AGENTS.md";
+ const cases: Array<[string, string, string]> = [
+ ["only the import", `${line}\n`, ""],
+ ["the import and no line ending", line, ""],
+ ["first line", `${line}\n# Notes\n`, "# Notes\n"],
+ ["middle line", `# Notes\n${line}\nmore\n`, "# Notes\nmore\n"],
+ ["last line", `# Notes\n${line}`, "# Notes\n"],
+ ["CRLF text", `${line}\r\n# Notes\r\nmore\r\n`, "# Notes\r\nmore\r\n"],
+ ["written another way", "@../.agents/AGENTS.md\n# Notes\n", "# Notes\n"],
+ ["twice", `${line}\n# Notes\n${line}\n`, "# Notes\n"],
+ ["other @ lines stay", `${line}\n@README.md\n`, "@README.md\n"],
+ ["blank lines stay", `${line}\n\n# Notes\n`, "\n# Notes\n"],
+ ["not there", "# Notes\n@README.md\n", "# Notes\n@README.md\n"],
+ ["a byte order mark", `\uFEFF${line}\n# Notes\n`, "\uFEFF# Notes\n"],
+ ];
+
+ it.effect.each(cases)("%s", ([, text, expected]) =>
+ Effect.gen(function* () {
+ const { shared } = yield* targets;
+ expect(removeAgentsMdImport(text, shared)).toBe(expected);
+ }),
+ );
+
+ it.effect("leaves a mention inside a code block alone", () =>
+ Effect.gen(function* () {
+ const { shared } = yield* targets;
+ const text = `\`\`\`\n${line}\n\`\`\`\n`;
+ expect(removeAgentsMdImport(text, shared)).toBe(text);
+ }),
+ );
+
+ it.effect.each(["", "# Notes\n", "# Notes", "\n\nA\r\nB\r\n", "@README.md\n"])(
+ "undoes an add: %j",
+ (text) =>
+ Effect.gen(function* () {
+ const { shared } = yield* targets;
+ expect(removeAgentsMdImport(addAgentsMdImport(text, shared), shared)).toBe(text);
+ }),
+ );
+ });
+});
+
+it.layer(NodePath.layerWin32, { excludeTestServices: true })(
+ "AGENTS.md imports on Windows",
+ (it) => {
+ it.effect("writes forward slashes and reads its own paths back", () =>
+ Effect.gen(function* () {
+ const target = {
+ path: yield* Path.Path,
+ agentsMdPath: "C:\\Users\\user\\.agents\\AGENTS.md",
+ claudeMdDirectory: "C:\\Users\\user\\.claude",
+ homeDirectory: "C:\\Users\\user",
+ };
+ expect(agentsMdImportLine(target)).toBe("@~/.agents/AGENTS.md");
+ expect(hasAgentsMdImport("@~/.agents/AGENTS.md\r\n", target)).toBe(true);
+ expect(hasAgentsMdImport("@../.agents/AGENTS.md\r\n", target)).toBe(true);
+ expect(hasAgentsMdImport("@~/.agents/OTHER.md\r\n", target)).toBe(false);
+ }),
+ );
+ },
+);
diff --git a/apps/server/src/instructions/ClaudeInstructionSetting.ts b/apps/server/src/instructions/ClaudeInstructionSetting.ts
new file mode 100644
index 000000000000..4941d1423b45
--- /dev/null
+++ b/apps/server/src/instructions/ClaudeInstructionSetting.ts
@@ -0,0 +1,208 @@
+/**
+ * ClaudeInstructionSetting - Claude's "Project instructions" setting, the import line that lets a
+ * CLAUDE.md read an AGENTS.md, and the Claude Code version that can read AGENTS.md at all.
+ *
+ * Pure functions over parsed JSON and text; the caller reads and writes the files. Sources:
+ * https://code.claude.com/docs/en/memory ("Choose which instruction files load", "Import
+ * additional files", "When AGENTS.md support is unavailable").
+ *
+ * The setting is `pluginConfigs["cc-plugin-agents-md@builtin"].options.instructionFiles` in the
+ * Claude config folder's `settings.json`. Claude Code ignores it in project and local settings.
+ * Before 2.1.285 the plugin's id was `agents-md@builtin` and Claude Code 2.1.285 and later reads
+ * either, so reading checks both ids and writing keeps a legacy entry that has a value in step.
+ *
+ * Imports are `@path` in the text of a CLAUDE.md. Only an import that is a line by itself is
+ * managed here; Claude also imports a path mentioned inside a sentence, which these functions
+ * neither detect nor remove. Lines inside fenced code blocks are not imports, as in Claude.
+ *
+ * @module ClaudeInstructionSetting
+ */
+import { ClaudeInstructionValue } from "@t3tools/contracts";
+import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";
+import type * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+
+import { parseJsonc, type JsoncChange } from "../skills/JsoncSettings.ts";
+
+/** What Claude does when the setting is absent: AGENTS.md only when there is no CLAUDE.md. */
+export const DEFAULT_CLAUDE_INSTRUCTION_VALUE: ClaudeInstructionValue = "claude-md-or-agents-md";
+
+/** The first Claude Code release that reads AGENTS.md. */
+const MIN_AGENTS_MD_CLAUDE_VERSION = "2.1.277";
+
+const PLUGIN_ID = "cc-plugin-agents-md@builtin";
+const LEGACY_PLUGIN_ID = "agents-md@builtin";
+const OPTION = "instructionFiles";
+
+const settingPath = (pluginId: string) => ["pluginConfigs", pluginId, "options", OPTION] as const;
+
+/** A parsed JSON object, such as the contents of `settings.json`. */
+export type JsonObject = Record;
+
+const isObject = (value: unknown): value is JsonObject =>
+ typeof value === "object" && value !== null && !Array.isArray(value);
+
+const isInstructionValue = Schema.is(ClaudeInstructionValue);
+
+const getIn = (root: unknown, keys: readonly string[]): unknown => {
+ let current = root;
+ for (const key of keys) {
+ if (!isObject(current)) return undefined;
+ current = current[key];
+ }
+ return current;
+};
+
+/**
+ * The text of a `settings.json` as an object, or `undefined` when Claude couldn't read it as one.
+ * Comments and trailing commas are fine, as they are for the skill settings in the same file. A
+ * byte order mark in front of the text, which the file reads keep, isn't part of the JSON.
+ */
+export const parseSettingsJson = (text: string): JsonObject | undefined => {
+ const { value, valid } = parseJsonc(text.startsWith("\uFEFF") ? text.slice(1) : text);
+ return valid && isObject(value) ? value : undefined;
+};
+
+export interface ClaudeInstructionSetting {
+ readonly value: ClaudeInstructionValue;
+ /** False when no known value is set and `value` is Claude's default. */
+ readonly explicit: boolean;
+}
+
+/** The "Project instructions" value in a parsed `settings.json`. */
+export const readClaudeInstructionSetting = (settings: unknown): ClaudeInstructionSetting => {
+ for (const pluginId of [PLUGIN_ID, LEGACY_PLUGIN_ID]) {
+ const value = getIn(settings, settingPath(pluginId));
+ if (isInstructionValue(value)) return { value, explicit: true };
+ }
+ return { value: DEFAULT_CLAUDE_INSTRUCTION_VALUE, explicit: false };
+};
+
+/**
+ * What to change in a `settings.json` (for `editJsoncFile`) to set "Project instructions" to
+ * `value`, or back to Claude's default when `value` is null: the entry goes, and the editor takes
+ * the objects it leaves empty with it. A legacy entry that has a value is kept in step. Everything
+ * else is left as it is, so the editor refuses (and the caller leaves the file alone) when
+ * `pluginConfigs` or the plugin's entry exists but isn't an object.
+ */
+export const claudeInstructionChanges = (
+ settings: JsonObject,
+ value: ClaudeInstructionValue | null,
+): ReadonlyArray => {
+ if (value === null) {
+ return [PLUGIN_ID, LEGACY_PLUGIN_ID].flatMap((pluginId) =>
+ getIn(settings, settingPath(pluginId)) === undefined
+ ? []
+ : [{ path: settingPath(pluginId), value: undefined }],
+ );
+ }
+ return [
+ { path: settingPath(PLUGIN_ID), value },
+ ...(getIn(settings, settingPath(LEGACY_PLUGIN_ID)) === undefined
+ ? []
+ : [{ path: settingPath(LEGACY_PLUGIN_ID), value }]),
+ ];
+};
+
+/**
+ * Whether a Claude Code version can read AGENTS.md. Takes the first word, so
+ * `2.1.291 (Claude Code)` works. Prereleases sort below their release, and anything that isn't a
+ * version is false.
+ */
+export const supportsAgentsMd = (version: string | null | undefined): boolean => {
+ const word = version?.trim().split(/\s+/)[0]?.split("+")[0];
+ if (word === undefined || word === "" || parseSemver(word) === null) return false;
+ return compareSemverVersions(word, MIN_AGENTS_MD_CLAUDE_VERSION) >= 0;
+};
+
+export interface AgentsMdImportTarget {
+ /** Resolves the paths, so the answer follows the platform the files are on. */
+ readonly path: Path.Path;
+ /** The AGENTS.md the import points at. */
+ readonly agentsMdPath: string;
+ /** The folder of the CLAUDE.md; a relative import resolves against it. */
+ readonly claudeMdDirectory: string;
+ /** What `~` means in an import. */
+ readonly homeDirectory: string;
+}
+
+/** The import line for the target: `@~/...` under the home directory, else the absolute path. */
+export const agentsMdImportLine = (target: AgentsMdImportTarget): string => {
+ const { path } = target;
+ const relative = path.relative(target.homeDirectory, target.agentsMdPath);
+ const inHome =
+ relative !== "" &&
+ relative !== ".." &&
+ !relative.startsWith(`..${path.sep}`) &&
+ !path.isAbsolute(relative);
+ const written = inHome ? `~/${relative.split(path.sep).join("/")}` : target.agentsMdPath;
+ return `@${written.replaceAll(" ", "\\ ")}`;
+};
+
+/** The path an import line points at, or `undefined` when the line is not an import by itself. */
+const importedPath = (line: string, target: AgentsMdImportTarget): string | undefined => {
+ const body = line.trim();
+ if (!body.startsWith("@")) return undefined;
+ const written = body.slice(1);
+ if (written === "" || /(? {
+ const resolvedTarget = target.path.resolve(target.agentsMdPath);
+ let fence: { readonly marker: string; readonly length: number } | undefined;
+ return text
+ .split(/(?<=\n)/)
+ .filter((line) => line !== "")
+ .map((line) => {
+ const content = line.replace(/\r?\n$/, "");
+ const opened = FENCE.exec(content);
+ if (fence === undefined) {
+ if (opened?.[1] !== undefined) {
+ fence = { marker: opened[1].charAt(0), length: opened[1].length };
+ return { line, isImport: false };
+ }
+ return { line, isImport: importedPath(content, target) === resolvedTarget };
+ }
+ const closes =
+ opened?.[1] !== undefined &&
+ opened[1].charAt(0) === fence.marker &&
+ opened[1].length >= fence.length &&
+ opened[2]?.trim() === "";
+ if (closes) fence = undefined;
+ return { line, isImport: false };
+ });
+};
+
+/** Whether the text has a line that imports the target. */
+export const hasAgentsMdImport = (text: string, target: AgentsMdImportTarget): boolean =>
+ scanImports(text, target).some((entry) => entry.isImport);
+
+/**
+ * The text with an import of the target as its first line and everything else as it was. Text
+ * that already imports the target comes back as it is.
+ */
+export const addAgentsMdImport = (text: string, target: AgentsMdImportTarget): string => {
+ if (hasAgentsMdImport(text, target)) return text;
+ const lineEnding = /\r?\n/.exec(text)?.[0] ?? "\n";
+ const bom = text.startsWith("") ? "" : "";
+ return `${bom}${agentsMdImportLine(target)}${lineEnding}${text.slice(bom.length)}`;
+};
+
+/** The text without any line that imports the target, everything else as it was. */
+export const removeAgentsMdImport = (text: string, target: AgentsMdImportTarget): string => {
+ const kept = scanImports(text, target)
+ .filter((entry) => !entry.isImport)
+ .map((entry) => entry.line)
+ .join("");
+ return kept !== "" && text.startsWith("\uFEFF") && !kept.startsWith("\uFEFF")
+ ? `\uFEFF${kept}`
+ : kept;
+};
diff --git a/apps/server/src/instructions/InstructionCatalog.test.ts b/apps/server/src/instructions/InstructionCatalog.test.ts
new file mode 100644
index 000000000000..61b9f82fa9dd
--- /dev/null
+++ b/apps/server/src/instructions/InstructionCatalog.test.ts
@@ -0,0 +1,1012 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { describe, expect, it } from "@effect/vitest";
+import {
+ InstructionError,
+ InstructionListResult,
+ InstructionReadResult,
+ ProviderDriverKind,
+ ProviderInstanceId,
+ type InstructionAgentAccess,
+ type InstructionEntry,
+} from "@t3tools/contracts";
+import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
+import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+
+import * as InstructionCatalog from "./InstructionCatalog.ts";
+import { layerFor, makeMachine, type MachineOptions } from "./testing/machine.ts";
+
+const encodeList = Schema.encodeUnknownEffect(InstructionListResult);
+const encodeRead = Schema.encodeUnknownEffect(InstructionReadResult);
+
+/** The catalog on the machine at `home`; every list goes through the RPC schema encode. */
+const onMachine = (
+ home: string,
+ options: MachineOptions,
+ use: (catalog: InstructionCatalog.InstructionCatalog["Service"]) => Effect.Effect,
+) =>
+ Effect.gen(function* () {
+ return yield* use(yield* InstructionCatalog.InstructionCatalog);
+ }).pipe(Effect.provide(layerFor(home, options)));
+
+const listed = (
+ catalog: InstructionCatalog.InstructionCatalog["Service"],
+ input: { readonly cwd?: string } = {},
+) =>
+ Effect.gen(function* () {
+ const result = yield* catalog.list(input);
+ yield* encodeList(result);
+ return result;
+ });
+
+const entryOf = (entries: readonly InstructionEntry[], id: string) => {
+ const entry = entries.find((candidate) => candidate.id === id);
+ if (!entry) throw new Error(`No entry ${id} in ${entries.map((e) => e.id).join(", ")}`);
+ return entry;
+};
+
+/** What each agent does with a file: `state`, plus a reason when it has one. */
+const accessOf = (entry: InstructionEntry) =>
+ Object.fromEntries(
+ entry.access.map((access) => [
+ access.instanceId,
+ access.reason === undefined ? access.state : `${access.state}:${access.reason}`,
+ ]),
+ );
+
+const accessFor = (entry: InstructionEntry, instanceId: string): InstructionAgentAccess => {
+ const access = entry.access.find((candidate) => candidate.instanceId === instanceId);
+ if (!access) throw new Error(`No access for ${instanceId} on ${entry.id}`);
+ return access;
+};
+
+const CLAUDE = { versions: { claudeAgent: "2.1.291" } } satisfies MachineOptions;
+
+it.layer(NodeServices.layer, { excludeTestServices: true })("InstructionCatalog", (it) => {
+ describe("project files", () => {
+ it.effect(
+ "lists a missing AGENTS.md and CLAUDE.local.md so they can be created, and no other",
+ () =>
+ Effect.gen(function* () {
+ const { home, project } = yield* makeMachine;
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const { entries } = yield* listed(catalog, { cwd: project });
+ const projectEntries = entries.filter((entry) => entry.scope === "project");
+
+ expect(projectEntries.map((entry) => entry.id)).toEqual([
+ "project:shared:AGENTS.md",
+ "project:claudeLocal:CLAUDE.local.md",
+ ]);
+ expect(accessFor(projectEntries[1]!, "claudeAgent").state).toBe("direct");
+ expect(projectEntries[0]).toMatchObject({
+ kind: "shared",
+ exists: false,
+ size: 0,
+ readOnly: false,
+ path: `${project}/AGENTS.md`,
+ relativePath: "AGENTS.md",
+ });
+ }),
+ );
+ }),
+ );
+
+ it.effect("reads no project files without a project", () =>
+ Effect.gen(function* () {
+ const { home, write } = yield* makeMachine;
+ yield* write("repos/app/AGENTS.md", "rules");
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const { entries } = yield* listed(catalog);
+ expect(entries.some((entry) => entry.scope === "project")).toBe(false);
+ }),
+ );
+ }),
+ );
+
+ it.effect("tells which agents read a CLAUDE.md that has no AGENTS.md next to it", () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ yield* write("repos/app/CLAUDE.md", "claude rules");
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const { entries } = yield* listed(catalog, { cwd: project });
+ const claudeMd = entryOf(entries, "project:claude:CLAUDE.md");
+
+ expect(claudeMd).toMatchObject({ exists: true, size: 12, kind: "claude" });
+ expect(accessOf(claudeMd)).toEqual({
+ claudeAgent: "direct",
+ codex: "none",
+ cursor: "none",
+ grok: "direct",
+ // OpenCode and Pi fall back to CLAUDE.md only because nothing named AGENTS.md exists.
+ opencode: "direct",
+ antigravity: "none",
+ pi: "direct",
+ });
+ }),
+ );
+ }),
+ );
+
+ it.effect("stops Pi and OpenCode reading CLAUDE.md once AGENTS.md exists", () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ yield* write("repos/app/CLAUDE.md", "claude rules");
+ yield* write("repos/app/AGENTS.md", "shared rules");
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const { entries } = yield* listed(catalog, { cwd: project });
+ const claudeMd = entryOf(entries, "project:claude:CLAUDE.md");
+ const agentsMd = entryOf(entries, "project:shared:AGENTS.md");
+
+ expect(accessFor(claudeMd, "pi")).toMatchObject({
+ state: "none",
+ blockingFile: "AGENTS.md",
+ });
+ expect(accessFor(claudeMd, "opencode")).toMatchObject({
+ state: "none",
+ blockingFile: "AGENTS.md",
+ });
+ expect(accessFor(claudeMd, "claudeAgent").state).toBe("direct");
+ expect(accessFor(claudeMd, "grok").state).toBe("direct");
+ // Everyone but Claude reads AGENTS.md; Claude's own CLAUDE.md wins by default.
+ expect(accessOf(agentsMd)).toEqual({
+ claudeAgent: "none:claudeFiles",
+ codex: "direct",
+ cursor: "direct",
+ grok: "direct",
+ opencode: "direct",
+ antigravity: "direct",
+ pi: "direct",
+ });
+ expect(accessFor(agentsMd, "claudeAgent").blockingFile).toBe("CLAUDE.md");
+ }),
+ );
+ }),
+ );
+
+ it.effect("makes Codex and Pi prefer AGENTS.override.md", () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ yield* write("repos/app/AGENTS.md", "shared rules");
+ yield* write("repos/app/AGENTS.override.md", "override");
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const { entries } = yield* listed(catalog, { cwd: project });
+ const agentsMd = entryOf(entries, "project:shared:AGENTS.md");
+
+ for (const blocked of ["codex", "pi"]) {
+ expect(accessFor(agentsMd, blocked)).toMatchObject({
+ state: "none",
+ blockingFile: "AGENTS.override.md",
+ });
+ }
+ expect(accessFor(agentsMd, "opencode").state).toBe("direct");
+ }),
+ );
+ }),
+ );
+
+ it.effect("doesn't credit Grok with CLAUDE.local.md, which it skips when git ignores it", () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ yield* write("repos/app/CLAUDE.local.md", "mine");
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const { entries } = yield* listed(catalog, { cwd: project });
+ const local = entryOf(entries, "project:claudeLocal:CLAUDE.local.md");
+
+ expect(local.kind).toBe("claudeLocal");
+ expect(accessFor(local, "claudeAgent").state).toBe("direct");
+ expect(accessFor(local, "grok").state).toBe("none");
+ }),
+ );
+ }),
+ );
+
+ it.effect("switches OpenCode's CLAUDE.md fallback off with its environment variable", () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ yield* write("repos/app/CLAUDE.md", "claude rules");
+ yield* onMachine(
+ home,
+ {
+ ...CLAUDE,
+ providerInstances: {
+ [ProviderInstanceId.make("opencode")]: {
+ driver: ProviderDriverKind.make("opencode"),
+ enabled: true,
+ environment: [
+ { name: "OPENCODE_DISABLE_CLAUDE_CODE", value: "1", sensitive: false },
+ ],
+ },
+ },
+ },
+ (catalog) =>
+ Effect.gen(function* () {
+ const { entries } = yield* listed(catalog, { cwd: project });
+ expect(
+ accessFor(entryOf(entries, "project:claude:CLAUDE.md"), "opencode").state,
+ ).toBe("none");
+ }),
+ );
+ }),
+ );
+ });
+
+ describe("Claude and a project's AGENTS.md", () => {
+ const claudeAccess = (entries: readonly InstructionEntry[]) =>
+ accessFor(entryOf(entries, "project:shared:AGENTS.md"), "claudeAgent");
+
+ it.effect("is blocked by CLAUDE.local.md and names it", () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ yield* write("repos/app/AGENTS.md", "rules");
+ yield* write("repos/app/CLAUDE.local.md", "mine");
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const { entries, claude } = yield* listed(catalog, { cwd: project });
+
+ expect(claudeAccess(entries)).toEqual({
+ instanceId: "claudeAgent",
+ driver: "claudeAgent",
+ state: "none",
+ reason: "claudeFiles",
+ blockingFile: "CLAUDE.local.md",
+ });
+ expect(claude).toEqual([
+ {
+ instanceId: "claudeAgent",
+ value: "claude-md-or-agents-md",
+ explicit: false,
+ supported: true,
+ version: "2.1.291",
+ },
+ ]);
+ }),
+ );
+ }),
+ );
+
+ it.effect("reads it through the setting when no CLAUDE file stands in the way", () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ yield* write("repos/app/AGENTS.md", "rules");
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const { entries } = yield* listed(catalog, { cwd: project });
+ expect(claudeAccess(entries)).toMatchObject({ state: "setting" });
+ expect(claudeAccess(entries).reason).toBeUndefined();
+ }),
+ );
+ }),
+ );
+
+ it.effect("reads both files when the setting says so, and none when it says never", () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ yield* write("repos/app/AGENTS.md", "rules");
+ yield* write("repos/app/CLAUDE.md", "claude rules");
+ yield* write(
+ ".claude/settings.json",
+ JSON.stringify({
+ pluginConfigs: {
+ "cc-plugin-agents-md@builtin": {
+ options: { instructionFiles: "claude-md-and-agents-md" },
+ },
+ },
+ }),
+ );
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const both = yield* listed(catalog, { cwd: project });
+ expect(claudeAccess(both.entries)).toMatchObject({ state: "setting" });
+ expect(both.claude[0]).toMatchObject({
+ value: "claude-md-and-agents-md",
+ explicit: true,
+ });
+ }),
+ );
+ // The legacy plugin id counts too.
+ yield* write(
+ ".claude/settings.json",
+ JSON.stringify({
+ pluginConfigs: { "agents-md@builtin": { options: { instructionFiles: "claude-md" } } },
+ }),
+ );
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const never = yield* listed(catalog, { cwd: project });
+ expect(claudeAccess(never.entries)).toMatchObject({
+ state: "none",
+ reason: "settingOff",
+ });
+ expect(never.claude[0]).toMatchObject({ value: "claude-md", explicit: true });
+ }),
+ );
+ }),
+ );
+
+ it.effect(
+ "reads it through a CLAUDE.md that imports it, unless the setting is managed-only",
+ () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ yield* write("repos/app/AGENTS.md", "rules");
+ yield* write("repos/app/CLAUDE.md", "@AGENTS.md\nmore");
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const { entries } = yield* listed(catalog, { cwd: project });
+ expect(claudeAccess(entries)).toMatchObject({ state: "import" });
+ }),
+ );
+ yield* write(
+ ".claude/settings.json",
+ JSON.stringify({
+ pluginConfigs: {
+ "cc-plugin-agents-md@builtin": { options: { instructionFiles: "managed-only" } },
+ },
+ }),
+ );
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const { entries } = yield* listed(catalog, { cwd: project });
+ expect(claudeAccess(entries)).toMatchObject({ state: "none", reason: "settingOff" });
+ }),
+ );
+ }),
+ );
+
+ it.effect(
+ "can't read it on a Claude Code version before 2.1.277, or with no version known",
+ () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ yield* write("repos/app/AGENTS.md", "rules");
+ for (const versions of [{ claudeAgent: "2.1.200" }, {}]) {
+ yield* onMachine(home, { versions }, (catalog) =>
+ Effect.gen(function* () {
+ const { entries, claude } = yield* listed(catalog, { cwd: project });
+ expect(claudeAccess(entries)).toMatchObject({
+ state: "none",
+ reason: "oldVersion",
+ });
+ expect(claude[0]).toMatchObject({ supported: false });
+ }),
+ );
+ }
+ }),
+ );
+
+ it.effect("keeps an import working on an old version", () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ yield* write("repos/app/AGENTS.md", "rules");
+ yield* write("repos/app/CLAUDE.md", "@AGENTS.md");
+ yield* onMachine(home, { versions: { claudeAgent: "2.0.0" } }, (catalog) =>
+ Effect.gen(function* () {
+ const { entries } = yield* listed(catalog, { cwd: project });
+ expect(claudeAccess(entries)).toMatchObject({ state: "import" });
+ }),
+ );
+ }),
+ );
+
+ it.effect(
+ "gives a CLAUDE.md that only imports AGENTS.md no entry, and keeps the import working",
+ () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ yield* write("repos/app/AGENTS.md", "rules");
+ for (const only of ["@AGENTS.md\n", "\n@./AGENTS.md\n\n"]) {
+ yield* write("repos/app/CLAUDE.md", only);
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const { entries } = yield* listed(catalog, { cwd: project });
+ expect(entries.map((entry) => entry.id)).not.toContain("project:claude:CLAUDE.md");
+ expect(claudeAccess(entries)).toMatchObject({ state: "import" });
+ }),
+ );
+ }
+ // Anything else in the file keeps its entry, and so does an empty one.
+ for (const text of ["@AGENTS.md\n- Run the tests.\n", ""]) {
+ yield* write("repos/app/CLAUDE.md", text);
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const { entries } = yield* listed(catalog, { cwd: project });
+ expect(entries.map((entry) => entry.id)).toContain("project:claude:CLAUDE.md");
+ }),
+ );
+ }
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "gives a CLAUDE.md that is the same file as AGENTS.md no entry, whichever links to the other",
+ () =>
+ Effect.gen(function* () {
+ const { home, fs, path, project, write, link } = yield* makeMachine;
+ for (const [real, linked] of [
+ ["AGENTS.md", "CLAUDE.md"],
+ ["CLAUDE.md", "AGENTS.md"],
+ ] as const) {
+ yield* fs.remove(path.join(project, "AGENTS.md"), { force: true });
+ yield* fs.remove(path.join(project, "CLAUDE.md"), { force: true });
+ yield* write(`repos/app/${real}`, "rules");
+ yield* link(`repos/app/${real}`, `repos/app/${linked}`);
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const { entries } = yield* listed(catalog, { cwd: project });
+ expect(entries.map((entry) => entry.id)).not.toContain("project:claude:CLAUDE.md");
+ expect(claudeAccess(entries)).toMatchObject({ state: "import" });
+ }),
+ );
+ }
+ }),
+ );
+
+ it.effect("reads a settings.json and an AGENTS.md that start with a byte order mark", () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ yield* write(
+ ".claude/settings.json",
+ `\uFEFF${JSON.stringify({
+ pluginConfigs: {
+ "cc-plugin-agents-md@builtin": { options: { instructionFiles: "claude-md" } },
+ },
+ })}`,
+ );
+ yield* write("repos/app/AGENTS.md", "\uFEFFrules");
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const { claude, unreadable } = yield* listed(catalog, { cwd: project });
+ expect(claude[0]).toMatchObject({ value: "claude-md", explicit: true });
+ expect(unreadable).toEqual([]);
+ // The text is the file's, mark included, and its size is the file's bytes.
+ const read = yield* catalog.read({ cwd: project, id: "project:shared:AGENTS.md" });
+ expect(read.contents).toBe("\uFEFFrules");
+ expect(
+ (yield* listed(catalog, { cwd: project })).entries.find(
+ (entry) => entry.id === "project:shared:AGENTS.md",
+ ),
+ ).toMatchObject({ exists: true, size: 8 });
+ }),
+ );
+ }),
+ );
+
+ it.effect("reports a settings.json that isn't JSON and falls back to Claude's default", () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ yield* write(".claude/settings.json", "{ not json");
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const { claude, unreadable } = yield* listed(catalog, { cwd: project });
+ expect(claude[0]).toMatchObject({ value: "claude-md-or-agents-md", explicit: false });
+ expect(unreadable).toEqual([
+ { path: `${home}/.claude/settings.json`, reason: "It isn't valid JSON." },
+ ]);
+ }),
+ );
+ }),
+ );
+ });
+
+ describe("files in subfolders", () => {
+ it.effect("comes from the file index, without the top folder's own files or dependencies", () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ yield* write("repos/app/AGENTS.md", "root");
+ yield* write("repos/app/.claude/CLAUDE.md", "root claude");
+ yield* write("repos/app/apps/web/AGENTS.md", "web");
+ yield* write("repos/app/packages/ui/CLAUDE.md", "ui");
+ yield* write("repos/app/node_modules/dep/AGENTS.md", "dependency");
+ yield* write("repos/app/apps/web/README.md", "not an instruction file");
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const { entries } = yield* listed(catalog, { cwd: project });
+ const nested = entries.filter((entry) => entry.kind === "nested");
+
+ expect(nested.map((entry) => entry.id)).toEqual([
+ "project:nested:apps/web/AGENTS.md",
+ "project:nested:packages/ui/CLAUDE.md",
+ ]);
+ expect(nested[0]).toMatchObject({
+ scope: "project",
+ relativePath: "apps/web/AGENTS.md",
+ exists: true,
+ size: 3,
+ readOnly: false,
+ access: [],
+ });
+ // The top folder's own `.claude/CLAUDE.md` has its own entry.
+ expect(entries.map((entry) => entry.id)).toContain("project:claude:.claude/CLAUDE.md");
+ }),
+ );
+ }),
+ );
+
+ it.effect("caps them at 50", () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ for (let index = 0; index < 60; index += 1) {
+ yield* write(`repos/app/packages/p${String(index).padStart(2, "0")}/AGENTS.md`, "x");
+ }
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const { entries } = yield* listed(catalog, { cwd: project });
+ expect(entries.filter((entry) => entry.kind === "nested")).toHaveLength(50);
+ }),
+ );
+ }),
+ );
+ });
+
+ describe("the shared file for all projects", () => {
+ it.effect(
+ "is ~/.agents/AGENTS.md when nothing links anywhere, and listed even if missing",
+ () =>
+ Effect.gen(function* () {
+ const { home } = yield* makeMachine;
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const result = yield* listed(catalog);
+ const shared = entryOf(result.entries, "global:shared");
+
+ expect(result.sharedPath).toBe(`${home}/.agents/AGENTS.md`);
+ expect(shared).toMatchObject({
+ scope: "global",
+ kind: "shared",
+ path: `${home}/.agents/AGENTS.md`,
+ exists: false,
+ size: 0,
+ });
+ // Cursor and Antigravity have no home file, so they have nothing to say about it.
+ expect(Object.keys(accessOf(shared)).toSorted()).toEqual([
+ "claudeAgent",
+ "codex",
+ "grok",
+ "opencode",
+ "pi",
+ ]);
+ expect(Object.values(accessOf(shared)).every((state) => state === "none")).toBe(true);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "keeps the one real file the agents' home files already link to",
+ () =>
+ Effect.gen(function* () {
+ const { home, write, link } = yield* makeMachine;
+ yield* write("library/everything.md", "all my rules");
+ yield* link("library/everything.md", ".claude/CLAUDE.md");
+ yield* link("library/everything.md", ".codex/AGENTS.md");
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const result = yield* listed(catalog);
+ const shared = entryOf(result.entries, "global:shared");
+
+ expect(result.sharedPath).toBe(`${home}/library/everything.md`);
+ expect(shared).toMatchObject({ exists: true, size: 12 });
+ // OpenCode and Grok read ~/.claude/CLAUDE.md as well, which is one of the links.
+ expect(accessOf(shared)).toEqual({
+ claudeAgent: "link",
+ codex: "link",
+ grok: "link",
+ opencode: "link",
+ pi: "none",
+ });
+ // Claude's home file is the link, so there is no row of its own for it.
+ expect(result.entries.map((entry) => entry.id)).toEqual(["global:shared"]);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "falls back to the default when the links lead to different files",
+ () =>
+ Effect.gen(function* () {
+ const { home, write, link } = yield* makeMachine;
+ yield* write("library/one.md", "one");
+ yield* write("library/two.md", "two");
+ yield* link("library/one.md", ".claude/CLAUDE.md");
+ yield* link("library/two.md", ".codex/AGENTS.md");
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ expect((yield* listed(catalog)).sharedPath).toBe(`${home}/.agents/AGENTS.md`);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "sees Claude's import line, and a link that leads to the shared file before it exists",
+ () =>
+ Effect.gen(function* () {
+ const { home, path, write, fs } = yield* makeMachine;
+ yield* write(".claude/CLAUDE.md", "@~/.agents/AGENTS.md\n\nmy own notes\n");
+ yield* fs.makeDirectory(path.join(home, ".codex"), { recursive: true });
+ yield* fs.symlink(`${home}/.agents/AGENTS.md`, path.join(home, ".codex/AGENTS.md"));
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const result = yield* listed(catalog);
+ const shared = entryOf(result.entries, "global:shared");
+
+ expect(shared.exists).toBe(false);
+ expect(accessOf(shared)).toMatchObject({ claudeAgent: "import", codex: "link" });
+ // The notes next to the import line are worth a row of their own.
+ expect(entryOf(result.entries, "global:claude:claudeAgent")).toMatchObject({
+ kind: "claude",
+ owner: "claudeAgent",
+ sameAsShared: false,
+ path: `${home}/.claude/CLAUDE.md`,
+ });
+ }),
+ );
+ }),
+ );
+
+ it.effect("gives Claude no row of its own when its file is only the import line", () =>
+ Effect.gen(function* () {
+ const { home, write } = yield* makeMachine;
+ yield* write(".claude/CLAUDE.md", "@~/.agents/AGENTS.md\n");
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const result = yield* listed(catalog);
+ expect(accessOf(entryOf(result.entries, "global:shared")).claudeAgent).toBe("import");
+ expect(result.entries.map((entry) => entry.id)).toEqual(["global:shared"]);
+ }),
+ );
+ }),
+ );
+ });
+
+ describe("an agent's own home file", () => {
+ it.effect.skipIf(!symlinksSupported)(
+ "stops the agent joining the shared file when it holds different text",
+ () =>
+ Effect.gen(function* () {
+ const { home, write } = yield* makeMachine;
+ yield* write(".agents/AGENTS.md", "shared");
+ yield* write(".codex/AGENTS.md", "codex notes");
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const result = yield* listed(catalog);
+
+ expect(accessFor(entryOf(result.entries, "global:shared"), "codex")).toEqual({
+ instanceId: "codex",
+ driver: "codex",
+ state: "none",
+ reason: "ownFile",
+ });
+ expect(entryOf(result.entries, "global:agentOwn:codex")).toMatchObject({
+ kind: "agentOwn",
+ owner: "codex",
+ path: `${home}/.codex/AGENTS.md`,
+ sameAsShared: false,
+ });
+ expect(accessOf(entryOf(result.entries, "global:agentOwn:codex"))).toMatchObject({
+ codex: "direct",
+ pi: "none",
+ });
+ }),
+ );
+ }),
+ );
+
+ it.effect("notes when the agent's file has the same text as the shared one", () =>
+ Effect.gen(function* () {
+ const { home, write } = yield* makeMachine;
+ yield* write(".agents/AGENTS.md", "same text\n");
+ yield* write(".codex/AGENTS.md", "same text");
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const { entries } = yield* listed(catalog);
+ expect(entryOf(entries, "global:agentOwn:codex").sameAsShared).toBe(true);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "makes a Codex override file win over a link, and names it",
+ () =>
+ Effect.gen(function* () {
+ const { home, path, write, fs } = yield* makeMachine;
+ yield* write(".agents/AGENTS.md", "shared");
+ yield* write(".codex/AGENTS.override.md", "override");
+ yield* fs.symlink(`${home}/.agents/AGENTS.md`, path.join(home, ".codex/AGENTS.md"));
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const { entries } = yield* listed(catalog);
+
+ expect(accessFor(entryOf(entries, "global:shared"), "codex")).toMatchObject({
+ state: "none",
+ reason: "ownFile",
+ blockingFile: "AGENTS.override.md",
+ });
+ expect(entryOf(entries, "global:agentOwn:codex").path).toBe(
+ `${home}/.codex/AGENTS.override.md`,
+ );
+ }),
+ );
+ }),
+ );
+
+ it.effect("passes over an empty Codex override file, as Codex does", () =>
+ Effect.gen(function* () {
+ const { home, write } = yield* makeMachine;
+ yield* write(".codex/AGENTS.override.md", "");
+ yield* write(".codex/AGENTS.md", "codex notes");
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const { entries } = yield* listed(catalog);
+ expect(entryOf(entries, "global:agentOwn:codex").path).toBe(`${home}/.codex/AGENTS.md`);
+ }),
+ );
+ }),
+ );
+
+ it.effect("takes Pi's CLAUDE.md as its own file when it has no AGENTS.md", () =>
+ Effect.gen(function* () {
+ const { home, write } = yield* makeMachine;
+ yield* write(".pi/agent/CLAUDE.md", "pi notes");
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const { entries } = yield* listed(catalog);
+ expect(accessFor(entryOf(entries, "global:shared"), "pi")).toMatchObject({
+ state: "none",
+ reason: "ownFile",
+ blockingFile: "CLAUDE.md",
+ });
+ expect(entryOf(entries, "global:agentOwn:pi").path).toBe(`${home}/.pi/agent/CLAUDE.md`);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "lets OpenCode and Grok reach the shared file through ~/.claude/CLAUDE.md",
+ () =>
+ Effect.gen(function* () {
+ const { home, write, link } = yield* makeMachine;
+ yield* write(".agents/AGENTS.md", "shared");
+ yield* link(".agents/AGENTS.md", ".claude/CLAUDE.md");
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const { entries } = yield* listed(catalog);
+ expect(accessOf(entryOf(entries, "global:shared"))).toEqual({
+ claudeAgent: "link",
+ codex: "none",
+ grok: "link",
+ opencode: "link",
+ pi: "none",
+ });
+ }),
+ );
+ // OpenCode only falls back when it has no file of its own.
+ yield* write(".config/opencode/AGENTS.md", "opencode notes");
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const { entries } = yield* listed(catalog);
+ expect(accessFor(entryOf(entries, "global:shared"), "opencode")).toMatchObject({
+ state: "none",
+ reason: "ownFile",
+ });
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "follows each instance's own home folder, not just the default",
+ () =>
+ Effect.gen(function* () {
+ const { home, write, link } = yield* makeMachine;
+ yield* write(".agents/AGENTS.md", "shared");
+ yield* write("work-claude/CLAUDE.md", "@~/.agents/AGENTS.md\nnotes");
+ yield* link(".agents/AGENTS.md", "codex-work/AGENTS.md");
+ yield* onMachine(
+ home,
+ {
+ versions: { claude_work: "2.1.291" },
+ providerInstances: {
+ [ProviderInstanceId.make("claudeAgent")]: {
+ driver: ProviderDriverKind.make("claudeAgent"),
+ enabled: false,
+ },
+ [ProviderInstanceId.make("claude_work")]: {
+ driver: ProviderDriverKind.make("claudeAgent"),
+ config: { homePath: `${home}/work-claude` },
+ },
+ [ProviderInstanceId.make("codex")]: {
+ driver: ProviderDriverKind.make("codex"),
+ environment: [
+ { name: "CODEX_HOME", value: `${home}/codex-work`, sensitive: false },
+ ],
+ },
+ },
+ },
+ (catalog) =>
+ Effect.gen(function* () {
+ const { entries, claude } = yield* listed(catalog);
+
+ expect(accessOf(entryOf(entries, "global:shared"))).toMatchObject({
+ claude_work: "import",
+ codex: "link",
+ });
+ expect(claude.map((choice) => choice.instanceId)).toEqual(["claude_work"]);
+ expect(entryOf(entries, "global:claude:claude_work").path).toBe(
+ `${home}/work-claude/CLAUDE.md`,
+ );
+ }),
+ );
+ }),
+ );
+ });
+
+ describe("read", () => {
+ it.effect("returns the text and a revision, through a link too", () =>
+ Effect.gen(function* () {
+ const { home, project, write, link } = yield* makeMachine;
+ yield* write(".agents/AGENTS.md", "shared text");
+ yield* write("repos/app/AGENTS.md", "project text");
+ yield* link(".agents/AGENTS.md", "repos/app/CLAUDE.md");
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const global = yield* catalog.read({ id: "global:shared" });
+ const viaLink = yield* catalog.read({ cwd: project, id: "project:claude:CLAUDE.md" });
+ yield* encodeRead(global);
+
+ expect(global).toMatchObject({
+ id: "global:shared",
+ contents: "shared text",
+ tooLarge: false,
+ });
+ expect(global.revision).toMatch(/^[0-9a-f]{64}$/);
+ expect(viaLink.contents).toBe("shared text");
+ expect(viaLink.revision).toBe(global.revision);
+ }),
+ );
+ }),
+ );
+
+ it.effect("says nothing for a missing file, and when a file is over 1 MB", () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ yield* write("repos/app/CLAUDE.md", "x".repeat(1_048_577));
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ expect(yield* catalog.read({ cwd: project, id: "project:shared:AGENTS.md" })).toEqual({
+ id: "project:shared:AGENTS.md",
+ contents: null,
+ revision: null,
+ tooLarge: false,
+ });
+ expect(yield* catalog.read({ cwd: project, id: "project:claude:CLAUDE.md" })).toEqual({
+ id: "project:claude:CLAUDE.md",
+ contents: null,
+ revision: null,
+ tooLarge: true,
+ });
+ }),
+ );
+ }),
+ );
+
+ it.effect("reads under a folder only when it is a registered project's workspace root", () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ yield* write(".agents/AGENTS.md", "shared text");
+ yield* write("repos/app/AGENTS.md", "project text");
+ yield* write("elsewhere/AGENTS.md", "not a project's");
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const refusal = (cwd: string, id: string) =>
+ catalog.read({ cwd, id }).pipe(
+ Effect.flip,
+ Effect.map((error) => [error._tag, error.reason]),
+ );
+ const unregistered = ["unregisteredProject"];
+
+ // A folder above the project, one beside it, and a relative one reach no file under
+ // them, even by an id the table builds.
+ expect(yield* refusal(home, "project:nested:elsewhere/AGENTS.md")).toEqual([
+ "InstructionError",
+ ...unregistered,
+ ]);
+ expect(yield* refusal(`${home}/elsewhere`, "project:shared:AGENTS.md")).toEqual([
+ "InstructionError",
+ ...unregistered,
+ ]);
+ expect(yield* refusal("repos/app", "project:shared:AGENTS.md")).toEqual([
+ "InstructionError",
+ ...unregistered,
+ ]);
+ expect(
+ yield* catalog.list({ cwd: home }).pipe(
+ Effect.flip,
+ Effect.map((error) => error.reason),
+ ),
+ ).toBe("unregisteredProject");
+ expect(
+ yield* catalog.resolve({ cwd: home, id: "project:shared:AGENTS.md" }).pipe(
+ Effect.flip,
+ Effect.map((error) => error.reason),
+ ),
+ ).toBe("unregisteredProject");
+
+ // The registered folder, and the home files without any folder, read as before.
+ expect(
+ (yield* catalog.read({ cwd: project, id: "project:shared:AGENTS.md" })).contents,
+ ).toBe("project text");
+ expect((yield* catalog.read({ id: "global:shared" })).contents).toBe("shared text");
+ expect(
+ (yield* catalog.list({})).entries.some((entry) => entry.scope === "global"),
+ ).toBe(true);
+ }),
+ );
+ }),
+ );
+
+ it.effect("refuses ids the table doesn't have", () =>
+ Effect.gen(function* () {
+ const { home, project } = yield* makeMachine;
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const ids = [
+ "project:shared:../AGENTS.md",
+ "project:shared:CLAUDE.md",
+ "project:claude:README.md",
+ "project:nested:AGENTS.md",
+ "project:nested:../outside/AGENTS.md",
+ "project:nested:apps/../../AGENTS.md",
+ "project:nested:/etc/AGENTS.md",
+ "project:nested:apps/web/README.md",
+ "global:agentOwn:claudeAgent",
+ "global:claude:codex",
+ "global:agentOwn:cursor",
+ "global:agentOwn:no-such-agent",
+ "global:shared:extra",
+ "managed:codex",
+ "nonsense",
+ ];
+ for (const id of ids) {
+ const error = yield* catalog.read({ cwd: project, id }).pipe(Effect.flip);
+ expect(error, id).toBeInstanceOf(InstructionError);
+ expect(error.reason, id).toBe("unknownEntry");
+ }
+ // A project id needs a project.
+ const noProject = yield* catalog
+ .read({ id: "project:shared:AGENTS.md" })
+ .pipe(Effect.flip);
+ expect(noProject.reason).toBe("unknownEntry");
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "refuses a subfolder that is a link out of the project",
+ () =>
+ Effect.gen(function* () {
+ const { home, project, write, fs, path } = yield* makeMachine;
+ yield* write("elsewhere/AGENTS.md", "outside");
+ yield* fs.symlink(path.join(home, "elsewhere"), path.join(project, "linked"));
+ yield* onMachine(home, CLAUDE, (catalog) =>
+ Effect.gen(function* () {
+ const error = yield* catalog
+ .read({ cwd: project, id: "project:nested:linked/AGENTS.md" })
+ .pipe(Effect.flip);
+ expect(error.reason).toBe("unknownEntry");
+ }),
+ );
+ }),
+ );
+ });
+});
diff --git a/apps/server/src/instructions/InstructionCatalog.ts b/apps/server/src/instructions/InstructionCatalog.ts
new file mode 100644
index 000000000000..f65c3cd16931
--- /dev/null
+++ b/apps/server/src/instructions/InstructionCatalog.ts
@@ -0,0 +1,990 @@
+/**
+ * InstructionCatalog - a read-only look at the instruction files (AGENTS.md, CLAUDE.md and
+ * friends) the enabled agents read, and at which agent reads which.
+ *
+ * Files are found by reading the places `AgentInstructionFiles` names, never by asking an agent.
+ * Nothing is cached, watched, spawned or written, and every read is bounded. A list looks only at
+ * the project's top folder and at the agents' home files; a file in a subfolder comes from the
+ * project's file index. What each agent reads follows its rules in the table, not an assumption:
+ * Pi stops at the first file of a folder, OpenCode falls back to CLAUDE.md only when there is no
+ * AGENTS.md, Codex prefers AGENTS.override.md, and Claude reads a project AGENTS.md only as its
+ * "Project instructions" setting, its own CLAUDE.md files and its version allow.
+ *
+ * The all-projects file is one real file the agents' home files link to, `~/.agents/AGENTS.md`
+ * unless the agents' home files already link to one other file, which is then kept (see
+ * `sharedLocation`). Ids are built here and are the only way to name a file: a client never sends
+ * a path, so every id is resolved again from the table (see `resolve`).
+ *
+ * @module InstructionCatalog
+ */
+import {
+ InstructionError,
+ PROVIDER_DISPLAY_NAMES,
+ ProviderInstanceId,
+ resolveProviderInstanceEnabled,
+ type ClaudeInstructionChoice,
+ type InstructionAgentAccess,
+ type InstructionAgentReason,
+ type InstructionAgentState,
+ type InstructionEntry,
+ type InstructionKind,
+ type InstructionListInput,
+ type InstructionListResult,
+ type InstructionProblem,
+ type InstructionReadInput,
+ type InstructionReadResult,
+ type InstructionScope,
+ type ProviderDriverKind,
+ type ProviderInstanceConfig,
+} from "@t3tools/contracts";
+import * as HostProcess from "@t3tools/shared/HostProcess";
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
+import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
+import { AGENT_SKILL_FOLDERS } from "@t3tools/provider-core/server/AgentSkillFolders";
+
+import * as ProjectService from "../project/ProjectService.ts";
+import { deriveProviderInstanceConfigMap } from "../provider/ProviderInstanceRegistryHydration.ts";
+import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
+import * as Settings from "../serverSettings.ts";
+import { resolveAgentConfigHome } from "../skills/AgentConfigHome.ts";
+import * as WorkspaceEntries from "../workspace/WorkspaceEntries.ts";
+import {
+ AGENT_INSTRUCTION_FILES,
+ claudeManagedInstructionPath,
+ type AgentInstructionRules,
+ type HomeInstructionRules,
+} from "./AgentInstructionFiles.ts";
+import {
+ DEFAULT_CLAUDE_INSTRUCTION_VALUE,
+ hasAgentsMdImport,
+ parseSettingsJson,
+ readClaudeInstructionSetting,
+ removeAgentsMdImport,
+ supportsAgentsMd,
+ type AgentsMdImportTarget,
+ type ClaudeInstructionSetting,
+} from "./ClaudeInstructionSetting.ts";
+import { inspect, readText, type FileFacts, type ReadOutcome } from "./InstructionFileIO.ts";
+
+/** Where the all-projects file goes, next to `~/.agents/skills`, unless the agents already share another. */
+const DEFAULT_SHARED_FILE = ".agents/AGENTS.md";
+/** The personal file Claude Code tells people to keep out of git. */
+const PERSONAL_FILE = "CLAUDE.local.md";
+const CLAUDE_DRIVER = "claudeAgent";
+const NESTED_NAMES: ReadonlySet = new Set(["AGENTS.md", "CLAUDE.md"]);
+const MAX_NESTED = 50;
+const NESTED_SEARCH_LIMIT = 200;
+const CONCURRENCY = 8;
+/** The default folder of an agent that follows `XDG_CONFIG_HOME` sits under this in the home directory. */
+const XDG_PREFIX = ".config/";
+
+/** The files of a project's top folder that get an entry, with the id each one has. */
+const ROOT_FILES = [
+ { name: "AGENTS.md", kind: "shared" },
+ { name: "CLAUDE.md", kind: "claude" },
+ { name: ".claude/CLAUDE.md", kind: "claude" },
+ { name: PERSONAL_FILE, kind: "claudeLocal" },
+] as const satisfies ReadonlyArray<{ readonly name: string; readonly kind: InstructionKind }>;
+
+const GLOBAL_SHARED_ID = "global:shared";
+const MANAGED_ID = "managed:claude";
+
+const rootId = (file: (typeof ROOT_FILES)[number]) => `project:${file.kind}:${file.name}`;
+const nestedId = (relativePath: string) => `project:nested:${relativePath}`;
+const claudeHomeId = (instanceId: string) => `global:claude:${instanceId}`;
+const agentOwnId = (instanceId: string) => `global:agentOwn:${instanceId}`;
+
+const unknownEntry = () =>
+ new InstructionError({
+ reason: "unknownEntry",
+ message: "That isn't an instruction file T3 Code manages.",
+ });
+
+/** A flag-style environment variable counts as set unless it is empty, `0` or `false`. */
+const isFlagSet = (value: string | undefined) =>
+ value !== undefined && !["", "0", "false"].includes(value.trim().toLowerCase());
+
+/** An enabled provider instance whose instruction files T3 Code knows. */
+interface AgentInstance {
+ readonly instanceId: ProviderInstanceId;
+ readonly driver: ProviderDriverKind;
+ readonly displayName: string;
+ readonly rules: AgentInstructionRules;
+ readonly version: string | null;
+ /** The process environment with the instance's own variables laid over it. */
+ readonly env: NodeJS.ProcessEnv;
+ /** The agent's home folder as this instance resolves it; undefined when it has no home file. */
+ readonly directory: string | undefined;
+}
+
+export interface SharedFile {
+ /** Where the all-projects file is, or would be created. */
+ readonly path: string;
+ /** Its real path after following links; undefined while it doesn't exist. */
+ readonly real: string | undefined;
+ readonly exists: boolean;
+}
+
+/** How one agent reaches the all-projects file, and what it takes to change that. */
+export interface AgentReach {
+ readonly instanceId: ProviderInstanceId;
+ readonly driver: ProviderDriverKind;
+ readonly displayName: string;
+ /** The agent's home folder. */
+ readonly directory: string;
+ /** `link`: a symlink at `joinPath`. `import`: an import line at the top of the file at `joinPath`. */
+ readonly join: "link" | "import";
+ readonly joinPath: string;
+ readonly state: InstructionAgentState;
+ readonly reason?: InstructionAgentReason | undefined;
+ readonly blockingFile?: string | undefined;
+ /** The files the agent reads the shared file through. `own: false` is a folder of another agent. */
+ readonly via: ReadonlyArray<{
+ readonly path: string;
+ readonly kind: "direct" | "link" | "import";
+ readonly own: boolean;
+ }>;
+ /** The agent's own home file, when it has one that is not the shared file. */
+ readonly ownFile: string | undefined;
+ /** Every home file the agent loads, whatever its text. */
+ readonly reads: ReadonlySet;
+}
+
+export interface SharedView {
+ readonly file: SharedFile;
+ readonly homeDirectory: string;
+ /** Every enabled agent that has a home file, in the table's order. */
+ readonly agents: ReadonlyArray;
+}
+
+/** What an id names, as the table and the disk say now. */
+export interface ResolvedInstruction {
+ readonly id: string;
+ readonly scope: InstructionScope;
+ readonly kind: InstructionKind;
+ /** Where the file is or would be created; it may be a link. */
+ readonly path: string;
+ readonly relativePath?: string | undefined;
+ readonly readOnly: boolean;
+ /** The instance whose home file this is, for `agentOwn` and Claude's own file. */
+ readonly owner?: ProviderInstanceId | undefined;
+}
+
+export class InstructionCatalog extends Context.Service<
+ InstructionCatalog,
+ {
+ /**
+ * The instruction files in the project's top folder (when `cwd` is given) and the user's home
+ * folder, with the agents that read each. Subfolder files come from the project's file index.
+ * A `cwd` that isn't a registered project's workspace root is refused, here and in `read` and
+ * `resolve`, before anything under it is read.
+ */
+ readonly list: (
+ input: InstructionListInput,
+ ) => Effect.Effect;
+ /** The text of one file from `list`, or nothing when it is missing or too large. */
+ readonly read: (
+ input: InstructionReadInput,
+ ) => Effect.Effect;
+ /** The file an id names, looked up from the table again. Nothing is read or written. */
+ readonly resolve: (input: {
+ readonly cwd?: string | undefined;
+ readonly id: string;
+ }) => Effect.Effect;
+ /** The all-projects file and how each agent reaches it, for turning agents on or off. */
+ readonly shared: Effect.Effect;
+ }
+>()("t3/instructions/InstructionCatalog") {}
+
+const make = Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const platform = yield* HostProcess.Platform;
+ const environment = yield* HostProcess.Environment;
+ const serverSettings = yield* Settings.ServerSettingsService;
+ const providers = yield* ProviderRegistry.ProviderRegistry;
+ const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
+ const projects = yield* ProjectService.ProjectService;
+ const fileSystemContext = yield* Effect.context();
+ const homeDirectory = yield* HostProcess.HomeDirectory;
+
+ const inspectAt = (file: string) => inspect(file).pipe(Effect.provideContext(fileSystemContext));
+ const readTextAt = (file: string) =>
+ readText(file).pipe(Effect.provideContext(fileSystemContext));
+
+ /** Text read once per file during one call. */
+ const makeTextReader = () => {
+ const texts = new Map();
+ return Effect.fnUntraced(function* (file: string) {
+ const known = texts.get(file);
+ if (known !== undefined) return known;
+ const outcome = yield* readTextAt(file);
+ texts.set(file, outcome);
+ return outcome;
+ });
+ };
+ type TextReader = ReturnType;
+
+ /**
+ * A project's folder is only read when it is the workspace root of a project the environment
+ * knows, so an id can't name a file under any path on the machine. Without a `cwd` only the
+ * agents' home files are reachable.
+ */
+ const requireProject = Effect.fnUntraced(function* (cwd: string | undefined) {
+ if (cwd === undefined) return undefined;
+ // The lookup resolves a relative path against the server's own folder, so it never sees one.
+ const project = path.isAbsolute(cwd)
+ ? yield* projects.getByWorkspaceRoot(cwd).pipe(
+ Effect.catchTags({
+ // A folder that is gone or isn't a folder can't be a project's root.
+ ProjectOperationError: (error) =>
+ error.operation === "normalize-workspace"
+ ? Effect.succeed(Option.none())
+ : Effect.die(error),
+ }),
+ )
+ : Option.none();
+ if (Option.isNone(project)) {
+ return yield* new InstructionError({
+ reason: "unregisteredProject",
+ message: "That folder isn't a project in this environment.",
+ });
+ }
+ return cwd;
+ });
+
+ const sizeOf = Effect.fnUntraced(function* (file: string) {
+ const info = yield* fileSystem.stat(file).pipe(Effect.option);
+ return Option.isSome(info) && info.value.type === "File" ? Number(info.value.size) : undefined;
+ });
+
+ // --- Agents -------------------------------------------------------------------------------
+
+ /**
+ * An agent's home folder. Claude, Codex and Grok follow the same settings and variables as their
+ * skill folders (`resolveAgentConfigHome`); the others follow the variables the table names.
+ */
+ const homeFolderOf = Effect.fnUntraced(function* (
+ config: ProviderInstanceConfig,
+ home: HomeInstructionRules,
+ env: NodeJS.ProcessEnv,
+ cwd: string | undefined,
+ ) {
+ const fallback = path.join(homeDirectory, home.folder);
+ if (AGENT_SKILL_FOLDERS.find((table) => table.agent === config.driver)?.configHome) {
+ return yield* resolveAgentConfigHome({
+ instance: config,
+ fallback,
+ environment,
+ cwd,
+ }).pipe(
+ Effect.provideService(Path.Path, path),
+ Effect.provideService(HostProcess.HomeDirectory, homeDirectory),
+ );
+ }
+ const named = home.folderEnv === undefined ? "" : (env[home.folderEnv]?.trim() ?? "");
+ const configured = expandHomePath(named, homeDirectory);
+ if (configured !== "" && path.isAbsolute(configured)) return configured;
+ const xdg = env.XDG_CONFIG_HOME?.trim() ?? "";
+ if (
+ home.xdgConfigHome === true &&
+ xdg !== "" &&
+ path.isAbsolute(xdg) &&
+ home.folder.startsWith(XDG_PREFIX)
+ ) {
+ return path.join(xdg, home.folder.slice(XDG_PREFIX.length));
+ }
+ return fallback;
+ });
+
+ /** The enabled provider instances whose instruction files T3 Code knows, in the table's order. */
+ const loadInstances = Effect.fnUntraced(function* (cwd: string | undefined) {
+ const settings = yield* serverSettings.getSettings.pipe(Effect.option);
+ if (Option.isNone(settings)) return [];
+ const snapshots = new Map(
+ (yield* providers.getProviders).map((provider) => [provider.instanceId, provider] as const),
+ );
+ const configs = Object.entries(deriveProviderInstanceConfigMap(settings.value));
+ const instances: AgentInstance[] = [];
+ for (const rules of AGENT_INSTRUCTION_FILES) {
+ for (const [id, config] of configs) {
+ if (config.driver !== rules.agent || !resolveProviderInstanceEnabled(config)) continue;
+ const instanceId = ProviderInstanceId.make(id);
+ const env = yield* mergeProviderInstanceEnvironment(config.environment, environment).pipe(
+ Effect.provideService(HostProcess.HomeDirectory, homeDirectory),
+ );
+ const snapshot = snapshots.get(instanceId);
+ instances.push({
+ instanceId,
+ driver: rules.agent,
+ displayName:
+ snapshot?.displayName?.trim() || (PROVIDER_DISPLAY_NAMES[rules.agent] ?? rules.agent),
+ rules,
+ version: snapshot?.version ?? null,
+ env,
+ directory:
+ rules.home === null ? undefined : yield* homeFolderOf(config, rules.home, env, cwd),
+ });
+ }
+ }
+ return instances;
+ });
+
+ // --- Claude's "Project instructions" setting ------------------------------------------------
+
+ const claudeChoiceOf = Effect.fnUntraced(function* (instance: AgentInstance, textOf: TextReader) {
+ const settingsPath = path.join(instance.directory ?? homeDirectory, "settings.json");
+ const outcome = yield* textOf(settingsPath);
+ let setting: ClaudeInstructionSetting = {
+ value: DEFAULT_CLAUDE_INSTRUCTION_VALUE,
+ explicit: false,
+ };
+ let problem: InstructionProblem | undefined;
+ if (outcome._tag === "Read") {
+ const settings = parseSettingsJson(outcome.text);
+ if (settings === undefined) problem = { path: settingsPath, reason: "It isn't valid JSON." };
+ else setting = readClaudeInstructionSetting(settings);
+ } else if (outcome._tag !== "Missing") {
+ problem = { path: settingsPath, reason: "T3 Code couldn't read it." };
+ }
+ return {
+ choice: {
+ instanceId: instance.instanceId,
+ ...setting,
+ supported: supportsAgentsMd(instance.version),
+ version: instance.version,
+ } satisfies ClaudeInstructionChoice,
+ problem,
+ };
+ });
+
+ // --- The all-projects file and how agents reach it -------------------------------------------
+
+ /**
+ * The all-projects file. When some agents' home files are links and all of them lead to the same
+ * real file, that file is the shared one, so people who already link everything to one file keep
+ * it. Otherwise it is `~/.agents/AGENTS.md`.
+ */
+ const sharedLocation = Effect.fnUntraced(function* (instances: readonly AgentInstance[]) {
+ const targets = new Set();
+ const candidates = instances.flatMap((instance) => {
+ const { home } = instance.rules;
+ const { directory } = instance;
+ return home === null || directory === undefined
+ ? []
+ : home.files.map((name) => path.join(directory, name));
+ });
+ const found = yield* Effect.forEach(candidates, (file) => inspectAt(file), {
+ concurrency: CONCURRENCY,
+ });
+ for (const facts of found) {
+ if (facts.linkTarget !== undefined && facts.isFile && facts.real !== undefined) {
+ targets.add(facts.real);
+ }
+ }
+ const [only] = targets.size === 1 ? [...targets] : [];
+ const location = only ?? path.join(homeDirectory, DEFAULT_SHARED_FILE);
+ const facts = yield* inspectAt(location);
+ return {
+ path: location,
+ real: facts.real,
+ exists: facts.isFile,
+ } satisfies SharedFile;
+ });
+
+ /** The file leads to the shared file, or is a link to it that leads nowhere yet. */
+ const reachesShared = (facts: FileFacts, shared: SharedFile) =>
+ facts.present &&
+ (facts.real !== undefined
+ ? facts.real === shared.real
+ : facts.linkTarget !== undefined && facts.linkTarget === shared.path);
+
+ interface NamedFile {
+ readonly name: string;
+ readonly path: string;
+ readonly facts: FileFacts;
+ }
+
+ /** What an agent finds in its home: its own files, and the other agents' it reads as well. */
+ const loadHome = Effect.fnUntraced(function* (instance: AgentInstance, shared: SharedFile) {
+ const home = instance.rules.home;
+ const directory = instance.directory;
+ if (home === null || directory === undefined) return undefined;
+ const inFolder = yield* Effect.forEach(
+ home.files,
+ (name) => {
+ const file = path.join(directory, name);
+ return inspectAt(file).pipe(
+ Effect.map((facts): NamedFile => ({ name, path: file, facts })),
+ );
+ },
+ { concurrency: CONCURRENCY },
+ );
+ const usable = inFolder.filter(
+ (file) =>
+ (file.facts.isFile && !(home.skipsEmpty === true && file.facts.size === 0)) ||
+ reachesShared(file.facts, shared),
+ );
+ const loaded = home.selection === "all" ? usable : usable.slice(0, 1);
+ const fallbacks: NamedFile[] = [];
+ for (const also of home.alsoReads ?? []) {
+ if (also.disabledByEnv?.some((name) => isFlagSet(instance.env[name]))) continue;
+ if (also.when === "no-own-file" && usable.length > 0) continue;
+ for (const name of also.files) {
+ const file = path.join(homeDirectory, also.folder, name);
+ const facts = yield* inspectAt(file);
+ if (facts.isFile || reachesShared(facts, shared))
+ fallbacks.push({ name, path: file, facts });
+ }
+ }
+ // The agent's own file is the one that would be replaced to join: the first file it reads in
+ // its folder, or for an agent that reads them all, the file the link would take.
+ const ownFile =
+ home.selection === "all" ? usable.find((file) => file.name === home.shared.file) : loaded[0];
+ return {
+ home,
+ directory,
+ loaded,
+ fallbacks,
+ ownFile:
+ ownFile !== undefined && ownFile.facts.isFile && !reachesShared(ownFile.facts, shared)
+ ? ownFile
+ : undefined,
+ };
+ });
+
+ const reachOf = Effect.fnUntraced(function* (
+ instance: AgentInstance,
+ shared: SharedFile,
+ textOf: TextReader,
+ ) {
+ const loaded = yield* loadHome(instance, shared);
+ if (loaded === undefined) return undefined;
+ const { home, directory } = loaded;
+ const joinPath = path.join(directory, home.shared.file);
+ const reach = (
+ state: InstructionAgentState,
+ rest: Pick & Partial>,
+ ): AgentReach => ({
+ instanceId: instance.instanceId,
+ driver: instance.driver,
+ displayName: instance.displayName,
+ directory,
+ join: home.shared.join,
+ joinPath,
+ state,
+ ownFile: loaded.ownFile?.path,
+ reads: new Set([...loaded.loaded, ...loaded.fallbacks].map((file) => file.path)),
+ ...rest,
+ });
+
+ const through = [
+ ...loaded.loaded.map((file) => ({ ...file, own: true })),
+ ...loaded.fallbacks.map((file) => ({ ...file, own: false })),
+ ]
+ .filter((file) => reachesShared(file.facts, shared))
+ .map((file) => ({
+ path: file.path,
+ kind: file.facts.linkTarget === undefined ? ("direct" as const) : ("link" as const),
+ own: file.own,
+ }));
+ if (through.length > 0) {
+ return reach(through.some((entry) => entry.kind === "direct") ? "direct" : "link", {
+ via: through,
+ });
+ }
+ if (home.shared.join === "import") {
+ const read = loaded.loaded[0] === undefined ? undefined : yield* textOf(joinPath);
+ return read?._tag === "Read" && hasAgentsMdImport(read.text, importTarget(joinPath, shared))
+ ? reach("import", { via: [{ path: joinPath, kind: "import", own: true }] })
+ : reach("none", { via: [] });
+ }
+ // A file of the agent's own sits where the link would go, or in front of it.
+ if (loaded.ownFile !== undefined) {
+ const blockingFile =
+ loaded.ownFile.name === home.shared.file ? undefined : loaded.ownFile.name;
+ return reach("none", { via: [], reason: "ownFile", blockingFile });
+ }
+ return reach("none", { via: [] });
+ });
+
+ const importTarget = (claudeMd: string, shared: SharedFile): AgentsMdImportTarget => ({
+ path,
+ agentsMdPath: shared.path,
+ claudeMdDirectory: path.dirname(claudeMd),
+ homeDirectory,
+ });
+
+ const scanShared = Effect.fnUntraced(function* (
+ instances: readonly AgentInstance[],
+ textOf: TextReader,
+ ) {
+ const file = yield* sharedLocation(instances);
+ const reaches = yield* Effect.forEach(
+ instances,
+ (instance) => reachOf(instance, file, textOf),
+ {
+ concurrency: CONCURRENCY,
+ },
+ );
+ return {
+ file,
+ homeDirectory,
+ agents: reaches.filter((reach) => reach !== undefined),
+ } satisfies SharedView;
+ });
+
+ // --- Project files --------------------------------------------------------------------------
+
+ interface ProjectFacts {
+ readonly cwd: string;
+ /** Names of the project's top-folder files that exist, with their sizes. */
+ readonly sizes: ReadonlyMap;
+ /** Some Claude file in the top folder imports the project's AGENTS.md. */
+ readonly claudeImportsAgentsMd: boolean;
+ /**
+ * The top folder's CLAUDE.md holds nothing but that import, or is the same file as AGENTS.md
+ * through a link, so it has nothing to show.
+ */
+ readonly claudeMdOnlyImports: boolean;
+ }
+
+ const loadProject = Effect.fnUntraced(function* (
+ cwd: string,
+ instances: readonly AgentInstance[],
+ textOf: TextReader,
+ ) {
+ const names = new Set(ROOT_FILES.map((file) => file.name));
+ for (const instance of instances) {
+ for (const file of instance.rules.project?.files ?? []) names.add(file.name);
+ }
+ const found = yield* Effect.forEach(
+ [...names],
+ (name) => sizeOf(path.join(cwd, name)).pipe(Effect.map((size) => [name, size] as const)),
+ { concurrency: CONCURRENCY },
+ );
+ const sizes = new Map();
+ for (const [name, size] of found) if (size !== undefined) sizes.set(name, size);
+ const agentsMd = path.join(cwd, "AGENTS.md");
+ let claudeImportsAgentsMd = false;
+ let claudeMdOnlyImports = false;
+ if (sizes.has("CLAUDE.md") && sizes.has("AGENTS.md")) {
+ const [claudeReal, agentsReal] = yield* Effect.all([
+ fileSystem.realPath(path.join(cwd, "CLAUDE.md")).pipe(Effect.option),
+ fileSystem.realPath(agentsMd).pipe(Effect.option),
+ ]);
+ if (
+ Option.isSome(claudeReal) &&
+ Option.isSome(agentsReal) &&
+ claudeReal.value === agentsReal.value
+ ) {
+ claudeImportsAgentsMd = true;
+ claudeMdOnlyImports = true;
+ }
+ }
+ for (const name of ["CLAUDE.md", ".claude/CLAUDE.md", PERSONAL_FILE]) {
+ if (!sizes.has(name)) continue;
+ if (name === "CLAUDE.md" && claudeMdOnlyImports) continue;
+ const file = path.join(cwd, name);
+ const read = yield* textOf(file);
+ if (read._tag !== "Read") continue;
+ const target = {
+ path,
+ agentsMdPath: agentsMd,
+ claudeMdDirectory: path.dirname(file),
+ homeDirectory,
+ } satisfies AgentsMdImportTarget;
+ if (!hasAgentsMdImport(read.text, target)) continue;
+ claudeImportsAgentsMd = true;
+ if (name === "CLAUDE.md" && removeAgentsMdImport(read.text, target).trim() === "") {
+ claudeMdOnlyImports = true;
+ }
+ }
+ return { cwd, sizes, claudeImportsAgentsMd, claudeMdOnlyImports } satisfies ProjectFacts;
+ });
+
+ const accessOf = (
+ instance: Pick,
+ state: InstructionAgentState,
+ extra: {
+ readonly reason?: InstructionAgentReason | undefined;
+ readonly blockingFile?: string | undefined;
+ } = {},
+ ): InstructionAgentAccess => ({
+ instanceId: instance.instanceId,
+ driver: instance.driver,
+ state,
+ ...(extra.reason === undefined ? {} : { reason: extra.reason }),
+ ...(extra.blockingFile === undefined ? {} : { blockingFile: extra.blockingFile }),
+ });
+
+ /** How Claude reaches the project's AGENTS.md: through its setting, an import, or not at all. */
+ const claudeAgentsMdAccess = (
+ instance: AgentInstance,
+ choice: ClaudeInstructionChoice,
+ project: ProjectFacts,
+ ) => {
+ if (choice.value !== "managed-only" && project.claudeImportsAgentsMd) {
+ return accessOf(instance, "import");
+ }
+ if (!choice.supported) return accessOf(instance, "none", { reason: "oldVersion" });
+ if (choice.value === "managed-only" || choice.value === "claude-md") {
+ return accessOf(instance, "none", { reason: "settingOff" });
+ }
+ if (choice.value === "claude-md-and-agents-md") return accessOf(instance, "setting");
+ const blockingFile = ["CLAUDE.md", ".claude/CLAUDE.md", PERSONAL_FILE].find((name) =>
+ project.sizes.has(name),
+ );
+ return blockingFile === undefined
+ ? accessOf(instance, "setting")
+ : accessOf(instance, "none", { reason: "claudeFiles", blockingFile });
+ };
+
+ /** How one agent reaches a file in the project's top folder. */
+ const projectAccess = (
+ instance: AgentInstance,
+ name: string,
+ project: ProjectFacts,
+ claude: ReadonlyMap,
+ ): InstructionAgentAccess | undefined => {
+ const rules = instance.rules.project;
+ if (rules === null) return undefined;
+ const index = rules.files.findIndex((file) => file.name === name);
+ const rule = rules.files[index];
+ if (rule === undefined) return accessOf(instance, "none");
+ if (rule.governedBy === "claudeProjectInstructions") {
+ const choice = claude.get(instance.instanceId);
+ return choice === undefined
+ ? accessOf(instance, "none")
+ : claudeAgentsMdAccess(instance, choice, project);
+ }
+ // The file is meant to stay out of git, which Grok skips; whether this one is isn't known here.
+ if (rules.skipsGitIgnored === true && name === PERSONAL_FILE) return accessOf(instance, "none");
+ if (rules.selection !== "all") {
+ const blocker = rules.files.slice(0, index).find((file) => project.sizes.has(file.name));
+ if (blocker !== undefined) return accessOf(instance, "none", { blockingFile: blocker.name });
+ if (index > 0 && rules.fallbackDisabledByEnv?.some((flag) => isFlagSet(instance.env[flag]))) {
+ return accessOf(instance, "none");
+ }
+ }
+ return accessOf(instance, "direct");
+ };
+
+ /**
+ * AGENTS.md and CLAUDE.md files below the project's top folder, from the file index. Which
+ * agents read one depends on the folder's other files and on what the agent opens, so these
+ * entries don't claim any.
+ */
+ const nestedFiles = Effect.fnUntraced(function* (cwd: string) {
+ const found = new Set();
+ for (const name of NESTED_NAMES) {
+ const result = yield* workspaceEntries
+ .search({ cwd, query: name, limit: NESTED_SEARCH_LIMIT, kind: "file" })
+ .pipe(Effect.option);
+ for (const entry of Option.isSome(result) ? result.value.entries : []) {
+ const segments = entry.path.split("/");
+ const base = segments.at(-1) ?? "";
+ if (entry.ignored === true || !NESTED_NAMES.has(base) || segments.length < 2) continue;
+ if (segments.includes(".git") || segments.includes("node_modules")) continue;
+ // `.claude/CLAUDE.md` in the top folder has its own entry.
+ if (segments.length === 2 && segments[0] === ".claude") continue;
+ found.add(entry.path);
+ }
+ }
+ return [...found].toSorted().slice(0, MAX_NESTED);
+ });
+
+ // --- Resolving ids ---------------------------------------------------------------------------
+
+ /** A relative path with a folder in it, naming an AGENTS.md or CLAUDE.md, that can't leave the project. */
+ const isNestedPath = (relative: string) => {
+ const segments = relative.split("/");
+ return (
+ segments.length >= 2 &&
+ !relative.includes("\0") &&
+ !relative.includes("\\") &&
+ !segments.some((segment) => segment === "" || segment === "." || segment === "..") &&
+ !segments.includes(".git") &&
+ NESTED_NAMES.has(segments.at(-1) ?? "")
+ );
+ };
+
+ const resolve: InstructionCatalog["Service"]["resolve"] = Effect.fn("InstructionCatalog.resolve")(
+ function* (input) {
+ const [scope = "", kind = "", ...tail] = input.id.split(":");
+ const rest = tail.join(":");
+ const cwd = yield* requireProject(input.cwd);
+
+ if (scope === "project") {
+ if (cwd === undefined) return yield* unknownEntry();
+ if (kind === "nested") {
+ if (!isNestedPath(rest)) return yield* unknownEntry();
+ const file = path.join(cwd, rest);
+ const [realFolder, realRoot] = yield* Effect.all([
+ fileSystem.realPath(path.dirname(file)).pipe(Effect.option),
+ fileSystem.realPath(cwd).pipe(Effect.option),
+ ]);
+ if (Option.isNone(realFolder) || Option.isNone(realRoot)) {
+ return yield* new InstructionError({
+ reason: "notFound",
+ message: "That folder doesn't exist.",
+ });
+ }
+ const inside = path.relative(realRoot.value, realFolder.value);
+ if (inside === ".." || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) {
+ return yield* unknownEntry();
+ }
+ return {
+ id: input.id,
+ scope: "project",
+ kind: "nested",
+ path: file,
+ relativePath: rest,
+ readOnly: false,
+ } satisfies ResolvedInstruction;
+ }
+ const root = ROOT_FILES.find((file) => file.kind === kind && file.name === rest);
+ if (root === undefined) return yield* unknownEntry();
+ return {
+ id: input.id,
+ scope: "project",
+ kind: root.kind,
+ path: path.join(cwd, root.name),
+ relativePath: root.name,
+ readOnly: false,
+ } satisfies ResolvedInstruction;
+ }
+
+ if (scope === "global" && kind === "shared" && rest === "") {
+ const instances = yield* loadInstances(cwd);
+ const shared = yield* sharedLocation(instances);
+ return {
+ id: input.id,
+ scope: "global",
+ kind: "shared",
+ path: shared.path,
+ readOnly: false,
+ } satisfies ResolvedInstruction;
+ }
+
+ if (scope === "global" && (kind === "claude" || kind === "agentOwn")) {
+ const instances = yield* loadInstances(cwd);
+ const instance = instances.find((candidate) => candidate.instanceId === rest);
+ const isClaude = instance?.driver === CLAUDE_DRIVER;
+ if (
+ instance?.rules.home == null ||
+ instance.directory === undefined ||
+ isClaude !== (kind === "claude")
+ ) {
+ return yield* unknownEntry();
+ }
+ const shared = yield* sharedLocation(instances);
+ const loaded = yield* loadHome(instance, shared);
+ const file =
+ kind === "claude"
+ ? path.join(instance.directory, instance.rules.home.shared.file)
+ : (loaded?.ownFile?.path ??
+ path.join(instance.directory, instance.rules.home.shared.file));
+ return {
+ id: input.id,
+ scope: "global",
+ kind,
+ path: file,
+ readOnly: false,
+ owner: instance.instanceId,
+ } satisfies ResolvedInstruction;
+ }
+
+ if (input.id === MANAGED_ID) {
+ const managed = claudeManagedInstructionPath(platform);
+ if (managed === undefined) return yield* unknownEntry();
+ return {
+ id: input.id,
+ scope: "managed",
+ kind: "managed",
+ path: managed,
+ readOnly: true,
+ } satisfies ResolvedInstruction;
+ }
+ return yield* unknownEntry();
+ },
+ );
+
+ // --- Listing ---------------------------------------------------------------------------------
+
+ const list: InstructionCatalog["Service"]["list"] = Effect.fn("InstructionCatalog.list")(
+ function* (input) {
+ const cwd = yield* requireProject(input.cwd);
+ const textOf = makeTextReader();
+ const instances = yield* loadInstances(cwd);
+ const claudeInstances = instances.filter((instance) => instance.driver === CLAUDE_DRIVER);
+ const unreadable: InstructionProblem[] = [];
+
+ const settings = yield* Effect.forEach(
+ claudeInstances,
+ (instance) => claudeChoiceOf(instance, textOf),
+ { concurrency: CONCURRENCY },
+ );
+ const claude = settings.map((setting) => setting.choice);
+ for (const { problem } of settings) if (problem !== undefined) unreadable.push(problem);
+ const claudeByInstance = new Map(claude.map((choice) => [choice.instanceId, choice]));
+
+ const view = yield* scanShared(instances, textOf);
+ const entries: InstructionEntry[] = [];
+
+ const problemAt = (file: string, reason: string) => unreadable.push({ path: file, reason });
+ const note = (file: string, read: ReadOutcome) => {
+ if (read._tag === "TooLarge") problemAt(file, "It's larger than 1 MB.");
+ else if (read._tag === "Unreadable") problemAt(file, "T3 Code couldn't read it.");
+ };
+
+ if (cwd !== undefined) {
+ const project = yield* loadProject(cwd, instances, textOf);
+ for (const root of ROOT_FILES) {
+ const size = project.sizes.get(root.name);
+ // The project's AGENTS.md and the user's own CLAUDE.local.md are listed while missing,
+ // so they can be created. The local one only when an agent would read it.
+ const creatable = root.kind === "shared" || root.kind === "claudeLocal";
+ if (size === undefined && !creatable) continue;
+ // Many repos keep a one-line CLAUDE.md that only points Claude at AGENTS.md. It already
+ // does its job, so it gets no entry, as a Global CLAUDE.md that only imports Global.
+ if (root.name === "CLAUDE.md" && project.claudeMdOnlyImports) continue;
+ const access = instances.flatMap((instance) => {
+ const found = projectAccess(instance, root.name, project, claudeByInstance);
+ return found === undefined ? [] : [found];
+ });
+ if (size === undefined && root.kind === "claudeLocal") {
+ if (access.every((entry) => entry.state === "none")) continue;
+ }
+ entries.push({
+ id: rootId(root),
+ scope: "project",
+ kind: root.kind,
+ path: path.join(cwd, root.name),
+ relativePath: root.name,
+ exists: size !== undefined,
+ size: size ?? 0,
+ readOnly: false,
+ access,
+ });
+ }
+ for (const relative of yield* nestedFiles(cwd)) {
+ const file = path.join(cwd, relative);
+ const size = yield* sizeOf(file);
+ if (size === undefined) continue;
+ entries.push({
+ id: nestedId(relative),
+ scope: "project",
+ kind: "nested",
+ path: file,
+ relativePath: relative,
+ exists: true,
+ size,
+ readOnly: false,
+ access: [],
+ });
+ }
+ }
+
+ // The all-projects file, and the files agents keep in their homes besides it.
+ const sharedRead = view.file.exists ? yield* textOf(view.file.path) : undefined;
+ if (sharedRead !== undefined) note(view.file.path, sharedRead);
+ const sharedText = sharedRead?._tag === "Read" ? sharedRead.text.trim() : undefined;
+ const sameAsShared = (read: ReadOutcome) =>
+ read._tag === "Read" && sharedText !== undefined && read.text.trim() === sharedText;
+ entries.push({
+ id: GLOBAL_SHARED_ID,
+ scope: "global",
+ kind: "shared",
+ path: view.file.path,
+ exists: view.file.exists,
+ size: view.file.exists ? ((yield* sizeOf(view.file.path)) ?? 0) : 0,
+ readOnly: false,
+ access: view.agents.map((reach) =>
+ accessOf(reach, reach.state, {
+ reason: reach.reason,
+ blockingFile: reach.blockingFile,
+ }),
+ ),
+ });
+
+ const listedHomeFiles = new Set();
+ for (const reach of view.agents) {
+ const instance = instances.find((candidate) => candidate.instanceId === reach.instanceId);
+ if (instance === undefined || reach.ownFile === undefined) continue;
+ if (listedHomeFiles.has(reach.ownFile)) continue;
+ const isClaude = reach.driver === CLAUDE_DRIVER;
+ const read = yield* textOf(reach.ownFile);
+ note(reach.ownFile, read);
+ // Claude's file is only worth a row when it holds more than the line that joins it.
+ if (isClaude && read._tag === "Read") {
+ const target = importTarget(reach.ownFile, view.file);
+ if (removeAgentsMdImport(read.text, target).trim() === "") continue;
+ }
+ listedHomeFiles.add(reach.ownFile);
+ const access = view.agents.map((other) =>
+ accessOf(other, other.reads.has(reach.ownFile ?? "") ? "direct" : "none"),
+ );
+ entries.push({
+ id: isClaude ? claudeHomeId(reach.instanceId) : agentOwnId(reach.instanceId),
+ scope: "global",
+ kind: isClaude ? "claude" : "agentOwn",
+ path: reach.ownFile,
+ exists: true,
+ size: (yield* sizeOf(reach.ownFile)) ?? 0,
+ readOnly: false,
+ owner: reach.instanceId,
+ access,
+ sameAsShared: sameAsShared(read),
+ });
+ }
+
+ const managedPath = claudeManagedInstructionPath(platform);
+ if (managedPath !== undefined) {
+ const size = yield* sizeOf(managedPath);
+ if (size !== undefined) {
+ entries.push({
+ id: MANAGED_ID,
+ scope: "managed",
+ kind: "managed",
+ path: managedPath,
+ exists: true,
+ size,
+ readOnly: true,
+ access: claudeInstances.map((instance) => accessOf(instance, "direct")),
+ });
+ }
+ }
+
+ return { entries, claude, sharedPath: view.file.path, unreadable };
+ },
+ );
+
+ const read: InstructionCatalog["Service"]["read"] = Effect.fn("InstructionCatalog.read")(
+ function* (input) {
+ const entry = yield* resolve(input);
+ const outcome = yield* readTextAt(entry.path);
+ return {
+ id: input.id,
+ contents: outcome._tag === "Read" ? outcome.text : null,
+ revision: outcome._tag === "Read" ? outcome.revision : null,
+ tooLarge: outcome._tag === "TooLarge",
+ };
+ },
+ );
+
+ const shared = Effect.gen(function* () {
+ const instances = yield* loadInstances(undefined);
+ return yield* scanShared(instances, makeTextReader());
+ }).pipe(Effect.withSpan("InstructionCatalog.shared"));
+
+ return InstructionCatalog.of({ list, read, resolve, shared });
+});
+
+export const layer = Layer.effect(InstructionCatalog, make);
diff --git a/apps/server/src/instructions/InstructionFileIO.ts b/apps/server/src/instructions/InstructionFileIO.ts
new file mode 100644
index 000000000000..041090c92307
--- /dev/null
+++ b/apps/server/src/instructions/InstructionFileIO.ts
@@ -0,0 +1,131 @@
+/**
+ * InstructionFileIO - the file reads and writes under instruction files.
+ *
+ * An instruction file is often a link: an agent's home file linked to the Global `AGENTS.md`, or
+ * the Global file itself behind a dotfiles checkout. So a read follows links, and a write goes to
+ * the file's real path (`writeTargetOf`), where `writeFileStringAtomically` replaces it with a
+ * temp file and a rename. Renaming over the link instead would swap the link for a copy and leave
+ * the Global file stale.
+ *
+ * @module InstructionFileIO
+ */
+// @effect-diagnostics nodeBuiltinImport:off - Revisions are sha256 hashes of a file's bytes.
+import * as NodeCrypto from "node:crypto";
+
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+
+import { readLinkTarget } from "../skills/SkillLinks.ts";
+
+/** The largest instruction file T3 Code reads or writes, in bytes. */
+export const INSTRUCTION_MAX_BYTES = 1_048_576;
+
+const MAX_LINK_HOPS = 32;
+
+/** What is at a path, looking at the path itself and at where it leads. */
+export interface FileFacts {
+ readonly path: string;
+ /** Something is at the path. A link that leads nowhere counts. */
+ readonly present: boolean;
+ /** Absolute path a link points at; undefined when the path is not a link. */
+ readonly linkTarget: string | undefined;
+ /** The path leads to a regular file. */
+ readonly isFile: boolean;
+ /** Bytes; 0 unless the path leads to a regular file. */
+ readonly size: number;
+ /** Where the path really is, when it leads somewhere that exists. */
+ readonly real: string | undefined;
+}
+
+export const inspect = Effect.fn("InstructionFileIO.inspect")(function* (target: string) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const link = yield* readLinkTarget(target).pipe(
+ Effect.orElseSucceed(() => ({ _tag: "Missing" }) as const),
+ );
+ const info = yield* fileSystem.stat(target).pipe(Effect.option);
+ const real = info._tag === "Some" ? yield* realPathOf(target) : undefined;
+ const isFile = info._tag === "Some" && info.value.type === "File";
+ return {
+ path: target,
+ present: link._tag !== "Missing" || info._tag === "Some",
+ linkTarget: link._tag === "Link" ? path.resolve(path.dirname(target), link.target) : undefined,
+ isFile,
+ size: isFile && info._tag === "Some" ? Number(info.value.size) : 0,
+ real,
+ } satisfies FileFacts;
+});
+
+const realPathOf = (target: string) =>
+ FileSystem.FileSystem.pipe(
+ Effect.flatMap((fileSystem) => fileSystem.realPath(target)),
+ Effect.orElseSucceed(() => undefined),
+ );
+
+export const sha256 = (bytes: Uint8Array) =>
+ NodeCrypto.createHash("sha256").update(bytes).digest("hex");
+
+export type ReadOutcome =
+ /** Nothing is there. */
+ | { readonly _tag: "Missing" }
+ /** Something is there that isn't a regular file, couldn't be read, or isn't UTF-8 text. */
+ | { readonly _tag: "Unreadable" }
+ | { readonly _tag: "TooLarge" }
+ | { readonly _tag: "Read"; readonly text: string; readonly revision: string };
+
+// A file's byte order mark stays in its text, as the character it is. Decoding without it would
+// drop the mark when the text is saved or an import line is added, and the BOM handling in
+// `ClaudeInstructionSetting` would never run.
+const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
+
+/** The text of the file at a path, following links, bounded to `INSTRUCTION_MAX_BYTES`. */
+export const readText = Effect.fn("InstructionFileIO.readText")(function* (target: string) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const info = yield* fileSystem.stat(target).pipe(Effect.option);
+ if (info._tag === "None") {
+ const present = yield* readLinkTarget(target).pipe(
+ Effect.map((state) => state._tag !== "Missing"),
+ Effect.orElseSucceed(() => false),
+ );
+ return (present ? { _tag: "Unreadable" } : { _tag: "Missing" }) as ReadOutcome;
+ }
+ if (info.value.type !== "File") return { _tag: "Unreadable" } as ReadOutcome;
+ if (Number(info.value.size) > INSTRUCTION_MAX_BYTES) return { _tag: "TooLarge" } as ReadOutcome;
+ const bytes = yield* fileSystem.readFile(target).pipe(Effect.option);
+ if (bytes._tag === "None") return { _tag: "Unreadable" } as ReadOutcome;
+ if (bytes.value.byteLength > INSTRUCTION_MAX_BYTES) return { _tag: "TooLarge" } as ReadOutcome;
+ try {
+ return {
+ _tag: "Read",
+ text: decoder.decode(bytes.value),
+ revision: sha256(bytes.value),
+ } as ReadOutcome;
+ } catch {
+ return { _tag: "Unreadable" } as ReadOutcome;
+ }
+});
+
+/**
+ * The path a write to `target` must go to: the real file behind any links, or where a link that
+ * leads nowhere would land, or `target` itself when it is not a link and doesn't exist yet.
+ */
+export const writeTargetOf = Effect.fn("InstructionFileIO.writeTargetOf")(function* (
+ target: string,
+) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const real = yield* fileSystem.realPath(target).pipe(Effect.option);
+ if (real._tag === "Some") return real.value;
+ let current = target;
+ for (let hop = 0; hop < MAX_LINK_HOPS; hop += 1) {
+ const state = yield* readLinkTarget(current).pipe(
+ Effect.orElseSucceed(() => ({ _tag: "Missing" }) as const),
+ );
+ if (state._tag !== "Link") break;
+ current = path.resolve(path.dirname(current), state.target);
+ }
+ // A new file lands in its folder as that folder really is.
+ const folder = yield* realPathOf(path.dirname(current));
+ return folder === undefined ? current : path.join(folder, path.basename(current));
+});
diff --git a/apps/server/src/instructions/InstructionLinks.ts b/apps/server/src/instructions/InstructionLinks.ts
new file mode 100644
index 000000000000..8ccb49b5ac77
--- /dev/null
+++ b/apps/server/src/instructions/InstructionLinks.ts
@@ -0,0 +1,92 @@
+/**
+ * InstructionLinks - the two writes that make an agent read the shared instruction file: a link at
+ * the agent's own file, and, when the agent already has a file of its own, a link that takes the
+ * file's place.
+ *
+ * Like `SkillLinks`, the operating system is the last guard. A link is made with a bare create, so
+ * anything already at the path makes it fail and nothing is removed first. Only `replaceWithLink`
+ * ever takes a file's place, and only after the caller has kept the file's text somewhere else.
+ *
+ * @module InstructionLinks
+ */
+// @effect-diagnostics nodeBuiltinImport:off - A temp link's name needs a random part.
+import * as NodeCrypto from "node:crypto";
+
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+
+export type CreateFileLinkResult =
+ /** The link was made. */
+ | "created"
+ /** What is there already leads to the shared file. */
+ | "unchanged"
+ /** Something else is there. It was left alone. */
+ | "taken"
+ /** The system doesn't allow links here (Windows without Developer Mode, a read-only folder). */
+ | "notAllowed";
+
+/** Whether the path leads to `target`, or is a link to it that leads nowhere yet. */
+const leadsTo = Effect.fnUntraced(function* (link: string, target: string) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const [real, expected] = yield* Effect.all([
+ fileSystem.realPath(link).pipe(Effect.option),
+ fileSystem.realPath(target).pipe(Effect.option),
+ ]);
+ if (real._tag === "Some" && expected._tag === "Some") return real.value === expected.value;
+ const written = yield* fileSystem.readLink(link).pipe(Effect.option);
+ return written._tag === "Some" && path.resolve(path.dirname(link), written.value) === target;
+});
+
+/**
+ * Makes `link` a symlink to `target`, an absolute path. Nothing is replaced: an existing entry
+ * fails the create, and counts as `unchanged` only when it already leads to `target`.
+ */
+export const createFileLink = Effect.fn("InstructionLinks.createFileLink")(function* (input: {
+ readonly link: string;
+ readonly target: string;
+}) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ yield* fileSystem.makeDirectory(path.dirname(input.link), { recursive: true });
+ return yield* fileSystem.symlink(input.target, input.link).pipe(
+ Effect.as("created" as CreateFileLinkResult),
+ Effect.catchTags({
+ PlatformError: (error) => {
+ const reason = error.reason._tag;
+ if (reason === "AlreadyExists") {
+ return leadsTo(input.link, input.target).pipe(
+ Effect.map((same): CreateFileLinkResult => (same ? "unchanged" : "taken")),
+ );
+ }
+ if (reason === "PermissionDenied") return Effect.succeed("notAllowed" as const);
+ return Effect.fail(error);
+ },
+ }),
+ );
+});
+
+/**
+ * Makes `file` a link to `target` in one step: the link is made under a temp name beside the file
+ * and renamed over it, so there is never a moment without a file. `stillSame` is asked right before
+ * the rename, and the file is left alone when it says no. Returns whether the file was replaced.
+ */
+export const replaceWithLink = Effect.fn("InstructionLinks.replaceWithLink")(function* (input: {
+ readonly file: string;
+ readonly target: string;
+ readonly stillSame: Effect.Effect;
+}) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const temp = path.join(
+ path.dirname(input.file),
+ `.${path.basename(input.file)}.${NodeCrypto.randomUUID()}.link`,
+ );
+ yield* fileSystem.symlink(input.target, temp);
+ return yield* Effect.gen(function* () {
+ if (!(yield* input.stillSame)) return false;
+ yield* fileSystem.rename(temp, input.file);
+ return true;
+ }).pipe(Effect.ensuring(fileSystem.remove(temp, { force: true }).pipe(Effect.ignore)));
+});
diff --git a/apps/server/src/instructions/InstructionManager.test.ts b/apps/server/src/instructions/InstructionManager.test.ts
new file mode 100644
index 000000000000..80152246eb09
--- /dev/null
+++ b/apps/server/src/instructions/InstructionManager.test.ts
@@ -0,0 +1,1464 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { describe, expect, it } from "@effect/vitest";
+import { InstructionAgentsResult, InstructionWriteResult } from "@t3tools/contracts";
+import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as PlatformError from "effect/PlatformError";
+import * as Schema from "effect/Schema";
+
+import { parseSettingsJson } from "./ClaudeInstructionSetting.ts";
+import { adoptedText } from "./InstructionManager.ts";
+import * as InstructionCatalog from "./InstructionCatalog.ts";
+import * as InstructionManager from "./InstructionManager.ts";
+import * as InstructionTracking from "./InstructionTracking.ts";
+import {
+ ALL_AGENTS,
+ agent,
+ layerFor,
+ makeMachine,
+ type MachineOptions,
+} from "./testing/machine.ts";
+import * as ProcessRunner from "../processRunner.ts";
+
+const encodeWrite = Schema.encodeUnknownEffect(InstructionWriteResult);
+const encodeAgents = Schema.encodeUnknownEffect(InstructionAgentsResult);
+
+const CLAUDE = { versions: { claudeAgent: "2.1.291" } } satisfies MachineOptions;
+
+const onMachine = (
+ home: string,
+ options: MachineOptions,
+ use: (services: {
+ readonly manager: InstructionManager.InstructionManager["Service"];
+ readonly catalog: InstructionCatalog.InstructionCatalog["Service"];
+ readonly tracking: InstructionTracking.InstructionTracking["Service"];
+ }) => Effect.Effect,
+) =>
+ Effect.gen(function* () {
+ return yield* use({
+ manager: yield* InstructionManager.InstructionManager,
+ catalog: yield* InstructionCatalog.InstructionCatalog,
+ tracking: yield* InstructionTracking.InstructionTracking,
+ });
+ }).pipe(Effect.provide(layerFor(home, options)));
+
+const stateOf = (
+ catalog: InstructionCatalog.InstructionCatalog["Service"],
+ id: string,
+ cwd?: string,
+) =>
+ catalog
+ .list(cwd === undefined ? {} : { cwd })
+ .pipe(
+ Effect.map(({ entries }) =>
+ Object.fromEntries(
+ (entries.find((entry) => entry.id === id)?.access ?? []).map((access) => [
+ access.instanceId,
+ access.state,
+ ]),
+ ),
+ ),
+ );
+
+const agents = (...names: string[]) => names.map((name) => agent(name));
+
+it.layer(NodeServices.layer, { excludeTestServices: true })("InstructionManager", (it) => {
+ describe("write", () => {
+ it.effect("creates a missing file, then refuses to create it again", () =>
+ Effect.gen(function* () {
+ const { home, project, read } = yield* makeMachine;
+ yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) =>
+ Effect.gen(function* () {
+ const created = yield* manager.write({
+ cwd: project,
+ id: "project:shared:AGENTS.md",
+ contents: "# Rules\n",
+ expectedRevision: null,
+ });
+ yield* encodeWrite(created);
+
+ expect(yield* read("repos/app/AGENTS.md")).toBe("# Rules\n");
+ expect(created.revision).toMatch(/^[0-9a-f]{64}$/);
+ const again = yield* manager
+ .write({
+ cwd: project,
+ id: "project:shared:AGENTS.md",
+ contents: "other",
+ expectedRevision: null,
+ })
+ .pipe(Effect.flip);
+ expect(again.reason).toBe("exists");
+ expect(yield* read("repos/app/AGENTS.md")).toBe("# Rules\n");
+ }),
+ );
+ }),
+ );
+
+ it.effect("keeps a new CLAUDE.local.md out of git, and a new AGENTS.md in", () =>
+ Effect.gen(function* () {
+ const { home, project, fs, path } = yield* makeMachine;
+ const processRunner = yield* ProcessRunner.ProcessRunner;
+ yield* processRunner.run({ command: "git", args: ["-C", project, "init", "-q"] });
+ yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) =>
+ Effect.gen(function* () {
+ for (const id of ["project:claudeLocal:CLAUDE.local.md", "project:shared:AGENTS.md"]) {
+ yield* manager.write({ cwd: project, id, contents: "notes", expectedRevision: null });
+ }
+ }),
+ );
+ const ignored = (file: string) =>
+ processRunner
+ .run({
+ command: "git",
+ args: ["-C", project, "check-ignore", "-q", file],
+ })
+ .pipe(Effect.map((result) => result.code === 0));
+ expect(yield* ignored("CLAUDE.local.md")).toBe(true);
+ expect(yield* ignored("AGENTS.md")).toBe(false);
+ expect(yield* fs.exists(path.join(project, "CLAUDE.local.md"))).toBe(true);
+ }).pipe(Effect.provide(ProcessRunner.layer)),
+ );
+
+ it.effect("replaces a file only when its revision is still the one that was read", () =>
+ Effect.gen(function* () {
+ const { home, project, write, read } = yield* makeMachine;
+ yield* write("repos/app/AGENTS.md", "first");
+ yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const id = "project:shared:AGENTS.md";
+ const opened = yield* catalog.read({ cwd: project, id });
+
+ const saved = yield* manager.write({
+ cwd: project,
+ id,
+ contents: "second",
+ expectedRevision: opened.revision,
+ });
+ expect(yield* read("repos/app/AGENTS.md")).toBe("second");
+ expect(saved.revision).not.toBe(opened.revision);
+ expect((yield* catalog.read({ cwd: project, id })).revision).toBe(saved.revision);
+
+ // Someone else edited the file after it was opened.
+ yield* write("repos/app/AGENTS.md", "edited elsewhere");
+ const stale = yield* manager
+ .write({ cwd: project, id, contents: "third", expectedRevision: saved.revision })
+ .pipe(Effect.flip);
+ expect(stale.reason).toBe("changedOnDisk");
+ expect(yield* read("repos/app/AGENTS.md")).toBe("edited elsewhere");
+
+ // A file that was there and is gone is a change too.
+ const gone = yield* manager
+ .write({
+ cwd: project,
+ id: "project:claude:CLAUDE.md",
+ contents: "x",
+ expectedRevision: saved.revision,
+ })
+ .pipe(Effect.flip);
+ expect(gone.reason).toBe("changedOnDisk");
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "writes through a link to the real file and keeps the link",
+ () =>
+ Effect.gen(function* () {
+ const { home, project, write, link, fs, path, read } = yield* makeMachine;
+ yield* write(".agents/AGENTS.md", "shared");
+ yield* link(".agents/AGENTS.md", "repos/app/CLAUDE.md");
+ yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const id = "project:claude:CLAUDE.md";
+ const opened = yield* catalog.read({ cwd: project, id });
+ yield* manager.write({
+ cwd: project,
+ id,
+ contents: "shared, edited",
+ expectedRevision: opened.revision,
+ });
+
+ expect(yield* read(".agents/AGENTS.md")).toBe("shared, edited");
+ expect(yield* fs.readLink(path.join(project, "CLAUDE.md"))).toBe(
+ path.join(home, ".agents/AGENTS.md"),
+ );
+ // No temp file was left in either folder.
+ expect(yield* fs.readDirectory(path.join(home, ".agents"))).toEqual(["AGENTS.md"]);
+ expect(yield* fs.readDirectory(project)).toEqual(["CLAUDE.md"]);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "creates the real file behind a link that leads nowhere",
+ () =>
+ Effect.gen(function* () {
+ const { home, fs, path, read } = yield* makeMachine;
+ yield* fs.makeDirectory(path.join(home, ".codex"), { recursive: true });
+ yield* fs.symlink(
+ path.join(home, ".agents/AGENTS.md"),
+ path.join(home, ".codex/AGENTS.md"),
+ );
+ yield* onMachine(home, CLAUDE, ({ manager }) =>
+ Effect.gen(function* () {
+ yield* manager.write({
+ id: "global:shared",
+ contents: "hello",
+ expectedRevision: null,
+ });
+ expect(yield* read(".agents/AGENTS.md")).toBe("hello");
+ expect(yield* fs.readLink(path.join(home, ".codex/AGENTS.md"))).toBe(
+ path.join(home, ".agents/AGENTS.md"),
+ );
+ }),
+ );
+ }),
+ );
+
+ it.effect("keeps a file's permissions", () =>
+ Effect.gen(function* () {
+ const { home, project, write, fs, path } = yield* makeMachine;
+ yield* write("repos/app/AGENTS.md", "first");
+ yield* fs.chmod(path.join(project, "AGENTS.md"), 0o600);
+ yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const id = "project:shared:AGENTS.md";
+ const opened = yield* catalog.read({ cwd: project, id });
+ yield* manager.write({
+ cwd: project,
+ id,
+ contents: "second",
+ expectedRevision: opened.revision,
+ });
+ expect((yield* fs.stat(path.join(project, "AGENTS.md"))).mode & 0o777).toBe(0o600);
+ }),
+ );
+ }),
+ );
+
+ it.effect("refuses an unregistered project, a managed file and too much text", () =>
+ Effect.gen(function* () {
+ const { home, project, fs, path } = yield* makeMachine;
+ yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) =>
+ Effect.gen(function* () {
+ const elsewhere = path.join(home, "repos/other");
+ yield* fs.makeDirectory(elsewhere, { recursive: true });
+ const unregistered = yield* manager
+ .write({
+ cwd: elsewhere,
+ id: "project:shared:AGENTS.md",
+ contents: "x",
+ expectedRevision: null,
+ })
+ .pipe(Effect.flip);
+ expect(unregistered.reason).toBe("unregisteredProject");
+ expect(yield* fs.exists(path.join(elsewhere, "AGENTS.md"))).toBe(false);
+
+ const managed = yield* manager
+ .write({ id: "managed:claude", contents: "x", expectedRevision: null })
+ .pipe(Effect.flip);
+ expect(managed.reason).toBe("readOnly");
+
+ const tooLarge = yield* manager
+ .write({
+ cwd: project,
+ id: "project:shared:AGENTS.md",
+ // Over 1 MB in bytes, though not in characters.
+ contents: "é".repeat(600_000),
+ expectedRevision: null,
+ })
+ .pipe(Effect.flip);
+ expect(tooLarge.reason).toBe("tooLarge");
+ expect(yield* fs.exists(path.join(project, "AGENTS.md"))).toBe(false);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "refuses a subfolder file whose folder leads out of the project, and ids that aren't in the table",
+ () =>
+ Effect.gen(function* () {
+ const { home, project, write, fs, path } = yield* makeMachine;
+ yield* write("elsewhere/AGENTS.md", "outside");
+ yield* fs.symlink(path.join(home, "elsewhere"), path.join(project, "linked"));
+ yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ for (const id of [
+ "project:nested:linked/AGENTS.md",
+ "project:nested:../elsewhere/AGENTS.md",
+ "project:nested:linked/../../AGENTS.md",
+ "project:shared:../../etc/passwd",
+ "global:agentOwn:cursor",
+ ]) {
+ const error = yield* manager
+ .write({ cwd: project, id, contents: "x", expectedRevision: null })
+ .pipe(Effect.flip);
+ expect(error.reason, id).toBe("unknownEntry");
+ }
+ expect(yield* fs.readFileString(path.join(home, "elsewhere/AGENTS.md"))).toBe(
+ "outside",
+ );
+
+ // A folder inside the project is fine.
+ yield* write("repos/app/apps/web/AGENTS.md", "web");
+ const opened = yield* catalog.read({
+ cwd: project,
+ id: "project:nested:apps/web/AGENTS.md",
+ });
+ yield* manager.write({
+ cwd: project,
+ id: "project:nested:apps/web/AGENTS.md",
+ contents: "web, edited",
+ expectedRevision: opened.revision,
+ });
+ expect(yield* fs.readFileString(path.join(project, "apps/web/AGENTS.md"))).toBe(
+ "web, edited",
+ );
+ }),
+ );
+ }),
+ );
+ });
+
+ describe("turning agents on and off", () => {
+ it.effect.skipIf(!symlinksSupported)(
+ "gives a link-based agent an absolute link, creating the shared file first",
+ () =>
+ Effect.gen(function* () {
+ const { home, fs, path } = yield* makeMachine;
+ yield* onMachine(home, CLAUDE, ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const result = yield* manager.enable({
+ id: "global:shared",
+ agents: agents("codex"),
+ });
+ yield* encodeAgents(result);
+
+ expect(result.results).toEqual([{ instanceId: "codex", outcome: "changed" }]);
+ expect(yield* fs.readLink(path.join(home, ".codex/AGENTS.md"))).toBe(
+ path.join(home, ".agents/AGENTS.md"),
+ );
+ expect(yield* fs.readFileString(path.join(home, ".agents/AGENTS.md"))).toBe("");
+ expect(yield* stateOf(catalog, "global:shared")).toMatchObject({
+ codex: "link",
+ pi: "none",
+ });
+
+ const again = yield* manager.enable({ id: "global:shared", agents: agents("codex") });
+ expect(again.results).toEqual([{ instanceId: "codex", outcome: "unchanged" }]);
+ }),
+ );
+ }),
+ );
+
+ it.effect("gives Claude an import line as the first line and keeps the rest of its file", () =>
+ Effect.gen(function* () {
+ const { home, write, read } = yield* makeMachine;
+ yield* write(".claude/CLAUDE.md", "# My notes\n\nBe brief.\n");
+ yield* onMachine(home, CLAUDE, ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const result = yield* manager.enable({
+ id: "global:shared",
+ agents: agents("claudeAgent"),
+ });
+
+ expect(result.results).toEqual([{ instanceId: "claudeAgent", outcome: "changed" }]);
+ expect(yield* read(".claude/CLAUDE.md")).toBe(
+ "@~/.agents/AGENTS.md\n# My notes\n\nBe brief.\n",
+ );
+ expect(yield* stateOf(catalog, "global:shared")).toMatchObject({
+ claudeAgent: "import",
+ });
+ expect(
+ (yield* manager.enable({ id: "global:shared", agents: agents("claudeAgent") }))
+ .results,
+ ).toEqual([{ instanceId: "claudeAgent", outcome: "unchanged" }]);
+
+ const off = yield* manager.disable({
+ id: "global:shared",
+ agents: agents("claudeAgent"),
+ });
+ expect(off.results).toEqual([{ instanceId: "claudeAgent", outcome: "changed" }]);
+ expect(yield* read(".claude/CLAUDE.md")).toBe("# My notes\n\nBe brief.\n");
+ expect(yield* stateOf(catalog, "global:shared")).toMatchObject({ claudeAgent: "none" });
+ }),
+ );
+ }),
+ );
+
+ it.effect("keeps a byte order mark first, through the import line and through an edit", () =>
+ Effect.gen(function* () {
+ const { home, fs, path, project, write } = yield* makeMachine;
+ // `fs.readFileString` drops a mark, so the files are read as bytes.
+ const read = (relative: string) =>
+ fs
+ .readFile(path.join(home, relative))
+ .pipe(
+ Effect.map((bytes) => new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes)),
+ );
+ yield* write(".claude/CLAUDE.md", "\uFEFF# My notes\n");
+ yield* write("repos/app/AGENTS.md", "\uFEFF# Project\n");
+ yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ // The mark stays the first character, and the import line goes after it.
+ yield* manager.enable({ id: "global:shared", agents: agents("claudeAgent") });
+ expect(yield* read(".claude/CLAUDE.md")).toBe(
+ "\uFEFF@~/.agents/AGENTS.md\n# My notes\n",
+ );
+ expect(yield* stateOf(catalog, "global:shared")).toMatchObject({
+ claudeAgent: "import",
+ });
+ expect(
+ (yield* manager.enable({ id: "global:shared", agents: agents("claudeAgent") }))
+ .results,
+ ).toEqual([{ instanceId: "claudeAgent", outcome: "unchanged" }]);
+ yield* manager.disable({ id: "global:shared", agents: agents("claudeAgent") });
+ expect(yield* read(".claude/CLAUDE.md")).toBe("\uFEFF# My notes\n");
+
+ // The editor gets the text with its mark, and saving it back writes the mark too.
+ const opened = yield* catalog.read({ cwd: project, id: "project:shared:AGENTS.md" });
+ expect(opened.contents).toBe("\uFEFF# Project\n");
+ const saved = yield* manager.write({
+ cwd: project,
+ id: "project:shared:AGENTS.md",
+ contents: `${opened.contents}More.\n`,
+ expectedRevision: opened.revision,
+ });
+ const bytes = yield* fs.readFile(path.join(project, "AGENTS.md"));
+ expect(Array.from(bytes.slice(0, 3))).toEqual([0xef, 0xbb, 0xbf]);
+ expect(new TextDecoder().decode(bytes.slice(3))).toBe("# Project\nMore.\n");
+ // The revision is of the bytes, mark included, so a fresh read agrees with the save.
+ expect(
+ (yield* catalog.read({ cwd: project, id: "project:shared:AGENTS.md" })).revision,
+ ).toBe(saved.revision);
+ }),
+ );
+ }),
+ );
+
+ it.effect(
+ "makes Claude's file for the import, and removes it again when nothing else is in it",
+ () =>
+ Effect.gen(function* () {
+ const { home, fs, path, read } = yield* makeMachine;
+ yield* onMachine(home, CLAUDE, ({ manager }) =>
+ Effect.gen(function* () {
+ yield* manager.enable({ id: "global:shared", agents: agents("claudeAgent") });
+ expect(yield* read(".claude/CLAUDE.md")).toBe("@~/.agents/AGENTS.md\n");
+
+ yield* manager.disable({ id: "global:shared", agents: agents("claudeAgent") });
+ expect(yield* fs.exists(path.join(home, ".claude/CLAUDE.md"))).toBe(false);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "turns everything on for 'all', and names an agent by its driver kind",
+ () =>
+ Effect.gen(function* () {
+ const { home, fs, path } = yield* makeMachine;
+ yield* onMachine(home, CLAUDE, ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const result = yield* manager.enable({ id: "global:shared", agents: "all" });
+ yield* encodeAgents(result);
+
+ // Cursor and Antigravity have no home file, so they aren't part of it.
+ expect(result.results.map((item) => item.instanceId).toSorted()).toEqual([
+ "claudeAgent",
+ "codex",
+ "grok",
+ "opencode",
+ "pi",
+ ]);
+ expect(result.results.every((item) => item.outcome === "changed")).toBe(true);
+ expect(yield* fs.readLink(path.join(home, ".config/opencode/AGENTS.md"))).toBe(
+ path.join(home, ".agents/AGENTS.md"),
+ );
+ expect(yield* fs.readLink(path.join(home, ".pi/agent/AGENTS.md"))).toBe(
+ path.join(home, ".agents/AGENTS.md"),
+ );
+ expect(Object.values(yield* stateOf(catalog, "global:shared"))).not.toContain("none");
+
+ const off = yield* manager.disable({ id: "global:shared", agents: agents("codex") });
+ expect(off.results).toEqual([{ instanceId: "codex", outcome: "changed" }]);
+ expect(yield* fs.exists(path.join(home, ".codex/AGENTS.md"))).toBe(false);
+ expect(yield* fs.exists(path.join(home, ".agents/AGENTS.md"))).toBe(true);
+ }),
+ );
+ }),
+ );
+
+ it.effect("says so for an agent that isn't enabled, and does the rest", () =>
+ Effect.gen(function* () {
+ const { home, fs, path } = yield* makeMachine;
+ yield* onMachine(home, CLAUDE, ({ manager }) =>
+ Effect.gen(function* () {
+ const result = yield* manager.enable({
+ id: "global:shared",
+ agents: agents("no-such-agent", "pi"),
+ });
+ yield* encodeAgents(result);
+ expect(result.results).toEqual([
+ {
+ instanceId: "no-such-agent",
+ outcome: "failed",
+ reason: "That agent isn't enabled in this environment.",
+ },
+ { instanceId: "pi", outcome: "changed" },
+ ]);
+ expect(yield* fs.exists(path.join(home, ".pi/agent/AGENTS.md"))).toBe(true);
+ }),
+ );
+ }),
+ );
+
+ it.effect("leaves an agent's own file alone and says to use Global instead first", () =>
+ Effect.gen(function* () {
+ const { home, write, read } = yield* makeMachine;
+ yield* write(".codex/AGENTS.md", "codex notes");
+ yield* onMachine(home, CLAUDE, ({ manager }) =>
+ Effect.gen(function* () {
+ const result = yield* manager.enable({
+ id: "global:shared",
+ agents: agents("codex"),
+ });
+
+ expect(result.results).toEqual([
+ {
+ instanceId: "codex",
+ outcome: "failed",
+ reason: "Codex has its own instructions. Use Global instead first.",
+ },
+ ]);
+ expect(yield* read(".codex/AGENTS.md")).toBe("codex notes");
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "never takes something else's place, and doesn't remove a link that isn't the shared one",
+ () =>
+ Effect.gen(function* () {
+ const { home, fs, path } = yield* makeMachine;
+ // A dangling link to somewhere else sits where Pi's link would go.
+ yield* fs.makeDirectory(path.join(home, ".pi/agent"), { recursive: true });
+ yield* fs.symlink(path.join(home, "gone.md"), path.join(home, ".pi/agent/AGENTS.md"));
+ yield* onMachine(home, CLAUDE, ({ manager }) =>
+ Effect.gen(function* () {
+ const result = yield* manager.enable({ id: "global:shared", agents: agents("pi") });
+
+ expect(result.results).toEqual([
+ {
+ instanceId: "pi",
+ outcome: "failed",
+ reason: "Something else is already at AGENTS.md.",
+ },
+ ]);
+ expect(yield* fs.readLink(path.join(home, ".pi/agent/AGENTS.md"))).toBe(
+ path.join(home, "gone.md"),
+ );
+ // Disabling an agent that doesn't read the shared file touches nothing.
+ expect(
+ (yield* manager.disable({ id: "global:shared", agents: agents("pi") })).results,
+ ).toEqual([{ instanceId: "pi", outcome: "unchanged" }]);
+ expect(yield* fs.readLink(path.join(home, ".pi/agent/AGENTS.md"))).toBe(
+ path.join(home, "gone.md"),
+ );
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "doesn't turn off an agent that reads the Global file itself",
+ () =>
+ Effect.gen(function* () {
+ const { home, write, link, read } = yield* makeMachine;
+ // Everything links to the Codex file, which is therefore the shared file.
+ yield* write(".codex/AGENTS.md", "the rules");
+ yield* link(".codex/AGENTS.md", ".claude/CLAUDE.md");
+ yield* onMachine(home, CLAUDE, ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ expect(yield* stateOf(catalog, "global:shared")).toMatchObject({
+ codex: "direct",
+ claudeAgent: "link",
+ });
+
+ const named = yield* manager.disable({
+ id: "global:shared",
+ agents: agents("codex"),
+ });
+ expect(named.results).toEqual([
+ {
+ instanceId: "codex",
+ outcome: "failed",
+ reason: "Codex reads the Global instructions where they are.",
+ },
+ ]);
+ const all = yield* manager.disable({ id: "global:shared", agents: "all" });
+ expect(all.results.find((item) => item.instanceId === "codex")).toMatchObject({
+ outcome: "unchanged",
+ });
+ expect(yield* read(".codex/AGENTS.md")).toBe("the rules");
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "removes a link Claude joined by, and won't unlink an agent that reads through another agent's file",
+ () =>
+ Effect.gen(function* () {
+ const { home, write, link, fs, path, read } = yield* makeMachine;
+ yield* write(".agents/AGENTS.md", "shared");
+ yield* link(".agents/AGENTS.md", ".claude/CLAUDE.md");
+ yield* onMachine(home, CLAUDE, ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ expect(yield* stateOf(catalog, "global:shared")).toMatchObject({
+ claudeAgent: "link",
+ opencode: "link",
+ });
+
+ // OpenCode only reads it because Claude's file links to it.
+ const opencode = yield* manager.disable({
+ id: "global:shared",
+ agents: agents("opencode"),
+ });
+ expect(opencode.results).toEqual([
+ {
+ instanceId: "opencode",
+ outcome: "failed",
+ reason: "OpenCode reads the Global instructions through another agent's file.",
+ },
+ ]);
+ expect(yield* fs.exists(path.join(home, ".claude/CLAUDE.md"))).toBe(true);
+
+ const claude = yield* manager.disable({
+ id: "global:shared",
+ agents: agents("claudeAgent"),
+ });
+ expect(claude.results).toEqual([{ instanceId: "claudeAgent", outcome: "changed" }]);
+ expect(yield* fs.exists(path.join(home, ".claude/CLAUDE.md"))).toBe(false);
+ expect(yield* read(".agents/AGENTS.md")).toBe("shared");
+ }),
+ );
+ }),
+ );
+
+ it.effect("only turns agents on or off for the shared file", () =>
+ Effect.gen(function* () {
+ const { home, project } = yield* makeMachine;
+ yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) =>
+ Effect.gen(function* () {
+ const error = yield* manager
+ .enable({ cwd: project, id: "project:shared:AGENTS.md", agents: "all" })
+ .pipe(Effect.flip);
+ expect(error.reason).toBe("unknownEntry");
+ }),
+ );
+ }),
+ );
+ });
+
+ describe("Claude's Project instructions setting", () => {
+ const setting = (value: string) => ({
+ pluginConfigs: { "cc-plugin-agents-md@builtin": { options: { instructionFiles: value } } },
+ });
+
+ it.effect(
+ "creates settings.json when it is missing, and nothing when removing from nothing",
+ () =>
+ Effect.gen(function* () {
+ const { home, fs, path, read } = yield* makeMachine;
+ yield* onMachine(home, CLAUDE, ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ yield* manager.setClaudeSetting({ instanceId: agent("claudeAgent"), value: null });
+ expect(yield* fs.exists(path.join(home, ".claude/settings.json"))).toBe(false);
+
+ yield* manager.setClaudeSetting({
+ instanceId: agent("claudeAgent"),
+ value: "claude-md-and-agents-md",
+ });
+ expect(parseSettingsJson(yield* read(".claude/settings.json"))).toEqual(
+ setting("claude-md-and-agents-md"),
+ );
+ expect((yield* read(".claude/settings.json")).endsWith("}\n")).toBe(true);
+ expect((yield* catalog.list({})).claude[0]).toMatchObject({
+ value: "claude-md-and-agents-md",
+ explicit: true,
+ });
+ }),
+ );
+ }),
+ );
+
+ it.effect("keeps every other key, updates a legacy entry, and cleans up when reset", () =>
+ Effect.gen(function* () {
+ const { home, write, read } = yield* makeMachine;
+ const other = {
+ theme: "dark",
+ permissions: { allow: ["Bash(ls)"] },
+ pluginConfigs: {
+ "some-other@plugin": { options: { keep: true } },
+ "agents-md@builtin": { options: { instructionFiles: "claude-md" } },
+ },
+ };
+ yield* write(".claude/settings.json", JSON.stringify(other));
+ yield* onMachine(home, CLAUDE, ({ manager }) =>
+ Effect.gen(function* () {
+ yield* manager.setClaudeSetting({
+ instanceId: agent("claudeAgent"),
+ value: "claude-md-or-agents-md",
+ });
+ const set = parseSettingsJson(yield* read(".claude/settings.json"));
+ expect(set).toEqual({
+ ...other,
+ pluginConfigs: {
+ "some-other@plugin": { options: { keep: true } },
+ "agents-md@builtin": { options: { instructionFiles: "claude-md-or-agents-md" } },
+ "cc-plugin-agents-md@builtin": {
+ options: { instructionFiles: "claude-md-or-agents-md" },
+ },
+ },
+ });
+
+ yield* manager.setClaudeSetting({ instanceId: agent("claudeAgent"), value: null });
+ expect(parseSettingsJson(yield* read(".claude/settings.json"))).toEqual({
+ theme: "dark",
+ permissions: { allow: ["Bash(ls)"] },
+ pluginConfigs: { "some-other@plugin": { options: { keep: true } } },
+ });
+ }),
+ );
+ }),
+ );
+
+ it.effect("keeps the comments in a settings.json, as the skill settings do", () =>
+ Effect.gen(function* () {
+ const { home, write, read } = yield* makeMachine;
+ yield* write(".claude/settings.json", '{\n // my theme\n "theme": "dark",\n}\n');
+ yield* onMachine(home, CLAUDE, ({ manager }) =>
+ Effect.gen(function* () {
+ yield* manager.setClaudeSetting({
+ instanceId: agent("claudeAgent"),
+ value: "claude-md",
+ });
+ const text = yield* read(".claude/settings.json");
+ expect(text).toContain("// my theme");
+ expect(parseSettingsJson(text)).toEqual({ theme: "dark", ...setting("claude-md") });
+ }),
+ );
+ }),
+ );
+
+ it.effect("refuses a settings.json it can't parse and leaves it as it was", () =>
+ Effect.gen(function* () {
+ const { home, write, read } = yield* makeMachine;
+ for (const broken of ["{ not json", "[]", "null", '{"pluginConfigs": "oops"}']) {
+ yield* write(".claude/settings.json", broken);
+ yield* onMachine(home, CLAUDE, ({ manager }) =>
+ Effect.gen(function* () {
+ const error = yield* manager
+ .setClaudeSetting({ instanceId: agent("claudeAgent"), value: "claude-md" })
+ .pipe(Effect.flip);
+ expect(error.reason, broken).toBe("invalidSettings");
+ expect(yield* read(".claude/settings.json")).toBe(broken);
+ }),
+ );
+ }
+ }),
+ );
+
+ it.effect("only changes a Claude agent that is enabled", () =>
+ Effect.gen(function* () {
+ const { home } = yield* makeMachine;
+ yield* onMachine(home, CLAUDE, ({ manager }) =>
+ Effect.gen(function* () {
+ for (const instanceId of ["codex", "nobody"]) {
+ const error = yield* manager
+ .setClaudeSetting({ instanceId: agent(instanceId), value: "claude-md" })
+ .pipe(Effect.flip);
+ expect(error.reason).toBe("unknownEntry");
+ }
+ }),
+ );
+ }),
+ );
+ });
+
+ describe("share", () => {
+ it.effect(
+ "words a refused rename as readOnly and any other failure as writeFailed, with its cause",
+ () =>
+ Effect.gen(function* () {
+ const { home, project, fs, write } = yield* makeMachine;
+ yield* write("repos/app/CLAUDE.md", "rules");
+ const failingRename = (reason: "PermissionDenied" | "Unknown") =>
+ FileSystem.FileSystem.of({
+ ...fs,
+ rename: (from) =>
+ Effect.fail(
+ PlatformError.systemError({
+ _tag: reason,
+ module: "FileSystem",
+ method: "rename",
+ pathOrDescriptor: from,
+ cause: new Error(reason),
+ }),
+ ),
+ });
+ const shareWith = (reason: "PermissionDenied" | "Unknown") =>
+ onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) =>
+ manager
+ .share({ cwd: project, id: "project:claude:CLAUDE.md", merge: false })
+ .pipe(Effect.flip),
+ ).pipe(Effect.provideService(FileSystem.FileSystem, failingRename(reason)));
+
+ const denied = yield* shareWith("PermissionDenied");
+ expect(denied.reason).toBe("readOnly");
+ expect(denied.message).toBe("T3 Code isn't allowed to change AGENTS.md.");
+ expect(denied.cause).toBeInstanceOf(PlatformError.PlatformError);
+
+ const failed = yield* shareWith("Unknown");
+ expect(failed.reason).toBe("writeFailed");
+ expect(failed.message).toBe("T3 Code couldn't change AGENTS.md.");
+ expect(failed.cause).toBeInstanceOf(PlatformError.PlatformError);
+ expect(yield* fs.exists(`${project}/CLAUDE.md`)).toBe(true);
+ }),
+ );
+
+ it.effect("renames CLAUDE.md to AGENTS.md", () =>
+ Effect.gen(function* () {
+ const { home, project, write, read, fs, path } = yield* makeMachine;
+ yield* write("repos/app/CLAUDE.md", "rules");
+ yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) =>
+ Effect.gen(function* () {
+ yield* manager.share({ cwd: project, id: "project:claude:CLAUDE.md", merge: false });
+
+ expect(yield* read("repos/app/AGENTS.md")).toBe("rules");
+ expect(yield* fs.exists(path.join(project, "CLAUDE.md"))).toBe(false);
+ }),
+ );
+ }),
+ );
+
+ it.effect(
+ "refuses when AGENTS.md exists, or the project isn't registered, or the file isn't CLAUDE.md",
+ () =>
+ Effect.gen(function* () {
+ const { home, project, write, read } = yield* makeMachine;
+ yield* write("repos/app/CLAUDE.md", "claude");
+ yield* write("repos/app/AGENTS.md", "agents");
+ yield* write("repos/app/.claude/CLAUDE.md", "dot claude");
+ yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) =>
+ Effect.gen(function* () {
+ const exists = yield* manager
+ .share({ cwd: project, id: "project:claude:CLAUDE.md", merge: false })
+ .pipe(Effect.flip);
+ expect(exists.reason).toBe("exists");
+ expect(yield* read("repos/app/AGENTS.md")).toBe("agents");
+ expect(yield* read("repos/app/CLAUDE.md")).toBe("claude");
+
+ const nested = yield* manager
+ .share({ cwd: project, id: "project:claude:.claude/CLAUDE.md", merge: false })
+ .pipe(Effect.flip);
+ expect(nested.reason).toBe("unknownEntry");
+ }),
+ );
+ yield* onMachine(home, { ...CLAUDE, registered: [] }, ({ manager }) =>
+ Effect.gen(function* () {
+ const unregistered = yield* manager
+ .share({ cwd: project, id: "project:claude:CLAUDE.md", merge: false })
+ .pipe(Effect.flip);
+ expect(unregistered.reason).toBe("unregisteredProject");
+ }),
+ );
+ }),
+ );
+
+ it.effect("says when there is no CLAUDE.md to share", () =>
+ Effect.gen(function* () {
+ const { home, project } = yield* makeMachine;
+ yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) =>
+ Effect.gen(function* () {
+ const error = yield* manager
+ .share({ cwd: project, id: "project:claude:CLAUDE.md", merge: false })
+ .pipe(Effect.flip);
+ expect(error.reason).toBe("notFound");
+ }),
+ );
+ }),
+ );
+ });
+
+ describe("share with merge", () => {
+ const CLAUDE_ID = "project:claude:CLAUDE.md";
+
+ it.effect("adds CLAUDE.md's text to the end of AGENTS.md, then deletes CLAUDE.md", () =>
+ Effect.gen(function* () {
+ const { home, project, write, read, fs, path } = yield* makeMachine;
+ yield* write("repos/app/AGENTS.md", "# App\n- Use pnpm.");
+ yield* write("repos/app/CLAUDE.md", "\n- Run the tests.\n\n");
+ yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) =>
+ Effect.gen(function* () {
+ yield* manager.share({ cwd: project, id: CLAUDE_ID, merge: true });
+
+ expect(yield* read("repos/app/AGENTS.md")).toBe(
+ "# App\n- Use pnpm.\n\n- Run the tests.\n",
+ );
+ expect(yield* fs.exists(path.join(project, "CLAUDE.md"))).toBe(false);
+ }),
+ );
+ }),
+ );
+
+ it.effect("takes the line that imports AGENTS.md out of what it adds", () =>
+ Effect.gen(function* () {
+ const { home, project, write, read } = yield* makeMachine;
+ yield* write("repos/app/AGENTS.md", "rules\n");
+ yield* write("repos/app/CLAUDE.md", "@AGENTS.md\n\nextra rule\n");
+ yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) =>
+ Effect.gen(function* () {
+ yield* manager.share({ cwd: project, id: CLAUDE_ID, merge: true });
+ expect(yield* read("repos/app/AGENTS.md")).toBe("rules\n\nextra rule\n");
+ }),
+ );
+ }),
+ );
+
+ it.effect(
+ "only deletes a CLAUDE.md that is just the import, or that AGENTS.md has already",
+ () =>
+ Effect.gen(function* () {
+ const { home, project, write, read, fs, path } = yield* makeMachine;
+ yield* write("repos/app/AGENTS.md", "- Use pnpm.\n- Run the tests.\n");
+ yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) =>
+ Effect.gen(function* () {
+ for (const text of ["@./AGENTS.md\n", "- Run the tests.\n", " \n"]) {
+ yield* write("repos/app/CLAUDE.md", text);
+ yield* manager.share({ cwd: project, id: CLAUDE_ID, merge: true });
+
+ expect(yield* read("repos/app/AGENTS.md")).toBe("- Use pnpm.\n- Run the tests.\n");
+ expect(yield* fs.exists(path.join(project, "CLAUDE.md"))).toBe(false);
+ }
+ }),
+ );
+ }),
+ );
+
+ it.effect("refuses without an AGENTS.md, and leaves CLAUDE.md alone", () =>
+ Effect.gen(function* () {
+ const { home, project, write, read } = yield* makeMachine;
+ yield* write("repos/app/CLAUDE.md", "rules");
+ yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) =>
+ Effect.gen(function* () {
+ const error = yield* manager
+ .share({ cwd: project, id: CLAUDE_ID, merge: true })
+ .pipe(Effect.flip);
+ expect(error.reason).toBe("notFound");
+ expect(yield* read("repos/app/CLAUDE.md")).toBe("rules");
+ }),
+ );
+ }),
+ );
+
+ it.effect("refuses when the result would be over 1 MB, and changes nothing", () =>
+ Effect.gen(function* () {
+ const { home, project, write, read } = yield* makeMachine;
+ yield* write("repos/app/AGENTS.md", "a".repeat(700_000));
+ yield* write("repos/app/CLAUDE.md", "b".repeat(700_000));
+ yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) =>
+ Effect.gen(function* () {
+ const error = yield* manager
+ .share({ cwd: project, id: CLAUDE_ID, merge: true })
+ .pipe(Effect.flip);
+ expect(error.reason).toBe("tooLarge");
+ expect((yield* read("repos/app/AGENTS.md")).length).toBe(700_000);
+ expect((yield* read("repos/app/CLAUDE.md")).length).toBe(700_000);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "writes through a linked AGENTS.md, and removes a CLAUDE.md that is a link without its target",
+ () =>
+ Effect.gen(function* () {
+ const { home, project, write, link, read, fs, path } = yield* makeMachine;
+ yield* write("dotfiles/app-agents.md", "rules\n");
+ yield* write("dotfiles/app-claude.md", "more rules\n");
+ yield* link("dotfiles/app-agents.md", "repos/app/AGENTS.md");
+ yield* link("dotfiles/app-claude.md", "repos/app/CLAUDE.md");
+ yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) =>
+ Effect.gen(function* () {
+ yield* manager.share({ cwd: project, id: CLAUDE_ID, merge: true });
+
+ // The text lands in the real file, so AGENTS.md is still a link to it.
+ expect(yield* read("dotfiles/app-agents.md")).toBe("rules\n\nmore rules\n");
+ expect(yield* fs.readLink(path.join(project, "AGENTS.md"))).toBe(
+ path.join(home, "dotfiles/app-agents.md"),
+ );
+ expect(yield* fs.exists(path.join(project, "CLAUDE.md"))).toBe(false);
+ expect(yield* read("dotfiles/app-claude.md")).toBe("more rules\n");
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "won't delete a CLAUDE.md that the project's AGENTS.md is a link to",
+ () =>
+ Effect.gen(function* () {
+ const { home, project, write, link, read } = yield* makeMachine;
+ yield* write("repos/app/CLAUDE.md", "rules");
+ yield* link("repos/app/CLAUDE.md", "repos/app/AGENTS.md");
+ yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) =>
+ Effect.gen(function* () {
+ const error = yield* manager
+ .share({ cwd: project, id: CLAUDE_ID, merge: true })
+ .pipe(Effect.flip);
+ expect(error.reason).toBe("exists");
+ expect(yield* read("repos/app/CLAUDE.md")).toBe("rules");
+ }),
+ );
+ }),
+ );
+ });
+
+ describe("adopt", () => {
+ it.effect.skipIf(!symlinksSupported)(
+ "adds the agent's text to the shared file under its name, then links the agent to it",
+ () =>
+ Effect.gen(function* () {
+ const { home, write, read, fs, path } = yield* makeMachine;
+ yield* write(".agents/AGENTS.md", "# Shared\n\nBe kind.\n");
+ yield* write(".codex/AGENTS.md", "Prefer small diffs.\n");
+ yield* onMachine(home, CLAUDE, ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ yield* manager.adopt({ id: "global:agentOwn:codex" });
+
+ expect(yield* read(".agents/AGENTS.md")).toBe(
+ "# Shared\n\nBe kind.\n\n## From Codex\n\nPrefer small diffs.\n",
+ );
+ expect(yield* fs.readLink(path.join(home, ".codex/AGENTS.md"))).toBe(
+ path.join(home, ".agents/AGENTS.md"),
+ );
+ expect(yield* stateOf(catalog, "global:shared")).toMatchObject({ codex: "link" });
+ // The agent's file is the link now, so there is nothing of its own left to move.
+ const list = yield* catalog.list({});
+ expect(list.entries.map((entry) => entry.id)).toEqual(["global:shared"]);
+ yield* manager.adopt({ id: "global:agentOwn:codex" });
+ expect(yield* read(".agents/AGENTS.md")).toBe(
+ "# Shared\n\nBe kind.\n\n## From Codex\n\nPrefer small diffs.\n",
+ );
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "replaces an agent's link to some other file and leaves that file as it was",
+ () =>
+ Effect.gen(function* () {
+ const { home, write, link, read, fs, path } = yield* makeMachine;
+ yield* write(".agents/AGENTS.md", "shared\n");
+ yield* write("dotfiles/grok.md", "grok notes\n");
+ yield* link("dotfiles/grok.md", ".grok/AGENTS.md");
+ // Two different link targets, so neither one is taken for the shared file.
+ yield* write("dotfiles/codex.md", "codex notes\n");
+ yield* link("dotfiles/codex.md", ".codex/AGENTS.md");
+ yield* onMachine(home, CLAUDE, ({ manager }) =>
+ Effect.gen(function* () {
+ yield* manager.adopt({ id: "global:agentOwn:grok" });
+
+ expect(yield* read(".agents/AGENTS.md")).toBe(
+ "shared\n\n## From Grok\n\ngrok notes\n",
+ );
+ expect(yield* read("dotfiles/grok.md")).toBe("grok notes\n");
+ expect(yield* fs.readLink(path.join(home, ".grok/AGENTS.md"))).toBe(
+ path.join(home, ".agents/AGENTS.md"),
+ );
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)("just links an agent whose text is the same", () =>
+ Effect.gen(function* () {
+ const { home, write, read, fs, path } = yield* makeMachine;
+ yield* write(".agents/AGENTS.md", "same\n");
+ yield* write(".pi/agent/AGENTS.md", "same");
+ yield* onMachine(home, CLAUDE, ({ manager }) =>
+ Effect.gen(function* () {
+ yield* manager.adopt({ id: "global:agentOwn:pi" });
+
+ expect(yield* read(".agents/AGENTS.md")).toBe("same\n");
+ expect(yield* fs.readLink(path.join(home, ".pi/agent/AGENTS.md"))).toBe(
+ path.join(home, ".agents/AGENTS.md"),
+ );
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "creates the shared file when it doesn't exist, and takes the file Pi really reads",
+ () =>
+ Effect.gen(function* () {
+ const { home, write, read, fs, path } = yield* makeMachine;
+ yield* write(".pi/agent/CLAUDE.md", "pi notes");
+ yield* onMachine(home, CLAUDE, ({ manager }) =>
+ Effect.gen(function* () {
+ yield* manager.adopt({ id: "global:agentOwn:pi" });
+
+ expect(yield* read(".agents/AGENTS.md")).toBe("## From Pi\n\npi notes\n");
+ // The file Pi reads is the one that became the link.
+ expect(yield* fs.readLink(path.join(home, ".pi/agent/CLAUDE.md"))).toBe(
+ path.join(home, ".agents/AGENTS.md"),
+ );
+ }),
+ );
+ }),
+ );
+
+ it.effect(
+ "refuses Claude's file, ids that aren't an agent's own file, and an agent with none",
+ () =>
+ Effect.gen(function* () {
+ const { home, write, read } = yield* makeMachine;
+ yield* write(".claude/CLAUDE.md", "my claude notes");
+ yield* onMachine(home, CLAUDE, ({ manager }) =>
+ Effect.gen(function* () {
+ for (const [id, reason] of [
+ ["global:claude:claudeAgent", "unknownEntry"],
+ ["global:shared", "unknownEntry"],
+ ["global:agentOwn:cursor", "unknownEntry"],
+ ["global:agentOwn:codex", "notFound"],
+ ] as const) {
+ const error = yield* manager.adopt({ id }).pipe(Effect.flip);
+ expect(error.reason, id).toBe(reason);
+ }
+ expect(yield* read(".claude/CLAUDE.md")).toBe("my claude notes");
+ }),
+ );
+ }),
+ );
+
+ it("keeps adopted text exact and doesn't add the same text twice", () => {
+ expect(adoptedText("", "Grok", "a\n")).toBe("## From Grok\n\na\n");
+ expect(adoptedText("shared", "Grok", "a")).toBe("shared\n\n## From Grok\n\na\n");
+ expect(adoptedText("shared\n", "Grok", "a")).toBe("shared\n\n## From Grok\n\na\n");
+ expect(adoptedText("x\n\n## From Grok\n\na\n", "Grok", "a")).toBe("x\n\n## From Grok\n\na\n");
+ expect(adoptedText("a", "Grok", "a\n")).toBe("a");
+ expect(adoptedText("shared", "Grok", " \n")).toBe("shared");
+ });
+ });
+
+ describe("move", () => {
+ it.effect(
+ "moves a project file's text to the end of the Global file, then deletes the file",
+ () =>
+ Effect.gen(function* () {
+ const { home, project, write, read, fs, path } = yield* makeMachine;
+ yield* write("repos/app/CLAUDE.md", "- Run the tests.\n");
+ yield* write("repos/app/CLAUDE.local.md", "- Be brief.");
+ yield* write("repos/app/AGENTS.md", "- Run the tests.\n");
+ yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) =>
+ Effect.gen(function* () {
+ // There is no Global file yet, so the first move makes it.
+ yield* manager.move({ cwd: project, id: "project:claude:CLAUDE.md" });
+ expect(yield* read(".agents/AGENTS.md")).toBe("- Run the tests.\n");
+
+ yield* manager.move({ cwd: project, id: "project:claudeLocal:CLAUDE.local.md" });
+ expect(yield* read(".agents/AGENTS.md")).toBe("- Run the tests.\n\n- Be brief.\n");
+
+ // Text the Global file has already isn't added twice.
+ yield* manager.move({ cwd: project, id: "project:shared:AGENTS.md" });
+ expect(yield* read(".agents/AGENTS.md")).toBe("- Run the tests.\n\n- Be brief.\n");
+
+ for (const file of ["CLAUDE.md", "CLAUDE.local.md", "AGENTS.md"]) {
+ expect(yield* fs.exists(path.join(project, file)), file).toBe(false);
+ }
+ }),
+ );
+ }),
+ );
+
+ it.effect("leaves out the lines that import the Global file or the project's AGENTS.md", () =>
+ Effect.gen(function* () {
+ const { home, project, write, read } = yield* makeMachine;
+ yield* write(".agents/AGENTS.md", "global\n");
+ yield* write("repos/app/CLAUDE.md", "@~/.agents/AGENTS.md\n@AGENTS.md\n\nextra rule\n");
+ yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) =>
+ Effect.gen(function* () {
+ yield* manager.move({ cwd: project, id: "project:claude:CLAUDE.md" });
+ expect(yield* read(".agents/AGENTS.md")).toBe("global\n\nextra rule\n");
+ }),
+ );
+ }),
+ );
+
+ it.effect("copies the Global file to the project's AGENTS.md and keeps it", () =>
+ Effect.gen(function* () {
+ const { home, project, write, read, fs, path } = yield* makeMachine;
+ yield* write(".agents/AGENTS.md", "- Use pnpm.\n");
+ yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) =>
+ Effect.gen(function* () {
+ yield* manager.move({ cwd: project, id: "global:shared" });
+ expect(yield* read("repos/app/AGENTS.md")).toBe("- Use pnpm.\n");
+
+ yield* fs.remove(path.join(project, "AGENTS.md"));
+ yield* write("repos/app/AGENTS.md", "# App");
+ yield* manager.move({ cwd: project, id: "global:shared" });
+ yield* manager.move({ cwd: project, id: "global:shared" });
+ expect(yield* read("repos/app/AGENTS.md")).toBe("# App\n\n- Use pnpm.\n");
+ expect(yield* read(".agents/AGENTS.md")).toBe("- Use pnpm.\n");
+ }),
+ );
+ }),
+ );
+
+ it.effect("refuses what it can't move, and changes nothing", () =>
+ Effect.gen(function* () {
+ const { home, project, write, read } = yield* makeMachine;
+ yield* write("repos/app/apps/web/CLAUDE.md", "nested");
+ yield* write("repos/app/CLAUDE.md", "b".repeat(700_000));
+ yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) =>
+ Effect.gen(function* () {
+ const cases = [
+ ["project:nested:apps/web/CLAUDE.md", "unknownEntry"],
+ ["project:claudeLocal:CLAUDE.local.md", "notFound"],
+ ["global:shared", "notFound"],
+ ["managed:claude", "unknownEntry"],
+ ] as const;
+ for (const [id, reason] of cases) {
+ const error = yield* manager.move({ cwd: project, id }).pipe(Effect.flip);
+ expect(error.reason, id).toBe(reason);
+ }
+
+ yield* write(".agents/AGENTS.md", "a".repeat(700_000));
+ const error = yield* manager
+ .move({ cwd: project, id: "project:claude:CLAUDE.md" })
+ .pipe(Effect.flip);
+ expect(error.reason).toBe("tooLarge");
+ expect((yield* read(".agents/AGENTS.md")).length).toBe(700_000);
+ expect((yield* read("repos/app/CLAUDE.md")).length).toBe(700_000);
+ expect(yield* read("repos/app/apps/web/CLAUDE.md")).toBe("nested");
+ }),
+ );
+ yield* onMachine(home, { ...CLAUDE, registered: [] }, ({ manager }) =>
+ Effect.gen(function* () {
+ const error = yield* manager
+ .move({ cwd: project, id: "project:claude:CLAUDE.md" })
+ .pipe(Effect.flip);
+ expect(error.reason).toBe("unregisteredProject");
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "refuses when one file is a link to the other, and writes through a linked Global file",
+ () =>
+ Effect.gen(function* () {
+ const { home, project, write, link, read, fs, path } = yield* makeMachine;
+ yield* write("repos/app/CLAUDE.md", "project rules\n");
+ yield* link("repos/app/CLAUDE.md", ".agents/AGENTS.md");
+ yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) =>
+ Effect.gen(function* () {
+ for (const id of ["project:claude:CLAUDE.md", "global:shared"]) {
+ yield* link("repos/app/CLAUDE.md", "repos/app/AGENTS.md");
+ const error = yield* manager.move({ cwd: project, id }).pipe(Effect.flip);
+ expect(error.reason, id).toBe("sameFile");
+ yield* fs.remove(path.join(project, "AGENTS.md"));
+ }
+ expect(yield* read("repos/app/CLAUDE.md")).toBe("project rules\n");
+
+ yield* fs.remove(path.join(home, ".agents/AGENTS.md"));
+ yield* write("dotfiles/agents.md", "global\n");
+ yield* link("dotfiles/agents.md", ".agents/AGENTS.md");
+ yield* manager.move({ cwd: project, id: "project:claude:CLAUDE.md" });
+ expect(yield* read("dotfiles/agents.md")).toBe("global\n\nproject rules\n");
+ expect(yield* fs.readLink(path.join(home, ".agents/AGENTS.md"))).toBe(
+ path.join(home, "dotfiles/agents.md"),
+ );
+ expect(yield* fs.exists(path.join(project, "CLAUDE.md"))).toBe(false);
+ }),
+ );
+ }),
+ );
+ });
+
+ describe("delete", () => {
+ it.effect("removes a real file, and a link without touching what it leads to", () =>
+ Effect.gen(function* () {
+ const { home, project, write, link, fs, path, read } = yield* makeMachine;
+ yield* write("repos/app/CLAUDE.local.md", "mine");
+ yield* write(".agents/AGENTS.md", "shared");
+ yield* link(".agents/AGENTS.md", "repos/app/CLAUDE.md");
+ yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) =>
+ Effect.gen(function* () {
+ yield* manager.delete({ cwd: project, id: "project:claudeLocal:CLAUDE.local.md" });
+ expect(yield* fs.exists(path.join(project, "CLAUDE.local.md"))).toBe(false);
+
+ yield* manager.delete({ cwd: project, id: "project:claude:CLAUDE.md" });
+ expect(yield* fs.exists(path.join(project, "CLAUDE.md"))).toBe(false);
+ expect(yield* read(".agents/AGENTS.md")).toBe("shared");
+ }),
+ );
+ }),
+ );
+
+ it.effect(
+ "refuses the shared files, a missing file, a managed file and an unregistered project",
+ () =>
+ Effect.gen(function* () {
+ const { home, project, write, read } = yield* makeMachine;
+ yield* write("repos/app/AGENTS.md", "project shared");
+ yield* write(".agents/AGENTS.md", "global shared");
+ yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) =>
+ Effect.gen(function* () {
+ const cases = [
+ [{ cwd: project, id: "project:shared:AGENTS.md" }, "readOnly"],
+ [{ id: "global:shared" }, "readOnly"],
+ [{ cwd: project, id: "project:claude:CLAUDE.md" }, "notFound"],
+ [{ id: "managed:claude" }, "readOnly"],
+ ] as const;
+ for (const [input, reason] of cases) {
+ const error = yield* manager.delete(input).pipe(Effect.flip);
+ expect(error.reason, input.id).toBe(reason);
+ }
+ expect(yield* read("repos/app/AGENTS.md")).toBe("project shared");
+ expect(yield* read(".agents/AGENTS.md")).toBe("global shared");
+ }),
+ );
+ yield* onMachine(home, { ...CLAUDE, registered: [] }, ({ manager }) =>
+ Effect.gen(function* () {
+ const error = yield* manager
+ .delete({ cwd: project, id: "project:claude:CLAUDE.md" })
+ .pipe(Effect.flip);
+ expect(error.reason).toBe("unregisteredProject");
+ }),
+ );
+ }),
+ );
+
+ it.effect("deletes an agent's own file", () =>
+ Effect.gen(function* () {
+ const { home, write, fs, path } = yield* makeMachine;
+ yield* write(".codex/AGENTS.md", "codex notes");
+ yield* onMachine(home, CLAUDE, ({ manager }) =>
+ Effect.gen(function* () {
+ yield* manager.delete({ id: "global:agentOwn:codex" });
+ expect(yield* fs.exists(path.join(home, ".codex/AGENTS.md"))).toBe(false);
+ }),
+ );
+ }),
+ );
+ });
+
+ describe("tracking", () => {
+ it.effect("tells which project instruction files git tracks", () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ yield* write("repos/app/AGENTS.md", "tracked");
+ yield* write("repos/app/CLAUDE.md", "untracked");
+ yield* write("repos/app/apps/web/CLAUDE.md", "nested, tracked");
+ const processRunner = yield* ProcessRunner.ProcessRunner;
+ const git = (args: ReadonlyArray) =>
+ processRunner.run({
+ command: "git",
+ args: [
+ "-C",
+ project,
+ "-c",
+ "user.name=Test",
+ "-c",
+ "user.email=test@example.com",
+ "-c",
+ "commit.gpgsign=false",
+ ...args,
+ ],
+ });
+ yield* git(["init", "-q"]);
+ yield* git(["add", "AGENTS.md", "apps/web/CLAUDE.md"]);
+ yield* git(["commit", "-q", "-m", "init"]);
+
+ yield* onMachine(home, CLAUDE, ({ tracking }) =>
+ Effect.gen(function* () {
+ const result = yield* tracking.tracked({
+ cwd: project,
+ ids: [
+ "project:shared:AGENTS.md",
+ "project:claude:CLAUDE.md",
+ "project:nested:apps/web/CLAUDE.md",
+ "project:nested:apps/missing/AGENTS.md",
+ // Not project files, or not in the table.
+ "global:shared",
+ "project:nested:../AGENTS.md",
+ ],
+ });
+ expect(result.tracked).toEqual([
+ "project:shared:AGENTS.md",
+ "project:nested:apps/web/CLAUDE.md",
+ ]);
+ }),
+ );
+ }).pipe(Effect.provide(ProcessRunner.layer)),
+ );
+
+ it.effect("refuses a folder that isn't a registered project, without running git", () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ yield* write("repos/app/AGENTS.md", "x");
+ yield* onMachine(home, { ...CLAUDE, registered: [] }, ({ tracking }) =>
+ Effect.gen(function* () {
+ const error = yield* tracking
+ .tracked({ cwd: project, ids: ["project:shared:AGENTS.md"] })
+ .pipe(Effect.flip);
+ expect(error.reason).toBe("unregisteredProject");
+ }),
+ );
+ }),
+ );
+
+ it.effect("counts nothing as tracked outside a repository", () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ yield* write("repos/app/AGENTS.md", "x");
+ yield* onMachine(home, CLAUDE, ({ tracking }) =>
+ Effect.gen(function* () {
+ expect(
+ (yield* tracking.tracked({ cwd: project, ids: ["project:shared:AGENTS.md"] }))
+ .tracked,
+ ).toEqual([]);
+ }),
+ );
+ }),
+ );
+ });
+
+ it.effect("names every agent that has a home file", () =>
+ Effect.gen(function* () {
+ const { home } = yield* makeMachine;
+ yield* onMachine(home, CLAUDE, ({ catalog }) =>
+ Effect.gen(function* () {
+ const view = yield* catalog.shared;
+ expect(view.agents.map((reach) => reach.instanceId).toSorted()).toEqual(
+ ALL_AGENTS.filter((id) => id !== "cursor" && id !== "antigravity").toSorted(),
+ );
+ expect(view.agents.find((reach) => reach.instanceId === "claudeAgent")).toMatchObject({
+ join: "import",
+ joinPath: `${home}/.claude/CLAUDE.md`,
+ });
+ }),
+ );
+ }),
+ );
+});
diff --git a/apps/server/src/instructions/InstructionManager.ts b/apps/server/src/instructions/InstructionManager.ts
new file mode 100644
index 000000000000..51cf4f67207c
--- /dev/null
+++ b/apps/server/src/instructions/InstructionManager.ts
@@ -0,0 +1,860 @@
+/**
+ * InstructionManager - changes instruction files and who reads them.
+ *
+ * Every write starts from the id the client sent, which `InstructionCatalog.resolve` looks up in
+ * the table again, so a client can only reach files the table names, and only the files of a
+ * registered project's folder, which the catalog checks. Writes run one request at a time.
+ *
+ * - A file's text is replaced at its real path behind any links, by temp file and rename, and only
+ * if its revision is still the one the client read.
+ * - An agent reads the Global file (the one every project shares) because a link at its own home
+ * file points at it, or, for Claude, because its CLAUDE.md imports it. Both are made without
+ * replacing anything: a link with a bare create, an import line by adding text. An agent that
+ * already has a file of its own is moved over with `adopt`, which keeps that file's text in the
+ * Global file first.
+ * - The only writes that take a real file are `adopt` (its text is kept first), `share` (a
+ * rename, refused when AGENTS.md exists; or a merge, where CLAUDE.md's text is written to the end
+ * of AGENTS.md before CLAUDE.md goes), `move` (the same merge, into the Global file) and
+ * `delete`.
+ *
+ * @module InstructionManager
+ */
+import {
+ InstructionError,
+ ProviderDriverKind,
+ type ClaudeInstructionSettingInput,
+ type InstructionAdoptInput,
+ type InstructionAgentsInput,
+ type InstructionAgentsResult,
+ type InstructionDeleteInput,
+ type InstructionMoveInput,
+ type InstructionShareInput,
+ type InstructionWriteInput,
+ type InstructionWriteResult,
+ type ProviderInstanceId,
+} from "@t3tools/contracts";
+import * as Cause from "effect/Cause";
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+import type * as PlatformError from "effect/PlatformError";
+import * as Semaphore from "effect/Semaphore";
+import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
+
+import { editJsoncFile, readSettingsText } from "../skills/JsoncSettings.ts";
+import { excludeNewFile } from "../skills/SkillGitExclude.ts";
+import { removeLink } from "../skills/SkillLinks.ts";
+import * as VcsProcess from "../vcs/VcsProcess.ts";
+import {
+ addAgentsMdImport,
+ claudeInstructionChanges,
+ parseSettingsJson,
+ removeAgentsMdImport,
+ type AgentsMdImportTarget,
+} from "./ClaudeInstructionSetting.ts";
+import * as InstructionCatalog from "./InstructionCatalog.ts";
+import {
+ INSTRUCTION_MAX_BYTES,
+ inspect,
+ readText,
+ sha256,
+ writeTargetOf,
+} from "./InstructionFileIO.ts";
+import { createFileLink, replaceWithLink } from "./InstructionLinks.ts";
+
+type AgentResult = InstructionAgentsResult["results"][number];
+
+const encoder = new TextEncoder();
+
+const LIMIT_MESSAGE = "Instruction files can be at most 1 MB.";
+
+/** The project's AGENTS.md, which the Global file is copied to. */
+const PROJECT_AGENTS_ID = "project:shared:AGENTS.md";
+
+/**
+ * The text that adopting an agent's own file adds to the Global file: the agent's text under a
+ * heading with the agent's name. Nothing is added when the Global file has that text already.
+ */
+export const adoptedText = (sharedText: string, agentName: string, agentText: string) => {
+ const own = agentText.trim();
+ if (own === "" || sharedText.trim() === own) return sharedText;
+ const section = `## From ${agentName}\n\n${own}\n`;
+ if (sharedText.includes(section)) return sharedText;
+ if (sharedText === "") return section;
+ return `${sharedText}${sharedText.endsWith("\n") ? "" : "\n"}\n${section}`;
+};
+
+/**
+ * `into` with `own` added at its end after a blank line, for a merge. Nothing is added when `own`
+ * is empty or `into` has it already.
+ */
+const mergedText = (into: string, own: string) => {
+ const text = own.trim();
+ if (text === "" || into.includes(text)) return into;
+ if (into.trim() === "") return `${text}\n`;
+ return `${into}${into.endsWith("\n") ? "" : "\n"}\n${text}\n`;
+};
+
+export class InstructionManager extends Context.Service<
+ InstructionManager,
+ {
+ /**
+ * Replace the text of a file, or create it. `expectedRevision` is the revision that was read,
+ * or null for a file that must not exist yet.
+ */
+ readonly write: (
+ input: InstructionWriteInput,
+ ) => Effect.Effect;
+ /**
+ * Make each agent read the Global file: a link at its own home file, or for
+ * Claude an import line. `"all"` means every enabled agent. An agent is named by its
+ * instance id, or by its driver kind to mean every instance of that driver when no instance
+ * has that id.
+ */
+ readonly enable: (
+ input: InstructionAgentsInput,
+ ) => Effect.Effect;
+ /** Stop each agent reading the Global file by removing its link or import line. */
+ readonly disable: (
+ input: InstructionAgentsInput,
+ ) => Effect.Effect;
+ /** Set Claude's "Project instructions" setting; null goes back to Claude's default. */
+ readonly setClaudeSetting: (
+ input: ClaudeInstructionSettingInput,
+ ) => Effect.Effect;
+ /**
+ * Rename a project's CLAUDE.md to AGENTS.md, when it has no AGENTS.md; or with `merge`, add its
+ * text to the end of the project's AGENTS.md and delete it.
+ */
+ readonly share: (input: InstructionShareInput) => Effect.Effect;
+ /** Add an agent's own text to the Global file, then make the agent's file a link to it. */
+ readonly adopt: (input: InstructionAdoptInput) => Effect.Effect;
+ /**
+ * Move a project's AGENTS.md, CLAUDE.md or CLAUDE.local.md to Global: its text goes at the end
+ * of the Global file, created when missing, then the project file is deleted. Given the Global
+ * file, its text goes at the end of the project's AGENTS.md instead and the Global file stays.
+ */
+ readonly move: (input: InstructionMoveInput) => Effect.Effect;
+ /** Delete an instruction file. A link is removed and what it points at stays. */
+ readonly delete: (input: InstructionDeleteInput) => Effect.Effect;
+ }
+>()("t3/instructions/InstructionManager") {}
+
+const make = Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const catalog = yield* InstructionCatalog.InstructionCatalog;
+ const writeLock = yield* Semaphore.make(1);
+ const fileSystemContext = yield* Effect.context<
+ FileSystem.FileSystem | Path.Path | VcsProcess.VcsProcess
+ >();
+
+ const inspectAt = (file: string) => inspect(file).pipe(Effect.provideContext(fileSystemContext));
+ const readTextAt = (file: string) =>
+ readText(file).pipe(Effect.provideContext(fileSystemContext));
+ const writeTargetAt = (file: string) =>
+ writeTargetOf(file).pipe(Effect.provideContext(fileSystemContext));
+
+ /**
+ * Turns a failed file operation into an error a client can word, with the platform error kept
+ * as its cause. A refused permission is `denied`, the file being off limits; any other failure
+ * is `failed`, which is still a reason the client can show rather than a defect.
+ */
+ const guard = (
+ effect: Effect.Effect,
+ errors: {
+ readonly denied: (cause: PlatformError.PlatformError) => InstructionError;
+ readonly failed: (cause: PlatformError.PlatformError) => InstructionError;
+ },
+ ) =>
+ effect.pipe(
+ Effect.catchTags({
+ PlatformError: (cause) =>
+ Effect.fail(
+ cause.reason._tag === "PermissionDenied" ? errors.denied(cause) : errors.failed(cause),
+ ),
+ }),
+ );
+
+ /** Writing, renaming or removing `file`: off limits when the system refuses, else `writeFailed`. */
+ const whenWriting = (file: string) => ({
+ denied: (cause: PlatformError.PlatformError) =>
+ new InstructionError({
+ reason: "readOnly",
+ message: `T3 Code isn't allowed to change ${path.basename(file)}.`,
+ cause,
+ }),
+ failed: (cause: PlatformError.PlatformError) =>
+ new InstructionError({
+ reason: "writeFailed",
+ message: `T3 Code couldn't change ${path.basename(file)}.`,
+ cause,
+ }),
+ });
+
+ /** Linking `file`: every failure of it is `linkFailed`, which the client explains. */
+ const whenLinking = (file: string) => {
+ const linkFailed = (cause: PlatformError.PlatformError) =>
+ new InstructionError({
+ reason: "linkFailed",
+ message: `T3 Code couldn't link ${path.basename(file)}. Links need permission on this system.`,
+ cause,
+ });
+ return { denied: linkFailed, failed: linkFailed };
+ };
+
+ const invalidSettings = new InstructionError({
+ reason: "invalidSettings",
+ message: "Claude's settings.json isn't valid JSON, so T3 Code left it alone.",
+ });
+
+ /** A file keeps its permissions through a write; a new one gets the default. */
+ const writeText = (file: string, contents: string) =>
+ guard(
+ Effect.gen(function* () {
+ const mode = yield* fileSystem.stat(file).pipe(
+ Effect.map((info) => info.mode & 0o777),
+ Effect.orElseSucceed(() => undefined),
+ );
+ yield* writeFileStringAtomically({
+ filePath: file,
+ contents,
+ ...(mode === undefined ? {} : { mode }),
+ });
+ }).pipe(Effect.provideContext(fileSystemContext)),
+ whenWriting(file),
+ );
+
+ const importTargetOf = (
+ claudeMd: string,
+ view: InstructionCatalog.SharedView,
+ ): AgentsMdImportTarget => ({
+ path,
+ agentsMdPath: view.file.path,
+ claudeMdDirectory: path.dirname(claudeMd),
+ homeDirectory: view.homeDirectory,
+ });
+
+ // --- write ---------------------------------------------------------------------------------
+
+ const write: InstructionManager["Service"]["write"] = Effect.fn("InstructionManager.write")(
+ function* (input) {
+ return yield* writeLock.withPermits(1)(
+ Effect.gen(function* () {
+ const entry = yield* catalog.resolve(input);
+ if (entry.readOnly) {
+ return yield* new InstructionError({
+ reason: "readOnly",
+ message: "That file is set by your organization.",
+ });
+ }
+ const bytes = encoder.encode(input.contents);
+ if (bytes.byteLength > INSTRUCTION_MAX_BYTES) {
+ return yield* new InstructionError({ reason: "tooLarge", message: LIMIT_MESSAGE });
+ }
+ const target = yield* writeTargetAt(entry.path);
+ const current = yield* readTextAt(target);
+ if (current._tag === "TooLarge")
+ return yield* new InstructionError({ reason: "tooLarge", message: LIMIT_MESSAGE });
+ if (current._tag === "Unreadable") {
+ return yield* new InstructionError({
+ reason: "readOnly",
+ message: "T3 Code can't read that file as text.",
+ });
+ }
+ if (current._tag === "Missing" && input.expectedRevision !== null) {
+ return yield* new InstructionError({
+ reason: "changedOnDisk",
+ message: "That file changed on disk. Reload it first.",
+ });
+ }
+ if (current._tag === "Read") {
+ if (input.expectedRevision === null) {
+ return yield* new InstructionError({
+ reason: "exists",
+ message: "That file already exists.",
+ });
+ }
+ if (input.expectedRevision !== current.revision) {
+ return yield* new InstructionError({
+ reason: "changedOnDisk",
+ message: "That file changed on disk. Reload it first.",
+ });
+ }
+ }
+ yield* writeText(target, input.contents);
+ // CLAUDE.local.md is the user's own, not the repository's: a new one stays out of git, as
+ // Claude Code does for its local settings.
+ if (current._tag === "Missing" && entry.kind === "claudeLocal") {
+ yield* excludeNewFile({
+ projectRoot: input.cwd ?? path.dirname(entry.path),
+ file: entry.path,
+ }).pipe(
+ Effect.provideContext(fileSystemContext),
+ Effect.catchCause((cause) =>
+ Cause.hasInterruptsOnly(cause)
+ ? Effect.interrupt
+ : Effect.logWarning("could not keep CLAUDE.local.md out of git", {
+ file: entry.path,
+ cause: Cause.pretty(cause),
+ }),
+ ),
+ );
+ }
+ return { id: input.id, revision: sha256(bytes) };
+ }),
+ );
+ },
+ );
+
+ // --- enable and disable ----------------------------------------------------------------------
+
+ const unchanged = (reach: InstructionCatalog.AgentReach, reason?: string): AgentResult => ({
+ instanceId: reach.instanceId,
+ outcome: "unchanged",
+ ...(reason === undefined ? {} : { reason }),
+ });
+ const changed = (reach: InstructionCatalog.AgentReach): AgentResult => ({
+ instanceId: reach.instanceId,
+ outcome: "changed",
+ });
+ const failed = (reach: InstructionCatalog.AgentReach, reason: string): AgentResult => ({
+ instanceId: reach.instanceId,
+ outcome: "failed",
+ reason,
+ });
+
+ /** An empty shared file is made first, so a link to it works as soon as it exists. */
+ const ensureSharedFile = Effect.fnUntraced(function* (view: InstructionCatalog.SharedView) {
+ if (view.file.exists) return;
+ const target = yield* writeTargetAt(view.file.path);
+ if ((yield* readTextAt(target))._tag === "Missing") {
+ yield* writeText(target, "");
+ }
+ });
+
+ const enableOne = Effect.fnUntraced(function* (
+ reach: InstructionCatalog.AgentReach,
+ view: InstructionCatalog.SharedView,
+ ) {
+ if (reach.state !== "none") return unchanged(reach);
+ if (reach.reason === "ownFile") {
+ return failed(
+ reach,
+ `${reach.displayName} has its own instructions. Use Global instead first.`,
+ );
+ }
+ yield* ensureSharedFile(view);
+
+ if (reach.join === "import") {
+ const target = yield* writeTargetAt(reach.joinPath);
+ const current = yield* readTextAt(target);
+ if (current._tag === "TooLarge" || current._tag === "Unreadable") {
+ return failed(reach, `T3 Code couldn't read ${path.basename(reach.joinPath)}.`);
+ }
+ const text = current._tag === "Read" ? current.text : "";
+ const updated = addAgentsMdImport(text, importTargetOf(reach.joinPath, view));
+ if (updated === text) return unchanged(reach);
+ if (encoder.encode(updated).byteLength > INSTRUCTION_MAX_BYTES) {
+ return failed(reach, LIMIT_MESSAGE);
+ }
+ yield* writeText(target, updated);
+ return changed(reach);
+ }
+
+ const result = yield* createFileLink({ link: reach.joinPath, target: view.file.path }).pipe(
+ Effect.provideContext(fileSystemContext),
+ Effect.catchTags({ PlatformError: () => Effect.succeed("failed" as const) }),
+ );
+ if (result === "created") return changed(reach);
+ if (result === "unchanged") return unchanged(reach);
+ if (result === "taken") {
+ return failed(reach, `Something else is already at ${path.basename(reach.joinPath)}.`);
+ }
+ return failed(
+ reach,
+ result === "notAllowed"
+ ? "T3 Code isn't allowed to make links here."
+ : "T3 Code couldn't make the link.",
+ );
+ });
+
+ const disableOne = Effect.fnUntraced(function* (
+ reach: InstructionCatalog.AgentReach,
+ view: InstructionCatalog.SharedView,
+ explicit: boolean,
+ ) {
+ if (reach.state === "none") return unchanged(reach);
+ if (reach.state === "direct") {
+ const reason = `${reach.displayName} reads the Global instructions where they are.`;
+ return explicit ? failed(reach, reason) : unchanged(reach, reason);
+ }
+
+ if (reach.state === "import") {
+ const target = yield* writeTargetAt(reach.joinPath);
+ const current = yield* readTextAt(target);
+ if (current._tag !== "Read") {
+ return failed(reach, `T3 Code couldn't read ${path.basename(reach.joinPath)}.`);
+ }
+ const updated = removeAgentsMdImport(current.text, importTargetOf(reach.joinPath, view));
+ if (updated === current.text) return unchanged(reach);
+ const facts = yield* inspectAt(reach.joinPath);
+ // The line was all the file held and the file is not a link: nothing is left worth keeping.
+ if (updated.trim() === "" && facts.linkTarget === undefined) {
+ yield* guard(fileSystem.remove(reach.joinPath), whenWriting(reach.joinPath));
+ } else {
+ yield* writeText(target, updated);
+ }
+ return changed(reach);
+ }
+
+ const links = reach.via.filter((entry) => entry.own && entry.kind === "link");
+ if (links.length === 0) {
+ return failed(
+ reach,
+ `${reach.displayName} reads the Global instructions through another agent's file.`,
+ );
+ }
+ let removed = false;
+ for (const link of links) {
+ const written = yield* fileSystem.readLink(link.path).pipe(Effect.option);
+ if (Option.isNone(written)) continue;
+ const result = yield* removeLink({ path: link.path, expectedTarget: written.value }).pipe(
+ Effect.provideContext(fileSystemContext),
+ Effect.catchTags({ SkillLinkError: () => Effect.succeed("failed" as const) }),
+ );
+ if (result === "failed" || result === "changed") {
+ return failed(reach, "The link changed, so it was left alone.");
+ }
+ removed = removed || result === "removed";
+ }
+ return removed ? changed(reach) : unchanged(reach);
+ });
+
+ /** Looks up the agents asked for among those with a home file; a name that matches none fails. */
+ const pickAgents = (
+ view: InstructionCatalog.SharedView,
+ agents: InstructionAgentsInput["agents"],
+ ) => {
+ const picked = new Map();
+ const unknown: ProviderInstanceId[] = [];
+ if (agents === "all") {
+ for (const reach of view.agents) picked.set(reach.instanceId, reach);
+ return { picked, unknown };
+ }
+ for (const name of agents) {
+ const driver = ProviderDriverKind.make(name);
+ const byId = view.agents.filter((reach) => reach.instanceId === name);
+ const matches =
+ byId.length > 0 ? byId : view.agents.filter((reach) => reach.driver === driver);
+ if (matches.length === 0) unknown.push(name);
+ for (const reach of matches) picked.set(reach.instanceId, reach);
+ }
+ return { picked, unknown };
+ };
+
+ const changeAgents = (
+ input: InstructionAgentsInput,
+ change: (
+ reach: InstructionCatalog.AgentReach,
+ view: InstructionCatalog.SharedView,
+ ) => Effect.Effect,
+ ) =>
+ writeLock.withPermits(1)(
+ Effect.gen(function* () {
+ const entry = yield* catalog.resolve(input);
+ if (entry.scope !== "global" || entry.kind !== "shared") {
+ return yield* new InstructionError({
+ reason: "unknownEntry",
+ message: "Only the Global instructions can be turned on or off.",
+ });
+ }
+ const view = yield* catalog.shared;
+ const { picked, unknown } = pickAgents(view, input.agents);
+ const results: AgentResult[] = unknown.map((instanceId) => ({
+ instanceId,
+ outcome: "failed",
+ reason: "That agent isn't enabled in this environment.",
+ }));
+ for (const reach of picked.values()) results.push(yield* change(reach, view));
+ return { results } satisfies InstructionAgentsResult;
+ }),
+ );
+
+ // --- Claude's setting ------------------------------------------------------------------------
+
+ const setClaudeSetting: InstructionManager["Service"]["setClaudeSetting"] = Effect.fn(
+ "InstructionManager.setClaudeSetting",
+ )(function* (input) {
+ yield* writeLock.withPermits(1)(
+ Effect.gen(function* () {
+ const view = yield* catalog.shared;
+ const claude = view.agents.find(
+ (reach) => reach.instanceId === input.instanceId && reach.driver === "claudeAgent",
+ );
+ if (claude === undefined) {
+ return yield* new InstructionError({
+ reason: "unknownEntry",
+ message: "That isn't a Claude agent in this environment.",
+ });
+ }
+ const file = path.join(claude.directory, "settings.json");
+ const text = yield* readSettingsText(file).pipe(Effect.provideContext(fileSystemContext));
+ // A missing file is an empty object; one Claude couldn't read is never written over.
+ const settings = text === undefined || text.trim() === "" ? {} : parseSettingsJson(text);
+ if (settings === undefined) return yield* invalidSettings;
+ // Nothing to take away from a file that isn't there.
+ if (text === undefined && input.value === null) return;
+ const result = yield* editJsoncFile({
+ file,
+ changes: claudeInstructionChanges(settings, input.value),
+ }).pipe(Effect.provideContext(fileSystemContext));
+ if (result === "invalid") return yield* invalidSettings;
+ if (result === "failed") {
+ return yield* new InstructionError({
+ reason: "readOnly",
+ message: `T3 Code isn't allowed to change ${path.basename(file)}.`,
+ });
+ }
+ }),
+ );
+ });
+
+ // --- share, adopt, delete --------------------------------------------------------------------
+
+ const share: InstructionManager["Service"]["share"] = Effect.fn("InstructionManager.share")(
+ function* (input) {
+ yield* writeLock.withPermits(1)(
+ Effect.gen(function* () {
+ const entry = yield* catalog.resolve(input);
+ if (entry.kind !== "claude" || entry.relativePath !== "CLAUDE.md") {
+ return yield* new InstructionError({
+ reason: "unknownEntry",
+ message: "Only a project's CLAUDE.md can be shared.",
+ });
+ }
+ const from = yield* inspectAt(entry.path);
+ if (!from.isFile)
+ return yield* new InstructionError({
+ reason: "notFound",
+ message: "That file doesn't exist.",
+ });
+ const agentsMd = path.join(path.dirname(entry.path), "AGENTS.md");
+ const into = yield* inspectAt(agentsMd);
+ if (!input.merge) {
+ if (into.present)
+ return yield* new InstructionError({
+ reason: "exists",
+ message: "This project already has an AGENTS.md.",
+ });
+ return yield* guard(fileSystem.rename(entry.path, agentsMd), whenWriting(agentsMd));
+ }
+ if (!into.isFile) {
+ return yield* new InstructionError({
+ reason: "notFound",
+ message: "This project has no AGENTS.md to merge into.",
+ });
+ }
+ // AGENTS.md is a link to this very file, so deleting the file would take AGENTS.md too.
+ if (from.linkTarget === undefined && into.real === from.real) {
+ return yield* new InstructionError({
+ reason: "exists",
+ message: "AGENTS.md is a link to CLAUDE.md.",
+ });
+ }
+
+ const claudeText = yield* readTextAt(entry.path);
+ if (claudeText._tag === "TooLarge")
+ return yield* new InstructionError({ reason: "tooLarge", message: LIMIT_MESSAGE });
+ if (claudeText._tag !== "Read") {
+ return yield* new InstructionError({
+ reason: "readOnly",
+ message: "T3 Code can't read that file as text.",
+ });
+ }
+ const agentsTarget = yield* writeTargetAt(agentsMd);
+ const agentsText = yield* readTextAt(agentsTarget);
+ if (agentsText._tag === "TooLarge")
+ return yield* new InstructionError({ reason: "tooLarge", message: LIMIT_MESSAGE });
+ if (agentsText._tag !== "Read") {
+ return yield* new InstructionError({
+ reason: "readOnly",
+ message: "T3 Code can't read AGENTS.md as text.",
+ });
+ }
+
+ // A line that imports AGENTS.md would only point AGENTS.md at itself, so it doesn't move.
+ const view = yield* catalog.shared;
+ const own = removeAgentsMdImport(claudeText.text, {
+ path,
+ agentsMdPath: agentsMd,
+ claudeMdDirectory: path.dirname(entry.path),
+ homeDirectory: view.homeDirectory,
+ });
+ const joined = mergedText(agentsText.text, own);
+ if (joined !== agentsText.text) {
+ if (encoder.encode(joined).byteLength > INSTRUCTION_MAX_BYTES) {
+ return yield* new InstructionError({ reason: "tooLarge", message: LIMIT_MESSAGE });
+ }
+ yield* writeText(agentsTarget, joined);
+ }
+ // The text is in AGENTS.md now, so CLAUDE.md can go. A link goes and what it points at stays.
+ yield* guard(fileSystem.remove(entry.path), whenWriting(entry.path));
+ }),
+ );
+ },
+ );
+
+ const adopt: InstructionManager["Service"]["adopt"] = Effect.fn("InstructionManager.adopt")(
+ function* (input) {
+ yield* writeLock.withPermits(1)(
+ Effect.gen(function* () {
+ const entry = yield* catalog.resolve(input);
+ if (entry.kind !== "agentOwn" || entry.owner === undefined) {
+ return yield* new InstructionError({
+ reason: "unknownEntry",
+ message: "Only an agent's own instructions can be moved.",
+ });
+ }
+ const view = yield* catalog.shared;
+ const reach = view.agents.find((candidate) => candidate.instanceId === entry.owner);
+ if (reach === undefined) {
+ return yield* new InstructionError({
+ reason: "unknownEntry",
+ message: "That agent isn't enabled in this environment.",
+ });
+ }
+ // Nothing of its own to keep: it already reads the shared file, or has no file.
+ if (reach.ownFile === undefined) {
+ if (reach.state !== "none") return;
+ return yield* new InstructionError({
+ reason: "notFound",
+ message: "That agent has no instructions of its own.",
+ });
+ }
+ const ownFile = reach.ownFile;
+ const own = yield* readTextAt(ownFile);
+ if (own._tag === "TooLarge")
+ return yield* new InstructionError({ reason: "tooLarge", message: LIMIT_MESSAGE });
+ if (own._tag !== "Read") {
+ return yield* new InstructionError({
+ reason: "notFound",
+ message: "T3 Code can't read that agent's instructions.",
+ });
+ }
+ const before = yield* inspectAt(ownFile);
+
+ const sharedTarget = yield* writeTargetAt(view.file.path);
+ const shared = yield* readTextAt(sharedTarget);
+ if (shared._tag === "TooLarge")
+ return yield* new InstructionError({ reason: "tooLarge", message: LIMIT_MESSAGE });
+ if (shared._tag === "Unreadable") {
+ return yield* new InstructionError({
+ reason: "readOnly",
+ message: "T3 Code can't read the Global instructions as text.",
+ });
+ }
+ const sharedText = shared._tag === "Read" ? shared.text : "";
+ const merged = adoptedText(sharedText, reach.displayName, own.text);
+ const mergedBytes = encoder.encode(merged);
+ if (mergedBytes.byteLength > INSTRUCTION_MAX_BYTES) {
+ return yield* new InstructionError({ reason: "tooLarge", message: LIMIT_MESSAGE });
+ }
+ if (shared._tag === "Missing" || merged !== sharedText) {
+ yield* writeText(sharedTarget, merged);
+ }
+
+ // The agent's text is in the Global file now; its file can become the link.
+ const replaced = yield* guard(
+ replaceWithLink({
+ file: ownFile,
+ target: view.file.path,
+ stillSame: Effect.gen(function* () {
+ const now = yield* inspectAt(ownFile);
+ const text = yield* readTextAt(ownFile);
+ return (
+ now.linkTarget === before.linkTarget &&
+ text._tag === "Read" &&
+ text.revision === own.revision
+ );
+ }),
+ }).pipe(Effect.provideContext(fileSystemContext)),
+ whenLinking(ownFile),
+ );
+ if (!replaced) {
+ return yield* new InstructionError({
+ reason: "changedOnDisk",
+ message: "That file changed on disk. Nothing was linked.",
+ });
+ }
+ }),
+ );
+ },
+ );
+
+ /** The text of a file a move reads, which must be there and be text. */
+ const readForMove = Effect.fnUntraced(function* (file: string, name: string) {
+ const text = yield* readTextAt(file);
+ if (text._tag === "TooLarge")
+ return yield* new InstructionError({ reason: "tooLarge", message: LIMIT_MESSAGE });
+ if (text._tag === "Missing")
+ return yield* new InstructionError({
+ reason: "notFound",
+ message: "That file doesn't exist.",
+ });
+ if (text._tag === "Unreadable") {
+ return yield* new InstructionError({
+ reason: "readOnly",
+ message: `T3 Code can't read ${name} as text.`,
+ });
+ }
+ return text;
+ });
+
+ /**
+ * Adds `own` to the end of the file at `into`, creating it when missing. Refused when the two
+ * paths are one file, since a move would then delete the text it just kept.
+ */
+ const mergeInto = Effect.fnUntraced(function* (from: string, into: string, own: string) {
+ const [fromFacts, intoFacts] = yield* Effect.all([inspectAt(from), inspectAt(into)]);
+ if (fromFacts.real !== undefined && fromFacts.real === intoFacts.real) {
+ return yield* new InstructionError({
+ reason: "sameFile",
+ message: `${path.basename(from)} and ${path.basename(into)} are the same file.`,
+ });
+ }
+ const target = yield* writeTargetAt(into);
+ const current = yield* readTextAt(target);
+ if (current._tag === "TooLarge")
+ return yield* new InstructionError({ reason: "tooLarge", message: LIMIT_MESSAGE });
+ if (current._tag === "Unreadable") {
+ return yield* new InstructionError({
+ reason: "readOnly",
+ message: `T3 Code can't read ${path.basename(into)} as text.`,
+ });
+ }
+ const text = current._tag === "Read" ? current.text : "";
+ const joined = mergedText(text, own);
+ if (joined === text) return;
+ if (encoder.encode(joined).byteLength > INSTRUCTION_MAX_BYTES) {
+ return yield* new InstructionError({ reason: "tooLarge", message: LIMIT_MESSAGE });
+ }
+ yield* writeText(target, joined);
+ });
+
+ const move: InstructionManager["Service"]["move"] = Effect.fn("InstructionManager.move")(
+ function* (input) {
+ yield* writeLock.withPermits(1)(
+ Effect.gen(function* () {
+ const entry = yield* catalog.resolve(input);
+ if (entry.scope === "global" && entry.kind === "shared") {
+ const projectFile = yield* catalog.resolve({
+ cwd: input.cwd,
+ id: PROJECT_AGENTS_ID,
+ });
+ const global = yield* readForMove(entry.path, "the Global instructions");
+ return yield* mergeInto(entry.path, projectFile.path, global.text);
+ }
+
+ if (entry.scope !== "project" || entry.kind === "nested" || entry.readOnly) {
+ return yield* new InstructionError({
+ reason: "unknownEntry",
+ message: "Only a file in a project's top folder can be moved to Global.",
+ });
+ }
+ const view = yield* catalog.shared;
+ const name = path.basename(entry.path);
+ const file = yield* readForMove(entry.path, name);
+ const before = yield* inspectAt(entry.path);
+ // A line that imports the Global file or the project's AGENTS.md means nothing once the
+ // text is in the Global file, so it doesn't move.
+ const own = [view.file.path, path.join(path.dirname(entry.path), "AGENTS.md")].reduce(
+ (text, agentsMdPath) =>
+ removeAgentsMdImport(text, {
+ path,
+ agentsMdPath,
+ claudeMdDirectory: path.dirname(entry.path),
+ homeDirectory: view.homeDirectory,
+ }),
+ file.text,
+ );
+ yield* mergeInto(entry.path, view.file.path, own);
+
+ // The text is in the Global file now, so the project file can go, unless it changed
+ // meanwhile. A link goes and what it points at stays.
+ const now = yield* inspectAt(entry.path);
+ const text = yield* readTextAt(entry.path);
+ if (
+ now.linkTarget !== before.linkTarget ||
+ text._tag !== "Read" ||
+ text.revision !== file.revision
+ ) {
+ return yield* new InstructionError({
+ reason: "changedOnDisk",
+ message: `${name} changed on disk, so it was kept.`,
+ });
+ }
+ yield* guard(fileSystem.remove(entry.path), whenWriting(entry.path));
+ }),
+ );
+ },
+ );
+
+ const remove: InstructionManager["Service"]["delete"] = Effect.fn("InstructionManager.delete")(
+ function* (input) {
+ yield* writeLock.withPermits(1)(
+ Effect.gen(function* () {
+ const entry = yield* catalog.resolve(input);
+ if (entry.readOnly) {
+ return yield* new InstructionError({
+ reason: "readOnly",
+ message: "That file is set by your organization.",
+ });
+ }
+ if (entry.kind === "shared") {
+ return yield* new InstructionError({
+ reason: "readOnly",
+ message: "AGENTS.md files can't be deleted here.",
+ });
+ }
+ const facts = yield* inspectAt(entry.path);
+ if (!facts.present)
+ return yield* new InstructionError({
+ reason: "notFound",
+ message: "That file doesn't exist.",
+ });
+ if (!facts.isFile && facts.linkTarget === undefined) {
+ return yield* new InstructionError({
+ reason: "unknownEntry",
+ message: "That isn't a file.",
+ });
+ }
+ // A non-recursive remove: a link goes and its target stays, a file goes, a folder fails.
+ yield* guard(fileSystem.remove(entry.path), whenWriting(entry.path));
+ }),
+ );
+ },
+ );
+
+ return InstructionManager.of({
+ write,
+ enable: Effect.fn("InstructionManager.enable")(function* (input) {
+ return yield* changeAgents(input, (reach, view) => enableOne(reach, view));
+ }),
+ disable: Effect.fn("InstructionManager.disable")(function* (input) {
+ return yield* changeAgents(input, (reach, view) =>
+ disableOne(reach, view, input.agents !== "all"),
+ );
+ }),
+ setClaudeSetting,
+ share,
+ adopt,
+ move,
+ delete: remove,
+ });
+});
+
+export const layer = Layer.effect(InstructionManager, make);
diff --git a/apps/server/src/instructions/InstructionTracking.ts b/apps/server/src/instructions/InstructionTracking.ts
new file mode 100644
index 000000000000..2df10172a2d7
--- /dev/null
+++ b/apps/server/src/instructions/InstructionTracking.ts
@@ -0,0 +1,89 @@
+/**
+ * InstructionTracking - tells which project instruction files git tracks, so a confirmation for
+ * moving, merging or deleting one can say whether git can undo it.
+ *
+ * It is separate from `InstructionCatalog` because the catalog's list spawns nothing and loads on
+ * every page open; this runs one `git ls-files` for all the files asked about, and only when a
+ * person is about to confirm a change. Nothing is written.
+ *
+ * @module InstructionTracking
+ */
+import type {
+ InstructionError,
+ InstructionTrackedInput,
+ InstructionTrackedResult,
+} from "@t3tools/contracts";
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+
+import { trackedFiles } from "../vcs/GitTrackedFiles.ts";
+import * as VcsProcess from "../vcs/VcsProcess.ts";
+import * as InstructionCatalog from "./InstructionCatalog.ts";
+
+export class InstructionTracking extends Context.Service<
+ InstructionTracking,
+ {
+ /**
+ * The ids among `ids` of project files that git tracks. An id that doesn't name a project
+ * file, a file that sits outside the repository, and every file when git fails count as not
+ * tracked. A file is tracked by its own path, so a link git tracks counts, whatever it
+ * points at. A folder that isn't a registered project's workspace root is refused.
+ */
+ readonly tracked: (
+ input: InstructionTrackedInput,
+ ) => Effect.Effect;
+ }
+>()("t3/instructions/InstructionTracking") {}
+
+const make = Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const catalog = yield* InstructionCatalog.InstructionCatalog;
+ const vcs = yield* VcsProcess.VcsProcess;
+
+ const tracked: InstructionTracking["Service"]["tracked"] = Effect.fn(
+ "InstructionTracking.tracked",
+ )(function* (input) {
+ const realCwd = yield* fileSystem
+ .realPath(input.cwd)
+ .pipe(Effect.orElseSucceed(() => input.cwd));
+
+ // The path git knows each file by, from the project's real folder.
+ const files = new Map();
+ for (const id of input.ids) {
+ const entry = yield* catalog.resolve({ cwd: input.cwd, id }).pipe(
+ Effect.map(Option.some),
+ Effect.catchTags({
+ // An id that names nothing here is just not tracked; the folder is refused outright.
+ InstructionError: (error) =>
+ error.reason === "unregisteredProject"
+ ? Effect.fail(error)
+ : Effect.succeed(Option.none()),
+ }),
+ );
+ if (Option.isNone(entry) || entry.value.scope !== "project") continue;
+ const folder = yield* fileSystem
+ .realPath(path.dirname(entry.value.path))
+ .pipe(Effect.orElseSucceed(() => path.dirname(entry.value.path)));
+ const inside = path.relative(realCwd, path.join(folder, path.basename(entry.value.path)));
+ if (inside === "" || inside.startsWith("..") || path.isAbsolute(inside)) continue;
+ files.set(inside.replaceAll("\\", "/"), id);
+ }
+ if (files.size === 0) return { tracked: [] };
+
+ const trackedPaths = yield* trackedFiles(vcs, {
+ operation: "InstructionTracking.tracked",
+ cwd: input.cwd,
+ files: [...files.keys()],
+ });
+ return { tracked: [...files].flatMap(([file, id]) => (trackedPaths.has(file) ? [id] : [])) };
+ });
+
+ return InstructionTracking.of({ tracked });
+});
+
+export const layer = Layer.effect(InstructionTracking, make);
diff --git a/apps/server/src/instructions/testing/machine.ts b/apps/server/src/instructions/testing/machine.ts
new file mode 100644
index 000000000000..0adfa99bfc2c
--- /dev/null
+++ b/apps/server/src/instructions/testing/machine.ts
@@ -0,0 +1,194 @@
+/**
+ * A made-up machine for instruction tests: a temp home folder with real files and links, a project
+ * in it, and the instruction services running over it. Only what lives outside the files is a
+ * stand-in: the provider snapshots (versions), the project registry, and the project's file index,
+ * which walks the real folder.
+ */
+import {
+ ProjectId,
+ ProviderDriverKind,
+ ProviderInstanceId,
+ type Project,
+ type ServerProvider,
+} from "@t3tools/contracts";
+import * as HostProcess from "@t3tools/shared/HostProcess";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+
+import * as ProjectService from "../../project/ProjectService.ts";
+import * as ProviderRegistry from "../../provider/ProviderRegistry.ts";
+import * as Settings from "../../serverSettings.ts";
+import * as VcsProcess from "../../vcs/VcsProcess.ts";
+import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts";
+import * as InstructionCatalog from "../InstructionCatalog.ts";
+import * as InstructionManager from "../InstructionManager.ts";
+import * as InstructionTracking from "../InstructionTracking.ts";
+
+export const agent = ProviderInstanceId.make;
+
+/** Every agent that has instruction files, by instance id. */
+export const ALL_AGENTS = [
+ "claudeAgent",
+ "codex",
+ "cursor",
+ "grok",
+ "opencode",
+ "antigravity",
+ "pi",
+];
+
+export const makeProject = (workspaceRoot: string): Project => ({
+ id: ProjectId.make("project-instructions"),
+ title: "App",
+ workspaceRoot,
+ repositoryIdentity: null,
+ faviconPath: null,
+ projectIcon: null,
+ defaultModelSelection: null,
+ defaultThreadEnvMode: null,
+ autoPull: false,
+ scripts: [],
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ deletedAt: null,
+});
+
+/** The machine's own project, in its home folder. */
+const PROJECT_FOLDER = "repos/app";
+
+/** A temp home folder, with helpers to put files and links in it, and a project folder. */
+export const makeMachine = Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const home = yield* fs.realPath(
+ yield* fs.makeTempDirectoryScoped({ prefix: "t3code-instructions-" }),
+ );
+ const project = path.join(home, PROJECT_FOLDER);
+ yield* fs.makeDirectory(project, { recursive: true });
+ const write = (relative: string, contents: string) =>
+ Effect.gen(function* () {
+ const target = path.join(home, relative);
+ yield* fs.makeDirectory(path.dirname(target), { recursive: true });
+ yield* fs.writeFileString(target, contents);
+ });
+ const link = (target: string, from: string) =>
+ Effect.gen(function* () {
+ yield* fs.makeDirectory(path.dirname(path.join(home, from)), { recursive: true });
+ yield* fs.symlink(path.join(home, target), path.join(home, from));
+ });
+ const read = (relative: string) => fs.readFileString(path.join(home, relative));
+ return { fs, path, home, project, write, link, read };
+});
+
+export interface MachineOptions {
+ /** Folders that are projects; the machine's own project when absent. */
+ readonly registered?: readonly string[];
+ /** Claude Code's version, by instance id; absent means the status has no version. */
+ readonly versions?: Readonly>;
+ /** Extra enabled providers beyond the default Claude and Codex. */
+ readonly providers?: readonly string[];
+ readonly providerInstances?: NonNullable<
+ Parameters[0]
+ >["providerInstances"];
+ /** Environment variables of the server process, besides HOME. */
+ readonly env?: Readonly>;
+ readonly platform?: NodeJS.Platform;
+}
+
+const snapshotOf = (
+ instanceId: string,
+ driver: string,
+ version: string | null,
+): ServerProvider => ({
+ instanceId: ProviderInstanceId.make(instanceId),
+ driver: ProviderDriverKind.make(driver),
+ enabled: true,
+ installed: true,
+ version,
+ status: "ready",
+ auth: { status: "authenticated" },
+ checkedAt: "2026-01-01T00:00:00.000Z",
+ models: [],
+ slashCommands: [],
+ skills: [],
+});
+
+/**
+ * The instruction services on a machine whose home is `home`. The file index is a walk of the real
+ * project folder that matches names exactly, skipping `.git` and `node_modules`.
+ */
+export const layerFor = (home: string, options: MachineOptions = {}) => {
+ const sameFolder = (a: string, b: string) => a.replaceAll("\\", "/") === b.replaceAll("\\", "/");
+ const isProject = (root: string) =>
+ options.registered === undefined
+ ? sameFolder(root, `${home}/${PROJECT_FOLDER}`)
+ : options.registered.some((folder) => sameFolder(folder, root));
+ const versions = options.versions ?? {};
+ const enabled = Object.fromEntries(
+ (options.providers ?? ["cursor", "grok", "opencode", "antigravity", "pi"]).map((id) => [
+ ProviderInstanceId.make(id),
+ { driver: ProviderDriverKind.make(id), enabled: true },
+ ]),
+ );
+ const settings = Settings.layerTest({
+ providerInstances: { ...enabled, ...options.providerInstances },
+ });
+ const registry = Layer.mock(ProviderRegistry.ProviderRegistry)({
+ getProviders: Effect.succeed([
+ snapshotOf("claudeAgent", "claudeAgent", versions.claudeAgent ?? null),
+ ...Object.entries(versions)
+ .filter(([id]) => id !== "claudeAgent")
+ .map(([id, version]) => snapshotOf(id, "claudeAgent", version)),
+ ]),
+ });
+ const projects = Layer.mock(ProjectService.ProjectService)({
+ getByWorkspaceRoot: (root) =>
+ Effect.succeed(isProject(root) ? Option.some(makeProject(root)) : Option.none()),
+ });
+ const index = Layer.effect(
+ WorkspaceEntries.WorkspaceEntries,
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ return WorkspaceEntries.WorkspaceEntries.of({
+ ...({} as WorkspaceEntries.WorkspaceEntries["Service"]),
+ search: (input) =>
+ fs.readDirectory(input.cwd, { recursive: true }).pipe(
+ Effect.map((paths) => ({
+ entries: paths
+ .map((entry) => entry.replaceAll("\\", "/"))
+ .filter(
+ (entry) =>
+ !entry.split("/").some((part) => part === ".git" || part === "node_modules") &&
+ entry.split("/").at(-1) === input.query,
+ )
+ .toSorted()
+ .slice(0, input.limit)
+ .map((entry) => ({ path: entry, kind: "file" as const })),
+ truncated: false,
+ })),
+ Effect.orDie,
+ ),
+ });
+ }),
+ );
+ const catalog = InstructionCatalog.layer.pipe(
+ Layer.provide(settings),
+ Layer.provide(registry),
+ Layer.provide(index),
+ );
+ return Layer.mergeAll(InstructionManager.layer, InstructionTracking.layer).pipe(
+ Layer.provideMerge(catalog),
+ Layer.provide(projects),
+ Layer.provide(VcsProcess.layer),
+ Layer.provide(
+ Layer.mergeAll(
+ Layer.succeed(HostProcess.Environment, { HOME: home, ...options.env }),
+ Layer.succeed(HostProcess.HomeDirectory, home),
+ Layer.succeed(HostProcess.Platform, options.platform ?? "linux"),
+ ),
+ ),
+ );
+};
diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts
index 0e2b52696712..dff14d60dbfc 100644
--- a/apps/server/src/mcp/McpHttpServer.ts
+++ b/apps/server/src/mcp/McpHttpServer.ts
@@ -30,6 +30,10 @@ import { EnvironmentToolkit } from "./toolkits/environment/tools.ts";
import * as EnvironmentHandlers from "./toolkits/environment/handlers.ts";
import { ProjectToolkit } from "./toolkits/project/tools.ts";
import * as ProjectHandlers from "./toolkits/project/handlers.ts";
+import { InstructionsToolkit } from "./toolkits/instructions/tools.ts";
+import * as InstructionsHandlers from "./toolkits/instructions/handlers.ts";
+import { SkillsToolkit } from "./toolkits/skills/tools.ts";
+import * as SkillsHandlers from "./toolkits/skills/handlers.ts";
import { AttachmentToolkit } from "./toolkits/attachment/tools.ts";
import * as AttachmentHandlers from "./toolkits/attachment/handlers.ts";
import { ThreadToolkit } from "./toolkits/thread/tools.ts";
@@ -833,6 +837,13 @@ export const layerEnvironmentToolkit = toolkitRegistration(
const layerProjectRegistration = toolkitRegistration(ProjectToolkit, ProjectHandlers.layer);
+export const layerSkillsToolkit = toolkitRegistration(SkillsToolkit, SkillsHandlers.layer);
+
+export const layerInstructionsToolkit = toolkitRegistration(
+ InstructionsToolkit,
+ InstructionsHandlers.layer,
+);
+
export const layerAttachmentToolkit = toolkitRegistration(
AttachmentToolkit,
AttachmentHandlers.layer,
@@ -872,6 +883,8 @@ export const layer = Layer.mergeAll(
layerThreadToolkit,
layerAttachmentToolkit,
layerProjectRegistration,
+ layerSkillsToolkit,
+ layerInstructionsToolkit,
layerEnvironmentToolkit,
layerPreviewControlsRegistration,
layerWorktreeToolkitRegistration,
diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts
index a938ba7fd999..c0383cc17d01 100644
--- a/apps/server/src/mcp/toolkits/core.test.ts
+++ b/apps/server/src/mcp/toolkits/core.test.ts
@@ -47,6 +47,7 @@ import { PreviewControlsToolkit } from "./previewControls/tools.ts";
import { EnvironmentToolkit } from "./environment/tools.ts";
import * as EnvironmentHandlers from "./environment/handlers.ts";
import { ProjectToolkit } from "./project/tools.ts";
+import { SkillsToolkit } from "./skills/tools.ts";
import { AttachmentToolkit } from "./attachment/tools.ts";
import * as AttachmentHandlers from "./attachment/handlers.ts";
import { ThreadToolkit } from "./thread/tools.ts";
@@ -85,6 +86,7 @@ it("publishes unique tool names with reference-free object-root inputs", () => {
ThreadToolkit,
AttachmentToolkit,
ProjectToolkit,
+ SkillsToolkit,
EnvironmentToolkit,
PreviewControlsToolkit,
DeviceToolkit,
diff --git a/apps/server/src/mcp/toolkits/instructions/handlers.test.ts b/apps/server/src/mcp/toolkits/instructions/handlers.test.ts
new file mode 100644
index 000000000000..1c7e31ed3da8
--- /dev/null
+++ b/apps/server/src/mcp/toolkits/instructions/handlers.test.ts
@@ -0,0 +1,290 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { describe, expect, it } from "@effect/vitest";
+import {
+ EnvironmentId,
+ InstructionAgentsResult,
+ InstructionListResult,
+ InstructionReadResult,
+ InstructionWriteResult,
+ ProjectId,
+ ProviderInstanceId,
+ RunId,
+ ThreadId,
+ type OrchestrationV2ThreadShell,
+ type RuntimeMode,
+} from "@t3tools/contracts";
+import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Schema from "effect/Schema";
+import { McpSchema, McpServer } from "effect/ai";
+
+import { layerFor, makeMachine, makeProject } from "../../../instructions/testing/machine.ts";
+import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts";
+import * as ProjectService from "../../../project/ProjectService.ts";
+import * as McpHttpServer from "../../McpHttpServer.ts";
+import * as McpInvocationContext from "../../McpInvocationContext.ts";
+
+const threadId = ThreadId.make("instructions-mcp-thread");
+const projectId = ProjectId.make("project-instructions");
+const callingInstance = ProviderInstanceId.make("codex");
+
+const client = McpSchema.McpServerClient.of({
+ clientId: 1,
+ protocolVersion: "2025-06-18",
+ clientCapabilities: {},
+ clientInfo: { name: "instructions-mcp", version: "1" },
+ initializePayload: {
+ protocolVersion: "2025-06-18",
+ capabilities: {},
+ clientInfo: { name: "instructions-mcp", version: "1" },
+ },
+ getClient: Effect.die("unused"),
+});
+
+const scope: McpInvocationContext.McpInvocationScope = {
+ environmentId: EnvironmentId.make("instructions-mcp-environment"),
+ requestNamespace: "instructions-mcp-session",
+ thread: {
+ threadId,
+ providerSessionId: "instructions-mcp-session",
+ providerInstanceId: callingInstance,
+ },
+ client: undefined,
+ issuedAt: 0,
+ capabilities: new Set(["orchestration"]),
+};
+
+const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
+
+// Effect returns a declared tool failure as `isError` with its encoded payload as JSON text.
+const declaredFailure = (result: McpSchema.CallToolResult) => {
+ const text = result.content[0];
+ return result.isError === true && text?.type === "text" ? decodeJson(text.text) : undefined;
+};
+
+/** The production instructions registration over the real catalog and manager at `home`. */
+const mcpLayerFor = (
+ home: string,
+ project: string,
+ options: { readonly runtimeMode?: RuntimeMode } = {},
+) =>
+ McpHttpServer.layerInstructionsToolkit.pipe(
+ Layer.provideMerge(McpServer.McpServer.layer),
+ Layer.provide(layerFor(home, { registered: [project], versions: { claudeAgent: "2.1.291" } })),
+ Layer.provide(
+ Layer.mock(ProjectService.ProjectService)({
+ getById: (id) =>
+ Effect.succeed(id === projectId ? Option.some(makeProject(project)) : Option.none()),
+ }),
+ ),
+ Layer.provide(
+ Layer.mock(ThreadManagement.ThreadManagementService)({
+ getThreadShell: () =>
+ Effect.succeed({
+ id: threadId,
+ projectId,
+ providerInstanceId: callingInstance,
+ runtimeMode: options.runtimeMode ?? "full-access",
+ interactionMode: "default",
+ activeRunId: RunId.make("instructions-mcp-run"),
+ archivedAt: null,
+ deletedAt: null,
+ } as OrchestrationV2ThreadShell),
+ }),
+ ),
+ );
+
+const call = (name: string, args: Record) =>
+ Effect.gen(function* () {
+ const server = yield* McpServer.McpServer;
+ return yield* server
+ .callTool({ name, arguments: args })
+ .pipe(
+ Effect.provideService(McpInvocationContext.McpInvocationContext, scope),
+ Effect.provideService(McpSchema.McpServerClient, client),
+ );
+ });
+
+const decodeList = Schema.decodeUnknownSync(InstructionListResult);
+const decodeRead = Schema.decodeUnknownSync(InstructionReadResult);
+const decodeAgents = Schema.decodeUnknownSync(InstructionAgentsResult);
+const decodeWrite = Schema.decodeUnknownSync(InstructionWriteResult);
+
+describe("instructions MCP tools", () => {
+ it.layer(NodeServices.layer, { excludeTestServices: true })("over a real layout", (it) => {
+ it.effect("lists the calling thread's project files and the global ones", () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ yield* write("repos/app/AGENTS.md", "project rules");
+ yield* write(".codex/AGENTS.md", "codex notes");
+ yield* Effect.gen(function* () {
+ const result = decodeList((yield* call("t3_instructions_list", {})).structuredContent);
+
+ // CLAUDE.local.md is listed while missing, like the project's AGENTS.md was.
+ expect(result.entries.map((entry) => entry.id)).toEqual([
+ "project:shared:AGENTS.md",
+ "project:claudeLocal:CLAUDE.local.md",
+ "global:shared",
+ "global:agentOwn:codex",
+ ]);
+ expect(result.sharedPath).toBe(`${home}/.agents/AGENTS.md`);
+ }).pipe(Effect.provide(mcpLayerFor(home, project)));
+ }),
+ );
+
+ it.effect("reads one file's text by the id the list gave", () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ yield* write("repos/app/AGENTS.md", "project rules");
+ yield* Effect.gen(function* () {
+ const result = decodeRead(
+ (yield* call("t3_instructions_get", { id: "project:shared:AGENTS.md" }))
+ .structuredContent,
+ );
+ expect(result).toMatchObject({ contents: "project rules", tooLarge: false });
+
+ const missing = yield* call("t3_instructions_get", { id: "global:shared" });
+ expect(decodeRead(missing.structuredContent)).toMatchObject({
+ contents: null,
+ revision: null,
+ });
+
+ const unknown = yield* call("t3_instructions_get", { id: "project:claude:README.md" });
+ expect(declaredFailure(unknown)).toMatchObject({ code: "invalid_request" });
+ }).pipe(Effect.provide(mcpLayerFor(home, project)));
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "turns the shared file on for named agents, and off again",
+ () =>
+ Effect.gen(function* () {
+ const { home, project, fs, path, write, read } = yield* makeMachine;
+ yield* write(".claude/CLAUDE.md", "my notes\n");
+ yield* Effect.gen(function* () {
+ const on = yield* call("t3_instructions_enable", {
+ agents: ["codex", "claudeAgent"],
+ });
+ expect(on.isError).toBe(false);
+ expect(decodeAgents(on.structuredContent).results).toEqual([
+ { instanceId: "codex", outcome: "changed" },
+ { instanceId: "claudeAgent", outcome: "changed" },
+ ]);
+ expect(yield* fs.readLink(path.join(home, ".codex/AGENTS.md"))).toBe(
+ path.join(home, ".agents/AGENTS.md"),
+ );
+ expect(yield* read(".claude/CLAUDE.md")).toBe("@~/.agents/AGENTS.md\nmy notes\n");
+
+ const off = yield* call("t3_instructions_disable", { agents: ["codex"] });
+ expect(decodeAgents(off.structuredContent).results).toEqual([
+ { instanceId: "codex", outcome: "changed" },
+ ]);
+ expect(yield* fs.exists(path.join(home, ".codex/AGENTS.md"))).toBe(false);
+ // The shared file stays.
+ expect(yield* fs.exists(path.join(home, ".agents/AGENTS.md"))).toBe(true);
+ }).pipe(Effect.provide(mcpLayerFor(home, project)));
+ }),
+ );
+
+ it.effect("replaces a file's text with the revision it read, and refuses once it changed", () =>
+ Effect.gen(function* () {
+ const { home, project, write, read } = yield* makeMachine;
+ yield* write("repos/app/AGENTS.md", "project rules");
+ yield* Effect.gen(function* () {
+ const id = "project:shared:AGENTS.md";
+ const { revision } = decodeRead(
+ (yield* call("t3_instructions_get", { id })).structuredContent,
+ );
+
+ const written = yield* call("t3_instructions_write", {
+ id,
+ contents: "project rules\nrun the tests\n",
+ revision,
+ });
+ expect(decodeWrite(written.structuredContent).revision).not.toBe(revision);
+ expect(yield* read("repos/app/AGENTS.md")).toBe("project rules\nrun the tests\n");
+
+ // The revision read before is stale now, as it is after someone else's edit.
+ const stale = yield* call("t3_instructions_write", { id, contents: "lost", revision });
+ expect(declaredFailure(stale)).toMatchObject({
+ code: "invalid_request",
+ message: "That file changed on disk. Reload it first.",
+ });
+ expect(yield* read("repos/app/AGENTS.md")).toBe("project rules\nrun the tests\n");
+ }).pipe(Effect.provide(mcpLayerFor(home, project)));
+ }),
+ );
+
+ it.effect("creates a missing project AGENTS.md and Global AGENTS.md, but not over a file", () =>
+ Effect.gen(function* () {
+ const { home, project, read } = yield* makeMachine;
+ yield* Effect.gen(function* () {
+ for (const id of ["project:shared:AGENTS.md", "global:shared"]) {
+ const created = yield* call("t3_instructions_write", {
+ id,
+ contents: `${id} rules\n`,
+ revision: null,
+ });
+ expect(created.isError, id).toBe(false);
+ }
+ expect(yield* read("repos/app/AGENTS.md")).toBe("project:shared:AGENTS.md rules\n");
+ expect(yield* read(".agents/AGENTS.md")).toBe("global:shared rules\n");
+
+ const again = yield* call("t3_instructions_write", {
+ id: "global:shared",
+ contents: "replaced",
+ revision: null,
+ });
+ expect(declaredFailure(again)).toMatchObject({ message: "That file already exists." });
+ expect(yield* read(".agents/AGENTS.md")).toBe("global:shared rules\n");
+ }).pipe(Effect.provide(mcpLayerFor(home, project)));
+ }),
+ );
+
+ it.effect("lets a supervised thread read instructions but not change who reads them", () =>
+ Effect.gen(function* () {
+ const { home, project, fs, path } = yield* makeMachine;
+ yield* Effect.gen(function* () {
+ expect((yield* call("t3_instructions_list", {})).isError).toBe(false);
+
+ for (const [name, args] of [
+ ["t3_instructions_enable", { agents: "all" }],
+ ["t3_instructions_disable", { agents: ["codex"] }],
+ [
+ "t3_instructions_write",
+ { id: "project:shared:AGENTS.md", contents: "rules", revision: null },
+ ],
+ ] as const) {
+ const result = yield* call(name, args);
+ expect(declaredFailure(result), name).toMatchObject({ code: "capability_denied" });
+ }
+ expect(yield* fs.exists(path.join(home, ".codex"))).toBe(false);
+ expect(yield* fs.exists(path.join(project, "AGENTS.md"))).toBe(false);
+ }).pipe(Effect.provide(mcpLayerFor(home, project, { runtimeMode: "approval-required" })));
+ }),
+ );
+
+ it.effect("rejects inputs the tools do not accept before touching any service", () =>
+ Effect.gen(function* () {
+ const { home, project } = yield* makeMachine;
+ yield* Effect.gen(function* () {
+ for (const [name, args] of [
+ ["t3_instructions_enable", { agents: [] }],
+ ["t3_instructions_enable", { agents: ["not a slug"] }],
+ // Only enabling takes "all".
+ ["t3_instructions_disable", { agents: "all" }],
+ ["t3_instructions_get", {}],
+ ["t3_instructions_get", { id: "" }],
+ // A write names the revision it read, or null to create.
+ ["t3_instructions_write", { id: "global:shared", contents: "rules" }],
+ ] as const) {
+ const error = yield* call(name, args).pipe(Effect.flip);
+ expect(error._tag, `${name} ${Object.keys(args).join()}`).toBe("InvalidParams");
+ }
+ }).pipe(Effect.provide(mcpLayerFor(home, project)));
+ }),
+ );
+ });
+});
diff --git a/apps/server/src/mcp/toolkits/instructions/handlers.ts b/apps/server/src/mcp/toolkits/instructions/handlers.ts
new file mode 100644
index 000000000000..14742221279a
--- /dev/null
+++ b/apps/server/src/mcp/toolkits/instructions/handlers.ts
@@ -0,0 +1,83 @@
+import { OrchestratorMcpFailure, type InstructionError, type ProjectId } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as Option from "effect/Option";
+import * as InstructionCatalog from "../../../instructions/InstructionCatalog.ts";
+import * as InstructionManager from "../../../instructions/InstructionManager.ts";
+import * as ProjectService from "../../../project/ProjectService.ts";
+import * as McpToolAccess from "../../McpToolAccess.ts";
+import { readCaller, resolveProjectId, unavailable, type Caller } from "../../threadAccess.ts";
+import { InstructionsToolkit } from "./tools.ts";
+
+const GLOBAL_SHARED_ID = "global:shared";
+
+const instructionFailure = (error: InstructionError) =>
+ new OrchestratorMcpFailure({ code: "invalid_request", message: error.message });
+
+/**
+ * The folder of the project the call is about: the one passed, else the calling thread's. Without
+ * either, the call is about the user's home files alone.
+ */
+const projectFolder = Effect.fnUntraced(function* (
+ context: Caller,
+ projectId: ProjectId | undefined,
+) {
+ if (projectId === undefined && context.caller === undefined) return undefined;
+ const id = yield* resolveProjectId(context, projectId);
+ const projects = yield* ProjectService.ProjectService;
+ const project = yield* projects.getById(id).pipe(Effect.mapError(unavailable));
+ if (Option.isNone(project) || project.value.deletedAt !== null)
+ return yield* new OrchestratorMcpFailure({
+ code: "invalid_request",
+ message: "The project was not found.",
+ });
+ return project.value.workspaceRoot;
+});
+
+/**
+ * Writing a file, or linking agents to the Global file, rewrites files agents run from, so it
+ * needs full access; `McpToolAccess.writesEnvironment` checks that.
+ */
+export const layer = McpToolAccess.toLayer(InstructionsToolkit, {
+ t3_instructions_list: McpToolAccess.reads((input) =>
+ Effect.gen(function* () {
+ const context = yield* readCaller();
+ const cwd = yield* projectFolder(context, input.projectId);
+ const catalog = yield* InstructionCatalog.InstructionCatalog;
+ return yield* catalog.list({ cwd }).pipe(Effect.mapError(instructionFailure));
+ }),
+ ),
+ t3_instructions_get: McpToolAccess.reads(({ projectId, id }) =>
+ Effect.gen(function* () {
+ const context = yield* readCaller();
+ const cwd = yield* projectFolder(context, projectId);
+ const catalog = yield* InstructionCatalog.InstructionCatalog;
+ return yield* catalog.read({ cwd, id }).pipe(Effect.mapError(instructionFailure));
+ }),
+ ),
+ t3_instructions_write: McpToolAccess.writesEnvironment(
+ ({ projectId, revision, ...input }, check) =>
+ Effect.gen(function* () {
+ const cwd = yield* projectFolder(yield* check, projectId);
+ const manager = yield* InstructionManager.InstructionManager;
+ return yield* manager
+ .write({ cwd, ...input, expectedRevision: revision })
+ .pipe(Effect.mapError(instructionFailure));
+ }),
+ ),
+ t3_instructions_enable: McpToolAccess.writesEnvironment(({ agents }) =>
+ Effect.gen(function* () {
+ const manager = yield* InstructionManager.InstructionManager;
+ return yield* manager
+ .enable({ id: GLOBAL_SHARED_ID, agents })
+ .pipe(Effect.mapError(instructionFailure));
+ }),
+ ),
+ t3_instructions_disable: McpToolAccess.writesEnvironment(({ agents }) =>
+ Effect.gen(function* () {
+ const manager = yield* InstructionManager.InstructionManager;
+ return yield* manager
+ .disable({ id: GLOBAL_SHARED_ID, agents })
+ .pipe(Effect.mapError(instructionFailure));
+ }),
+ ),
+});
diff --git a/apps/server/src/mcp/toolkits/instructions/tools.ts b/apps/server/src/mcp/toolkits/instructions/tools.ts
new file mode 100644
index 000000000000..9b0182b16d01
--- /dev/null
+++ b/apps/server/src/mcp/toolkits/instructions/tools.ts
@@ -0,0 +1,105 @@
+import {
+ InstructionAgentsResult,
+ InstructionListResult,
+ InstructionReadInput,
+ InstructionReadResult,
+ InstructionWriteInput,
+ InstructionWriteResult,
+ OrchestratorMcpFailure,
+ ProjectId,
+ ProviderInstanceId,
+} from "@t3tools/contracts";
+import * as Schema from "effect/Schema";
+import { Tool, Toolkit } from "effect/ai";
+import * as ProjectService from "../../../project/ProjectService.ts";
+import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
+import * as InstructionCatalog from "../../../instructions/InstructionCatalog.ts";
+import * as InstructionManager from "../../../instructions/InstructionManager.ts";
+import * as McpInvocationContext from "../../McpInvocationContext.ts";
+
+const shared = {
+ failure: OrchestratorMcpFailure,
+ failureMode: "return" as const,
+ dependencies: [
+ McpInvocationContext.McpInvocationContext,
+ ThreadManagementService.ThreadManagementService,
+ ProjectService.ProjectService,
+ ],
+};
+
+const projectId = Schema.optional(ProjectId).annotate({
+ description:
+ "The project whose instruction files to use. Defaults to the calling thread's project; a client outside a T3 thread passes it for project files.",
+});
+const agentNames = Schema.Array(ProviderInstanceId).check(
+ Schema.isMinLength(1),
+ Schema.isMaxLength(64),
+);
+const agentsDescription =
+ "agents are named by provider instance id or driver kind, as in the access entries t3_instructions_list returns.";
+const resultNotes =
+ 'Each result says changed, unchanged or failed, with a reason when it failed. Only the Global instructions (id "global:shared") can be turned on or off. An agent that has its own instructions in its home folder is not changed; the user moves those into the Global instructions in T3 Code\'s settings.';
+
+const InstructionListTool = Tool.make("t3_instructions_list", {
+ ...shared,
+ description:
+ "List the instruction files (AGENTS.md, CLAUDE.md and the like) T3 Code can see, in a project and in the user's home folder, and which agents read each (access: direct = reads the file where it is, link = its own file links to it, import = Claude's CLAUDE.md imports it, setting = Claude reads it through its Project instructions setting, none = does not read it). Each file has an id; pass it to t3_instructions_get. Use t3_instructions_write to change a file's text or create a missing one (exists: false), and t3_instructions_enable and t3_instructions_disable to change which agents read the Global instructions, the one file every project shares. Moving or deleting the files, and Claude's Project instructions setting, are not available to agents.",
+ parameters: Schema.Struct({ projectId }),
+ success: InstructionListResult,
+ dependencies: [...shared.dependencies, InstructionCatalog.InstructionCatalog],
+})
+ .annotate(Tool.Readonly, true)
+ .annotate(Tool.Destructive, false);
+
+const InstructionGetTool = Tool.make("t3_instructions_get", {
+ ...shared,
+ description:
+ "Read one instruction file's whole text. Name it by the id t3_instructions_list returned. contents is null when the file does not exist or is too large. Pass revision to t3_instructions_write to change the file.",
+ parameters: Schema.Struct({ projectId, id: InstructionReadInput.fields.id }),
+ success: InstructionReadResult,
+ dependencies: [...shared.dependencies, InstructionCatalog.InstructionCatalog],
+})
+ .annotate(Tool.Readonly, true)
+ .annotate(Tool.Destructive, false);
+
+const InstructionEnableTool = Tool.make("t3_instructions_enable", {
+ ...shared,
+ description: `Let agents read the Global instructions: a link from the agent's own home file to it, or for Claude an import line in its CLAUDE.md. Nothing else is changed or deleted. agents is "all" for every enabled agent, or a list; ${agentsDescription} ${resultNotes} Requires a live full-access/default calling thread or a full-access client.`,
+ parameters: Schema.Struct({
+ agents: Schema.Union([Schema.Literal("all"), agentNames]),
+ }),
+ success: InstructionAgentsResult,
+ dependencies: [...shared.dependencies, InstructionManager.InstructionManager],
+}).annotate(Tool.Destructive, false);
+
+const InstructionDisableTool = Tool.make("t3_instructions_disable", {
+ ...shared,
+ description: `Stop agents reading the Global instructions by removing the agent's link, or Claude's import line. The Global file itself is never deleted, and an agent that reads it where it is stays on. Turn it back on with t3_instructions_enable. ${agentsDescription} ${resultNotes} Requires a live full-access/default calling thread or a full-access client.`,
+ parameters: Schema.Struct({ agents: agentNames }),
+ success: InstructionAgentsResult,
+ dependencies: [...shared.dependencies, InstructionManager.InstructionManager],
+}).annotate(Tool.Destructive, false);
+
+const InstructionWriteTool = Tool.make("t3_instructions_write", {
+ ...shared,
+ description:
+ "Replace one instruction file's whole text, as the editor in T3 Code's settings does, or create a missing one: a project's AGENTS.md or CLAUDE.local.md, or the Global AGENTS.md, which t3_instructions_list lists with exists: false. Name it by its id. revision is the one t3_instructions_get returned; the write is refused when the file changed since, so read it again and redo the change. null creates a file and is refused when one already exists. A file set by the organization can't be written. A new CLAUDE.local.md is kept out of git. Returns the file's new revision. Requires a live full-access/default calling thread or a full-access client.",
+ parameters: Schema.Struct({
+ projectId,
+ id: InstructionWriteInput.fields.id,
+ contents: InstructionWriteInput.fields.contents,
+ revision: InstructionWriteInput.fields.expectedRevision.annotate({
+ description: "The revision t3_instructions_get returned, or null to create a missing file.",
+ }),
+ }),
+ success: InstructionWriteResult,
+ dependencies: [...shared.dependencies, InstructionManager.InstructionManager],
+}).annotate(Tool.Destructive, true);
+
+export const InstructionsToolkit = Toolkit.make(
+ InstructionListTool,
+ InstructionGetTool,
+ InstructionWriteTool,
+ InstructionEnableTool,
+ InstructionDisableTool,
+);
diff --git a/apps/server/src/mcp/toolkits/skills/handlers.test.ts b/apps/server/src/mcp/toolkits/skills/handlers.test.ts
new file mode 100644
index 000000000000..09a8a8054989
--- /dev/null
+++ b/apps/server/src/mcp/toolkits/skills/handlers.test.ts
@@ -0,0 +1,715 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { describe, expect, it } from "@effect/vitest";
+import {
+ EnvironmentId,
+ ProjectId,
+ ProviderDriverKind,
+ ProviderInstanceId,
+ RunId,
+ SkillListResult,
+ ThreadId,
+ type OrchestrationV2ThreadShell,
+ type Project,
+ type RuntimeMode,
+ type SkillSummary,
+} from "@t3tools/contracts";
+import * as HostProcess from "@t3tools/shared/HostProcess";
+import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+import * as Queue from "effect/Queue";
+import * as Schema from "effect/Schema";
+import { McpSchema, McpServer } from "effect/ai";
+
+import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts";
+import * as ProjectService from "../../../project/ProjectService.ts";
+import * as ProviderInstanceRegistry from "../../../provider/ProviderInstanceRegistry.ts";
+import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts";
+import * as Settings from "../../../serverSettings.ts";
+import * as VcsProcess from "../../../vcs/VcsProcess.ts";
+import * as SkillCatalog from "../../../skills/SkillCatalog.ts";
+import * as SkillManager from "../../../skills/SkillManager.ts";
+import * as SkillTracking from "../../../skills/SkillTracking.ts";
+import * as ProcessRunner from "../../../processRunner.ts";
+import * as McpHttpServer from "../../McpHttpServer.ts";
+import * as McpInvocationContext from "../../McpInvocationContext.ts";
+
+const threadId = ThreadId.make("skills-mcp-thread");
+const projectId = ProjectId.make("skills-mcp-project");
+const claudeDriver = ProviderDriverKind.make("claudeAgent");
+const callingInstance = ProviderInstanceId.make("codex");
+
+const client = McpSchema.McpServerClient.of({
+ clientId: 1,
+ protocolVersion: "2025-06-18",
+ clientCapabilities: {},
+ clientInfo: { name: "skills-mcp", version: "1" },
+ initializePayload: {
+ protocolVersion: "2025-06-18",
+ capabilities: {},
+ clientInfo: { name: "skills-mcp", version: "1" },
+ },
+ getClient: Effect.die("unused"),
+});
+
+const scope: McpInvocationContext.McpInvocationScope = {
+ environmentId: EnvironmentId.make("skills-mcp-environment"),
+ requestNamespace: "skills-mcp-session",
+ thread: {
+ threadId,
+ providerSessionId: "skills-mcp-session",
+ providerInstanceId: callingInstance,
+ },
+ client: undefined,
+ issuedAt: 0,
+ capabilities: new Set(["orchestration"]),
+};
+
+const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
+
+// Effect returns a declared tool failure as `isError` with its encoded payload as JSON text.
+const declaredFailure = (result: McpSchema.CallToolResult) => {
+ const text = result.content[0];
+ return result.isError === true && text?.type === "text" ? decodeJson(text.text) : undefined;
+};
+
+const skillFile = (name: string) => `---\nname: ${name}\ndescription: The ${name} skill.\n---\n`;
+
+/** A made-up machine: `alpha` linked into the shared folder, `beta` in Claude's own folder, and one project. */
+const makeMachine = Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const home = yield* fs.realPath(
+ yield* fs.makeTempDirectoryScoped({ prefix: "t3code-skills-mcp-" }),
+ );
+ const write = (relative: string, contents: string) =>
+ Effect.gen(function* () {
+ const target = path.join(home, relative);
+ yield* fs.makeDirectory(path.dirname(target), { recursive: true });
+ yield* fs.writeFileString(target, contents);
+ });
+ yield* write("library/skills/alpha/SKILL.md", skillFile("alpha"));
+ // Only Claude (and Cursor) read this folder.
+ yield* write(".claude/skills/beta/SKILL.md", skillFile("beta"));
+ yield* fs.makeDirectory(path.join(home, ".agents/skills"), { recursive: true });
+ yield* fs.symlink(
+ path.join(home, "library/skills/alpha"),
+ path.join(home, ".agents/skills/alpha"),
+ );
+ yield* write("repos/app/.agents/skills/verify/SKILL.md", skillFile("verify"));
+ return { fs, path, home, project: path.join(home, "repos/app") };
+});
+
+const projectAt = (workspaceRoot: string): Project => ({
+ id: projectId,
+ title: "App",
+ workspaceRoot,
+ repositoryIdentity: null,
+ faviconPath: null,
+ projectIcon: null,
+ defaultModelSelection: null,
+ defaultThreadEnvMode: null,
+ autoPull: false,
+ scripts: [],
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ deletedAt: null,
+});
+
+type Refresh = {
+ readonly instanceId: ProviderInstanceId;
+ readonly cwd: string | undefined;
+ readonly fresh: boolean | undefined;
+};
+
+/**
+ * The production skills registration over the real catalog and manager, on the machine at `home`.
+ * The provider registry is a stand-in that queues each picker refresh it is asked for.
+ */
+const layerFor = (
+ home: string,
+ project: string,
+ options: {
+ readonly refreshes?: Queue.Queue;
+ readonly runtimeMode?: RuntimeMode;
+ readonly providerInstances?: NonNullable<
+ Parameters[0]
+ >["providerInstances"];
+ } = {},
+) => {
+ const noteRefresh = (refresh: Refresh) =>
+ (options.refreshes === undefined ? Effect.void : Queue.offer(options.refreshes, refresh)).pipe(
+ Effect.as([]),
+ );
+ return McpHttpServer.layerSkillsToolkit.pipe(
+ Layer.provideMerge(McpServer.McpServer.layer),
+ Layer.provide(
+ Layer.merge(SkillManager.layer, SkillTracking.layer).pipe(
+ Layer.provideMerge(
+ SkillCatalog.layer.pipe(
+ Layer.provide(
+ Settings.layerTest({
+ providerInstances: {
+ ...Object.fromEntries(
+ ["cursor", "grok"].map((driver) => [
+ ProviderInstanceId.make(driver),
+ { driver: ProviderDriverKind.make(driver), enabled: true },
+ ]),
+ ),
+ ...options.providerInstances,
+ },
+ }),
+ ),
+ ),
+ ),
+ ),
+ ),
+ Layer.provide(
+ Layer.mock(ProviderRegistry.ProviderRegistry)({
+ refreshInstance: (instanceId) =>
+ noteRefresh({ instanceId, cwd: undefined, fresh: undefined }),
+ refreshWorkspaceSnapshot: ({ instanceId, cwd, fresh }) =>
+ noteRefresh({ instanceId, cwd, fresh }),
+ }),
+ ),
+ // No agent here has a settings writer, so none is ever looked up.
+ Layer.provide(
+ Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({
+ getInstance: () => Effect.succeed(undefined),
+ }),
+ ),
+ Layer.provide(
+ Layer.mock(ProjectService.ProjectService)({
+ getById: (id) =>
+ Effect.succeed(id === projectId ? Option.some(projectAt(project)) : Option.none()),
+ getByWorkspaceRoot: (root) =>
+ Effect.succeed(root === project ? Option.some(projectAt(project)) : Option.none()),
+ }),
+ ),
+ Layer.provide(
+ Layer.mock(ThreadManagement.ThreadManagementService)({
+ getThreadShell: () =>
+ Effect.succeed({
+ id: threadId,
+ projectId,
+ providerInstanceId: callingInstance,
+ runtimeMode: options.runtimeMode ?? "full-access",
+ interactionMode: "default",
+ activeRunId: RunId.make("skills-mcp-run"),
+ archivedAt: null,
+ deletedAt: null,
+ } as OrchestrationV2ThreadShell),
+ }),
+ ),
+ // The manager keeps the links it makes out of git, so it runs git.
+ Layer.provide(VcsProcess.layer),
+ Layer.provide(Layer.succeed(HostProcess.Environment, { HOME: home })),
+ Layer.provide(Layer.succeed(HostProcess.HomeDirectory, home)),
+ );
+};
+
+const call = (name: string, args: Record) =>
+ Effect.gen(function* () {
+ const server = yield* McpServer.McpServer;
+ return yield* server
+ .callTool({ name, arguments: args })
+ .pipe(
+ Effect.provideService(McpInvocationContext.McpInvocationContext, scope),
+ Effect.provideService(McpSchema.McpServerClient, client),
+ );
+ });
+
+const git = (cwd: string, args: ReadonlyArray) =>
+ Effect.gen(function* () {
+ const processRunner = yield* ProcessRunner.ProcessRunner;
+ return yield* processRunner.run({ command: "git", args: ["-C", cwd, ...args] });
+ }).pipe(Effect.provide(ProcessRunner.layer));
+
+const decodeList = Schema.decodeUnknownSync(SkillListResult);
+const decodePlan = Schema.decodeUnknownSync(Schema.Struct({ plan: Schema.Array(Schema.String) }));
+const planOf = (result: McpSchema.CallToolResult) => decodePlan(result.structuredContent).plan;
+const listSkills = (args: Record = {}) =>
+ call("t3_skill_list", args).pipe(
+ Effect.map((result) => decodeList(result.structuredContent).skills),
+ );
+
+const refOf = (skills: ReadonlyArray, scope: "project" | "global", name: string) => {
+ const skill = skills.find((item) => item.scope === scope && item.name === name);
+ if (!skill) throw new Error(`No ${scope} skill ${name} in the list`);
+ return { scope, name, home: skill.home };
+};
+
+const stateOf = (skills: ReadonlyArray, scope: "project" | "global", name: string) =>
+ Object.fromEntries(
+ (skills.find((item) => item.scope === scope && item.name === name)?.access ?? []).map(
+ (entry) => [entry.instanceId, entry.state],
+ ),
+ );
+
+describe("skills MCP tools", () => {
+ it.layer(NodeServices.layer, { excludeTestServices: true })("over a real skill layout", (it) => {
+ it.effect("lists the calling thread's project skills and the global ones", () =>
+ Effect.gen(function* () {
+ const { home, project } = yield* makeMachine;
+ yield* Effect.gen(function* () {
+ const skills = yield* listSkills();
+
+ expect(skills.map((skill) => `${skill.scope}:${skill.name}`)).toEqual([
+ "global:alpha",
+ "global:beta",
+ "project:verify",
+ ]);
+ expect(stateOf(skills, "global", "alpha")).toMatchObject({
+ claudeAgent: "none",
+ codex: "direct",
+ });
+ expect(stateOf(skills, "global", "beta")).toMatchObject({
+ claudeAgent: "direct",
+ codex: "none",
+ });
+ // The home an agent passes back to enable is the one the list shows.
+ expect(refOf(skills, "project", "verify").home).toBe(".agents/skills/verify");
+ }).pipe(Effect.provide(layerFor(home, project)));
+ }),
+ );
+
+ it.effect("reads one skill's text and files", () =>
+ Effect.gen(function* () {
+ const { home, project } = yield* makeMachine;
+ yield* Effect.gen(function* () {
+ const skills = yield* listSkills();
+ const result = yield* call("t3_skill_get", refOf(skills, "global", "beta"));
+
+ expect(result.structuredContent).toMatchObject({
+ contents: skillFile("beta"),
+ files: [{ path: "SKILL.md" }],
+ });
+ }).pipe(Effect.provide(layerFor(home, project)));
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "turns a skill on for a named agent, and the list then shows it",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project } = yield* makeMachine;
+ yield* Effect.gen(function* () {
+ const skills = yield* listSkills();
+
+ const result = yield* call("t3_skill_enable", {
+ skills: [refOf(skills, "global", "alpha")],
+ agents: ["claudeAgent"],
+ });
+
+ expect(result.structuredContent).toEqual({
+ outcomes: [
+ {
+ skill: refOf(skills, "global", "alpha"),
+ status: "changed",
+ blocked: [],
+ affected: [],
+ },
+ ],
+ });
+ expect(yield* fs.readLink(path.join(home, ".claude/skills/alpha"))).toBe(
+ path.join(home, "library/skills/alpha"),
+ );
+ expect(stateOf(yield* listSkills(), "global", "alpha").claudeAgent).toBe("link");
+ }).pipe(Effect.provide(layerFor(home, project)));
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "turns a project skill on for every agent, using the thread's project",
+ () =>
+ Effect.gen(function* () {
+ const { home, project } = yield* makeMachine;
+ yield* Effect.gen(function* () {
+ const skills = yield* listSkills();
+
+ const result = yield* call("t3_skill_enable", {
+ skills: [refOf(skills, "project", "verify")],
+ agents: "all",
+ });
+
+ expect(result.structuredContent).toMatchObject({
+ outcomes: [{ status: "changed", blocked: [] }],
+ });
+ const states = stateOf(yield* listSkills(), "project", "verify");
+ expect(Object.values(states).every((state) => state !== "none")).toBe(true);
+ }).pipe(Effect.provide(layerFor(home, project)));
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "refreshes the thread's project picker for the agent a skill was turned on for",
+ () =>
+ Effect.gen(function* () {
+ const { home, project } = yield* makeMachine;
+ const refreshes = yield* Queue.unbounded();
+ yield* Effect.gen(function* () {
+ const skills = yield* listSkills();
+
+ yield* call("t3_skill_enable", {
+ skills: [refOf(skills, "project", "verify")],
+ agents: ["claudeAgent"],
+ });
+
+ expect(yield* Queue.take(refreshes)).toEqual({
+ instanceId: "claudeAgent",
+ cwd: project,
+ fresh: true,
+ });
+ expect(yield* Queue.size(refreshes)).toBe(0);
+ }).pipe(Effect.provide(layerFor(home, project, { refreshes })));
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "shows an agent why a skill could not be turned off for it",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project } = yield* makeMachine;
+ yield* Effect.gen(function* () {
+ const skills = yield* listSkills();
+
+ const result = yield* call("t3_skill_disable", {
+ skills: [refOf(skills, "global", "alpha")],
+ agents: ["cursor"],
+ });
+
+ // Cursor reads the shared folder itself and has no setting for one skill, so there
+ // is nothing of its own to remove or write.
+ expect(result.isError).toBe(false);
+ expect(result.structuredContent).toMatchObject({
+ outcomes: [
+ { status: "skipped", blocked: [{ instanceId: "cursor", reason: "alwaysOn" }] },
+ ],
+ });
+ expect(yield* fs.exists(path.join(home, ".agents/skills/alpha"))).toBe(true);
+ }).pipe(Effect.provide(layerFor(home, project)));
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "turns a skill on for every instance of a driver named by its driver kind",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project } = yield* makeMachine;
+ yield* Effect.gen(function* () {
+ const skills = yield* listSkills();
+ // Only the two named instances are agents here; no instance is called "claudeAgent".
+ expect(stateOf(skills, "global", "alpha")).toMatchObject({
+ claude_home: "none",
+ claude_work: "none",
+ });
+
+ const result = yield* call("t3_skill_enable", {
+ skills: [refOf(skills, "global", "alpha")],
+ agents: ["claudeAgent"],
+ });
+
+ expect(result.structuredContent).toMatchObject({ outcomes: [{ status: "changed" }] });
+ expect(yield* fs.readLink(path.join(home, ".claude/skills/alpha"))).toBe(
+ path.join(home, "library/skills/alpha"),
+ );
+ expect(yield* fs.readLink(path.join(home, "work-claude/skills/alpha"))).toBe(
+ path.join(home, "library/skills/alpha"),
+ );
+ }).pipe(
+ Effect.provide(
+ layerFor(home, project, {
+ providerInstances: {
+ [ProviderInstanceId.make("claudeAgent")]: {
+ driver: claudeDriver,
+ enabled: false,
+ },
+ [ProviderInstanceId.make("claude_home")]: { driver: claudeDriver },
+ [ProviderInstanceId.make("claude_work")]: {
+ driver: claudeDriver,
+ config: { homePath: `${home}/work-claude` },
+ },
+ },
+ }),
+ ),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)("turns a skill back off", () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project } = yield* makeMachine;
+ yield* Effect.gen(function* () {
+ const skill = refOf(yield* listSkills(), "global", "alpha");
+ yield* call("t3_skill_enable", { skills: [skill], agents: ["claudeAgent"] });
+
+ const result = yield* call("t3_skill_disable", {
+ skills: [skill],
+ agents: ["claudeAgent"],
+ });
+
+ expect(result.structuredContent).toMatchObject({ outcomes: [{ status: "changed" }] });
+ expect(yield* fs.exists(path.join(home, ".claude/skills/alpha"))).toBe(false);
+ // The skill's own folder is untouched.
+ expect(yield* fs.exists(path.join(home, "library/skills/alpha/SKILL.md"))).toBe(true);
+ }).pipe(Effect.provide(layerFor(home, project)));
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "says what a move would do, changes nothing until confirmed, then moves the skill",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project } = yield* makeMachine;
+ yield* Effect.gen(function* () {
+ const beta = refOf(yield* listSkills(), "global", "beta");
+
+ const planned = yield* call("t3_skill_move", { skills: [beta], to: "project" });
+
+ expect(planOf(planned)).toEqual([
+ "“beta” moves into App, so anyone who clones it gets it.",
+ "Agents that use it now keep using it.",
+ "Nothing has changed yet. To do it, call t3_skill_move again with the same arguments and confirm: true.",
+ ]);
+ expect(yield* fs.exists(path.join(home, ".claude/skills/beta/SKILL.md"))).toBe(true);
+
+ const moved = yield* call("t3_skill_move", {
+ skills: [beta],
+ to: "project",
+ confirm: true,
+ });
+
+ expect(moved.structuredContent).toMatchObject({
+ outcomes: [{ skill: beta, status: "changed" }],
+ });
+ expect(yield* fs.exists(path.join(home, ".claude/skills/beta"))).toBe(false);
+ expect(yield* fs.exists(path.join(project, ".agents/skills/beta/SKILL.md"))).toBe(true);
+ // Claude used it before, so it still does, through a link in the project.
+ expect(stateOf(yield* listSkills(), "project", "beta").claudeAgent).toBe("link");
+ }).pipe(Effect.provide(layerFor(home, project)));
+ }),
+ );
+
+ it.effect("says git can undo taking a tracked skill out of its project", () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project } = yield* makeMachine;
+ yield* git(project, ["init"]);
+ yield* git(project, ["add", ".agents/skills/verify"]);
+ yield* fs.makeDirectory(path.join(project, ".agents/skills/draft"));
+ yield* fs.writeFileString(
+ path.join(project, ".agents/skills/draft/SKILL.md"),
+ skillFile("draft"),
+ );
+ yield* Effect.gen(function* () {
+ const verify = refOf(yield* listSkills(), "project", "verify");
+
+ const toGlobal = planOf(yield* call("t3_skill_move", { skills: [verify], to: "global" }));
+ expect(toGlobal).toContain("“verify” becomes Global and will be on in every project.");
+ expect(toGlobal).toContain("git tracks it, so you can undo this with git.");
+
+ const deleting = planOf(yield* call("t3_skill_delete", { skills: [verify] }));
+ expect(deleting[0]).toBe("This deletes .agents/skills/verify and any links to it.");
+ expect(deleting).toContain("git tracks it, so you can undo this with git.");
+ expect(deleting).not.toContain("It can't be undone.");
+
+ // A skill git doesn't track can't come back.
+ const draft = refOf(yield* listSkills(), "project", "draft");
+ const both = planOf(yield* call("t3_skill_delete", { skills: [verify, draft] }));
+ expect(both).toContain(
+ "git tracks “verify”, so you can undo deleting it with git. The rest can't be undone.",
+ );
+ expect(yield* fs.exists(path.join(project, ".agents/skills/verify/SKILL.md"))).toBe(true);
+ }).pipe(Effect.provide(layerFor(home, project)));
+ }),
+ );
+
+ it.effect("plans keeping one copy for the projects named, and refuses an unknown one", () =>
+ Effect.gen(function* () {
+ const { home, project } = yield* makeMachine;
+ yield* Effect.gen(function* () {
+ const beta = refOf(yield* listSkills(), "global", "beta");
+
+ const planned = yield* call("t3_skill_move", {
+ skills: [beta],
+ to: { projects: [projectId] },
+ });
+ expect(planOf(planned)[0]).toBe("“beta” will be on in App only.");
+
+ const unknown = yield* call("t3_skill_move", {
+ skills: [beta],
+ to: { projects: ["no-such-project"] },
+ confirm: true,
+ });
+ expect(declaredFailure(unknown)).toMatchObject({
+ code: "invalid_request",
+ message: "The project was not found.",
+ });
+ }).pipe(Effect.provide(layerFor(home, project)));
+ }),
+ );
+
+ it.effect("says what a delete would remove, then deletes the skill once confirmed", () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project } = yield* makeMachine;
+ yield* Effect.gen(function* () {
+ const beta = refOf(yield* listSkills(), "global", "beta");
+
+ const planned = planOf(yield* call("t3_skill_delete", { skills: [beta] }));
+
+ expect(planned[0]).toBe("This deletes ~/.claude/skills/beta and any links to it.");
+ expect(planned[1]).toMatch(/^.*claudeAgent.* will stop using it\.$/);
+ expect(planned.slice(2)).toEqual([
+ "It can't be undone.",
+ "Nothing has changed yet. To do it, call t3_skill_delete again with the same arguments and confirm: true.",
+ ]);
+ expect(yield* fs.exists(path.join(home, ".claude/skills/beta/SKILL.md"))).toBe(true);
+
+ const deleted = yield* call("t3_skill_delete", { skills: [beta], confirm: true });
+
+ expect(deleted.structuredContent).toMatchObject({
+ outcomes: [{ skill: beta, status: "changed" }],
+ });
+ expect(yield* fs.exists(path.join(home, ".claude/skills/beta"))).toBe(false);
+ }).pipe(Effect.provide(layerFor(home, project)));
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "leaves a skill that is only linked into an agent's folder, and says so",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project } = yield* makeMachine;
+ yield* Effect.gen(function* () {
+ const alpha = refOf(yield* listSkills(), "global", "alpha");
+
+ expect(planOf(yield* call("t3_skill_delete", { skills: [alpha] }))).toEqual([
+ "“alpha” is reached through a link, not kept in an agent's skill folder, so it stays.",
+ "There is nothing to delete.",
+ ]);
+ const deleted = yield* call("t3_skill_delete", { skills: [alpha], confirm: true });
+ expect(deleted.structuredContent).toMatchObject({
+ outcomes: [{ status: "skipped", reason: "linked" }],
+ });
+ expect(yield* fs.exists(path.join(home, "library/skills/alpha/SKILL.md"))).toBe(true);
+ }).pipe(Effect.provide(layerFor(home, project)));
+ }),
+ );
+
+ it.effect("tells the agent when a skill is no longer where the list said", () =>
+ Effect.gen(function* () {
+ const { home, project } = yield* makeMachine;
+ yield* Effect.gen(function* () {
+ const gone = { scope: "global", name: "beta", home: "~/.agents/skills/beta" };
+
+ expect(planOf(yield* call("t3_skill_delete", { skills: [gone] }))).toEqual([
+ "“beta” isn't at ~/.agents/skills/beta any more, so it is left out. List the skills again.",
+ "There is nothing to delete.",
+ ]);
+ }).pipe(Effect.provide(layerFor(home, project)));
+ }),
+ );
+
+ it.effect("tells the agent when an agent name is not one it has", () =>
+ Effect.gen(function* () {
+ const { home, project } = yield* makeMachine;
+ yield* Effect.gen(function* () {
+ const skills = yield* listSkills();
+
+ const result = yield* call("t3_skill_enable", {
+ skills: [refOf(skills, "global", "beta")],
+ agents: ["no-such-agent"],
+ });
+
+ expect(declaredFailure(result)).toMatchObject({
+ code: "invalid_request",
+ message: "That agent isn't enabled in this environment.",
+ });
+ }).pipe(Effect.provide(layerFor(home, project)));
+ }),
+ );
+
+ it.effect("lets a supervised thread read skills but not change them", () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project } = yield* makeMachine;
+ yield* Effect.gen(function* () {
+ const skills = yield* listSkills();
+ expect(skills.length).toBeGreaterThan(0);
+
+ const result = yield* call("t3_skill_enable", {
+ skills: [refOf(skills, "global", "beta")],
+ agents: ["codex"],
+ });
+
+ expect(declaredFailure(result)).toMatchObject({ code: "capability_denied" });
+ expect(yield* fs.exists(path.join(home, ".agents/skills/beta"))).toBe(false);
+
+ // Even a plan needs the access to carry it out.
+ for (const [name, args] of [
+ ["t3_skill_move", { skills: [refOf(skills, "global", "beta")], to: "project" }],
+ ["t3_skill_delete", { skills: [refOf(skills, "global", "beta")], confirm: true }],
+ ] as const) {
+ expect(declaredFailure(yield* call(name, args)), name).toMatchObject({
+ code: "capability_denied",
+ });
+ }
+ expect(yield* fs.exists(path.join(home, ".claude/skills/beta/SKILL.md"))).toBe(true);
+ }).pipe(Effect.provide(layerFor(home, project, { runtimeMode: "approval-required" })));
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "creates a skill in the calling thread's project and tells the agent a taken name",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project } = yield* makeMachine;
+ yield* Effect.gen(function* () {
+ const args = { scope: "project", name: "ship-it", description: "Ship the release." };
+ const result = yield* call("t3_skill_create", args);
+
+ expect(result.structuredContent).toEqual({
+ skill: { scope: "project", name: "ship-it", home: ".agents/skills/ship-it" },
+ blocked: [],
+ });
+ expect(yield* fs.readLink(path.join(project, ".claude/skills/ship-it"))).toBe(
+ "../../.agents/skills/ship-it",
+ );
+ expect(declaredFailure(yield* call("t3_skill_create", args))).toMatchObject({
+ code: "invalid_request",
+ message: "A skill with this name already exists there.",
+ });
+ }).pipe(Effect.provide(layerFor(home, project)));
+ }),
+ );
+
+ it.effect("rejects inputs the tools do not accept before touching any service", () =>
+ Effect.gen(function* () {
+ const { home, project } = yield* makeMachine;
+ yield* Effect.gen(function* () {
+ const skill = { scope: "global", name: "beta", home: "~/.claude/skills/beta" };
+ for (const [name, args] of [
+ ["t3_skill_enable", { skills: [skill], agents: [] }],
+ ["t3_skill_enable", { skills: [], agents: "all" }],
+ ["t3_skill_enable", { skills: [skill], agents: ["not a slug"] }],
+ // Only enabling takes "all".
+ ["t3_skill_disable", { skills: [skill], agents: "all" }],
+ ["t3_skill_create", { scope: "global", name: "Ship It", description: "Ship." }],
+ ["t3_skill_create", { scope: "global", name: "ship-it", description: "two\nlines" }],
+ [
+ "t3_skill_enable",
+ { skills: [{ scope: "everywhere", name: "beta", home: "x" }], agents: "all" },
+ ],
+ ["t3_skill_move", { skills: [skill], to: "everywhere" }],
+ ["t3_skill_move", { skills: [skill], to: { projects: [] } }],
+ ["t3_skill_delete", { skills: [skill], confirm: "yes" }],
+ ] as const) {
+ const error = yield* call(name, args).pipe(Effect.flip);
+ expect(error._tag, `${name} ${Object.keys(args).join()}`).toBe("InvalidParams");
+ }
+ }).pipe(Effect.provide(layerFor(home, project)));
+ }),
+ );
+ });
+});
diff --git a/apps/server/src/mcp/toolkits/skills/handlers.ts b/apps/server/src/mcp/toolkits/skills/handlers.ts
new file mode 100644
index 000000000000..77a28a1f593d
--- /dev/null
+++ b/apps/server/src/mcp/toolkits/skills/handlers.ts
@@ -0,0 +1,182 @@
+import {
+ OrchestratorMcpFailure,
+ type ProjectId,
+ type SkillCreateError,
+ type SkillPlacement,
+ type SkillRef,
+ type SkillRequestError,
+} from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as Option from "effect/Option";
+import * as ProjectService from "../../../project/ProjectService.ts";
+import * as SkillCatalog from "../../../skills/SkillCatalog.ts";
+import * as SkillManager from "../../../skills/SkillManager.ts";
+import * as SkillTracking from "../../../skills/SkillTracking.ts";
+import * as McpToolAccess from "../../McpToolAccess.ts";
+import { readCaller, resolveProjectId, unavailable, type Caller } from "../../threadAccess.ts";
+import { planDelete, planMove } from "./plans.ts";
+import { SkillsToolkit } from "./tools.ts";
+
+const skillFailure = (error: SkillRequestError | SkillCreateError) =>
+ new OrchestratorMcpFailure({ code: "invalid_request", message: error.message });
+
+/** A registered project that hasn't been deleted. */
+const registeredProject = Effect.fnUntraced(function* (id: ProjectId) {
+ const projects = yield* ProjectService.ProjectService;
+ const project = yield* projects.getById(id).pipe(Effect.mapError(unavailable));
+ if (Option.isNone(project) || project.value.deletedAt !== null)
+ return yield* new OrchestratorMcpFailure({
+ code: "invalid_request",
+ message: "The project was not found.",
+ });
+ return project.value;
+});
+
+/**
+ * The project the call is about: the one passed, else the calling thread's. Without either,
+ * project skills can't be reached: `required` says whether that is a failure (a change to a
+ * project skill) or just means the global skills alone (a read).
+ */
+const projectFolder = Effect.fnUntraced(function* (
+ context: Caller,
+ projectId: ProjectId | undefined,
+ required: boolean,
+) {
+ if (projectId === undefined && context.caller === undefined && !required) return undefined;
+ return (yield* registeredProject(yield* resolveProjectId(context, projectId))).workspaceRoot;
+});
+
+/**
+ * Changing skills rewrites the folders agents run from, so it needs full access;
+ * `McpToolAccess.writesEnvironment` checks that. Only a change to a project skill needs a project.
+ */
+const changeFolder = (
+ context: Caller,
+ projectId: ProjectId | undefined,
+ skills: ReadonlyArray,
+) =>
+ projectFolder(
+ context,
+ projectId,
+ skills.some((skill) => skill.scope === "project"),
+ );
+
+/** The names of the project skills among `skills` git tracks, so a plan can say git can undo it. */
+const trackedNames = (cwd: string | undefined, skills: ReadonlyArray) =>
+ Effect.gen(function* () {
+ if (cwd === undefined || !skills.some((skill) => skill.scope === "project")) return [];
+ const tracking = yield* SkillTracking.SkillTracking;
+ return (yield* tracking.tracked({ cwd, skills })).tracked;
+ });
+
+/**
+ * Where `t3_skill_move` puts the skills, the folder of the project they are listed for, and the
+ * names of the projects the placement is about. "project" is the project the call is about.
+ */
+const moveTarget = Effect.fnUntraced(function* (
+ context: Caller,
+ projectId: ProjectId | undefined,
+ skills: ReadonlyArray,
+ to: "project" | "global" | { readonly projects: ReadonlyArray },
+) {
+ if (to === "project") {
+ const project = yield* registeredProject(yield* resolveProjectId(context, projectId));
+ const cwd = project.workspaceRoot;
+ const placement: SkillPlacement = { kind: "project", cwd };
+ return { cwd, placement, projectNames: [project.title] };
+ }
+ const cwd = yield* changeFolder(context, projectId, skills);
+ if (to === "global") {
+ const placement: SkillPlacement = { kind: "global" };
+ return { cwd, placement, projectNames: [] };
+ }
+ const projects = yield* Effect.forEach(to.projects, registeredProject);
+ const placement: SkillPlacement = {
+ kind: "projects",
+ cwds: projects.map((project) => project.workspaceRoot),
+ };
+ return { cwd, placement, projectNames: projects.map((project) => project.title) };
+});
+
+/** The skills as the list shows them now, which is what a plan is worded from. */
+const listedSkills = (cwd: string | undefined) =>
+ Effect.gen(function* () {
+ const catalog = yield* SkillCatalog.SkillCatalog;
+ return (yield* catalog.list({ cwd })).skills;
+ });
+
+export const layer = McpToolAccess.toLayer(SkillsToolkit, {
+ t3_skill_list: McpToolAccess.reads((input) =>
+ Effect.gen(function* () {
+ const context = yield* readCaller();
+ const cwd = yield* projectFolder(context, input.projectId, false);
+ const catalog = yield* SkillCatalog.SkillCatalog;
+ return yield* catalog.list({ cwd }).pipe(Effect.mapError(skillFailure));
+ }),
+ ),
+ t3_skill_get: McpToolAccess.reads(({ projectId, ...skill }) =>
+ Effect.gen(function* () {
+ const context = yield* readCaller();
+ const cwd = yield* projectFolder(context, projectId, skill.scope === "project");
+ const catalog = yield* SkillCatalog.SkillCatalog;
+ return yield* catalog.get({ cwd, ...skill }).pipe(Effect.mapError(skillFailure));
+ }),
+ ),
+ t3_skill_enable: McpToolAccess.writesEnvironment(({ projectId, ...input }, check) =>
+ Effect.gen(function* () {
+ const cwd = yield* changeFolder(yield* check, projectId, input.skills);
+ const manager = yield* SkillManager.SkillManager;
+ return yield* manager.enable({ cwd, ...input }).pipe(Effect.mapError(skillFailure));
+ }),
+ ),
+ t3_skill_disable: McpToolAccess.writesEnvironment(({ projectId, ...input }, check) =>
+ Effect.gen(function* () {
+ const cwd = yield* changeFolder(yield* check, projectId, input.skills);
+ const manager = yield* SkillManager.SkillManager;
+ return yield* manager.disable({ cwd, ...input }).pipe(Effect.mapError(skillFailure));
+ }),
+ ),
+ t3_skill_create: McpToolAccess.writesEnvironment(({ projectId, ...input }, check) =>
+ Effect.gen(function* () {
+ const cwd = yield* projectFolder(yield* check, projectId, input.scope === "project");
+ const manager = yield* SkillManager.SkillManager;
+ return yield* manager.create({ cwd, ...input }).pipe(Effect.mapError(skillFailure));
+ }),
+ ),
+ // Without `confirm: true` these only read: the list, and which project skills git tracks.
+ t3_skill_move: McpToolAccess.writesEnvironment(({ projectId, skills, to, confirm }, check) =>
+ Effect.gen(function* () {
+ const { cwd, placement, projectNames } = yield* moveTarget(
+ yield* check,
+ projectId,
+ skills,
+ to,
+ );
+ if (confirm === true) {
+ const manager = yield* SkillManager.SkillManager;
+ return yield* manager
+ .place({ cwd, skills, to: placement })
+ .pipe(Effect.mapError(skillFailure));
+ }
+ return yield* Effect.gen(function* () {
+ const listed = yield* listedSkills(cwd);
+ const tracked = placement.kind === "project" ? [] : yield* trackedNames(cwd, skills);
+ return { plan: planMove({ listed, skills, to: placement, projectNames, tracked }) };
+ }).pipe(Effect.mapError(skillFailure));
+ }),
+ ),
+ t3_skill_delete: McpToolAccess.writesEnvironment(({ projectId, skills, confirm }, check) =>
+ Effect.gen(function* () {
+ const cwd = yield* changeFolder(yield* check, projectId, skills);
+ if (confirm === true) {
+ const manager = yield* SkillManager.SkillManager;
+ return yield* manager.delete({ cwd, skills }).pipe(Effect.mapError(skillFailure));
+ }
+ return yield* Effect.gen(function* () {
+ const listed = yield* listedSkills(cwd);
+ const tracked = yield* trackedNames(cwd, skills);
+ return { plan: planDelete({ listed, skills, tracked }) };
+ }).pipe(Effect.mapError(skillFailure));
+ }),
+ ),
+});
diff --git a/apps/server/src/mcp/toolkits/skills/plans.test.ts b/apps/server/src/mcp/toolkits/skills/plans.test.ts
new file mode 100644
index 000000000000..c111c7d376c2
--- /dev/null
+++ b/apps/server/src/mcp/toolkits/skills/plans.test.ts
@@ -0,0 +1,42 @@
+import type { SkillSummary } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import { planDelete, planMove } from "./plans.ts";
+
+const skill = (name: string, extra: Partial = {}): SkillSummary => ({
+ name,
+ scope: "global",
+ home: `~/.codex/skills/.system/${name}`,
+ description: `The ${name} skill.`,
+ realFolder: true,
+ copies: [],
+ access: [],
+ ...extra,
+});
+const ref = ({ scope, name, home }: SkillSummary) => ({ scope, name, home });
+
+describe("plans for skills that come with an agent", () => {
+ const imagegen = skill("imagegen", { provided: "agent" });
+
+ it("leaves them out of a deletion", () => {
+ expect(planDelete({ listed: [imagegen], skills: [ref(imagegen)], tracked: [] })).toEqual([
+ "“imagegen” comes with an agent, so it stays as it is.",
+ "There is nothing to delete.",
+ ]);
+ });
+
+ it("leaves them out of a move", () => {
+ expect(
+ planMove({
+ listed: [imagegen],
+ skills: [ref(imagegen)],
+ to: { kind: "project", cwd: "/home/user/acme-web" },
+ projectNames: ["acme-web"],
+ tracked: [],
+ }),
+ ).toEqual([
+ "“imagegen” comes with an agent, so it stays as it is.",
+ "There is nothing to move.",
+ ]);
+ });
+});
diff --git a/apps/server/src/mcp/toolkits/skills/plans.ts b/apps/server/src/mcp/toolkits/skills/plans.ts
new file mode 100644
index 000000000000..a7707842517e
--- /dev/null
+++ b/apps/server/src/mcp/toolkits/skills/plans.ts
@@ -0,0 +1,188 @@
+/**
+ * What `t3_skill_move` and `t3_skill_delete` would do, in plain words, for a call without
+ * `confirm: true`. It words the same plan the Settings page confirms (`SkillsSettings.logic.ts`),
+ * from the list as it is now and the project skills git tracks.
+ *
+ * @module plans
+ */
+import type { SkillPlacement, SkillRef, SkillSummary } from "@t3tools/contracts";
+
+/** The most skill names one sentence lists before it says how many more. */
+const SHOWN = 10;
+
+const joinNames = (names: readonly string[]) =>
+ names.length <= 1 ? (names[0] ?? "") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
+
+const namesOf = (skills: ReadonlyArray<{ readonly name: string }>) => {
+ const quoted = skills.map((skill) => `“${skill.name}”`);
+ return quoted.length <= SHOWN
+ ? joinNames(quoted)
+ : `${quoted.slice(0, SHOWN).join(", ")} and ${quoted.length - SHOWN} more`;
+};
+
+const one = (skills: readonly unknown[]) => skills.length === 1;
+const it = (skills: readonly unknown[]) => (one(skills) ? "it" : "them");
+const is = (skills: readonly unknown[]) => (one(skills) ? "is" : "are");
+
+const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? "" : "s"}`;
+
+const nextStep = (tool: string) =>
+ `Nothing has changed yet. To do it, call ${tool} again with the same arguments and confirm: true.`;
+
+/**
+ * The skills asked for as the list shows them now, leaving out those it no longer shows there and
+ * those that come with an agent, which are never moved or deleted.
+ */
+function lookUp(listed: readonly SkillSummary[], refs: readonly SkillRef[]) {
+ const found: SkillSummary[] = [];
+ const provided: SkillSummary[] = [];
+ const missing: SkillRef[] = [];
+ for (const ref of refs) {
+ const skill = listed.find(
+ (item) => item.scope === ref.scope && item.name === ref.name && item.home === ref.home,
+ );
+ if (skill === undefined) missing.push(ref);
+ else if (skill.provided !== undefined) provided.push(skill);
+ else found.push(skill);
+ }
+ const lines = missing.map(
+ (ref) =>
+ `“${ref.name}” isn't at ${ref.home} any more, so it is left out. List the skills again.`,
+ );
+ if (provided.length > 0) {
+ lines.push(
+ `${namesOf(provided)} ${one(provided) ? "comes with an agent, so it stays" : "come with agents, so they stay"} as ${one(provided) ? "it is" : "they are"}.`,
+ );
+ }
+ return { found, lines };
+}
+
+/** Whether the skill is placed that way already, so there is nothing to do for it. */
+function placedAlready(skill: SkillSummary, to: SkillPlacement) {
+ switch (to.kind) {
+ case "project":
+ return skill.scope === "project";
+ case "global":
+ return skill.scope === "global" && !skill.projects?.length;
+ case "projects": {
+ const used = new Set(skill.projects ?? []);
+ return (
+ skill.scope === "global" &&
+ used.size === new Set(to.cwds).size &&
+ to.cwds.every((cwd) => used.has(cwd))
+ );
+ }
+ }
+}
+
+/**
+ * A sentence saying which of the project skills a change removes git can bring back, and whether
+ * that is all of them.
+ */
+function gitNote(
+ removed: readonly SkillSummary[],
+ tracked: ReadonlySet,
+ undo: (them: string) => string,
+) {
+ const inGit = removed.filter((skill) => skill.scope === "project" && tracked.has(skill.name));
+ if (inGit.length === 0) return undefined;
+ const whole = inGit.length === removed.length;
+ return {
+ whole,
+ text: whole
+ ? `git tracks ${it(removed)}, so you can undo this with git.`
+ : `git tracks ${namesOf(inGit)}, so you can undo ${undo(it(inGit))} with git.`,
+ };
+}
+
+export function planMove(input: {
+ readonly listed: readonly SkillSummary[];
+ readonly skills: readonly SkillRef[];
+ readonly to: SkillPlacement;
+ /** The names of the projects `to` names, in its order. */
+ readonly projectNames: readonly string[];
+ /** The project skills git tracks. */
+ readonly tracked: readonly string[];
+}): string[] {
+ const { found, lines } = lookUp(input.listed, input.skills);
+ const already = found.filter((skill) => placedAlready(skill, input.to));
+ const coming = found.filter((skill) => !placedAlready(skill, input.to));
+ if (already.length > 0) {
+ lines.push(`${namesOf(already)} ${is(already)} used there already.`);
+ }
+ if (coming.length === 0) {
+ lines.push("There is nothing to move.");
+ return lines;
+ }
+ const what = namesOf(coming);
+ const where = joinNames(input.projectNames);
+ const becomeGlobal = coming.every((skill) => skill.scope === "global")
+ ? ""
+ : ` ${one(coming) ? "becomes" : "become"} Global and`;
+ switch (input.to.kind) {
+ case "project":
+ lines.push(
+ `${what} ${one(coming) ? "moves" : "move"} into ${where}, so anyone who clones it gets ${it(coming)}.`,
+ );
+ break;
+ case "global":
+ lines.push(`${what}${becomeGlobal} will be on in every project.`);
+ break;
+ case "projects":
+ lines.push(
+ input.projectNames.length === 1
+ ? `${what}${becomeGlobal} will be on in ${where} only.`
+ : `${what}${becomeGlobal} will be on in ${where}. There's one copy, so an edit shows up in ${input.projectNames.length === 2 ? "both" : "all of them"}.`,
+ );
+ break;
+ }
+ lines.push(`Agents that use ${it(coming)} now keep using ${it(coming)}.`);
+ // A move into a project makes new files there, so git has nothing to undo.
+ const git =
+ input.to.kind === "project"
+ ? undefined
+ : gitNote(coming, new Set(input.tracked), (them) => `taking ${them} out of the project`);
+ if (git !== undefined) lines.push(git.text);
+ lines.push(nextStep("t3_skill_move"));
+ return lines;
+}
+
+export function planDelete(input: {
+ readonly listed: readonly SkillSummary[];
+ readonly skills: readonly SkillRef[];
+ /** The project skills git tracks. */
+ readonly tracked: readonly string[];
+}): string[] {
+ const { found, lines } = lookUp(input.listed, input.skills);
+ const targets = found.filter((skill) => skill.realFolder === true);
+ const kept = found.filter((skill) => skill.realFolder !== true);
+ if (kept.length > 0) {
+ lines.push(
+ `${namesOf(kept)} ${is(kept)} reached through a link, not kept in an agent's skill folder, so ${one(kept) ? "it stays" : "they stay"}.`,
+ );
+ }
+ if (targets.length === 0) {
+ lines.push("There is nothing to delete.");
+ return lines;
+ }
+ lines.push(
+ one(targets)
+ ? `This deletes ${targets[0]!.home} and any links to it.`
+ : `This deletes ${plural(targets.length, "folder")} and any links to them: ${targets.map((skill) => skill.home).join(", ")}.`,
+ );
+ const losing = [
+ ...new Set(
+ targets.flatMap((skill) =>
+ skill.access
+ .filter((access) => access.state === "direct" || access.state === "link")
+ .map((access) => access.instanceId),
+ ),
+ ),
+ ];
+ if (losing.length > 0) lines.push(`${joinNames(losing)} will stop using ${it(targets)}.`);
+ const git = gitNote(targets, new Set(input.tracked), (them) => `deleting ${them}`);
+ if (git === undefined) lines.push("It can't be undone.");
+ else lines.push(git.whole ? git.text : `${git.text} The rest can't be undone.`);
+ lines.push(nextStep("t3_skill_delete"));
+ return lines;
+}
diff --git a/apps/server/src/mcp/toolkits/skills/tools.ts b/apps/server/src/mcp/toolkits/skills/tools.ts
new file mode 100644
index 000000000000..65f617df2d76
--- /dev/null
+++ b/apps/server/src/mcp/toolkits/skills/tools.ts
@@ -0,0 +1,164 @@
+import {
+ OrchestratorMcpFailure,
+ ProjectId,
+ ProviderInstanceId,
+ SkillBatchResult,
+ SkillCreateInput,
+ SkillCreateResult,
+ SkillGetResult,
+ SkillListResult,
+ SkillRef,
+} from "@t3tools/contracts";
+import * as Schema from "effect/Schema";
+import { Tool, Toolkit } from "effect/ai";
+import * as ProjectService from "../../../project/ProjectService.ts";
+import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
+import * as SkillCatalog from "../../../skills/SkillCatalog.ts";
+import * as SkillManager from "../../../skills/SkillManager.ts";
+import * as SkillTracking from "../../../skills/SkillTracking.ts";
+import * as McpInvocationContext from "../../McpInvocationContext.ts";
+
+const shared = {
+ failure: OrchestratorMcpFailure,
+ failureMode: "return" as const,
+ dependencies: [
+ McpInvocationContext.McpInvocationContext,
+ ThreadManagementService.ThreadManagementService,
+ ProjectService.ProjectService,
+ ],
+};
+
+const projectId = Schema.optional(ProjectId).annotate({
+ description:
+ "The project whose skills to use. Defaults to the calling thread's project; a client outside a T3 thread passes it for project skills.",
+});
+const skills = Schema.Array(SkillRef)
+ .check(Schema.isMinLength(1), Schema.isMaxLength(200))
+ .annotate({
+ description:
+ "The skills to change, each exactly as t3_skill_list returned it: scope, name and home.",
+ });
+const agentNames = Schema.Array(ProviderInstanceId).check(
+ Schema.isMinLength(1),
+ Schema.isMaxLength(64),
+);
+const agentsDescription =
+ "agents are named by provider instance id or driver kind, as in the access entries t3_skill_list returns.";
+const accessNote = "Requires a live full-access/default calling thread or a full-access client.";
+const confirm = Schema.optional(Schema.Boolean).annotate({
+ description:
+ "true to do it. Without it nothing changes, and the result is the plan: what would happen, in plain words.",
+});
+const twoSteps = (tool: string) =>
+ `Call it first without confirm and tell the user the plan; call ${tool} again with the same arguments and confirm: true only once they agree.`;
+/** A call without `confirm: true` changes nothing and returns this instead of the outcomes. */
+const SkillPlanResult = Schema.Struct({ plan: Schema.Array(Schema.String) });
+const placeNotes =
+ 'Skipped outcomes give a reason: "notFound" or "changed" means the skill is no longer where the list said (list again), "linked" means its folder is reached through a link and stays where it is, "destinationTaken" means something with that name is already there and is never replaced, "inUse" means another program is using the folder, "provided" means the skill comes with an agent and stays where it is, "failed" means a folder couldn\'t be written. blocked lists agents that used a skill but couldn\'t be given it at its new place. sourceDropped means the installer\'s record of where the skill came from couldn\'t go along, so it won\'t update from there.';
+const resultNotes =
+ 'Each outcome says changed, unchanged or skipped. blocked lists agents the change did not reach: "alwaysOn" means the agent reads the skill\'s own folder and T3 Code knows no setting that switches one skill off for it (the access entry says fixed); "setElsewhere" means a project or organization setting decides it; "failed" means the agent\'s settings could not be written safely; "shadowed" means it loads another skill with that name first; "entryTaken" means something else is where the link would go; "provided" means the skill comes with another agent or its plugin. affected lists agents that gained or lost the skill without being asked, because they read the same folder.';
+
+const SkillListTool = Tool.make("t3_skill_list", {
+ ...shared,
+ description:
+ "List the agent skills T3 Code can see, in a project and in the user's home folder, and which agents can use each (access: direct = reads the skill's folder, link = reached through a link, off = it can see the skill but its own settings switch it off, none = cannot use it; fixed = T3 Code cannot switch that agent for that skill). provided = the skill comes with an agent or one of its plugins: only that agent is listed, only its own setting can switch it, and it can't be moved or deleted. A skill is named by scope, name and home. Use t3_skill_enable and t3_skill_disable to change who uses it, t3_skill_move to change which projects use it, t3_skill_delete to delete it, and t3_skill_create to make a new one.",
+ parameters: Schema.Struct({ projectId }),
+ success: SkillListResult,
+ dependencies: [...shared.dependencies, SkillCatalog.SkillCatalog],
+})
+ .annotate(Tool.Readonly, true)
+ .annotate(Tool.Destructive, false);
+
+const SkillGetTool = Tool.make("t3_skill_get", {
+ ...shared,
+ description:
+ "Read one skill's whole description, its SKILL.md text and its file list. Name the skill by scope, name and home as t3_skill_list returned them.",
+ parameters: Schema.Struct({ projectId, ...SkillRef.fields }),
+ success: SkillGetResult,
+ dependencies: [...shared.dependencies, SkillCatalog.SkillCatalog],
+})
+ .annotate(Tool.Readonly, true)
+ .annotate(Tool.Destructive, false);
+
+const SkillEnableTool = Tool.make("t3_skill_enable", {
+ ...shared,
+ description: `Let agents use skills by linking each skill into the agent's own skill folder, or by taking away the setting that switches it off in the agent's own settings. Nothing is copied or deleted. agents is "all" for every enabled agent, or a list; ${agentsDescription} ${resultNotes} ${accessNote}`,
+ parameters: Schema.Struct({
+ projectId,
+ skills,
+ agents: Schema.Union([Schema.Literal("all"), agentNames]),
+ }),
+ success: SkillBatchResult,
+ dependencies: [...shared.dependencies, SkillManager.SkillManager],
+}).annotate(Tool.Destructive, false);
+
+const SkillDisableTool = Tool.make("t3_skill_disable", {
+ ...shared,
+ description: `Stop agents using skills by removing the agent's link to each skill, or, for an agent that reads the skill's folder itself, by switching the skill off in that agent's own settings (Claude Code, Codex, OpenCode and Pi have one). The skill's own folder is never deleted, and an agent T3 Code can't switch stays on (blocked: alwaysOn). Turn a skill back on with t3_skill_enable. ${agentsDescription} ${resultNotes} ${accessNote}`,
+ parameters: Schema.Struct({ projectId, skills, agents: agentNames }),
+ success: SkillBatchResult,
+ dependencies: [...shared.dependencies, SkillManager.SkillManager],
+}).annotate(Tool.Destructive, false);
+
+const SkillCreateTool = Tool.make("t3_skill_create", {
+ ...shared,
+ description: `Create a skill: a SKILL.md with this name and one-line description in the shared .agents/skills folder of the project (scope project) or of the user's home folder (scope global), with a placeholder body to replace by editing the file. It is turned on for every enabled agent, by a link for agents that read another folder; blocked lists agents it could not reach. The name is lowercase letters, digits and single hyphens, at most 64 characters. Refused when a folder an agent reads in that scope already has something with that name. ${accessNote}`,
+ parameters: Schema.Struct({
+ projectId,
+ scope: SkillCreateInput.fields.scope,
+ name: SkillCreateInput.fields.name,
+ description: SkillCreateInput.fields.description,
+ }),
+ success: SkillCreateResult,
+ dependencies: [...shared.dependencies, SkillManager.SkillManager],
+}).annotate(Tool.Destructive, false);
+
+const SkillMoveTool = Tool.make("t3_skill_move", {
+ ...shared,
+ description: `Choose where skills are used, as Use in… does in T3 Code's settings. to is "project" for this project only (the skill's folder moves into the project's .agents/skills, so anyone who clones the project gets it), "global" for every project (the folder moves into the user's home folder), or { projects } for only those projects (one Global copy, linked into each, so an edit shows up in all of them). Agents that used a skill keep using it, and nothing with the same name is ever replaced. ${twoSteps("t3_skill_move")} ${placeNotes} ${accessNote}`,
+ parameters: Schema.Struct({
+ projectId,
+ skills,
+ to: Schema.Union([
+ Schema.Literals(["project", "global"]),
+ Schema.Struct({
+ projects: Schema.Array(ProjectId)
+ .check(Schema.isMinLength(1), Schema.isMaxLength(64))
+ .annotate({
+ description: "The projects to use the skills in, as t3_project_list names them.",
+ }),
+ }),
+ ]),
+ confirm,
+ }),
+ success: Schema.Union([SkillBatchResult, SkillPlanResult]),
+ dependencies: [
+ ...shared.dependencies,
+ SkillCatalog.SkillCatalog,
+ SkillManager.SkillManager,
+ SkillTracking.SkillTracking,
+ ],
+}).annotate(Tool.Destructive, true);
+
+const SkillDeleteTool = Tool.make("t3_skill_delete", {
+ ...shared,
+ description: `Delete skills: each skill's own folder and the links agents reach it through. Only a skill whose folder is in an agent's skill folder (realFolder in t3_skill_list) can be deleted; one only linked there stays (skipped: "linked"). It can't be undone, except with git for a project skill git tracks; the plan says which. A deleted skill's outcome lists in affected the agents that lost it. ${twoSteps("t3_skill_delete")} ${accessNote}`,
+ parameters: Schema.Struct({ projectId, skills, confirm }),
+ success: Schema.Union([SkillBatchResult, SkillPlanResult]),
+ dependencies: [
+ ...shared.dependencies,
+ SkillCatalog.SkillCatalog,
+ SkillManager.SkillManager,
+ SkillTracking.SkillTracking,
+ ],
+}).annotate(Tool.Destructive, true);
+
+export const SkillsToolkit = Toolkit.make(
+ SkillListTool,
+ SkillGetTool,
+ SkillEnableTool,
+ SkillDisableTool,
+ SkillCreateTool,
+ SkillMoveTool,
+ SkillDeleteTool,
+);
diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts
index 91257c2fdb80..375bf158a9e3 100644
--- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts
+++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts
@@ -21,6 +21,11 @@ import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts";
import * as ScheduledTaskService from "../../../scheduledTasks/ScheduledTaskService.ts";
import * as SecretRequests from "../../../secrets/SecretRequests.ts";
import * as ServerSettings from "../../../serverSettings.ts";
+import * as InstructionCatalog from "../../../instructions/InstructionCatalog.ts";
+import * as InstructionManager from "../../../instructions/InstructionManager.ts";
+import * as SkillCatalog from "../../../skills/SkillCatalog.ts";
+import * as SkillManager from "../../../skills/SkillManager.ts";
+import * as SkillTracking from "../../../skills/SkillTracking.ts";
import * as VcsStatusBroadcaster from "../../../vcs/VcsStatusBroadcaster.ts";
import * as ServerSecretStore from "../../../auth/ServerSecretStore.ts";
import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts";
@@ -55,6 +60,11 @@ const layerStubServices = Layer.mergeAll(
Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({}),
Layer.mock(ThreadLaunchService.ThreadLaunchService)({}),
Layer.mock(ThreadSearch.ThreadSearch)({}),
+ Layer.mock(SkillCatalog.SkillCatalog)({}),
+ Layer.mock(SkillManager.SkillManager)({}),
+ Layer.mock(SkillTracking.SkillTracking)({}),
+ Layer.mock(InstructionCatalog.InstructionCatalog)({}),
+ Layer.mock(InstructionManager.InstructionManager)({}),
);
const ToolsListPayload = Schema.fromJsonString(
diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts
index 2ad903ff792a..2131dea213c9 100644
--- a/apps/server/src/observability/RpcInstrumentation.ts
+++ b/apps/server/src/observability/RpcInstrumentation.ts
@@ -33,6 +33,29 @@ const RPC_AGGREGATES = {
[WS_METHODS.serverProbe]: "server",
[WS_METHODS.serverGetConfig]: "server",
[WS_METHODS.serverRefreshProviders]: "server",
+ [WS_METHODS.serverListSkills]: "server",
+ [WS_METHODS.serverGetSkill]: "server",
+ [WS_METHODS.serverEnableSkills]: "server",
+ [WS_METHODS.serverDisableSkills]: "server",
+ [WS_METHODS.serverPlaceSkills]: "server",
+ [WS_METHODS.serverDeleteSkills]: "server",
+ [WS_METHODS.serverSkillsTracked]: "server",
+ [WS_METHODS.serverCreateSkill]: "server",
+ [WS_METHODS.serverShareSkills]: "server",
+ [WS_METHODS.serverCheckSkillUpdates]: "server",
+ [WS_METHODS.serverGetSkillChanges]: "server",
+ [WS_METHODS.serverUpdateSkill]: "server",
+ [WS_METHODS.serverListInstructions]: "server",
+ [WS_METHODS.serverReadInstruction]: "server",
+ [WS_METHODS.serverWriteInstruction]: "server",
+ [WS_METHODS.serverEnableInstruction]: "server",
+ [WS_METHODS.serverDisableInstruction]: "server",
+ [WS_METHODS.serverSetClaudeInstructionFiles]: "server",
+ [WS_METHODS.serverShareInstruction]: "server",
+ [WS_METHODS.serverAdoptInstruction]: "server",
+ [WS_METHODS.serverDeleteInstruction]: "server",
+ [WS_METHODS.serverMoveInstruction]: "server",
+ [WS_METHODS.serverInstructionsTracked]: "server",
[WS_METHODS.serverUpdateProvider]: "server",
[WS_METHODS.providerAuthStart]: "provider",
[WS_METHODS.providerConsumeResetCredit]: "provider",
diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
index 6777f0a2902a..2760acfde1d9 100644
--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
@@ -58,6 +58,8 @@ import { PreviewControlsToolkit } from "../../mcp/toolkits/previewControls/tools
import { HtmlToolkit } from "../../mcp/toolkits/html/tools.ts";
import { EnvironmentToolkit } from "../../mcp/toolkits/environment/tools.ts";
import { ProjectToolkit } from "../../mcp/toolkits/project/tools.ts";
+import { InstructionsToolkit } from "../../mcp/toolkits/instructions/tools.ts";
+import { SkillsToolkit } from "../../mcp/toolkits/skills/tools.ts";
import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts";
import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts";
import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts";
@@ -642,6 +644,8 @@ describe("ClaudeAdapterV2 MCP query overrides", () => {
...Object.values(ThreadToolkit.tools),
...Object.values(WorktreeToolkit.tools),
...Object.values(ProjectToolkit.tools),
+ ...Object.values(SkillsToolkit.tools),
+ ...Object.values(InstructionsToolkit.tools),
...Object.values(EnvironmentToolkit.tools),
...Object.values(PreviewControlsToolkit.tools),
...Object.values(HtmlToolkit.tools),
diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
index 79d7bdd7e115..34e5d8d984dc 100644
--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
@@ -947,6 +947,10 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [
"mcp__t3-code__t3_thread_search",
"mcp__t3-code__t3_preview_list",
"mcp__t3-code__t3_environment_read",
+ "mcp__t3-code__t3_skill_list",
+ "mcp__t3-code__t3_skill_get",
+ "mcp__t3-code__t3_instructions_list",
+ "mcp__t3-code__t3_instructions_get",
"mcp__t3-code__t3_queue_list",
"mcp__t3-code__t3_queue_read",
"mcp__t3-code__html_preview",
diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts
index cb1a5513cd01..d5d6e2fcc345 100644
--- a/apps/server/src/provider/CodexProvider.ts
+++ b/apps/server/src/provider/CodexProvider.ts
@@ -41,6 +41,7 @@ import {
} from "@t3tools/provider-core/server/snapshotProbe";
import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits";
+import type { SkillSettingsChange } from "@t3tools/provider-core/server/driver";
import {
codexRateLimitsFailureMessage,
codexRateLimitsToLimits,
@@ -496,6 +497,17 @@ export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(functi
return parseCodexSkillsListResponse(skillsResponse, input.cwd);
});
+/**
+ * Opens a short-lived `codex app-server` for the caller's scope and returns the way to write
+ * Codex's per-skill settings through it (`skills/config/write`).
+ */
+export const openCodexSkillSettingsWriter = Effect.fn("openCodexSkillSettingsWriter")(function* (
+ input: Parameters[0],
+) {
+ const { client } = yield* withCodexAppServerClient(input);
+ return (change: SkillSettingsChange) => client.request("skills/config/write", change);
+});
+
const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] =>
appendCustomCodexModels([], codexSettings.customModels);
diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.test.ts b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts
index f5bc8ec3f4d3..48b4697a3200 100644
--- a/apps/server/src/provider/Drivers/AntigravitySkills.test.ts
+++ b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts
@@ -342,6 +342,53 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => {
);
});
+it.layer(NodeServices.layer)("Antigravity skill folders", (it) => {
+ // The Skills settings page reads the same folders from a shared table, so a change to the
+ // table that reorders or adds a folder would change what the `$` picker offers.
+ it.effect("reads the home and project folders in a fixed, interleaved order", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const input = yield* makeWorkspace();
+ const folders = [
+ { label: "home config", base: input.userHome, folder: ".gemini/config/skills" },
+ { label: "project .gemini", base: input.cwd, folder: ".gemini/skills" },
+ { label: "home cli", base: input.userHome, folder: ".gemini/antigravity-cli/skills" },
+ { label: "project .agents", base: input.cwd, folder: ".agents/skills" },
+ { label: "project .agent", base: input.cwd, folder: ".agent/skills" },
+ ];
+ const ignored = [
+ { base: input.userHome, folder: ".agents/skills" },
+ { base: input.cwd, folder: ".claude/skills" },
+ { base: input.cwd, folder: ".codex/skills" },
+ ];
+ for (const { label, base, folder } of folders) {
+ yield* writeSkill(
+ path.join(base, folder, "probe"),
+ `---\nname: probe\ndescription: ${label}\n---\n`,
+ );
+ }
+ for (const { base, folder } of ignored) {
+ yield* writeSkill(
+ path.join(base, folder, "probe"),
+ "---\nname: probe\ndescription: ignored\n---\n",
+ );
+ }
+
+ // Each folder wins until its skill is removed, so the order is the folders' order.
+ for (const { label, base, folder } of folders) {
+ const found = yield* discoverAntigravitySkills(input);
+ assert.deepEqual(
+ found.map((skill) => [skill.name, skill.description]),
+ [["probe", label]],
+ );
+ yield* fileSystem.remove(path.join(base, folder, "probe"), { recursive: true });
+ }
+ assert.deepEqual(yield* discoverAntigravitySkills(input), []);
+ }),
+ );
+});
+
it("resolves the home the agent expands ~ against", () => {
assert.equal(
resolveAntigravityUserHome(
diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.ts b/apps/server/src/provider/Drivers/AntigravitySkills.ts
index 1bc4e370013f..971176063e35 100644
--- a/apps/server/src/provider/Drivers/AntigravitySkills.ts
+++ b/apps/server/src/provider/Drivers/AntigravitySkills.ts
@@ -1,4 +1,4 @@
-import type { ServerProviderSkill } from "@t3tools/contracts";
+import { ProviderDriverKind, type ServerProviderSkill } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
@@ -7,6 +7,13 @@ import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import { parse as parseYamlDocument } from "yaml";
+import {
+ ANTIGRAVITY_USER_SKILL_SUBFOLDERS,
+ skillRootsFor,
+} from "@t3tools/provider-core/server/AgentSkillFolders";
+
+const ANTIGRAVITY_DRIVER = ProviderDriverKind.make("antigravity");
+
/**
* The home directory the agent expands `~` against, matching Python's
* `os.path.expanduser` in the launch environment T3 hands the process:
@@ -41,10 +48,8 @@ export function antigravityUserSkillDirectories(
path: Path.Path,
geminiHome: string,
): readonly [configSkills: string, cliSkills: string] {
- return [
- path.join(geminiHome, "config", "skills"),
- path.join(geminiHome, "antigravity-cli", "skills"),
- ];
+ const [configSkills, cliSkills] = ANTIGRAVITY_USER_SKILL_SUBFOLDERS;
+ return [path.join(geminiHome, configSkills), path.join(geminiHome, cliSkills)];
}
const MAX_SKILL_BYTES = 1_000_000;
@@ -166,17 +171,11 @@ export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")(
> {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
- const [configSkills, cliSkills] = antigravityUserSkillDirectories(
- path,
- path.join(input.userHome, ".gemini"),
+ const roots = skillRootsFor(ANTIGRAVITY_DRIVER).map((root) =>
+ root.scope === "global"
+ ? { directory: path.join(input.userHome, root.folder), scope: "user" }
+ : { directory: path.resolve(input.cwd, root.folder), scope: "project" },
);
- const roots = [
- { directory: configSkills, scope: "user" },
- { directory: path.resolve(input.cwd, ".gemini", "skills"), scope: "project" },
- { directory: cliSkills, scope: "user" },
- { directory: path.resolve(input.cwd, ".agents", "skills"), scope: "project" },
- { directory: path.resolve(input.cwd, ".agent", "skills"), scope: "project" },
- ];
const budget: ScanBudget = {
remainingBytes: MAX_SCAN_BYTES,
remainingEntries: MAX_SCAN_ENTRIES,
diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts
index c4b328cdc337..6d9c7489cf7b 100644
--- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts
+++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts
@@ -724,6 +724,44 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {
}),
);
+ // The Skills settings page reads the same folders from a shared table, so a change to the
+ // table that reorders or adds a folder would change what the `$` picker offers.
+ it.effect("reads the config folder first, then the project's .claude/skills, and no others", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
+ const configDir = path.join(tempDir, "claude-home");
+ const workspace = path.join(tempDir, "workspace");
+ const folders = [
+ { label: "config", skills: path.join(configDir, "skills") },
+ { label: "project .claude", skills: path.join(workspace, ".claude", "skills") },
+ ];
+ const ignored = [".agents/skills", ".codex/skills", ".cursor/skills", ".gemini/skills"];
+ for (const { label, skills } of folders) {
+ yield* writeSkill(skills, "probe", `---\ndescription: ${label}\n---\n`);
+ }
+ for (const folder of ignored) {
+ yield* writeSkill(
+ path.join(workspace, folder),
+ "probe",
+ "---\ndescription: ignored\n---\n",
+ );
+ }
+
+ // Each folder wins until its skill is removed, so the order is the folders' order.
+ for (const { label, skills } of folders) {
+ const found = yield* discoverClaudeSkills({ homePath: configDir }, workspace);
+ assert.deepEqual(
+ found.map((skill) => [skill.name, skill.description]),
+ [["probe", label]],
+ );
+ yield* fs.remove(path.join(skills, "probe"), { recursive: true });
+ }
+ assert.deepEqual(yield* discoverClaudeSkills({ homePath: configDir }, workspace), []);
+ }),
+ );
+
it.effect("returns an empty list when no skill roots exist", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts
index 40ab84d65ed6..a776657aea9a 100644
--- a/apps/server/src/provider/Drivers/ClaudeSkills.ts
+++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts
@@ -14,7 +14,11 @@
* @module provider/Drivers/ClaudeSkills
*/
-import type { ClaudeSettings, ServerProviderSkill } from "@t3tools/contracts";
+import {
+ ProviderDriverKind,
+ type ClaudeSettings,
+ type ServerProviderSkill,
+} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
@@ -24,9 +28,12 @@ import { fromLenientJson } from "@t3tools/shared/schemaJson";
import { parse as parseYamlDocument } from "yaml";
import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
+import { skillFoldersFor } from "@t3tools/provider-core/server/AgentSkillFolders";
type ClaudeSkillScope = "user" | "project";
+const CLAUDE_DRIVER = ProviderDriverKind.make("claudeAgent");
+
const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
type SkillFrontmatter =
@@ -34,6 +41,7 @@ type SkillFrontmatter =
| { readonly kind: "malformed" }
| {
readonly kind: "parsed";
+ readonly name?: string;
readonly description?: string;
readonly userInvocationOnly?: boolean;
readonly userInvocable?: boolean;
@@ -68,7 +76,11 @@ function parseFrontmatterBoolean(value: unknown): boolean | undefined {
}
}
-function parseSkillFrontmatter(contents: string): SkillFrontmatter {
+/**
+ * How Claude Code reads a SKILL.md header. `malformed` skills don't load there; the Skills
+ * settings page reads headers the same way, so it reports what Claude would skip.
+ */
+export function parseSkillFrontmatter(contents: string): SkillFrontmatter {
const match = FRONTMATTER_PATTERN.exec(contents);
if (!match) {
return { kind: "missing" };
@@ -103,8 +115,10 @@ function parseSkillFrontmatter(contents: string): SkillFrontmatter {
const record = parsed as Record;
const description = typeof record.description === "string" ? record.description.trim() : "";
+ const name = typeof record.name === "string" ? record.name.trim() : "";
return {
kind: "parsed",
+ ...(name ? { name } : {}),
...(description ? { description } : {}),
...(parseFrontmatterBoolean(record["disable-model-invocation"]) === true
? { userInvocationOnly: true }
@@ -202,11 +216,16 @@ const findRepositoryRoot = Effect.fn("findRepositoryRoot")(function* (
* boolean) makes it drop every override in that file, so this schema does the
* same rather than applying the valid siblings the CLI ignores.
*/
-const SkillOverrideValue = Schema.Literals(["on", "name-only", "user-invocable-only", "off"]);
+export const SkillOverrideValue = Schema.Literals([
+ "on",
+ "name-only",
+ "user-invocable-only",
+ "off",
+]);
// Lenient because these settings files are hand-edited and Claude Code itself
// tolerates comments and trailing commas in them.
-const SkillOverrideSettings = fromLenientJson(
+export const SkillOverrideSettings = fromLenientJson(
Schema.Struct({
skillOverrides: Schema.optional(Schema.Record(Schema.String, SkillOverrideValue)),
}),
@@ -235,16 +254,29 @@ function parseSkillOverride(value: typeof SkillOverrideValue.Type): SkillOverrid
}
}
-const readSkillOverrides = Effect.fn("readSkillOverrides")(function* (
+/**
+ * One settings file Claude Code merges for `skillOverrides`: where it is, and what it says about
+ * each skill. `overrides` is undefined when the file is absent or the CLI would ignore the whole
+ * map (a file that doesn't parse, or an entry with a value that isn't one of the four), which is
+ * why `invalid` tells the two apart for a caller that means to write the file.
+ */
+export interface SkillOverrideLayer {
+ readonly path: string;
+ readonly overrides: ReadonlyMap | undefined;
+ readonly invalid: boolean;
+}
+
+/** The settings files that carry `skillOverrides`, lowest precedence first (see above). */
+export const readSkillOverrideLayers = Effect.fn("readSkillOverrideLayers")(function* (
configDirPath: string,
cwd: string | undefined,
environment: NodeJS.ProcessEnv,
-): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> {
+): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const platform = yield* HostProcess.Platform;
- const overridesByName = new Map();
const repositoryRoot = cwd === undefined ? undefined : yield* findRepositoryRoot(cwd);
+ const layers: SkillOverrideLayer[] = [];
for (const settingsPath of skillOverrideSettingsPaths(
path,
@@ -258,6 +290,7 @@ const readSkillOverrides = Effect.fn("readSkillOverrides")(function* (
.readFileString(settingsPath)
.pipe(Effect.orElseSucceed(() => undefined));
if (contents === undefined) {
+ layers.push({ path: settingsPath, overrides: undefined, invalid: false });
continue;
}
@@ -270,16 +303,28 @@ const readSkillOverrides = Effect.fn("readSkillOverrides")(function* (
),
Effect.orElseSucceed(() => undefined),
);
- const overrides = parsed?.skillOverrides;
- if (!overrides) {
- continue;
- }
+ layers.push({
+ path: settingsPath,
+ overrides: parsed?.skillOverrides && new Map(Object.entries(parsed.skillOverrides)),
+ invalid: parsed === undefined,
+ });
+ }
+
+ return layers;
+});
- for (const [name, value] of Object.entries(overrides)) {
+const readSkillOverrides = Effect.fn("readSkillOverrides")(function* (
+ configDirPath: string,
+ cwd: string | undefined,
+ environment: NodeJS.ProcessEnv,
+): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> {
+ const overridesByName = new Map();
+ const layers = yield* readSkillOverrideLayers(configDirPath, cwd, environment);
+ for (const layer of layers) {
+ for (const [name, value] of layer.overrides ?? []) {
overridesByName.set(name, parseSkillOverride(value));
}
}
-
return overridesByName;
});
@@ -289,7 +334,7 @@ const readSkillOverrides = Effect.fn("readSkillOverrides")(function* (
* `CLAUDE_CONFIG_DIR` by `makeClaudeEnvironment`), then a `CLAUDE_CONFIG_DIR`
* already present in the process environment, then `~/.claude`.
*/
-const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(function* (
+export const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(function* (
config: Pick,
environment: NodeJS.ProcessEnv,
cwd?: string,
@@ -332,9 +377,16 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function*
const configDirPath = yield* resolveClaudeConfigDirPath(config, environment ?? process.env, cwd);
const skillOverrides = yield* readSkillOverrides(configDirPath, cwd, environment ?? process.env);
+ // The user folder follows the config dir, which is `~/.claude` unless overridden; the project
+ // folder comes from the shared table.
const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [
{ directory: path.join(configDirPath, "skills"), scope: "user" },
- ...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []),
+ ...(cwd
+ ? skillFoldersFor(CLAUDE_DRIVER, "project").map((folder) => ({
+ directory: path.join(cwd, folder),
+ scope: "project" as const,
+ }))
+ : []),
];
const skillsByName = new Map();
diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts
index 8aa546d103e1..3cc1dea2f299 100644
--- a/apps/server/src/provider/Drivers/CodexDriver.ts
+++ b/apps/server/src/provider/Drivers/CodexDriver.ts
@@ -45,6 +45,7 @@ import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts";
import {
checkCodexProviderStatus,
makePendingCodexProvider,
+ openCodexSkillSettingsWriter,
probeCodexSkillsForCwd,
withCodexAppServerClient,
} from "../CodexProvider.ts";
@@ -52,7 +53,11 @@ import { resolveCodexLaunchArgs } from "../codexLaunchArgs.ts";
import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider";
import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog";
import { applyCodexModelCatalog } from "../codexModelCatalog.ts";
-import type { ProviderDriver, ProviderInstance } from "@t3tools/provider-core/server/driver";
+import type {
+ ProviderDriver,
+ ProviderInstance,
+ SkillSettingsWriter,
+} from "@t3tools/provider-core/server/driver";
import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity";
import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
import {
@@ -305,6 +310,46 @@ export const CodexDriver: ProviderDriver =
+ openCodexSkillSettingsWriter({
+ binaryPath: effectiveConfig.binaryPath,
+ homePath: effectiveConfig.homePath,
+ launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv),
+ // Writes the user's config; any directory serves, same as the status probe.
+ cwd: process.cwd(),
+ environment: processEnv,
+ }).pipe(
+ Effect.map(
+ (write): SkillSettingsWriter =>
+ (change) =>
+ write(change).pipe(
+ Effect.timeout("20 seconds"),
+ Effect.mapError(
+ (cause) =>
+ new ProviderDriverError({
+ driver: DRIVER_KIND,
+ instanceId,
+ detail: "Codex could not change the skill's setting.",
+ cause,
+ }),
+ ),
+ ),
+ ),
+ Effect.timeout("20 seconds"),
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
+ Effect.mapError(
+ (cause) =>
+ new ProviderDriverError({
+ driver: DRIVER_KIND,
+ instanceId,
+ detail: "Codex could not be started to change the skill's setting.",
+ cause,
+ }),
+ ),
+ );
+
// Redemption spends something on the user's account. It serialises on
// the account (instances sharing a Codex home share the credit), keeps
// one idempotency key until Codex reports an outcome, and is bounded so
@@ -380,6 +425,7 @@ export const CodexDriver: ProviderDriver rows.map((row) => row.workspaceRoot)),
+ Effect.orElseSucceed((): string[] => []),
+ );
+ }),
+ ).pipe(Layer.provide(ProjectStore.layer)),
+ ),
+);
+
const layerRuntimeCoreDependenciesBase = Layer.mergeAll(
AgentAwarenessRelay.layer,
// Asks T3 Connect to deliver webhooks it held while this environment was offline.
@@ -574,8 +599,25 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(
ProviderUsageLimitsIngestion.layer,
layerProviderInstallationRefresh,
ReplayMarkers.layer,
+ // It reads through SkillCatalog, checks folders against ProjectService and refreshes the
+ // composer's skill lists through ProviderRegistry, all provided below; being here makes it one
+ // instance, so skill writes run one request at a time.
+ SkillManager.layer,
+ // Reads through SkillCatalog and runs git through VcsProcess.
+ SkillTracking.layer,
+ // Reads skill sources from GitHub through the HTTP client; one instance, so its cache and the
+ // pause after GitHub's rate limit are shared, and updates run one at a time.
+ SkillUpdates.layer.pipe(Layer.provide(GitHubSkillSource.layer)),
+ // Instruction files. The manager checks folders against ProjectService, so, like SkillManager,
+ // being here makes it one instance and instruction writes run one request at a time. All three
+ // read through one InstructionCatalog, which reads the file index and the provider snapshots.
+ Layer.mergeAll(InstructionManager.layer, InstructionTracking.layer).pipe(
+ Layer.provideMerge(InstructionCatalog.layer),
+ ),
).pipe(
// Core Services
+ // It checks a project's folder against ProjectService, which the next layer provides.
+ Layer.provideMerge(layerSkillCatalog),
Layer.provideMerge(layerOrchestrationApplication),
Layer.provideMerge(RuntimeLayer.layerEventInfrastructure),
Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)),
diff --git a/apps/server/src/skills/AgentConfigHome.ts b/apps/server/src/skills/AgentConfigHome.ts
new file mode 100644
index 000000000000..29c4f77a3ff2
--- /dev/null
+++ b/apps/server/src/skills/AgentConfigHome.ts
@@ -0,0 +1,58 @@
+/**
+ * AgentConfigHome - where a provider instance keeps its config, which its own skill and
+ * instruction folders live under.
+ *
+ * This follows the setting or variable that moves the agent's home, in the order the agent applies
+ * them: Claude's `homePath` setting, then `CLAUDE_CONFIG_DIR`; Codex's `homePath`, then
+ * `CODEX_HOME`; Grok's `GROK_HOME`. Anything else stays at the default folder.
+ *
+ * This is where the agent's own folders are, not always where it reads its settings from: a Codex
+ * instance with a shadow home runs in that home, and `codexSettingsHome` says so for the settings
+ * file the skill switches read and write.
+ *
+ * @module AgentConfigHome
+ */
+import type { ProviderInstanceConfig } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
+import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
+
+import * as HostProcess from "@t3tools/shared/HostProcess";
+
+import { resolveClaudeConfigDirPath } from "../provider/Drivers/ClaudeSkills.ts";
+
+const decodeHomePath = Schema.decodeUnknownOption(
+ Schema.Struct({ homePath: Schema.optional(Schema.String) }),
+);
+
+/** The instance's `homePath` setting, trimmed; empty when it has none. */
+const instanceHomePath = (instance: ProviderInstanceConfig) =>
+ Option.getOrUndefined(decodeHomePath(instance.config))?.homePath?.trim() ?? "";
+
+export const resolveAgentConfigHome = Effect.fnUntraced(function* (input: {
+ readonly instance: ProviderInstanceConfig;
+ /** The agent's default folder, used when nothing moves it. */
+ readonly fallback: string;
+ /** The server process's environment; the instance's own variables are laid over it. */
+ readonly environment: NodeJS.ProcessEnv;
+ readonly cwd: string | undefined;
+}) {
+ const path = yield* Path.Path;
+ const home = yield* HostProcess.HomeDirectory;
+ const { instance, fallback, cwd } = input;
+ const env = yield* mergeProviderInstanceEnvironment(instance.environment, input.environment);
+ const setting = instanceHomePath(instance);
+ const absoluteOr = (value: string | undefined) =>
+ value && path.isAbsolute(value) ? value : fallback;
+ if (instance.driver === "claudeAgent") {
+ return yield* resolveClaudeConfigDirPath({ homePath: setting }, env, cwd);
+ }
+ if (instance.driver === "codex") {
+ return absoluteOr(expandHomePath(setting || (env.CODEX_HOME?.trim() ?? ""), home));
+ }
+ if (instance.driver === "grok") return absoluteOr(env.GROK_HOME?.trim());
+ return fallback;
+});
diff --git a/apps/server/src/skills/AgentSkillSettings.ts b/apps/server/src/skills/AgentSkillSettings.ts
new file mode 100644
index 000000000000..4bf1c9faa323
--- /dev/null
+++ b/apps/server/src/skills/AgentSkillSettings.ts
@@ -0,0 +1,173 @@
+/**
+ * AgentSkillSettings - switching a skill off through an agent's own settings.
+ *
+ * An agent that reaches a skill through a link T3 Code can remove is switched off by removing
+ * the link. An agent that reads the skill's real folder directly has no link to remove, so where
+ * it has a per-skill setting, that setting is what Off writes (and On takes away). Without one
+ * the agent is `fixed`: the Skills page disables its switch and a request reports `alwaysOn`.
+ *
+ * One decision per adapter on main, each checked against the agent's documentation and, where the
+ * CLI is installed, against the CLI itself:
+ * - Claude Code: `skillOverrides: { "": "off" }` in the settings files, user and local
+ * layers written here. https://code.claude.com/docs/en/skills ("Override skill visibility from
+ * settings"); the layers and the whole-map validation are in `ClaudeSkills.ts`.
+ * - Codex: `[[skills.config]] path = "" enabled = false` in `$CODEX_HOME/config.toml`,
+ * written through the app-server's `skills/config/write` and read from the file. Checked
+ * against codex 0.160.1: the path is recorded as the real path of SKILL.md even when it is
+ * given through a link, `enabled = true` removes the entry, and a name-keyed entry is honoured
+ * too. https://developers.openai.com/codex/skills
+ * - OpenCode: `permission.skill.: "deny"` in the global config. Checked against opencode
+ * 1.18.31 (`opencode debug agent build` shows the rule). https://opencode.ai/docs/skills
+ * - Pi: `-skills//SKILL.md` in the `skills` array of `/settings.json`, the
+ * exact-exclusion form `pi config` writes. https://pi.dev/docs/latest/settings ("Resource
+ * arrays support glob exclusions with `!pattern`, exact inclusion with `+path`, and exact
+ * exclusion with `-path`") and `addAutoDiscoveredResources` in `package-manager.ts` at
+ * https://github.com/earendil-works/pi/blob/43d3763991/packages/coding-agent/src/core/package-manager.ts,
+ * which applies the user's array to the skills found in `~/.agents/skills`. Only for Global
+ * skills: a project's skills are filtered by the project's own `.pi/settings.json`, which is
+ * usually committed, so a project skill is `fixed`, and so is a Global skill used in only some
+ * projects, which Pi finds in the projects' folders. Not run against Pi (not installed here).
+ * - Cursor: `fixed`. Its skills page documents no setting to switch one skill off, only the
+ * `disable-model-invocation` field in the skill's own file. https://cursor.com/docs/context/skills
+ * - Grok: `fixed`. The docs list `[skills] paths` for extra folders and a TUI `/skills` modal,
+ * but no setting that names a skill. https://docs.x.ai/build/features/skills-plugins-marketplaces
+ * - Antigravity: `fixed`. Its CLI settings reference has no skill keys.
+ * https://www.antigravity.google/docs/settings?tab=cli
+ * - Muse Code and ACP registry agents: `fixed`. Neither has skill folders in `AgentSkillFolders`,
+ * so they are not on the Skills page and there is nothing to switch.
+ *
+ * @module AgentSkillSettings
+ */
+import type { ProviderDriverKind, SkillScope } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import type * as FileSystem from "effect/FileSystem";
+import type * as Path from "effect/Path";
+
+import type * as VcsProcess from "../vcs/VcsProcess.ts";
+
+import { claudeSwitches, setClaudeSwitch } from "./ClaudeSkillSettings.ts";
+import { codexSwitches } from "./CodexSkillSettings.ts";
+import { openCodeSwitches, setOpenCodeSwitch } from "./OpenCodeSkillSettings.ts";
+import { piSwitches, setPiSwitch } from "./PiSkillSettings.ts";
+
+type SwitchKind = "claude" | "codex" | "opencode" | "pi";
+
+/**
+ * The adapters that have a per-skill setting, and for which skills. `inProjects`: the setting also
+ * reaches a Global skill the agent finds only through links in projects' folders (a skill used in
+ * some projects, see `SkillLibrary`). Claude and OpenCode name the skill and Codex records its real
+ * folder, so wherever the agent finds it they apply; Pi applies its list to the user-level folders
+ * only.
+ */
+const SWITCHES: Readonly<
+ Record<
+ string,
+ {
+ readonly kind: SwitchKind;
+ readonly scopes: readonly SkillScope[];
+ readonly inProjects: boolean;
+ }
+ >
+> = {
+ claudeAgent: { kind: "claude", scopes: ["global", "project"], inProjects: true },
+ codex: { kind: "codex", scopes: ["global", "project"], inProjects: true },
+ opencode: { kind: "opencode", scopes: ["global", "project"], inProjects: true },
+ pi: { kind: "pi", scopes: ["global"], inProjects: false },
+};
+
+/**
+ * Which settings switch a skill of this scope for the agent, if T3 Code knows any. `reach` is
+ * `projects` for a Global skill that the agent finds only through links in projects' folders.
+ */
+export const skillSwitchKind = (
+ driver: ProviderDriverKind,
+ scope: SkillScope,
+ reach: "folder" | "projects" = "folder",
+) => {
+ const entry = SWITCHES[driver];
+ if (entry === undefined || !entry.scopes.includes(scope)) return undefined;
+ return reach === "projects" && !entry.inProjects ? undefined : entry.kind;
+};
+
+/** What T3 Code needs to know about an agent instance to read and write its settings. */
+export interface SkillSwitchContext {
+ readonly driver: ProviderDriverKind;
+ /** Claude's config folder or Codex's home, where the instance's settings say. */
+ readonly configHome: string;
+ readonly homeDirectory: string;
+ /** The instance's environment over the server's. */
+ readonly environment: NodeJS.ProcessEnv;
+ /** The project the list was read for, whose own settings are layered over the user's. */
+ readonly cwd: string | undefined;
+}
+
+/** A skill as a settings file can name it. */
+export interface SwitchedSkill {
+ readonly scope: SkillScope;
+ /** The skill's folder name, which is how Claude Code and OpenCode name it. */
+ readonly name: string;
+ /** The `name` in the skill's header, which is how Codex names it; absent when it has none. */
+ readonly declaredName: string | undefined;
+ /** Absolute path of the skill's folder after following links. */
+ readonly home: string;
+ /** Every path in the agents' folders that reaches the skill. */
+ readonly entryPaths: readonly string[];
+}
+
+/** What an agent's settings say, read once for a whole list. */
+export interface SkillSwitchView {
+ /** The agent's own settings switch the skill off. */
+ readonly off: (skill: SwitchedSkill) => boolean;
+}
+
+/**
+ * What happened to a settings write. `setElsewhere`: a layer the write can't reach still decides
+ * the skill, so nothing was written. `failed`: the file can't be edited safely or the disk said no.
+ */
+export type SkillSwitchWrite = "written" | "unchanged" | "setElsewhere" | "failed";
+
+const NOTHING_SWITCHED: SkillSwitchView = { off: () => false };
+
+/** Reads the agent's settings, which never starts a process. An unreadable file switches nothing. */
+export const loadSkillSwitches = (
+ context: SkillSwitchContext,
+): Effect.Effect => {
+ switch (SWITCHES[context.driver]?.kind) {
+ case "claude":
+ return claudeSwitches(context);
+ case "codex":
+ return codexSwitches(context);
+ case "opencode":
+ return openCodeSwitches(context);
+ case "pi":
+ return piSwitches(context);
+ default:
+ return Effect.succeed(NOTHING_SWITCHED);
+ }
+};
+
+/**
+ * Writes the agent's settings file so the skill is off (or, with `off: false`, no longer off).
+ * Codex's settings are written by Codex itself (see `CodexSkillSettings`), so it isn't handled
+ * here.
+ */
+export const setSkillSwitch = (
+ context: SkillSwitchContext,
+ skill: SwitchedSkill,
+ off: boolean,
+): Effect.Effect<
+ SkillSwitchWrite,
+ never,
+ FileSystem.FileSystem | Path.Path | VcsProcess.VcsProcess
+> => {
+ switch (SWITCHES[context.driver]?.kind) {
+ case "claude":
+ return setClaudeSwitch(context, skill, off);
+ case "opencode":
+ return setOpenCodeSwitch(context, skill, off);
+ case "pi":
+ return setPiSwitch(context, skill, off);
+ default:
+ return Effect.succeed("failed" as const);
+ }
+};
diff --git a/apps/server/src/skills/ClaudeSkillSettings.ts b/apps/server/src/skills/ClaudeSkillSettings.ts
new file mode 100644
index 000000000000..5983148a22df
--- /dev/null
+++ b/apps/server/src/skills/ClaudeSkillSettings.ts
@@ -0,0 +1,129 @@
+/**
+ * ClaudeSkillSettings - Claude Code's `skillOverrides`, read and written for the Skills page.
+ *
+ * The layers, their order and the CLI's whole-map validation are `ClaudeSkills.ts`'s (verified
+ * against the CLI there); this reads them with the same reader the `$` picker uses. A skill is off
+ * when the last layer that names it says `"off"`.
+ *
+ * A write goes to the layer that fits the skill: a Global skill to the user's `settings.json`, a
+ * project skill to the project's `.claude/settings.local.json` (just the user; the page never
+ * edits a file a team shares). What the result would be is worked out first, over every layer, so
+ * a layer above the one written (a project's local file over the user's, or the managed policy)
+ * that keeps the skill the way it is makes this `setElsewhere` and writes nothing. Turning a skill
+ * on removes the key; `"on"` is written only when a layer below the target still says off. When
+ * the project's local file is created here, it is kept out of git as Claude Code does when it
+ * creates the file (`excludeNewFile`).
+ *
+ * @module ClaudeSkillSettings
+ */
+import * as Cause from "effect/Cause";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+
+import {
+ readSkillOverrideLayers,
+ SkillOverrideValue,
+ type SkillOverrideLayer,
+} from "../provider/Drivers/ClaudeSkills.ts";
+import type { SkillSwitchContext, SkillSwitchView, SwitchedSkill } from "./AgentSkillSettings.ts";
+import { editJsoncFile } from "./JsoncSettings.ts";
+import { excludeNewFile } from "./SkillGitExclude.ts";
+
+type OverrideValue = typeof SkillOverrideValue.Type;
+
+const isValidSettings = Schema.is(
+ Schema.Struct({
+ skillOverrides: Schema.optional(Schema.Record(Schema.String, SkillOverrideValue)),
+ }),
+);
+
+/** What the layers add up to for one skill, with `target` holding `replacement` instead. */
+const outcome = (
+ layers: ReadonlyArray,
+ name: string,
+ target?: { readonly index: number; readonly replacement: OverrideValue | undefined },
+) => {
+ let decided: { readonly index: number; readonly value: OverrideValue } | undefined;
+ layers.forEach((layer, index) => {
+ const value = target?.index === index ? target.replacement : layer.overrides?.get(name);
+ if (value !== undefined) decided = { index, value };
+ });
+ return decided;
+};
+
+export const claudeSwitches = (context: SkillSwitchContext) =>
+ Effect.gen(function* () {
+ const layers = yield* readSkillOverrideLayers(
+ context.configHome,
+ context.cwd,
+ context.environment,
+ );
+ return {
+ off: (skill: SwitchedSkill) => outcome(layers, skill.name)?.value === "off",
+ } satisfies SkillSwitchView;
+ });
+
+export const setClaudeSwitch = Effect.fn("setClaudeSwitch")(function* (
+ context: SkillSwitchContext,
+ skill: SwitchedSkill,
+ off: boolean,
+) {
+ const path = yield* Path.Path;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const targetPath =
+ skill.scope === "global"
+ ? path.join(context.configHome, "settings.json")
+ : context.cwd === undefined
+ ? undefined
+ : path.join(context.cwd, ".claude", "settings.local.json");
+ const layers = yield* readSkillOverrideLayers(
+ context.configHome,
+ context.cwd,
+ context.environment,
+ );
+ const index = layers.findIndex((layer) => layer.path === targetPath);
+ if (targetPath === undefined || index < 0) return "failed" as const;
+
+ const current = layers[index]?.overrides?.get(skill.name);
+ // Only a project's local file is kept out of git, and only when this write is what makes it.
+ const existed = yield* fileSystem.exists(targetPath).pipe(Effect.orElseSucceed(() => true));
+ const edit = Effect.fnUntraced(function* (value: OverrideValue | undefined) {
+ const result = yield* editJsoncFile({
+ file: targetPath,
+ changes: [{ path: ["skillOverrides", skill.name], value }],
+ accept: isValidSettings,
+ });
+ if (result === "written" && !existed && skill.scope === "project" && context.cwd) {
+ // The file works either way; it would only show up in git status.
+ yield* excludeNewFile({ projectRoot: context.cwd, file: targetPath }).pipe(
+ Effect.catchCause((cause) =>
+ Cause.hasInterruptsOnly(cause)
+ ? Effect.interrupt
+ : Effect.logWarning("could not keep Claude's local settings out of git", {
+ file: targetPath,
+ cause: Cause.pretty(cause),
+ }),
+ ),
+ );
+ }
+ return result === "invalid" ? ("failed" as const) : result;
+ });
+
+ if (off) {
+ if (outcome(layers, skill.name, { index, replacement: "off" })?.value !== "off") {
+ return "setElsewhere" as const;
+ }
+ return current === "off" ? ("unchanged" as const) : yield* edit("off");
+ }
+
+ const without = outcome(layers, skill.name, { index, replacement: undefined });
+ if (without?.value !== "off") {
+ return current === undefined ? ("unchanged" as const) : yield* edit(undefined);
+ }
+ // A layer is still switching it off: one below the target is overridden with an explicit "on";
+ // one above is out of reach.
+ if (without.index > index) return "setElsewhere" as const;
+ return current === "on" ? ("unchanged" as const) : yield* edit("on");
+});
diff --git a/apps/server/src/skills/CodexSkillSettings.ts b/apps/server/src/skills/CodexSkillSettings.ts
new file mode 100644
index 000000000000..5af3e574f72e
--- /dev/null
+++ b/apps/server/src/skills/CodexSkillSettings.ts
@@ -0,0 +1,159 @@
+/**
+ * CodexSkillSettings - which skills Codex's own config switches off, and what to ask Codex to
+ * write to change that.
+ *
+ * Codex keeps this in `$CODEX_HOME/config.toml` as `[[skills.config]]` tables, each naming a skill
+ * by `path` (its SKILL.md) or by `name` and carrying `enabled`. Reading is a plain file read with
+ * `smol-toml`, so listing never starts Codex. Writing is Codex's: `skills/config/write` through
+ * the app-server (`ProviderInstance.openSkillSettingsWriter`, see `CodexProvider.ts`), which owns the file's format.
+ *
+ * Checked against codex 0.160.1 with a throwaway `CODEX_HOME`:
+ * - Codex canonicalizes a path on write and on load, so a skill reached through a link is
+ * recorded (and matched) by the real path of its SKILL.md, and `~` and paths relative to the
+ * config's folder are honoured. A path naming the skill's folder instead of SKILL.md is not.
+ * - `enabled: true` removes the matching entry, and does nothing when there is none.
+ * - A name entry is matched against the skill's `name` header (its folder name when it has none)
+ * and is cleared only by writing the name, not the path.
+ * - The write's `effectiveEnabled` answers for the path alone, so it can't see a name entry; the
+ * file is read again to tell what is decided now.
+ * - Entries apply in file order and the last one that names the skill wins.
+ * - A project's `.codex/config.toml` is not read: its skill rules did not apply in an untrusted
+ * project, and the page keeps to the user's own file.
+ *
+ * An instance with a shadow home (`shadowHomePath`) runs Codex there, so the app-server writes
+ * the shadow home's `config.toml` and that is the file to read back (`codexSettingsHome`). The
+ * shadow home links the shared home's `config.toml` only when it existed as the instance started;
+ * without one the write lands in a file of the shadow home's own.
+ *
+ * @module CodexSkillSettings
+ */
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+import { parse as parseToml } from "smol-toml";
+import type { SkillSettingsChange } from "@t3tools/provider-core/server/driver";
+import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
+
+import type { SkillSwitchContext, SkillSwitchView, SwitchedSkill } from "./AgentSkillSettings.ts";
+
+/** One `[[skills.config]]` table, its path made absolute and real. */
+export interface CodexSkillRule {
+ readonly selector: { readonly path: string } | { readonly name: string };
+ readonly enabled: boolean;
+}
+
+const isRecord = (value: unknown): value is Record =>
+ typeof value === "object" && value !== null && !Array.isArray(value);
+
+const decodeShadowHome = Schema.decodeUnknownOption(
+ Schema.Struct({ shadowHomePath: Schema.optional(Schema.String) }),
+);
+
+/**
+ * The home an instance's Codex runs with, whose `config.toml` its app-server writes: the shadow
+ * home when the instance has one, else its home (`sharedHome`). The driver's own layout makes
+ * the same choice (`resolveCodexHomeLayout`); the skill folders stay where `sharedHome` says,
+ * since the shadow home links the shared `skills` folder.
+ */
+export const codexSettingsHome = (
+ path: Path.Path,
+ instanceConfig: unknown,
+ sharedHome: string,
+ homeDirectory: string,
+) => {
+ const shadow =
+ Option.getOrUndefined(decodeShadowHome(instanceConfig))?.shadowHomePath?.trim() ?? "";
+ return shadow === "" ? sharedHome : path.resolve(expandHomePath(shadow, homeDirectory));
+};
+
+/** The rules in the user's config, in file order; none when it is missing or can't be parsed. */
+export const readCodexSkillRules = (context: SkillSwitchContext) =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const text = yield* fileSystem
+ .readFileString(path.join(context.configHome, "config.toml"))
+ .pipe(Effect.orElseSucceed(() => undefined));
+ if (text === undefined) return [];
+ const document = yield* Effect.try(() => parseToml(text)).pipe(
+ Effect.tapError((cause) =>
+ Effect.logDebug("codex config is unreadable; no skill rules", { cause }),
+ ),
+ Effect.orElseSucceed(() => undefined),
+ );
+ const entries = isRecord(document?.skills) ? document.skills.config : undefined;
+ if (!Array.isArray(entries)) return [];
+
+ return yield* Effect.forEach(entries, (entry) =>
+ Effect.gen(function* () {
+ if (!isRecord(entry) || typeof entry.enabled !== "boolean") return [];
+ if (typeof entry.name === "string" && entry.path === undefined) {
+ return [
+ { selector: { name: entry.name }, enabled: entry.enabled } satisfies CodexSkillRule,
+ ];
+ }
+ if (typeof entry.path !== "string" || entry.name !== undefined) return [];
+ const expanded = entry.path.startsWith("~/")
+ ? path.join(context.homeDirectory, entry.path.slice(2))
+ : entry.path === "~"
+ ? context.homeDirectory
+ : entry.path;
+ const absolute = path.resolve(context.configHome, expanded);
+ const real = yield* fileSystem
+ .realPath(absolute)
+ .pipe(Effect.orElseSucceed(() => absolute));
+ return [{ selector: { path: real }, enabled: entry.enabled } satisfies CodexSkillRule];
+ }),
+ ).pipe(Effect.map((rules) => rules.flat()));
+ });
+
+/** The path Codex records for a skill: its SKILL.md, where the skill's real folder is. */
+export const codexSkillFile = (path: Path.Path, skill: SwitchedSkill) =>
+ path.join(skill.home, "SKILL.md");
+
+const names = (skill: SwitchedSkill) => new Set([skill.declaredName ?? skill.name]);
+
+const namesSkill = (rule: CodexSkillRule, file: string, skill: SwitchedSkill) =>
+ "path" in rule.selector ? rule.selector.path === file : names(skill).has(rule.selector.name);
+
+/** Whether the rules leave the skill off: the last rule that names it says so. */
+export const codexRulesSwitchOff = (
+ rules: ReadonlyArray,
+ file: string,
+ skill: SwitchedSkill,
+) => rules.findLast((rule) => namesSkill(rule, file, skill))?.enabled === false;
+
+export const codexSwitches = (context: SkillSwitchContext) =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const rules = yield* readCodexSkillRules(context);
+ return {
+ off: (skill: SwitchedSkill) => codexRulesSwitchOff(rules, codexSkillFile(path, skill), skill),
+ } satisfies SkillSwitchView;
+ });
+
+/**
+ * The writes that make the skill off (or no longer off) given the rules now: none when it is
+ * already so. Turning on clears every kind of entry that names the skill, since Codex removes an
+ * entry only by the selector it was written with.
+ */
+export const planCodexSwitch = (
+ rules: ReadonlyArray,
+ file: string,
+ skill: SwitchedSkill,
+ off: boolean,
+): ReadonlyArray => {
+ if (codexRulesSwitchOff(rules, file, skill) === off) return [];
+ if (off) return [{ path: file, enabled: false }];
+ const matching = rules.filter((rule) => namesSkill(rule, file, skill));
+ return [
+ ...(matching.some((rule) => "path" in rule.selector)
+ ? [{ path: file, enabled: true } satisfies SkillSettingsChange]
+ : []),
+ ...[
+ ...new Set(matching.flatMap((rule) => ("name" in rule.selector ? [rule.selector.name] : []))),
+ ].map((name) => ({ name, enabled: true }) satisfies SkillSettingsChange),
+ ];
+};
diff --git a/apps/server/src/skills/GitHubSkillSource.ts b/apps/server/src/skills/GitHubSkillSource.ts
new file mode 100644
index 000000000000..dec641addf6b
--- /dev/null
+++ b/apps/server/src/skills/GitHubSkillSource.ts
@@ -0,0 +1,287 @@
+/**
+ * GitHubSkillSource - reads the repositories skills were installed from, anonymously, through
+ * the server's HTTP client.
+ *
+ * Everything is read by git's own content addresses: a repository's tree once per check, a folder
+ * tree by its SHA, and each file by its blob SHA, which is checked against the bytes. A tree or
+ * file named by its SHA never changes, so those are kept; a repository's tree at a branch is kept
+ * for a while so opening a skill after a check costs nothing.
+ *
+ * GitHub allows 60 requests an hour without signing in, shared by everyone on the same network.
+ * When it says the limit is used up, nothing more is asked until the time it gives, so a busy
+ * page can't keep spending it. Renamed repositories answer with a redirect, which is followed.
+ *
+ * @module GitHubSkillSource
+ */
+import * as Clock from "effect/Clock";
+import * as Context from "effect/Context";
+import * as Duration from "effect/Duration";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Schema from "effect/Schema";
+import { HttpClient, type HttpClientResponse } from "effect/http";
+
+import { gitBlobSha, treeShaOfEntries } from "./SkillUpdatePlan.ts";
+
+const API = "https://api.github.com";
+const REQUEST_TIMEOUT = Duration.seconds(20);
+/** How long a repository's tree at a branch is reused before it is asked for again. */
+const REPO_TREE_TTL_MS = 15 * 60_000;
+/** A check asked for again this soon reuses the answer, so the button can't spend the limit. */
+const REFRESH_FLOOR_MS = 10_000;
+const MAX_REPO_TREES = 20;
+const MAX_FOLDER_TREES = 50;
+const MAX_CACHED_BLOB_BYTES = 8 * 1024 * 1024;
+/** Used when GitHub says the limit is used up but not until when. */
+const DEFAULT_PAUSE_MS = 60_000;
+
+export class SkillSourceError extends Schema.TaggedError()("SkillSourceError", {
+ /**
+ * `rateLimited`: GitHub won't take more requests from this network until `retryAt`.
+ * `notFound`: no such repository, branch or object, or a private repository.
+ * `unavailable`: GitHub couldn't be reached or answered with something unexpected.
+ */
+ problem: Schema.Literals(["rateLimited", "notFound", "unavailable"]),
+ /** Epoch milliseconds, for `rateLimited`. */
+ retryAt: Schema.optional(Schema.Number),
+ cause: Schema.optional(Schema.Defect()),
+}) {
+ override get message(): string {
+ return this.problem === "rateLimited"
+ ? "GitHub's request limit is used up."
+ : this.problem === "notFound"
+ ? "GitHub doesn't have that repository or object."
+ : "GitHub couldn't be read.";
+ }
+}
+
+/** One entry of a GitHub tree listing. */
+export interface TreeEntry {
+ readonly path: string;
+ readonly mode: string;
+ /** `blob` for a file or link, `tree` for a folder, `commit` for a submodule. */
+ readonly type: string;
+ readonly sha: string;
+ readonly size?: number | undefined;
+}
+
+export interface RepoTree {
+ /** The listed tree's own SHA. */
+ readonly sha: string;
+ readonly entries: ReadonlyArray;
+ /** GitHub cut the listing short, so something missing from it may still exist. */
+ readonly truncated: boolean;
+}
+
+const TreeResponse = Schema.Struct({
+ sha: Schema.String,
+ truncated: Schema.optional(Schema.Boolean),
+ tree: Schema.Array(
+ Schema.Struct({
+ path: Schema.String,
+ mode: Schema.String,
+ type: Schema.String,
+ sha: Schema.String,
+ size: Schema.optional(Schema.Number),
+ }),
+ ),
+});
+const decodeTree = Schema.decodeUnknownEffect(TreeResponse);
+
+const BlobResponse = Schema.Struct({
+ sha: Schema.String,
+ content: Schema.String,
+ encoding: Schema.String,
+});
+const decodeBlob = Schema.decodeUnknownEffect(BlobResponse);
+
+const OBJECT_SHA = /^[0-9a-f]{40}$/;
+const SOURCE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
+/** Characters a branch or tag name may hold, which keeps it one path in the URL. */
+const REF = /^(?!.*\.\.)[A-Za-z0-9._/@+-]{1,200}$/;
+
+export class GitHubSkillSource extends Context.Service<
+ GitHubSkillSource,
+ {
+ /**
+ * The repository's whole tree at `ref`, or at its default branch. One request, reused for a
+ * while; `refresh` asks again unless the last answer is only seconds old.
+ */
+ readonly repoTree: (input: {
+ readonly repo: string;
+ readonly ref?: string | undefined;
+ readonly refresh?: boolean | undefined;
+ }) => Effect.Effect;
+ /**
+ * Every file under the tree with this SHA, or undefined when GitHub no longer has it. The
+ * listing is checked against the SHA, so it is exactly that version.
+ */
+ readonly treeBySha: (input: {
+ readonly repo: string;
+ readonly sha: string;
+ }) => Effect.Effect;
+ /** A file's bytes by its blob SHA, checked against it. */
+ readonly blob: (input: {
+ readonly repo: string;
+ readonly sha: string;
+ }) => Effect.Effect;
+ }
+>()("t3/skills/GitHubSkillSource") {}
+
+/** Epoch milliseconds GitHub asks to wait until, when a response says the limit is used up. */
+const rateLimitedUntil = (response: HttpClientResponse.HttpClientResponse, now: number) => {
+ if (response.status !== 403 && response.status !== 429) return undefined;
+ const header = (name: string): string | undefined => response.headers[name];
+ const retryAfter = Number(header("retry-after"));
+ if (Number.isFinite(retryAfter) && retryAfter > 0) return now + retryAfter * 1000;
+ if (header("x-ratelimit-remaining") !== "0") {
+ // A 429 is always a rate limit; a 403 without the header is a refusal of another kind.
+ return response.status === 429 ? now + DEFAULT_PAUSE_MS : undefined;
+ }
+ const reset = Number(header("x-ratelimit-reset"));
+ return Number.isFinite(reset) && reset * 1000 > now ? reset * 1000 : now + DEFAULT_PAUSE_MS;
+};
+
+/** Keeps a map to `max` entries by dropping the oldest. */
+const remember = (map: Map, key: K, value: V, max: number) => {
+ map.delete(key);
+ map.set(key, value);
+ for (const oldest of map.keys()) {
+ if (map.size <= max) break;
+ map.delete(oldest);
+ }
+};
+
+const make = Effect.gen(function* () {
+ const httpClient = yield* HttpClient.HttpClient;
+ let pausedUntil = 0;
+ const repoTrees = new Map();
+ const folderTrees = new Map();
+ const blobs = new Map();
+ let blobBytes = 0;
+
+ /** A GET of the API, as JSON; undefined when GitHub says there is no such thing. */
+ const getJson = Effect.fnUntraced(function* (endpoint: string) {
+ const now = yield* Clock.currentTimeMillis;
+ if (now < pausedUntil) {
+ return yield* new SkillSourceError({ problem: "rateLimited", retryAt: pausedUntil });
+ }
+ const response = yield* httpClient
+ .get(`${API}/${endpoint}`, {
+ headers: {
+ accept: "application/vnd.github+json",
+ "user-agent": "t3code-skill-updates",
+ "x-github-api-version": "2022-11-28",
+ },
+ })
+ .pipe(
+ Effect.timeout(REQUEST_TIMEOUT),
+ Effect.mapError((cause) => new SkillSourceError({ problem: "unavailable", cause })),
+ );
+ const limited = rateLimitedUntil(response, now);
+ if (limited !== undefined) {
+ pausedUntil = limited;
+ return yield* new SkillSourceError({ problem: "rateLimited", retryAt: limited });
+ }
+ // A 422 is GitHub refusing a ref or SHA it doesn't know.
+ if (response.status === 404 || response.status === 422) return undefined;
+ if (response.status !== 200) {
+ return yield* new SkillSourceError({ problem: "unavailable" });
+ }
+ return yield* response.json.pipe(
+ Effect.mapError((cause) => new SkillSourceError({ problem: "unavailable", cause })),
+ );
+ });
+
+ const readTree = Effect.fnUntraced(function* (repo: string, treeish: string) {
+ const json = yield* getJson(
+ `repos/${repo}/git/trees/${encodeURIComponent(treeish)}?recursive=1`,
+ );
+ if (json === undefined) return undefined;
+ const decoded = yield* decodeTree(json).pipe(
+ Effect.mapError((cause) => new SkillSourceError({ problem: "unavailable", cause })),
+ );
+ return {
+ sha: decoded.sha,
+ truncated: decoded.truncated === true,
+ entries: decoded.tree,
+ } satisfies RepoTree;
+ });
+
+ const checkRepo = (repo: string) =>
+ SOURCE.test(repo) ? Effect.void : Effect.fail(new SkillSourceError({ problem: "notFound" }));
+
+ const repoTree: GitHubSkillSource["Service"]["repoTree"] = Effect.fn(
+ "GitHubSkillSource.repoTree",
+ )(function* (input) {
+ yield* checkRepo(input.repo);
+ if (input.ref !== undefined && !REF.test(input.ref)) {
+ return yield* new SkillSourceError({ problem: "notFound" });
+ }
+ // GitHub ignores case in names, but not in a branch's.
+ const key = `${input.repo.toLowerCase()}@${input.ref ?? ""}`;
+ const now = yield* Clock.currentTimeMillis;
+ const kept = repoTrees.get(key);
+ if (kept && now - kept.at < (input.refresh ? REFRESH_FLOOR_MS : REPO_TREE_TTL_MS)) {
+ return kept.tree;
+ }
+ const tree = yield* readTree(input.repo, input.ref ?? "HEAD");
+ if (tree === undefined) return yield* new SkillSourceError({ problem: "notFound" });
+ remember(repoTrees, key, { at: now, tree }, MAX_REPO_TREES);
+ return tree;
+ });
+
+ const treeBySha: GitHubSkillSource["Service"]["treeBySha"] = Effect.fn(
+ "GitHubSkillSource.treeBySha",
+ )(function* (input) {
+ yield* checkRepo(input.repo);
+ if (!OBJECT_SHA.test(input.sha)) return undefined;
+ const key = `${input.repo.toLowerCase()}@${input.sha}`;
+ const kept = folderTrees.get(key);
+ if (kept) return kept;
+ const tree = yield* readTree(input.repo, input.sha);
+ if (tree === undefined) return undefined;
+ const files = tree.entries.filter((entry) => entry.type !== "tree");
+ if (tree.truncated || tree.sha !== input.sha || treeShaOfEntries(files) !== input.sha) {
+ return yield* new SkillSourceError({ problem: "unavailable" });
+ }
+ remember(folderTrees, key, tree, MAX_FOLDER_TREES);
+ return tree;
+ });
+
+ const blob: GitHubSkillSource["Service"]["blob"] = Effect.fn("GitHubSkillSource.blob")(
+ function* (input) {
+ yield* checkRepo(input.repo);
+ if (!OBJECT_SHA.test(input.sha)) return yield* new SkillSourceError({ problem: "notFound" });
+ const kept = blobs.get(input.sha);
+ if (kept) return kept;
+ const json = yield* getJson(`repos/${input.repo}/git/blobs/${input.sha}`);
+ if (json === undefined) return yield* new SkillSourceError({ problem: "notFound" });
+ const decoded = yield* decodeBlob(json).pipe(
+ Effect.mapError((cause) => new SkillSourceError({ problem: "unavailable", cause })),
+ );
+ const bytes =
+ decoded.encoding === "base64"
+ ? new Uint8Array(Buffer.from(decoded.content, "base64"))
+ : new TextEncoder().encode(decoded.content);
+ // Content addressing: bytes that don't hash to the SHA asked for are never used.
+ if (gitBlobSha(bytes) !== input.sha) {
+ return yield* new SkillSourceError({ problem: "unavailable" });
+ }
+ if (bytes.byteLength <= MAX_CACHED_BLOB_BYTES) {
+ blobs.set(input.sha, bytes);
+ blobBytes += bytes.byteLength;
+ for (const [sha, old] of blobs) {
+ if (blobBytes <= MAX_CACHED_BLOB_BYTES) break;
+ blobs.delete(sha);
+ blobBytes -= old.byteLength;
+ }
+ }
+ return bytes;
+ },
+ );
+
+ return GitHubSkillSource.of({ repoTree, treeBySha, blob });
+});
+
+export const layer = Layer.effect(GitHubSkillSource, make);
diff --git a/apps/server/src/skills/JsoncSettings.test.ts b/apps/server/src/skills/JsoncSettings.test.ts
new file mode 100644
index 000000000000..68e0ce895813
--- /dev/null
+++ b/apps/server/src/skills/JsoncSettings.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, it } from "@effect/vitest";
+
+import { editJsoncText, parseJsonc, type JsoncChange } from "./JsoncSettings.ts";
+
+const edit = (text: string, ...changes: JsoncChange[]) => {
+ const result = editJsoncText(text, changes);
+ if (result === undefined) return undefined;
+ expect(parseJsonc(result).valid).toBe(true);
+ return result;
+};
+
+describe("editJsoncText", () => {
+ it("adds a key without touching comments, key order or trailing commas", () => {
+ const text = '// top\n{\n "a": 1, // one\n /* two */ "b": {\n "c": 2,\n },\n}\n';
+ const result = edit(text, { path: ["b", "d"], value: "x" });
+ expect(result).toContain("// top");
+ expect(result).toContain("// one");
+ expect(result).toContain("/* two */");
+ expect(parseJsonc(result ?? "").value).toEqual({ a: 1, b: { c: 2, d: "x" } });
+ });
+
+ it("indents with what the file uses", () => {
+ expect(edit('{\n\t"a": 1\n}\n', { path: ["b"], value: 2 })).toContain('\t"b": 2');
+ expect(edit('{\n "a": 1\n}\n', { path: ["b"], value: 2 })).toContain(' "b": 2');
+ expect(edit('{\r\n "a": 1\r\n}\r\n', { path: ["b"], value: 2 })).toContain('\r\n "b": 2');
+ });
+
+ it("removes the only key of an object that has a trailing comma without leaving a stray comma", () => {
+ const text = '{\n "a": 1,\n "s": {\n "x": "off",\n },\n}\n';
+ const result = edit(text, { path: ["s", "x"], value: undefined });
+ expect(parseJsonc(result ?? "").value).toEqual({ a: 1 });
+ });
+
+ it("takes the objects and lists a removal emptied with it, but only those", () => {
+ const text = '{\n "p": { "s": { "x": "deny" }, "keep": 1 },\n "q": { "r": { "y": 1 } }\n}\n';
+ expect(parseJsonc(edit(text, { path: ["p", "s", "x"], value: undefined }) ?? "").value).toEqual(
+ { p: { keep: 1 }, q: { r: { y: 1 } } },
+ );
+ expect(parseJsonc(edit(text, { path: ["q", "r", "y"], value: undefined }) ?? "").value).toEqual(
+ { p: { s: { x: "deny" }, keep: 1 } },
+ );
+ expect(
+ parseJsonc(edit('{ "l": ["a"] }', { path: ["l", 0], value: undefined }) ?? "").value,
+ ).toEqual({});
+ });
+
+ it("changes nothing, and says so, when the value is already there", () => {
+ const text = '{ "a": { "b": "x" } }';
+ expect(edit(text, { path: ["a", "b"], value: "x" })).toBe(text);
+ expect(edit(text, { path: ["a", "gone"], value: undefined })).toBe(text);
+ });
+
+ it("adds to the end of a list, creating the list when there is none", () => {
+ expect(
+ parseJsonc(edit('{ "l": ["a"] }', { path: ["l"], value: "b", insert: true }) ?? "").value,
+ ).toEqual({ l: ["a", "b"] });
+ expect(parseJsonc(edit("{}", { path: ["l"], value: "b", insert: true }) ?? "").value).toEqual({
+ l: ["b"],
+ });
+ });
+
+ it("removes list items last first, so positions keep their meaning", () => {
+ const text = '{ "l": ["-a", "keep", "-b"] }';
+ expect(
+ parseJsonc(
+ edit(text, { path: ["l", 2], value: undefined }, { path: ["l", 0], value: undefined }) ??
+ "",
+ ).value,
+ ).toEqual({ l: ["keep"] });
+ });
+
+ it("refuses a path that runs through something that isn't an object or a list", () => {
+ expect(
+ edit('{ "permission": "allow" }', { path: ["permission", "skill", "x"], value: "deny" }),
+ ).toBeUndefined();
+ expect(edit('{ "l": {} }', { path: ["l"], value: "b", insert: true })).toBeUndefined();
+ expect(
+ edit('{ "skillOverrides": null }', { path: ["skillOverrides", "x"], value: "off" }),
+ ).toBeUndefined();
+ });
+});
+
+describe("parseJsonc", () => {
+ it("reads like the agents: comments and trailing commas unless it is plain JSON", () => {
+ const text = '{ // c\n "a": [1,], }';
+ expect(parseJsonc(text).valid).toBe(true);
+ expect(parseJsonc(text, true).valid).toBe(false);
+ expect(parseJsonc('{ "a": 1 }', true).valid).toBe(true);
+ });
+
+ it("takes only an object at the top", () => {
+ for (const text of ["[]", '"x"', "1", "", "{", '{ "a": }']) {
+ expect(parseJsonc(text).valid).toBe(false);
+ }
+ });
+});
diff --git a/apps/server/src/skills/JsoncSettings.ts b/apps/server/src/skills/JsoncSettings.ts
new file mode 100644
index 000000000000..e3884ab1841e
--- /dev/null
+++ b/apps/server/src/skills/JsoncSettings.ts
@@ -0,0 +1,216 @@
+/**
+ * JsoncSettings - change one value in an agent's JSON settings file and leave the rest as it was.
+ *
+ * Claude Code and OpenCode read their settings as JSONC (comments and trailing commas are fine),
+ * and people keep both in these files, so `jsonc-parser` edits the text in place: comments, key
+ * order and indentation survive. Two rules keep a bad write from ever happening:
+ * - A file that doesn't parse (or that the caller's `accept` refuses) is left alone and reported
+ * `invalid`, since an edit would either overwrite what the user wrote or leave a file the agent
+ * still can't read.
+ * - Nothing is written unless the edited text parses again and `accept` takes it.
+ *
+ * The write is atomic to the real file (a symlinked settings file stays a link) and keeps the
+ * file's permissions, since these files can hold credentials.
+ *
+ * @module JsoncSettings
+ */
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import { applyEdits, modify, parse, type FormattingOptions, type ParseError } from "jsonc-parser";
+import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
+
+/** Keys of objects, and positions in arrays. */
+export type JsonPath = ReadonlyArray;
+
+/**
+ * Set `value` at `path`, or remove the key (or array item) when `value` is undefined. With
+ * `insert`, `path` names an array and `value` is added at its end.
+ */
+export interface JsoncChange {
+ readonly path: JsonPath;
+ readonly value: unknown;
+ readonly insert?: boolean;
+}
+
+/** `invalid`: the file is there but can't be edited safely. `failed`: the disk said no. */
+export type JsoncEdit = "written" | "unchanged" | "invalid" | "failed";
+
+const isPlainObject = (value: unknown): value is Record =>
+ typeof value === "object" && value !== null && !Array.isArray(value);
+
+/**
+ * What the agent would read, the root an object. Comments and trailing commas are allowed unless
+ * `strict`, for an agent that reads its file with `JSON.parse`.
+ */
+export const parseJsonc = (text: string, strict = false) => {
+ if (strict) {
+ try {
+ const value: unknown = JSON.parse(text);
+ return { value, valid: isPlainObject(value) };
+ } catch {
+ return { value: undefined, valid: false };
+ }
+ }
+ const errors: ParseError[] = [];
+ const value: unknown = parse(text, errors, { allowTrailingComma: true });
+ return { value, valid: errors.length === 0 && isPlainObject(value) };
+};
+
+export const valueAt = (root: unknown, path: JsonPath): unknown => {
+ let node = root;
+ for (const key of path) {
+ if (typeof key === "number") {
+ if (!Array.isArray(node)) return undefined;
+ node = node[key];
+ } else {
+ if (!isPlainObject(node) || !Object.hasOwn(node, key)) return undefined;
+ node = node[key];
+ }
+ }
+ return node;
+};
+
+const sameValue = (left: unknown, right: unknown) =>
+ left === right || (left !== undefined && JSON.stringify(left) === JSON.stringify(right));
+
+/** New text is indented and ended the way the file already is. */
+const formattingOf = (text: string): FormattingOptions => {
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
+ const indent = /^([ \t]+)\S/m.exec(text)?.[1];
+ if (indent?.startsWith("\t")) return { insertSpaces: false, tabSize: 1, eol };
+ return { insertSpaces: true, tabSize: Math.min(Math.max(indent?.length ?? 2, 1), 8), eol };
+};
+
+const changeText = (text: string, change: JsoncChange): string | undefined => {
+ const options = { formattingOptions: formattingOf(text) };
+ const edited = applyEdits(text, modify(text, [...change.path], change.value, options));
+ if (parseJsonc(edited).valid) return edited;
+ // When the only key of an object has a trailing comma, removing it leaves a stray comma behind.
+ // Emptying the parent instead is always valid.
+ if (
+ change.value === undefined &&
+ change.path.length > 1 &&
+ typeof change.path.at(-1) === "string"
+ ) {
+ const emptied = applyEdits(text, modify(text, change.path.slice(0, -1), {}, options));
+ if (parseJsonc(emptied).valid) return emptied;
+ }
+ return undefined;
+};
+
+/** Objects and lists a removal emptied go with it, so the file doesn't keep `"skillOverrides": {}`. */
+const withoutEmptiedParents = (text: string, path: JsonPath) => {
+ let result = text;
+ for (let depth = path.length - 1; depth >= 1; depth -= 1) {
+ const parent = valueAt(parseJsonc(result).value, path.slice(0, depth));
+ const emptied = Array.isArray(parent)
+ ? parent.length === 0
+ : isPlainObject(parent) && Object.keys(parent).length === 0;
+ if (!emptied) break;
+ const removed = changeText(result, { path: path.slice(0, depth), value: undefined });
+ if (removed === undefined) break;
+ result = removed;
+ }
+ return result;
+};
+
+/**
+ * Each step of the path through the file is what the next key expects: an object where a name
+ * comes next, a list where a position comes next (or where a value is added to the end).
+ */
+const canReach = (root: unknown, change: JsoncChange) => {
+ for (let depth = 1; depth <= change.path.length; depth += 1) {
+ const here = valueAt(root, change.path.slice(0, depth));
+ const last = depth === change.path.length;
+ if (here === undefined || (last && !change.insert)) return true;
+ const wantsList = last ? true : typeof change.path[depth] === "number";
+ if (wantsList ? !Array.isArray(here) : !isPlainObject(here)) return false;
+ }
+ return true;
+};
+
+/**
+ * The text with the changes applied, in order; undefined when one of them can't be made without
+ * breaking the file. A path through something that isn't an object (`"permission": "allow"`) is
+ * not edited.
+ */
+export const editJsoncText = (text: string, changes: ReadonlyArray) => {
+ let result = text;
+ for (const change of changes) {
+ const current = parseJsonc(result).value;
+ if (!canReach(current, change)) return undefined;
+ // The editing library mangles single-line lists when it adds or removes one item, so a list
+ // is replaced whole; only the file Pi reads as plain JSON has any, and it has no comments.
+ const position = change.path.at(-1);
+ const listPath = change.insert ? change.path : change.path.slice(0, -1);
+ const list = valueAt(current, listPath);
+ const effective: JsoncChange = change.insert
+ ? { path: change.path, value: [...(Array.isArray(list) ? list : []), change.value] }
+ : typeof position === "number" && change.value === undefined && Array.isArray(list)
+ ? { path: listPath, value: list.filter((_, index) => index !== position) }
+ : change;
+ if (sameValue(valueAt(current, effective.path), effective.value)) continue;
+ const edited = changeText(result, effective);
+ if (edited === undefined) return undefined;
+ result =
+ change.value === undefined
+ ? withoutEmptiedParents(edited, effective === change ? change.path : [...listPath, 0])
+ : edited;
+ }
+ return result;
+};
+
+/** The file's text, or undefined when it isn't there or can't be read. */
+export const readSettingsText = (file: string) =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ return yield* fileSystem.readFileString(file).pipe(Effect.orElseSucceed(() => undefined));
+ });
+
+/**
+ * Apply `changes` to the settings file, creating it when it isn't there. `accept` is the agent's
+ * own idea of a valid file, checked on what is there and on what would be written; `strict` is for
+ * an agent that reads plain JSON.
+ */
+export const editJsoncFile = Effect.fn("editJsoncFile")(function* (input: {
+ readonly file: string;
+ readonly changes: ReadonlyArray;
+ readonly accept?: (value: Record) => boolean;
+ readonly strict?: boolean;
+}) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const exists = yield* fileSystem.exists(input.file).pipe(Effect.orElseSucceed(() => undefined));
+ if (exists === undefined) return "failed" as const;
+ const original = exists
+ ? yield* fileSystem.readFileString(input.file).pipe(Effect.orElseSucceed(() => undefined))
+ : "";
+ if (original === undefined) return "failed" as const;
+
+ // A file with nothing in it is an empty settings file.
+ const text = original.trim() === "" ? "{}" : original;
+ const accepted = (candidate: string) => {
+ const { value, valid } = parseJsonc(candidate, input.strict);
+ return valid && isPlainObject(value) && (input.accept?.(value) ?? true);
+ };
+ if (!accepted(text)) return "invalid" as const;
+
+ const edited = editJsoncText(text, input.changes);
+ if (edited === undefined || !accepted(edited)) return "invalid" as const;
+ if (edited === text) return "unchanged" as const;
+
+ const mode = exists
+ ? yield* fileSystem.stat(input.file).pipe(
+ Effect.map((info) => info.mode & 0o777),
+ Effect.orElseSucceed(() => undefined),
+ )
+ : undefined;
+ const contents = original.trim() === "" && !edited.endsWith("\n") ? `${edited}\n` : edited;
+ return yield* writeFileStringAtomically({
+ filePath: input.file,
+ contents,
+ ...(mode === undefined ? {} : { mode }),
+ }).pipe(
+ Effect.as("written" as const),
+ Effect.catch(() => Effect.succeed("failed" as const)),
+ );
+});
diff --git a/apps/server/src/skills/OpenCodeSkillSettings.ts b/apps/server/src/skills/OpenCodeSkillSettings.ts
new file mode 100644
index 000000000000..1d8b188c4ff3
--- /dev/null
+++ b/apps/server/src/skills/OpenCodeSkillSettings.ts
@@ -0,0 +1,213 @@
+/**
+ * OpenCodeSkillSettings - OpenCode's `permission.skill` rules, read and written for the Skills
+ * page.
+ *
+ * `"permission": { "skill": { "": "deny" } }` hides a skill from the agent
+ * (https://opencode.ai/docs/skills), where `` is the `name` in the skill's header, not its
+ * folder's (`state.skills[md.data.name]` in `skill/index.ts`). Rules are evaluated with `findLast`, the last rule whose
+ * pattern matches the name deciding, over the rules of every config layer in order
+ * (`evaluate` in `permission/index.ts` at
+ * https://github.com/anomalyco/opencode/blob/4ac0d9c3d1/packages/opencode/src/permission/index.ts);
+ * a pattern can use `*` and `?`. The layers, lowest first, as `config/config.ts` merges them:
+ * the global folder (`config.json`, `opencode.json`, `opencode.jsonc`), the project's
+ * `opencode.json[c]` and `.opencode/opencode.json[c]`, then the managed folder. Only the project's
+ * own folder is looked in, not the folders above it. `skill` can also be one action for every
+ * skill (`"skill": "deny"`), which reads as a `*` rule.
+ *
+ * A write goes to the global config (`opencode.jsonc` when there is one, else `opencode.json`),
+ * at the end of its rules so it is the last one to match. What the rules would add up to is
+ * worked out first, so a project or managed rule that keeps the skill the way it is makes this
+ * `setElsewhere` and writes nothing. Turning a skill on deletes the key; when a wildcard rule
+ * would still deny it, an `allow` is written in its place.
+ *
+ * @module OpenCodeSkillSettings
+ */
+import * as HostProcess from "@t3tools/shared/HostProcess";
+import * as Effect from "effect/Effect";
+import * as Path from "effect/Path";
+
+import type { SkillSwitchContext, SkillSwitchView, SwitchedSkill } from "./AgentSkillSettings.ts";
+import {
+ editJsoncFile,
+ parseJsonc,
+ readSettingsText,
+ valueAt,
+ type JsoncChange,
+} from "./JsoncSettings.ts";
+
+interface Rule {
+ readonly pattern: string;
+ readonly action: string;
+}
+
+interface Layer {
+ readonly file: string;
+ readonly global: boolean;
+ readonly exists: boolean;
+ readonly rules: ReadonlyArray;
+}
+
+const escapeRegExp = (value: string) => value.replace(/[.+^${}()|[\]\\]/g, "\\$&");
+
+const matches = (pattern: string, name: string) =>
+ new RegExp(`^${escapeRegExp(pattern).replace(/\*/g, ".*").replace(/\?/g, ".")}$`, "s").test(name);
+
+/** What the rules decide for a name: the action of the last one that matches, else none. */
+const actionFor = (layers: ReadonlyArray>, name: string) =>
+ layers.flatMap((layer) => layer.rules).findLast((rule) => matches(rule.pattern, name))?.action;
+
+const isOff = (layers: ReadonlyArray>, name: string) =>
+ actionFor(layers, name) === "deny";
+
+const rulesOf = (config: unknown): ReadonlyArray => {
+ const skill = valueAt(config, ["permission", "skill"]);
+ if (typeof skill === "string") return [{ pattern: "*", action: skill }];
+ if (typeof skill !== "object" || skill === null || Array.isArray(skill)) return [];
+ return Object.entries(skill).flatMap(([pattern, action]) =>
+ typeof action === "string" ? [{ pattern, action }] : [],
+ );
+};
+
+const managedFolder = (path: Path.Path, context: SkillSwitchContext, platform: NodeJS.Platform) => {
+ const override = context.environment.OPENCODE_TEST_MANAGED_CONFIG_DIR?.trim();
+ if (override) return override;
+ if (platform === "darwin") return "/Library/Application Support/opencode";
+ if (platform === "win32") {
+ return path.join(context.environment.ProgramData?.trim() || "C:\\ProgramData", "opencode");
+ }
+ return "/etc/opencode";
+};
+
+const readLayers = Effect.fnUntraced(function* (context: SkillSwitchContext) {
+ const path = yield* Path.Path;
+ const platform = yield* HostProcess.Platform;
+ const xdg = context.environment.XDG_CONFIG_HOME?.trim();
+ const globalFolder = path.join(
+ xdg && path.isAbsolute(xdg) ? xdg : path.join(context.homeDirectory, ".config"),
+ "opencode",
+ );
+ const files = [
+ ...["config.json", "opencode.json", "opencode.jsonc"].map((name) => ({
+ file: path.join(globalFolder, name),
+ global: true,
+ })),
+ ...(context.cwd === undefined
+ ? []
+ : [
+ ...["opencode.json", "opencode.jsonc"].map((name) => ({
+ file: path.join(context.cwd as string, name),
+ global: false,
+ })),
+ ...["opencode.json", "opencode.jsonc"].map((name) => ({
+ file: path.join(context.cwd as string, ".opencode", name),
+ global: false,
+ })),
+ ]),
+ ...["opencode.json", "opencode.jsonc"].map((name) => ({
+ file: path.join(managedFolder(path, context, platform), name),
+ global: false,
+ })),
+ ];
+ return yield* Effect.forEach(files, ({ file, global }) =>
+ readSettingsText(file).pipe(
+ Effect.map((text): Layer => {
+ const parsed = text === undefined ? undefined : parseJsonc(text);
+ return {
+ file,
+ global,
+ exists: text !== undefined,
+ rules: parsed?.valid ? rulesOf(parsed.value) : [],
+ };
+ }),
+ ),
+ );
+});
+
+/** What OpenCode calls the skill: its header's name, else the folder's. */
+const nameOf = (skill: SwitchedSkill) => skill.declaredName ?? skill.name;
+
+export const openCodeSwitches = (context: SkillSwitchContext) =>
+ Effect.gen(function* () {
+ const layers = yield* readLayers(context);
+ return {
+ off: (skill: SwitchedSkill) => isOff(layers, nameOf(skill)),
+ } satisfies SkillSwitchView;
+ });
+
+export const setOpenCodeSwitch = Effect.fn("setOpenCodeSwitch")(function* (
+ context: SkillSwitchContext,
+ skill: SwitchedSkill,
+ off: boolean,
+) {
+ const path = yield* Path.Path;
+ const name = nameOf(skill);
+ // A name with a wildcard in it would be a rule for other skills too.
+ if (/[*?]/.test(name)) return "failed" as const;
+ const layers = yield* readLayers(context);
+ if (isOff(layers, name) === off) return "unchanged" as const;
+
+ // The global file that wins over the other global files: where a new rule goes. `config.json`
+ // is the legacy name and is never created.
+ const globals = layers.filter((layer) => layer.global);
+ const target =
+ globals.findLast((layer) => layer.exists && path.basename(layer.file) !== "config.json") ??
+ globals.find((layer) => path.basename(layer.file) === "opencode.json");
+ if (target === undefined) return "failed" as const;
+
+ const withoutKey = (layer: Layer): Layer => ({
+ ...layer,
+ rules: layer.rules.filter((rule) => rule.pattern !== name),
+ });
+ const withRule = (action: "deny" | "allow") =>
+ layers.map((layer) =>
+ layer === target
+ ? { ...layer, rules: [...withoutKey(layer).rules, { pattern: name, action }] }
+ : layer.global
+ ? withoutKey(layer)
+ : layer,
+ );
+
+ // What each global file has to say about the key, to produce the plan, then check its result.
+ const key = ["permission", "skill", name] as const;
+ const plans = new Map();
+ const plan = (layer: Layer, ...changes: JsoncChange[]) =>
+ plans.set(layer.file, [...(plans.get(layer.file) ?? []), ...changes]);
+ const asObject = Effect.fnUntraced(function* (layer: Layer) {
+ // `"skill": "deny"` becomes `{ "*": "deny" }` before a key is added next to it.
+ const text = yield* readSettingsText(layer.file);
+ const action =
+ text === undefined ? undefined : valueAt(parseJsonc(text).value, ["permission", "skill"]);
+ if (typeof action === "string")
+ plan(layer, { path: ["permission", "skill"], value: { "*": action } });
+ });
+
+ if (off) {
+ if (!isOff(withRule("deny"), name)) return "setElsewhere" as const;
+ yield* asObject(target);
+ plan(target, { path: key, value: undefined }, { path: key, value: "deny" });
+ } else {
+ // Deleting the key may be enough; a wildcard rule that still denies needs an `allow` after it.
+ const needsAllow = isOff(
+ layers.map((layer) => (layer.global ? withoutKey(layer) : layer)),
+ name,
+ );
+ if (needsAllow && isOff(withRule("allow"), name)) return "setElsewhere" as const;
+ for (const layer of globals) {
+ if (layer.rules.some((rule) => rule.pattern === name))
+ plan(layer, { path: key, value: undefined });
+ }
+ if (needsAllow) {
+ yield* asObject(target);
+ plan(target, { path: key, value: "allow" });
+ }
+ }
+
+ const results = yield* Effect.forEach([...plans], ([file, changes]) =>
+ editJsoncFile({ file, changes }),
+ );
+ if (results.some((result) => result === "invalid" || result === "failed"))
+ return "failed" as const;
+ return results.some((result) => result === "written")
+ ? ("written" as const)
+ : ("unchanged" as const);
+});
diff --git a/apps/server/src/skills/PiSkillSettings.ts b/apps/server/src/skills/PiSkillSettings.ts
new file mode 100644
index 000000000000..1cdb766cc8e7
--- /dev/null
+++ b/apps/server/src/skills/PiSkillSettings.ts
@@ -0,0 +1,100 @@
+/**
+ * PiSkillSettings - Pi's exact exclusions of a skill, read and written for the Skills page.
+ *
+ * Pi filters the resources it finds with the `skills` array in `/settings.json`: "glob
+ * exclusions with `!pattern`, exact inclusion with `+path`, and exact exclusion with `-path`"
+ * (https://pi.dev/docs/latest/settings). `addAutoDiscoveredResources` in
+ * https://github.com/earendil-works/pi/blob/43d3763991/packages/coding-agent/src/core/package-manager.ts
+ * applies the user's array to the skills found in `~/.pi/agent/skills` and `~/.agents/skills`,
+ * matching an exact entry against the SKILL.md path or its folder, relative to `~/.agents` or the
+ * agent dir (so `skills//SKILL.md` for both) or absolute. `pi config` writes the relative
+ * form (`config-selector.ts`), and a `-` entry beats a `+` one. The agent dir is `~/.pi/agent`
+ * unless `PI_CODING_AGENT_DIR` moves it (https://github.com/earendil-works/pi/blob/43d3763991/packages/coding-agent/docs/configuration.md).
+ *
+ * Only Global skills. A project's skills are filtered by the project's own `.pi/settings.json`,
+ * which a team usually commits, so the page leaves a project skill `fixed` instead of editing it.
+ * Glob exclusions (`!pattern`) are not evaluated: only the exact entries this page writes are.
+ * Pi reads the file with `JSON.parse` (`settings-manager.ts`), so unlike Claude's and OpenCode's
+ * it is plain JSON: a file with a comment or a trailing comma is one Pi can't read, and is left
+ * alone.
+ *
+ * @module PiSkillSettings
+ */
+import * as Effect from "effect/Effect";
+import * as Path from "effect/Path";
+
+import type { SkillSwitchContext, SkillSwitchView, SwitchedSkill } from "./AgentSkillSettings.ts";
+import { editJsoncFile, parseJsonc, readSettingsText, valueAt } from "./JsoncSettings.ts";
+
+const settingsFile = (path: Path.Path, context: SkillSwitchContext) => {
+ const moved = context.environment.PI_CODING_AGENT_DIR?.trim() ?? "";
+ const agentDir =
+ moved === ""
+ ? path.join(context.homeDirectory, ".pi", "agent")
+ : moved === "~" || moved.startsWith("~/")
+ ? path.join(context.homeDirectory, moved.slice(2))
+ : path.resolve(context.cwd ?? context.homeDirectory, moved);
+ return path.join(agentDir, "settings.json");
+};
+
+const normalize = (value: string) => (value.startsWith("./") ? value.slice(2) : value);
+
+/** Every way an exact entry can name this skill: relative to its base folder, or absolute. */
+const namesOf = (skill: SwitchedSkill) =>
+ new Set(
+ [
+ `skills/${skill.name}`,
+ ...skill.entryPaths.flatMap((entry) => [entry, `${entry}/SKILL.md`]),
+ `skills/${skill.name}/SKILL.md`,
+ ].map(normalize),
+ );
+
+/** The `-` entries of the `skills` array that exclude this skill, with their positions. */
+const exclusionsOf = (skills: unknown, skill: SwitchedSkill) => {
+ const names = namesOf(skill);
+ return (Array.isArray(skills) ? skills : []).flatMap((entry: unknown, index) =>
+ typeof entry === "string" && entry.startsWith("-") && names.has(normalize(entry.slice(1)))
+ ? [index]
+ : [],
+ );
+};
+
+const isValidSettings = (value: Record) =>
+ value.skills === undefined ||
+ (Array.isArray(value.skills) && value.skills.every((entry) => typeof entry === "string"));
+
+export const piSwitches = (context: SkillSwitchContext) =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const text = yield* readSettingsText(settingsFile(path, context));
+ const parsed = text === undefined ? undefined : parseJsonc(text, true);
+ const skills = parsed?.valid ? valueAt(parsed.value, ["skills"]) : undefined;
+ return {
+ off: (skill: SwitchedSkill) =>
+ skill.scope === "global" && exclusionsOf(skills, skill).length > 0,
+ } satisfies SkillSwitchView;
+ });
+
+export const setPiSwitch = Effect.fn("setPiSwitch")(function* (
+ context: SkillSwitchContext,
+ skill: SwitchedSkill,
+ off: boolean,
+) {
+ if (skill.scope !== "global") return "failed" as const;
+ const file = settingsFile(yield* Path.Path, context);
+ const text = yield* readSettingsText(file);
+ const parsed = text === undefined ? undefined : parseJsonc(text, true);
+ if (parsed !== undefined && !parsed.valid) return "failed" as const;
+ const positions = exclusionsOf(valueAt(parsed?.value, ["skills"]), skill);
+
+ const changes = off
+ ? positions.length > 0
+ ? []
+ : [{ path: ["skills"], value: `-skills/${skill.name}/SKILL.md`, insert: true }]
+ : // Last first, so the positions still mean what they did.
+ positions.toReversed().map((index) => ({ path: ["skills", index], value: undefined }));
+ if (changes.length === 0) return "unchanged" as const;
+
+ const result = yield* editJsoncFile({ file, changes, accept: isValidSettings, strict: true });
+ return result === "invalid" ? ("failed" as const) : result;
+});
diff --git a/apps/server/src/skills/ProvidedSkills.ts b/apps/server/src/skills/ProvidedSkills.ts
new file mode 100644
index 000000000000..e417ffcf1c9b
--- /dev/null
+++ b/apps/server/src/skills/ProvidedSkills.ts
@@ -0,0 +1,168 @@
+/**
+ * ProvidedSkills - the folders of skills that come with an agent or one of its plugins, instead of
+ * the user's own skill folders. The Skills page lists them apart, and never moves, deletes or links
+ * them.
+ *
+ * - Codex installs its system skills itself in `$CODEX_HOME/skills/.system` and reports them with
+ * scope `system`. Its `[[skills.config]]` setting switches them like any other skill.
+ * - Claude Code records each installed plugin in `plugins/installed_plugins.json` under its config
+ * folder: version 2 keeps a list of installs per `name@marketplace` id, each with a `scope`
+ * (`user`, `managed`, or `project` and `local` with a `projectPath`), and version 1 one install
+ * per id. A plugin's skills are in its install's `skills` folder, and Claude names each one
+ * `:`. `skillOverrides` doesn't reach a plugin skill (checked against Claude Code
+ * 2.1.289, whose visibility check answers "on" for any skill whose source is a plugin), so T3 Code
+ * can't switch one. The whole plugin is switched by `enabledPlugins` in the settings files, where
+ * the last file that names it decides and `false` turns it off.
+ *
+ * Not listed: skill folders a plugin's manifest moves elsewhere, plugins synced from an account,
+ * and Claude Code's bundled skills, which are not files.
+ *
+ * @module ProvidedSkills
+ */
+import type { ProviderDriverKind, SkillScope } from "@t3tools/contracts";
+import * as HostProcess from "@t3tools/shared/HostProcess";
+import { fromLenientJson } from "@t3tools/shared/schemaJson";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+
+import { skillOverrideSettingsPaths } from "../provider/Drivers/ClaudeSkills.ts";
+
+/** A folder of skills that came with an agent. */
+export interface ProvidedRoot {
+ readonly kind: "agent" | "plugin";
+ readonly scope: SkillScope;
+ readonly directory: string;
+ /** The plugin's name, which Claude puts before each of its skills' names. */
+ readonly plugin?: string;
+ /** The agent's settings turn the whole plugin off. */
+ readonly off: boolean;
+}
+
+const PluginInstall = Schema.Struct({
+ installPath: Schema.String,
+ scope: Schema.optional(Schema.String),
+ projectPath: Schema.optional(Schema.String),
+});
+const decodeInstalledPlugins = Schema.decodeUnknownOption(
+ fromLenientJson(
+ Schema.Struct({
+ plugins: Schema.Record(
+ Schema.String,
+ Schema.Union([PluginInstall, Schema.Array(PluginInstall)]),
+ ),
+ }),
+ ),
+);
+const decodeEnabledPlugins = Schema.decodeUnknownOption(
+ fromLenientJson(
+ Schema.Struct({
+ enabledPlugins: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
+ }),
+ ),
+);
+
+/** `name` from a `name@marketplace` plugin id. */
+const pluginName = (id: string) => {
+ const at = id.lastIndexOf("@");
+ return at > 0 ? id.slice(0, at) : id;
+};
+
+/** Which plugins the settings files turn off: the last file that names a plugin decides. */
+const pluginsTurnedOff = Effect.fnUntraced(function* (input: {
+ readonly configHome: string;
+ readonly cwd: string | undefined;
+ readonly environment: NodeJS.ProcessEnv;
+}) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const platform = yield* HostProcess.Platform;
+ const decided = new Map();
+ for (const file of skillOverrideSettingsPaths(
+ path,
+ input.configHome,
+ input.cwd,
+ platform,
+ input.environment,
+ )) {
+ const text = yield* fileSystem.readFileString(file).pipe(Effect.orElseSucceed(() => ""));
+ if (text === "") continue;
+ const settings = decodeEnabledPlugins(text);
+ if (settings._tag === "None") continue;
+ for (const [id, value] of Object.entries(settings.value.enabledPlugins ?? {})) {
+ decided.set(id, value === false);
+ }
+ }
+ return new Set([...decided].flatMap(([id, off]) => (off ? [id] : [])));
+});
+
+/** Each installed Claude plugin's skills folder that applies here. */
+const claudePluginRoots = Effect.fnUntraced(function* (input: {
+ readonly configHome: string;
+ readonly cwd: string | undefined;
+ readonly environment: NodeJS.ProcessEnv;
+}) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const text = yield* fileSystem
+ .readFileString(path.join(input.configHome, "plugins", "installed_plugins.json"))
+ .pipe(Effect.orElseSucceed(() => ""));
+ const installed = text === "" ? undefined : decodeInstalledPlugins(text);
+ if (installed === undefined || installed._tag === "None") return [];
+ const off = yield* pluginsTurnedOff(input);
+ const roots = new Map();
+ for (const [id, entry] of Object.entries(installed.value.plugins)) {
+ for (const install of Array.isArray(entry) ? entry : [entry]) {
+ if (!path.isAbsolute(install.installPath)) continue;
+ // A project or local install is for one project; any other is for every project.
+ const forProject = install.scope === "project" || install.scope === "local";
+ if (
+ forProject &&
+ (input.cwd === undefined ||
+ install.projectPath === undefined ||
+ path.resolve(install.projectPath) !== path.resolve(input.cwd))
+ ) {
+ continue;
+ }
+ const directory = path.join(install.installPath, "skills");
+ if (roots.has(directory)) continue;
+ roots.set(directory, {
+ kind: "plugin",
+ scope: forProject ? "project" : "global",
+ directory,
+ plugin: pluginName(id),
+ off: off.has(id),
+ });
+ }
+ }
+ return [...roots.values()];
+});
+
+/** The folders of skills that come with this agent or its plugins. */
+export const providedSkillRoots = (input: {
+ readonly driver: ProviderDriverKind;
+ /** The instance's config folder: Claude's config folder or Codex's home. */
+ readonly configHome: string;
+ readonly cwd: string | undefined;
+ /** The instance's environment over the server's. */
+ readonly environment: NodeJS.ProcessEnv;
+}): Effect.Effect, never, FileSystem.FileSystem | Path.Path> =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ switch (input.driver) {
+ case "codex":
+ return [
+ {
+ kind: "agent",
+ scope: "global",
+ directory: path.join(input.configHome, "skills", ".system"),
+ off: false,
+ },
+ ];
+ case "claudeAgent":
+ return yield* claudePluginRoots(input);
+ default:
+ return [];
+ }
+ });
diff --git a/apps/server/src/skills/SkillCatalog.test.ts b/apps/server/src/skills/SkillCatalog.test.ts
new file mode 100644
index 000000000000..c3407599d97f
--- /dev/null
+++ b/apps/server/src/skills/SkillCatalog.test.ts
@@ -0,0 +1,1214 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { it, describe, expect } from "@effect/vitest";
+import {
+ ProjectId,
+ ProviderDriverKind,
+ ProviderInstanceId,
+ SkillGetResult,
+ SkillListResult,
+ SkillRequestError,
+ type Project,
+ type SkillAgentAccess,
+ type SkillSummary,
+} from "@t3tools/contracts";
+import * as HostProcess from "@t3tools/shared/HostProcess";
+import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
+import * as Cause from "effect/Cause";
+import * as Effect from "effect/Effect";
+import * as Exit from "effect/Exit";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+
+import * as ProjectService from "../project/ProjectService.ts";
+import { ProjectOperationError } from "../project/ProjectService.ts";
+import * as Settings from "../serverSettings.ts";
+import * as SkillCatalog from "./SkillCatalog.ts";
+
+const encodeList = Schema.encodeUnknownEffect(SkillListResult);
+const encodeGet = Schema.encodeUnknownEffect(SkillGetResult);
+
+const skillFile = (name: string, description: string) =>
+ `---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n`;
+
+/** A temp home and project laid out like a real machine: a synced library linked into two folders. */
+const makeMachine = Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-skill-catalog-" });
+ const home = yield* fs.realPath(root);
+ const project = path.join(home, "repos/app");
+ const write = (relative: string, contents: string, executable = false) =>
+ Effect.gen(function* () {
+ const target = path.join(home, relative);
+ yield* fs.makeDirectory(path.dirname(target), { recursive: true });
+ yield* fs.writeFileString(target, contents);
+ if (executable) yield* fs.chmod(target, 0o755);
+ });
+ const link = (target: string, from: string) =>
+ Effect.gen(function* () {
+ yield* fs.makeDirectory(path.dirname(path.join(home, from)), { recursive: true });
+ yield* fs.symlink(path.join(home, target), path.join(home, from));
+ });
+
+ // Global: a synced library linked into the standard folder, and into Claude's folder for one.
+ for (const name of ["architect", "grill"])
+ yield* write(`Knowledge/skills/${name}/SKILL.md`, skillFile(name, `The ${name} skill.`));
+ yield* write("Knowledge/skills/tdd/SKILL.md", skillFile("tdd", "Global test-first loop."));
+ yield* write("Knowledge/skills/shared/SKILL.md", skillFile("shared", "Same everywhere."));
+ yield* write("Knowledge/skills/architect/refs/principles.md", "# principles\n");
+ for (const name of ["architect", "grill", "tdd", "shared"])
+ yield* link(`Knowledge/skills/${name}`, `.agents/skills/${name}`);
+ yield* link("Knowledge/skills/architect", ".claude/skills/architect");
+ yield* link("missing/skills/gone", ".agents/skills/broken");
+ yield* write(
+ ".claude/skills/cloudflare/SKILL.md",
+ skillFile("cloudflare", "Deploy to Cloudflare."),
+ );
+ yield* write(".claude/skills/not-a-skill/notes.txt", "no SKILL.md here");
+ yield* write(".claude/skills/.hidden/SKILL.md", skillFile("hidden", "Hidden."));
+
+ // Project: a real standard folder, a skill that only Claude reads, and a copy of a global name.
+ yield* write(
+ "repos/app/.agents/skills/verify/SKILL.md",
+ "---\nname: verify\ndescription: >-\n Drive the app in a browser\n and capture evidence.\n---\n\n# verify\n",
+ );
+ yield* write("repos/app/.agents/skills/verify/bin/run", "#!/usr/bin/env bash\n", true);
+ yield* write("repos/app/.agents/skills/verify/lib/serve.mjs", "export {};\n");
+ yield* write("repos/app/.agents/skills/tdd/SKILL.md", skillFile("tdd", "Project test loop."));
+ yield* write("repos/app/.agents/skills/shared/SKILL.md", skillFile("shared", "Same everywhere."));
+ yield* write("repos/app/.claude/skills/own-copy/SKILL.md", skillFile("own-copy", "Claude only."));
+ yield* link("repos/app/.agents/skills/verify", "repos/app/.claude/skills/verify");
+ return { home, project, write, link };
+});
+
+/** Cursor, Grok, OpenCode, Antigravity and Pi are off until the user turns them on. */
+const ALL_AGENTS_ENABLED = Object.fromEntries(
+ ["cursor", "grok", "opencode", "antigravity", "pi"].map((driver) => [
+ ProviderInstanceId.make(driver),
+ { driver: ProviderDriverKind.make(driver), enabled: true },
+ ]),
+);
+
+const makeProject = (workspaceRoot: string): Project => ({
+ id: ProjectId.make("project-skill-catalog"),
+ title: "App",
+ workspaceRoot,
+ repositoryIdentity: null,
+ faviconPath: null,
+ projectIcon: null,
+ defaultModelSelection: null,
+ defaultThreadEnvMode: null,
+ autoPull: false,
+ scripts: [],
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ deletedAt: null,
+});
+
+/**
+ * The catalog as it sees a machine whose home is `home`, with these server settings. Only the
+ * `registered` folders are projects; by default that is the machine's `repos/app`.
+ */
+const withCatalog = (
+ home: string,
+ use: (catalog: SkillCatalog.SkillCatalog["Service"]) => Effect.Effect,
+ options: {
+ readonly settings?: Parameters[0];
+ readonly env?: NodeJS.ProcessEnv;
+ readonly registered?: readonly string[];
+ } = {},
+) =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const registered = options.registered ?? [path.join(home, "repos/app")];
+ const projects = Layer.mock(ProjectService.ProjectService)({
+ getByWorkspaceRoot: (root) =>
+ Effect.succeed(registered.includes(root) ? Option.some(makeProject(root)) : Option.none()),
+ });
+ return yield* Effect.gen(function* () {
+ return yield* use(yield* SkillCatalog.SkillCatalog);
+ }).pipe(
+ Effect.provide(
+ SkillCatalog.layer.pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ projects,
+ Settings.layerTest({
+ ...options.settings,
+ providerInstances: {
+ ...ALL_AGENTS_ENABLED,
+ ...options.settings?.providerInstances,
+ },
+ }),
+ ),
+ ),
+ ),
+ ),
+ );
+ }).pipe(
+ Effect.provideService(HostProcess.Environment, { HOME: home, ...options.env }),
+ Effect.provideService(HostProcess.HomeDirectory, home),
+ );
+
+const byKey = (skills: readonly SkillSummary[]) =>
+ new Map(skills.map((skill) => [`${skill.scope}:${skill.name}`, skill]));
+const accessOf = (skill: SkillSummary | undefined) =>
+ Object.fromEntries(
+ (skill?.access ?? []).map((entry: SkillAgentAccess) => [
+ entry.instanceId,
+ { state: entry.state, folder: entry.folder },
+ ]),
+ );
+const states = (skill: SkillSummary | undefined) =>
+ Object.fromEntries(Object.entries(accessOf(skill)).map(([agent, { state }]) => [agent, state]));
+
+const NOT_FOUND = {
+ home: null,
+ description: "",
+ contents: null,
+ files: [],
+ filesTruncated: false,
+};
+
+it.layer(NodeServices.layer, { excludeTestServices: true })("SkillCatalog", (it) => {
+ describe("list", () => {
+ it.effect.skipIf(!symlinksSupported)(
+ "tells how each agent reaches a skill: shared folder, link, own folder or not at all",
+ () =>
+ Effect.gen(function* () {
+ const { home, project } = yield* makeMachine;
+ const { skills } = yield* withCatalog(home, (catalog) => catalog.list({ cwd: project }));
+ const byName = byKey(skills);
+
+ const architect = byName.get("global:architect");
+ expect(architect).toMatchObject({
+ home: "~/Knowledge/skills/architect",
+ description: "The architect skill.",
+ });
+ expect(accessOf(architect)).toEqual({
+ // Claude only reads its own folder, so the library skill reaches it through a link.
+ claudeAgent: { state: "link", folder: "~/.claude/skills" },
+ codex: { state: "direct", folder: "~/.agents/skills" },
+ cursor: { state: "direct", folder: "~/.agents/skills" },
+ grok: { state: "direct", folder: "~/.agents/skills" },
+ opencode: { state: "direct", folder: "~/.agents/skills" },
+ // Antigravity reads `.agents/skills` in a project, but not in the global level.
+ antigravity: { state: "none", folder: "~/.gemini/config/skills" },
+ pi: { state: "direct", folder: "~/.agents/skills" },
+ });
+ expect(accessOf(byName.get("global:grill")).claudeAgent).toEqual({
+ state: "none",
+ folder: "~/.claude/skills",
+ });
+
+ const cloudflare = byName.get("global:cloudflare");
+ expect(cloudflare?.home).toBe("~/.claude/skills/cloudflare");
+ expect(accessOf(cloudflare)).toMatchObject({
+ claudeAgent: { state: "direct", folder: "~/.claude/skills" },
+ cursor: { state: "direct", folder: "~/.claude/skills" },
+ opencode: { state: "direct", folder: "~/.claude/skills" },
+ codex: { state: "none", folder: "~/.agents/skills" },
+ });
+
+ const verify = byName.get("project:verify");
+ expect(verify?.home).toBe(".agents/skills/verify");
+ expect(accessOf(verify)).toEqual({
+ claudeAgent: { state: "link", folder: ".claude/skills" },
+ codex: { state: "direct", folder: ".agents/skills" },
+ cursor: { state: "direct", folder: ".agents/skills" },
+ grok: { state: "none", folder: ".grok/skills" },
+ opencode: { state: "direct", folder: ".agents/skills" },
+ antigravity: { state: "direct", folder: ".agents/skills" },
+ pi: { state: "direct", folder: ".agents/skills" },
+ });
+ expect(accessOf(byName.get("project:own-copy"))).toMatchObject({
+ claudeAgent: { state: "direct", folder: ".claude/skills" },
+ codex: { state: "none", folder: ".agents/skills" },
+ pi: { state: "none", folder: ".pi/skills" },
+ });
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "skips folders that aren't skills and links that point nowhere",
+ () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ // Only SKILL.md makes a skill: a lowercase file, a folder named SKILL.md and a
+ // dot-folder don't.
+ yield* write(".agents/skills/lowercase/skill.md", skillFile("lowercase", "Lower."));
+ yield* write(".agents/skills/odd/SKILL.md/inner.txt", "a folder, not a file");
+ yield* write(".agents/skills/.dotted/SKILL.md", skillFile("dotted", "Dotted."));
+ yield* write(".agents/skills/some file.txt", "not a folder");
+ const { skills } = yield* withCatalog(home, (catalog) => catalog.list({ cwd: project }));
+ const names = skills
+ .filter((skill) => skill.scope === "global")
+ .map((skill) => skill.name);
+ for (const skipped of ["lowercase", "broken", "odd", ".dotted", "not-a-skill", ".hidden"])
+ expect(names).not.toContain(skipped);
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "accepts the folder names the agents' scanners accept, such as ones with spaces",
+ () =>
+ Effect.gen(function* () {
+ const { home, write } = yield* makeMachine;
+ for (const name of ["my skill", "Name_1.2", "plus+sign", "ünï"])
+ yield* write(`.agents/skills/${name}/SKILL.md`, skillFile(name, "Odd name."));
+ const { skills } = yield* withCatalog(home, (catalog) => catalog.list({}));
+ const names = skills.map((skill) => skill.name);
+ for (const name of ["my skill", "Name_1.2", "plus+sign", "ünï"])
+ expect(names).toContain(name);
+ const detail = yield* withCatalog(home, (catalog) =>
+ catalog.get({ scope: "global", name: "my skill", home: "~/.agents/skills/my skill" }),
+ );
+ expect(detail.home).toBe(`${home}/.agents/skills/my skill`);
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "reads descriptions the way Claude Code reads a header",
+ () =>
+ Effect.gen(function* () {
+ const { home, write } = yield* makeMachine;
+ const header = (name: string, body: string) =>
+ write(`.agents/skills/${name}/SKILL.md`, `---\nname: ${name}\n${body}\n---\nBody\n`);
+ yield* header("quoted", 'description: "Say \\"hi\\" often"');
+ yield* header("folded", "description: >-\n one\n two");
+ yield* header("literal", "description: |\n line one\n line two");
+ // YAML rejects an unquoted colon; Claude Code, and so the page, still reads it.
+ yield* header("colon", "description: Use when: testing");
+ yield* header("spaced", "description: spaced out");
+ yield* header("none", "other: value");
+ yield* write(".agents/skills/no-header/SKILL.md", "# No header\n");
+ yield* write(
+ ".agents/skills/windows/SKILL.md",
+ "---\r\nname: x\r\ndescription: crlf\r\n---\r\n",
+ );
+ const { skills } = yield* withCatalog(home, (catalog) => catalog.list({}));
+ const described = Object.fromEntries(
+ skills.map((skill) => [skill.name, skill.description]),
+ );
+ expect(described).toMatchObject({
+ quoted: 'Say "hi" often',
+ folded: "one two",
+ literal: "line one line two",
+ colon: "Use when: testing",
+ spaced: "spaced out",
+ none: "",
+ "no-header": "",
+ windows: "crlf",
+ });
+ expect(skills.every((skill) => skill.invalidHeader === undefined)).toBe(true);
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "reports a header Claude Code can't read, and Claude doesn't load the skill",
+ () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ yield* write(
+ ".claude/skills/broken-header/SKILL.md",
+ "---\nname: broken-header\ndescription: [never closed\n---\nBody\n",
+ );
+ // A header Claude skips doesn't shadow a later copy of the same name.
+ yield* write(
+ ".claude/skills/dup/SKILL.md",
+ "---\ndescription: [never closed\n---\nGlobal.\n",
+ );
+ yield* write("repos/app/.claude/skills/dup/SKILL.md", skillFile("dup", "Project copy."));
+ const { skills } = yield* withCatalog(home, (catalog) => catalog.list({ cwd: project }));
+ const byName = byKey(skills);
+
+ const broken = byName.get("global:broken-header");
+ expect(broken).toMatchObject({ invalidHeader: true, description: "" });
+ expect(states(broken)).toMatchObject({ claudeAgent: "none", cursor: "direct" });
+
+ expect(byName.get("global:dup")).toMatchObject({ invalidHeader: true });
+ expect(states(byName.get("global:dup")).claudeAgent).toBe("none");
+ expect(states(byName.get("project:dup")).claudeAgent).toBe("direct");
+ expect(byName.get("project:dup")?.invalidHeader).toBeUndefined();
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "shows a skill that Claude's own settings switch off as off for Claude",
+ () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ // The user's file switches two skills off, by folder name, and keeps one reachable by
+ // the user only; the project's local file turns one of the two back on and switches off
+ // a project skill.
+ yield* write(
+ ".claude/settings.json",
+ JSON.stringify({
+ skillOverrides: {
+ cloudflare: "off",
+ architect: "off",
+ "user-only": "user-invocable-only",
+ },
+ }),
+ );
+ yield* write(
+ "repos/app/.claude/settings.local.json",
+ JSON.stringify({ skillOverrides: { architect: "on", "own-copy": "off" } }),
+ );
+ yield* write(".claude/skills/user-only/SKILL.md", skillFile("user-only", "By hand."));
+ const { skills } = yield* withCatalog(home, (catalog) => catalog.list({ cwd: project }));
+ const byName = byKey(skills);
+
+ // Off: Claude can see it but doesn't use it, the others still do.
+ expect(accessOf(byName.get("global:cloudflare"))).toMatchObject({
+ claudeAgent: { state: "off", folder: "~/.claude/skills" },
+ cursor: { state: "direct", folder: "~/.claude/skills" },
+ });
+ // The project's later layer turns the user's "off" back on.
+ expect(states(byName.get("global:architect")).claudeAgent).toBe("link");
+ expect(states(byName.get("project:own-copy")).claudeAgent).toBe("off");
+ // The user can still invoke a skill that only the model is kept from.
+ expect(states(byName.get("global:user-only")).claudeAgent).toBe("direct");
+
+ // Without the project, only the user's layer applies.
+ const global = yield* withCatalog(home, (catalog) => catalog.list({}));
+ expect(states(byKey(global.skills).get("global:architect")).claudeAgent).toBe("off");
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "flags a name that exists more than once, and whether the copies are identical",
+ () =>
+ Effect.gen(function* () {
+ const { home, project } = yield* makeMachine;
+ const { skills } = yield* withCatalog(home, (catalog) => catalog.list({ cwd: project }));
+ const byName = byKey(skills);
+ expect(byName.get("project:tdd")?.copies).toEqual([
+ { scope: "global", home: "~/Knowledge/skills/tdd", same: false },
+ ]);
+ expect(byName.get("global:tdd")?.copies).toEqual([
+ { scope: "project", home: ".agents/skills/tdd", same: false },
+ ]);
+ expect(byName.get("project:shared")?.copies).toEqual([
+ { scope: "global", home: "~/Knowledge/skills/shared", same: true },
+ ]);
+ expect(byName.get("global:shared")?.copies).toEqual([
+ { scope: "project", home: ".agents/skills/shared", same: true },
+ ]);
+ expect(byName.get("project:verify")?.copies).toEqual([]);
+ expect(byName.get("global:architect")?.copies).toEqual([]);
+
+ // Without a project there is no second scope to compare with.
+ const globalOnly = yield* withCatalog(home, (catalog) => catalog.list({}));
+ expect(globalOnly.skills.every((skill) => skill.scope === "global")).toBe(true);
+ expect(globalOnly.skills.every((skill) => skill.copies.length === 0)).toBe(true);
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "gives a copy to an agent only when the agent loads it, and flags copies that differ",
+ () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ yield* write(
+ "repos/app/.claude/skills/tdd/SKILL.md",
+ skillFile("tdd", "Claude's own tdd."),
+ );
+ const { skills } = yield* withCatalog(home, (catalog) => catalog.list({ cwd: project }));
+ const tdds = skills.filter((skill) => skill.scope === "project" && skill.name === "tdd");
+ expect(tdds.map((skill) => skill.home).toSorted()).toEqual([
+ ".agents/skills/tdd",
+ ".claude/skills/tdd",
+ ]);
+ const shared = tdds.find((skill) => skill.home === ".agents/skills/tdd");
+ const claudes = tdds.find((skill) => skill.home === ".claude/skills/tdd");
+
+ // Claude reads its own folder, so the copy in `.claude/skills` is the one it loads.
+ expect(accessOf(claudes).claudeAgent).toEqual({
+ state: "direct",
+ folder: ".claude/skills",
+ });
+ expect(accessOf(shared).claudeAgent?.state).toBe("none");
+ // Cursor looks in the project's `.agents/skills` before its `.claude/skills`.
+ expect(accessOf(shared).cursor).toEqual({ state: "direct", folder: ".agents/skills" });
+ expect(accessOf(claudes).cursor?.state).toBe("none");
+ // Codex loads every copy of a name, from the folders it reads: the project's shared one
+ // and the global one, but not `.claude/skills`.
+ expect(accessOf(shared).codex).toEqual({ state: "direct", folder: ".agents/skills" });
+ expect(accessOf(claudes).codex?.state).toBe("none");
+ expect(accessOf(byKey(skills).get("global:tdd")).codex).toEqual({
+ state: "direct",
+ folder: "~/.agents/skills",
+ });
+
+ // All three project and global copies differ from each other, so each is a conflict.
+ for (const copy of [...tdds, byKey(skills).get("global:tdd")]) {
+ expect(copy?.copies.length).toBe(2);
+ expect(copy?.copies.every((other) => !other.same)).toBe(true);
+ }
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "loads one copy of a name for agents that take the first, and every copy for the others",
+ () =>
+ Effect.gen(function* () {
+ const { home, project } = yield* makeMachine;
+ const { skills } = yield* withCatalog(home, (catalog) => catalog.list({ cwd: project }));
+ const byName = byKey(skills);
+ // Cursor and Pi look in the project first and take the first copy; Antigravity doesn't
+ // read the global standard folder. Codex and OpenCode list every copy, and Grok reads
+ // no project `.agents/skills`.
+ expect(states(byName.get("project:tdd"))).toMatchObject({
+ cursor: "direct",
+ antigravity: "direct",
+ pi: "direct",
+ codex: "direct",
+ opencode: "direct",
+ grok: "none",
+ });
+ expect(states(byName.get("global:tdd"))).toMatchObject({
+ cursor: "none",
+ antigravity: "none",
+ pi: "none",
+ codex: "direct",
+ opencode: "direct",
+ grok: "direct",
+ });
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "gives a project and a global skill of one name to Codex, which loads both, and flags them",
+ () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ yield* write(
+ "repos/app/.agents/skills/grill-me/SKILL.md",
+ skillFile("grill-me", "Project grilling."),
+ );
+ yield* write(
+ ".agents/skills/grill-me/SKILL.md",
+ skillFile("grill-me", "Global grilling."),
+ );
+ const { skills } = yield* withCatalog(home, (catalog) => catalog.list({ cwd: project }));
+ const byName = byKey(skills);
+ const projectCopy = byName.get("project:grill-me");
+ const globalCopy = byName.get("global:grill-me");
+
+ for (const copy of [projectCopy, globalCopy]) {
+ expect(accessOf(copy).codex).toEqual({
+ state: "direct",
+ folder: copy?.scope === "project" ? ".agents/skills" : "~/.agents/skills",
+ });
+ expect(copy?.copies.map((other) => other.same)).toEqual([false]);
+ }
+ // Cursor and Pi take the project copy and not the global one.
+ for (const agent of ["cursor", "pi"] as const) {
+ expect(states(projectCopy)[agent]).toBe("direct");
+ expect(states(globalCopy)[agent]).toBe("none");
+ }
+ // Grok doesn't read the project's standard folder, so only the global copy reaches it.
+ expect(states(projectCopy).grok).toBe("none");
+ expect(states(globalCopy).grok).toBe("direct");
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "follows the config folders that enabled provider instances move, one entry per instance",
+ () =>
+ Effect.gen(function* () {
+ const { home, write } = yield* makeMachine;
+ yield* write("work-claude/skills/work-only/SKILL.md", skillFile("work-only", "Work."));
+ yield* write("env-claude/skills/env-only/SKILL.md", skillFile("env-only", "Env."));
+ yield* write("codex-alt/skills/codex-only/SKILL.md", skillFile("codex-only", "Codex."));
+ yield* write("grok-alt/skills/grok-only/SKILL.md", skillFile("grok-only", "Grok."));
+ yield* write(".pi/agent/skills/pi-only/SKILL.md", skillFile("pi-only", "Pi."));
+ const settings = {
+ providerInstances: {
+ // The instance's own setting wins over CLAUDE_CONFIG_DIR.
+ [ProviderInstanceId.make("claude_work")]: {
+ driver: ProviderDriverKind.make("claudeAgent"),
+ displayName: "Claude Work",
+ config: { homePath: `${home}/work-claude` },
+ },
+ // A disabled instance isn't an agent here, and its folders aren't read.
+ [ProviderInstanceId.make("pi")]: {
+ driver: ProviderDriverKind.make("pi"),
+ enabled: false,
+ },
+ },
+ };
+ const { skills } = yield* withCatalog(home, (catalog) => catalog.list({}), {
+ settings,
+ env: {
+ CLAUDE_CONFIG_DIR: `${home}/env-claude`,
+ CODEX_HOME: `${home}/codex-alt`,
+ GROK_HOME: `${home}/grok-alt`,
+ },
+ });
+ const byName = byKey(skills);
+
+ expect(skills[0]?.access.map((entry) => entry.instanceId)).toEqual([
+ "claude_work",
+ "claudeAgent",
+ "codex",
+ "cursor",
+ "grok",
+ "opencode",
+ "antigravity",
+ ]);
+ expect(accessOf(byName.get("global:work-only"))).toMatchObject({
+ claude_work: { state: "direct", folder: "~/work-claude/skills" },
+ claudeAgent: { state: "none", folder: "~/env-claude/skills" },
+ });
+ expect(accessOf(byName.get("global:env-only"))).toMatchObject({
+ claude_work: { state: "none", folder: "~/work-claude/skills" },
+ claudeAgent: { state: "direct", folder: "~/env-claude/skills" },
+ });
+ expect(accessOf(byName.get("global:codex-only")).codex).toEqual({
+ state: "direct",
+ folder: "~/codex-alt/skills",
+ });
+ expect(accessOf(byName.get("global:grok-only")).grok).toEqual({
+ state: "direct",
+ folder: "~/grok-alt/skills",
+ });
+ expect(byName.has("global:pi-only")).toBe(false);
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "caps the description at 160 characters and marks the cut",
+ () =>
+ Effect.gen(function* () {
+ const { home, write } = yield* makeMachine;
+ yield* write(".agents/skills/wordy/SKILL.md", skillFile("wordy", `"${"x".repeat(300)}"`));
+ yield* write(".agents/skills/exact/SKILL.md", skillFile("exact", `"${"x".repeat(160)}"`));
+ // The cap counts characters, not UTF-16 units: an emoji is one.
+ yield* write(
+ ".agents/skills/emoji/SKILL.md",
+ skillFile("emoji", `"${"🙂".repeat(200)}"`),
+ );
+ // A description longer than the first read still finishes its header.
+ yield* write(".agents/skills/epic/SKILL.md", skillFile("epic", `"${"y".repeat(5_000)}"`));
+ // One that outgrows even the second read has no description, but the skill stays.
+ yield* write(
+ ".agents/skills/endless/SKILL.md",
+ skillFile("endless", `"${"z".repeat(40_000)}"`),
+ );
+ const { skills } = yield* withCatalog(home, (catalog) => catalog.list({}));
+ const byName = byKey(skills);
+ expect(byName.get("global:wordy")?.description).toBe(`${"x".repeat(160)}…`);
+ expect(byName.get("global:exact")?.description).toBe("x".repeat(160));
+ expect(byName.get("global:emoji")?.description).toBe(`${"🙂".repeat(160)}…`);
+ expect(byName.get("global:epic")?.description).toBe(`${"y".repeat(160)}…`);
+ expect(byName.get("global:endless")?.description).toBe("");
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "reports folders it can't read, but not ones that don't exist",
+ () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ // A file where the folder should be can't be listed, on any platform and for any user.
+ yield* write(".gemini/config/skills", "not a folder");
+ yield* write("repos/app/.pi/skills", "not a folder");
+ const result = yield* withCatalog(home, (catalog) => catalog.list({ cwd: project }));
+ expect(result.unreadable).toEqual(
+ expect.arrayContaining([
+ { scope: "global", folder: "~/.gemini/config/skills" },
+ { scope: "project", folder: ".pi/skills" },
+ ]),
+ );
+ // `.codex/skills`, `.grok/skills` and the others simply aren't there.
+ expect(result.unreadable).toHaveLength(2);
+ // The rest of the list is unaffected.
+ expect(byKey(result.skills).has("global:architect")).toBe(true);
+ expect(byKey(result.skills).has("project:verify")).toBe(true);
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)("reads at most 1000 skill folders from one folder", () =>
+ Effect.gen(function* () {
+ const { home, write } = yield* makeMachine;
+ yield* Effect.forEach(
+ Array.from({ length: 1_005 }, (_, index) => `bulk-${String(index).padStart(4, "0")}`),
+ (name) => write(`.codex/skills/${name}/SKILL.md`, skillFile(name, "Bulk.")),
+ { concurrency: 16, discard: true },
+ );
+ const { skills } = yield* withCatalog(home, (catalog) => catalog.list({}));
+ expect(skills.filter((skill) => skill.name.startsWith("bulk-"))).toHaveLength(1_000);
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "refuses a SKILL.md that is a link out of the skill, and reads one that stays inside",
+ () =>
+ Effect.gen(function* () {
+ const { home, write, link } = yield* makeMachine;
+ yield* write(
+ "outside/SKILL.md",
+ "---\ndescription: Secret from outside.\n---\nTop secret.\n",
+ );
+ yield* write(".agents/skills/escaping/notes.md", "# notes\n");
+ yield* link("outside/SKILL.md", ".agents/skills/escaping/SKILL.md");
+ yield* write(
+ ".agents/skills/inside/README.md",
+ "---\ndescription: Linked inside.\n---\nBody.\n",
+ );
+ yield* link(".agents/skills/inside/README.md", ".agents/skills/inside/SKILL.md");
+
+ const { skills } = yield* withCatalog(home, (catalog) => catalog.list({}));
+ const byName = byKey(skills);
+ expect(byName.has("global:escaping")).toBe(false);
+ expect(byName.get("global:inside")?.description).toBe("Linked inside.");
+
+ const escaping = yield* withCatalog(home, (catalog) =>
+ catalog.get({ scope: "global", name: "escaping", home: "~/.agents/skills/escaping" }),
+ );
+ expect(escaping.contents).toBeNull();
+ expect(escaping.description).toBe("");
+ const inside = yield* withCatalog(home, (catalog) =>
+ catalog.get({ scope: "global", name: "inside", home: "~/.agents/skills/inside" }),
+ );
+ expect(inside.contents).toContain("Linked inside.");
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "never writes, and returns results the RPC success schema can encode",
+ () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const { home, project } = yield* makeMachine;
+ const before = yield* fs.readDirectory(home, { recursive: true });
+ const result = yield* withCatalog(home, (catalog) => catalog.list({ cwd: project }));
+ const encoded = yield* encodeList(result);
+ expect(encoded.skills).toHaveLength(result.skills.length);
+ expect(encoded.skills[0]?.access.map((entry) => entry.instanceId)).toEqual([
+ "claudeAgent",
+ "codex",
+ "cursor",
+ "grok",
+ "opencode",
+ "antigravity",
+ "pi",
+ ]);
+ expect(encoded.skills[0]?.access.map((entry) => entry.driver)).toEqual([
+ "claudeAgent",
+ "codex",
+ "cursor",
+ "grok",
+ "opencode",
+ "antigravity",
+ "pi",
+ ]);
+ const detail = yield* withCatalog(home, (catalog) =>
+ catalog.get({
+ cwd: project,
+ scope: "project",
+ name: "verify",
+ home: ".agents/skills/verify",
+ }),
+ );
+ expect((yield* encodeGet(detail)).files).toHaveLength(3);
+ expect(yield* fs.readDirectory(home, { recursive: true })).toEqual(before);
+ }),
+ );
+ });
+
+ describe("project folders", () => {
+ it.effect(
+ "refuses a folder that is gone as not a project, and dies on a failure of the lookup itself",
+ () =>
+ Effect.gen(function* () {
+ const { home } = yield* makeMachine;
+ const projects = Layer.mock(ProjectService.ProjectService)({
+ getByWorkspaceRoot: (root) =>
+ Effect.fail(
+ new ProjectOperationError({
+ operation: root.endsWith("/gone") ? "normalize-workspace" : "list-projects",
+ workspaceRoot: root,
+ cause: "stand-in",
+ }),
+ ),
+ });
+ const onMachine = (
+ use: (catalog: SkillCatalog.SkillCatalog["Service"]) => Effect.Effect,
+ ) =>
+ Effect.gen(function* () {
+ return yield* use(yield* SkillCatalog.SkillCatalog);
+ }).pipe(
+ Effect.provide(
+ SkillCatalog.layer.pipe(
+ Layer.provide(Layer.mergeAll(projects, Settings.layerTest({}))),
+ ),
+ ),
+ Effect.provideService(HostProcess.Environment, { HOME: home }),
+ );
+
+ const gone = yield* onMachine((catalog) =>
+ catalog.list({ cwd: `${home}/gone` }).pipe(Effect.flip),
+ );
+ expect(gone).toEqual(new SkillRequestError({ reason: "projectNotRegistered" }));
+
+ const failed = yield* onMachine((catalog) =>
+ catalog.list({ cwd: `${home}/there` }).pipe(Effect.exit),
+ );
+ expect(Exit.isFailure(failed) && Cause.hasDies(failed.cause)).toBe(true);
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "reads a project's skill folders only when the folder is a registered project",
+ () =>
+ Effect.gen(function* () {
+ const { home, project } = yield* makeMachine;
+ const refused = new SkillRequestError({ reason: "projectNotRegistered" });
+ const get = (cwd: string) => ({
+ cwd,
+ scope: "project" as const,
+ name: "verify",
+ home: ".agents/skills/verify",
+ });
+
+ // A folder that holds skills but isn't a project (the home, a project's subfolder, a
+ // relative path, a path that isn't there) is refused for the list and for one skill.
+ for (const cwd of [home, `${project}/.agents`, "repos/app", `${home}/missing`]) {
+ const registered = [project];
+ expect(
+ yield* withCatalog(home, (catalog) => catalog.list({ cwd }).pipe(Effect.flip), {
+ registered,
+ }),
+ ).toEqual(refused);
+ expect(
+ yield* withCatalog(home, (catalog) => catalog.get(get(cwd)).pipe(Effect.flip), {
+ registered,
+ }),
+ ).toEqual(refused);
+ }
+
+ // The registered project, and the Global folders without any `cwd`, are read as before.
+ const listed = yield* withCatalog(home, (catalog) => catalog.list({ cwd: project }));
+ expect(listed.skills.some((skill) => skill.scope === "project")).toBe(true);
+ const global = yield* withCatalog(home, (catalog) => catalog.list({}), {
+ registered: [],
+ });
+ expect(global.skills.map((skill) => skill.scope)).toEqual(
+ global.skills.map(() => "global"),
+ );
+ expect(global.skills.length).toBeGreaterThan(0);
+ const detail = yield* withCatalog(home, (catalog) => catalog.get(get(project)));
+ expect(detail.home).toBe(`${project}/.agents/skills/verify`);
+ }),
+ );
+ });
+
+ describe("what can be moved or deleted", () => {
+ it.effect.skipIf(!symlinksSupported)(
+ "marks a skill whose folder sits in an agent's folder, and not one reached through a link",
+ () =>
+ Effect.gen(function* () {
+ const { home, project } = yield* makeMachine;
+ const { skills } = yield* withCatalog(home, (catalog) => catalog.list({ cwd: project }));
+ const byName = byKey(skills);
+
+ expect(byName.get("project:verify")?.realFolder).toBe(true);
+ expect(byName.get("project:own-copy")?.realFolder).toBe(true);
+ expect(byName.get("global:cloudflare")?.realFolder).toBe(true);
+ // The library's skills are linked into the shared folder, so they live elsewhere.
+ expect(byName.get("global:architect")?.realFolder).toBeUndefined();
+ expect(byName.get("global:tdd")?.realFolder).toBeUndefined();
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "doesn't call a folder reached through a linked skills folder a real one",
+ () =>
+ Effect.gen(function* () {
+ const { home, project, write, link } = yield* makeMachine;
+ yield* write("elsewhere/relay/SKILL.md", skillFile("relay", "Reached through a link."));
+ // The project's whole `.cursor/skills` folder is a link to another folder.
+ yield* link("elsewhere", "repos/app/.cursor/skills");
+ const { skills } = yield* withCatalog(home, (catalog) => catalog.list({ cwd: project }));
+
+ expect(byKey(skills).get("project:relay")).toMatchObject({ name: "relay" });
+ expect(byKey(skills).get("project:relay")?.realFolder).toBeUndefined();
+ }),
+ );
+ });
+
+ describe("get", () => {
+ it.effect.skipIf(!symlinksSupported)(
+ "returns the full SKILL.md, the file list and which files can run",
+ () =>
+ Effect.gen(function* () {
+ const { home, project } = yield* makeMachine;
+ const detail = yield* withCatalog(home, (catalog) =>
+ catalog.get({
+ cwd: project,
+ scope: "project",
+ name: "verify",
+ home: ".agents/skills/verify",
+ }),
+ );
+ expect(detail.home).toBe(`${project}/.agents/skills/verify`);
+ expect(detail.description).toBe("Drive the app in a browser and capture evidence.");
+ expect(detail.contents).toContain("# verify");
+ expect(detail.files).toEqual([
+ { path: "SKILL.md", size: expect.any(Number), executable: false },
+ { path: "bin/run", size: expect.any(Number), executable: true },
+ { path: "lib/serve.mjs", size: expect.any(Number), executable: false },
+ ]);
+ expect(detail.filesTruncated).toBe(false);
+ // A skill reached through a link resolves to the library folder.
+ const linked = yield* withCatalog(home, (catalog) =>
+ catalog.get({
+ scope: "global",
+ name: "architect",
+ home: "~/Knowledge/skills/architect",
+ }),
+ );
+ expect(linked.home).toBe(`${home}/Knowledge/skills/architect`);
+ expect(linked.files.map((file) => file.path)).toEqual(["SKILL.md", "refs/principles.md"]);
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "picks the skill that matches the home the list returned",
+ () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ yield* write(
+ "repos/app/.claude/skills/tdd/SKILL.md",
+ skillFile("tdd", "Claude's own tdd."),
+ );
+ const read = (folder: string) =>
+ withCatalog(home, (catalog) =>
+ catalog.get({ cwd: project, scope: "project", name: "tdd", home: `${folder}/tdd` }),
+ );
+ expect((yield* read(".claude/skills")).contents).toContain("Claude's own tdd.");
+ expect((yield* read(".agents/skills")).contents).toContain("Project test loop.");
+ expect((yield* read(".pi/skills")).home).toBeNull();
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "bounds the files it lists and skips folders it shouldn't walk",
+ () =>
+ Effect.gen(function* () {
+ const { home, write } = yield* makeMachine;
+ yield* write(".agents/skills/big/SKILL.md", skillFile("big", "Many files."));
+ yield* Effect.forEach(
+ Array.from(
+ { length: 520 },
+ (_, index) => `refs/note-${String(index).padStart(3, "0")}.md`,
+ ),
+ (file) => write(`.agents/skills/big/${file}`, "note\n"),
+ { concurrency: 16, discard: true },
+ );
+ yield* write(".agents/skills/big/node_modules/dep/index.js", "module.exports = {};\n");
+ yield* write(".agents/skills/big/.git/HEAD", "ref: refs/heads/main\n");
+ const detail = yield* withCatalog(home, (catalog) =>
+ catalog.get({ scope: "global", name: "big", home: "~/.agents/skills/big" }),
+ );
+ expect(detail.files).toHaveLength(500);
+ expect(detail.filesTruncated).toBe(true);
+ expect(detail.files.some((file) => /node_modules|\.git/.test(file.path))).toBe(false);
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "stops walking after a fixed number of folders, however many a skill has",
+ () =>
+ Effect.gen(function* () {
+ const { home, write } = yield* makeMachine;
+ yield* write(".agents/skills/wide/SKILL.md", skillFile("wide", "Many folders."));
+ yield* Effect.forEach(
+ Array.from({ length: 300 }, (_, index) => `d-${String(index).padStart(3, "0")}`),
+ (folder) => write(`.agents/skills/wide/${folder}/note.md`, "note\n"),
+ { concurrency: 16, discard: true },
+ );
+ const detail = yield* withCatalog(home, (catalog) =>
+ catalog.get({ scope: "global", name: "wide", home: "~/.agents/skills/wide" }),
+ );
+ // SKILL.md and the first 199 folders' files: 200 folders are walked in all, root included.
+ expect(detail.files).toHaveLength(200);
+ expect(detail.files.some((file) => file.path.startsWith("d-198/"))).toBe(true);
+ expect(detail.files.some((file) => file.path.startsWith("d-199/"))).toBe(false);
+ expect(detail.filesTruncated).toBe(true);
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "stops at the file limit in one huge folder instead of looking at every entry",
+ () =>
+ Effect.gen(function* () {
+ const { home, write } = yield* makeMachine;
+ yield* write(".agents/skills/flat/SKILL.md", skillFile("flat", "One big folder."));
+ yield* Effect.forEach(
+ Array.from({ length: 1_100 }, (_, index) => `n-${String(index).padStart(4, "0")}.md`),
+ (file) => write(`.agents/skills/flat/${file}`, "note\n"),
+ { concurrency: 16, discard: true },
+ );
+ const detail = yield* withCatalog(home, (catalog) =>
+ catalog.get({ scope: "global", name: "flat", home: "~/.agents/skills/flat" }),
+ );
+ expect(detail.files).toHaveLength(500);
+ expect(detail.files[0]?.path).toBe("SKILL.md");
+ expect(detail.files.at(-1)?.path).toBe("n-0498.md");
+ expect(detail.filesTruncated).toBe(true);
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "lists a link inside a skill as one file and doesn't follow it",
+ () =>
+ Effect.gen(function* () {
+ const { home, link } = yield* makeMachine;
+ yield* link("Knowledge/skills/architect", "Knowledge/skills/grill/shortcut");
+ const detail = yield* withCatalog(home, (catalog) =>
+ catalog.get({ scope: "global", name: "grill", home: "~/Knowledge/skills/grill" }),
+ );
+ expect(detail.files).toEqual([
+ { path: "SKILL.md", size: expect.any(Number), executable: false },
+ { path: "shortcut", size: 0, executable: false },
+ ]);
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "shows no SKILL.md text when the file is too large, but still lists the skill",
+ () =>
+ Effect.gen(function* () {
+ const { home, write } = yield* makeMachine;
+ yield* write(
+ ".agents/skills/huge/SKILL.md",
+ `${skillFile("huge", "Huge file.")}${"x".repeat(1024 * 1024)}`,
+ );
+ const { skills } = yield* withCatalog(home, (catalog) => catalog.list({}));
+ expect(byKey(skills).get("global:huge")?.description).toBe("Huge file.");
+ const detail = yield* withCatalog(home, (catalog) =>
+ catalog.get({ scope: "global", name: "huge", home: "~/.agents/skills/huge" }),
+ );
+ expect(detail.contents).toBeNull();
+ expect(detail.description).toBe("Huge file.");
+ expect(detail.files.map((file) => file.path)).toEqual(["SKILL.md"]);
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)("only reads names the agents' scanners accept", () =>
+ Effect.gen(function* () {
+ const { home } = yield* makeMachine;
+ const names = [
+ "../Knowledge/skills/architect",
+ "..",
+ ".",
+ "",
+ ".hidden",
+ "a/b",
+ "a\\b",
+ "nul\0name",
+ "nope",
+ ];
+ for (const name of names) {
+ const detail = yield* withCatalog(home, (catalog) =>
+ catalog.get({ scope: "global", name, home: `~/.agents/skills/${name}` }),
+ );
+ expect(detail).toEqual(NOT_FOUND);
+ }
+ // A project scope with no project asks for nothing.
+ const noProject = yield* withCatalog(home, (catalog) =>
+ catalog.get({ scope: "project", name: "verify", home: ".agents/skills/verify" }),
+ );
+ expect(noProject.home).toBeNull();
+ // The home to match is a label the list returned, not a path to read.
+ const elsewhere = yield* withCatalog(home, (catalog) =>
+ catalog.get({ scope: "global", name: "cloudflare", home: "/etc" }),
+ );
+ expect(elsewhere.home).toBeNull();
+ expect((yield* encodeGet(elsewhere)).home).toBeNull();
+ }),
+ );
+ });
+
+ describe("skills that come with an agent", () => {
+ /**
+ * Codex's system skills, and Claude plugins installed for everyone, for this project, for
+ * another project, and one that this project's local settings turn off.
+ */
+ const makeProvided = Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const home = yield* fs.realPath(
+ yield* fs.makeTempDirectoryScoped({ prefix: "t3code-provided-skills-" }),
+ );
+ const project = path.join(home, "repos/app");
+ const write = (relative: string, contents: string) =>
+ Effect.gen(function* () {
+ const target = path.join(home, relative);
+ yield* fs.makeDirectory(path.dirname(target), { recursive: true });
+ yield* fs.writeFileString(target, contents);
+ });
+ const cache = ".claude/plugins/cache/acme-market";
+ yield* write(
+ ".codex/skills/.system/imagegen/SKILL.md",
+ skillFile("imagegen", "Make images."),
+ );
+ yield* write(".codex/skills/.system/docs/SKILL.md", skillFile("docs", "Read the docs."));
+ yield* write(".codex/config.toml", '[[skills.config]]\nname = "docs"\nenabled = false\n');
+ // The user's own skill of the same name as a system one.
+ yield* write(".agents/skills/imagegen/SKILL.md", skillFile("imagegen", "My own images."));
+ yield* write(
+ `${cache}/review-kit/1.0.0/skills/review/SKILL.md`,
+ skillFile("review", "Review."),
+ );
+ yield* write(`${cache}/lint-kit/2.0.0/skills/lint/SKILL.md`, skillFile("lint", "Lint."));
+ yield* write(`${cache}/other-kit/1.0.0/skills/other/SKILL.md`, skillFile("other", "Other."));
+ yield* write(`${cache}/quiet-kit/1.0.0/skills/hush/SKILL.md`, skillFile("hush", "Hush."));
+ const install = (name: string, version: string, scope: string, projectPath?: string) => [
+ {
+ scope,
+ ...(projectPath ? { projectPath } : {}),
+ installPath: path.join(home, cache, name, version),
+ version,
+ installedAt: "2026-01-01T00:00:00.000Z",
+ },
+ ];
+ yield* write(
+ ".claude/plugins/installed_plugins.json",
+ JSON.stringify({
+ version: 2,
+ plugins: {
+ "review-kit@acme-market": install("review-kit", "1.0.0", "user"),
+ "lint-kit@acme-market": install("lint-kit", "2.0.0", "project", project),
+ "other-kit@acme-market": install(
+ "other-kit",
+ "1.0.0",
+ "local",
+ path.join(home, "repos/other"),
+ ),
+ "quiet-kit@acme-market": install("quiet-kit", "1.0.0", "user"),
+ },
+ }),
+ );
+ yield* write(
+ ".claude/settings.json",
+ JSON.stringify({ enabledPlugins: { "quiet-kit@acme-market": true } }),
+ );
+ yield* write(
+ "repos/app/.claude/settings.local.json",
+ JSON.stringify({ enabledPlugins: { "quiet-kit@acme-market": false } }),
+ );
+ return { home, project };
+ });
+
+ const accessWithFixed = (skill: SkillSummary | undefined) =>
+ skill?.access.map(({ instanceId, state, fixed }) => ({ instanceId, state, fixed }));
+
+ it.effect("lists them with only the agent that has them, and on or off as it says", () =>
+ Effect.gen(function* () {
+ const { home, project } = yield* makeProvided;
+ const listed = yield* withCatalog(home, (catalog) => catalog.list({ cwd: project }));
+ yield* encodeList(listed);
+ const provided = listed.skills.filter((skill) => skill.provided !== undefined);
+ expect(provided.map((skill) => [skill.scope, skill.name, skill.provided])).toEqual([
+ ["global", "docs", "agent"],
+ ["global", "imagegen", "agent"],
+ ["project", "lint-kit:lint", "plugin"],
+ ["global", "quiet-kit:hush", "plugin"],
+ ["global", "review-kit:review", "plugin"],
+ ]);
+ const byName = new Map(provided.map((skill) => [skill.name, skill]));
+
+ // Codex switches its system skills in its own settings; no other agent can have one.
+ expect(byName.get("imagegen")).toMatchObject({
+ home: "~/.codex/skills/.system/imagegen",
+ copies: [],
+ });
+ expect(byName.get("imagegen")?.realFolder).toBeUndefined();
+ expect(accessWithFixed(byName.get("imagegen"))).toEqual([
+ { instanceId: "codex", state: "direct", fixed: undefined },
+ ]);
+ expect(accessWithFixed(byName.get("docs"))).toEqual([
+ { instanceId: "codex", state: "off", fixed: undefined },
+ ]);
+ // A plugin's skills go with the plugin, which T3 Code doesn't switch.
+ expect(byName.get("review-kit:review")).toMatchObject({
+ home: "~/.claude/plugins/cache/acme-market/review-kit/1.0.0/skills/review",
+ description: "Review.",
+ });
+ expect(accessWithFixed(byName.get("review-kit:review"))).toEqual([
+ { instanceId: "claudeAgent", state: "direct", fixed: true },
+ ]);
+ // The project's local settings turn the plugin off over the user's.
+ expect(accessWithFixed(byName.get("quiet-kit:hush"))).toEqual([
+ { instanceId: "claudeAgent", state: "off", fixed: true },
+ ]);
+
+ // The user's own skill of the same name is untouched by Codex's.
+ const mine = listed.skills.find(
+ (skill) => skill.name === "imagegen" && skill.provided === undefined,
+ );
+ expect(mine).toMatchObject({ home: "~/.agents/skills/imagegen", copies: [] });
+ expect(mine?.realFolder).toBe(true);
+ expect(listed.unreadable).toEqual([]);
+
+ // Without the project, its own plugin and its settings don't apply.
+ const global = yield* withCatalog(home, (catalog) => catalog.list({}));
+ const names = global.skills.filter((skill) => skill.provided).map((skill) => skill.name);
+ expect(names).not.toContain("lint-kit:lint");
+ expect(names).not.toContain("other-kit:other");
+ expect(
+ accessWithFixed(global.skills.find((skill) => skill.name === "quiet-kit:hush")),
+ ).toEqual([{ instanceId: "claudeAgent", state: "direct", fixed: true }]);
+ }),
+ );
+
+ it.effect("reads and resolves a plugin skill by the name the agent gives it", () =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const { home, project } = yield* makeProvided;
+ const name = "review-kit:review";
+ const folder = "~/.claude/plugins/cache/acme-market/review-kit/1.0.0/skills/review";
+ const detail = yield* withCatalog(home, (catalog) =>
+ catalog.get({ scope: "global", name, home: folder }),
+ );
+ expect(detail.home).toBe(path.join(home, folder.slice(2)));
+ expect(detail.contents).toBe(skillFile("review", "Review."));
+ expect(detail.files.map((file) => file.path)).toEqual(["SKILL.md"]);
+ // The folder name alone isn't the skill's name.
+ const bare = yield* withCatalog(home, (catalog) =>
+ catalog.get({ scope: "global", name: "review", home: folder }),
+ );
+ expect(bare).toEqual(NOT_FOUND);
+
+ const [resolved] = yield* withCatalog(home, (catalog) =>
+ catalog.resolve({ cwd: project, skills: [{ scope: "global", name }] }),
+ );
+ expect(resolved).toMatchObject({ provided: "plugin", own: false });
+ expect(
+ resolved?.agents.map((agent) => [agent.instanceId, agent.state, agent.settings]),
+ ).toEqual(
+ expect.arrayContaining([
+ ["claudeAgent", "direct", undefined],
+ ["codex", "none", undefined],
+ ]),
+ );
+ const [system] = yield* withCatalog(home, (catalog) =>
+ catalog
+ .resolve({ skills: [{ scope: "global", name: "imagegen" }] })
+ .pipe(Effect.map((skills) => skills.filter((skill) => skill.provided === "agent"))),
+ );
+ expect(
+ system?.agents.find((agent) => agent.instanceId === "codex")?.settings,
+ ).toBeDefined();
+ }),
+ );
+ });
+});
diff --git a/apps/server/src/skills/SkillCatalog.ts b/apps/server/src/skills/SkillCatalog.ts
new file mode 100644
index 000000000000..d0acb3e74b8f
--- /dev/null
+++ b/apps/server/src/skills/SkillCatalog.ts
@@ -0,0 +1,1203 @@
+/**
+ * SkillCatalog - a read-only look at the skills in the folders the enabled agents read.
+ *
+ * Skills are found by reading those folders directly, never by asking an agent to look. Nothing
+ * is cached, watched, spawned or written, and every read is bounded. A link that points nowhere
+ * and a folder without a SKILL.md are skipped rather than reported as failures, so one bad entry
+ * never hides the rest; a folder that exists but can't be read is reported with the list.
+ *
+ * Agents differ on skills that share a name (see `SkillCollision`): some load only the first
+ * copy in their folder order, so a copy that another folder shadows is `none` for that instance,
+ * and others load every copy.
+ *
+ * An agent that can see a skill but whose own settings switch it off is `off`, read from the
+ * agent's settings files and never by asking the agent (see `AgentSkillSettings`). One that reads
+ * the skill's folder directly, with no setting T3 Code can write, is `fixed`.
+ *
+ * Skills that come with an agent or one of its plugins (see `ProvidedSkills`) are listed too, as
+ * `provided`, with only the instances that have them. They are never T3 Code's to move or delete,
+ * and never compared with the user's own skills of the same name.
+ *
+ * @module SkillCatalog
+ */
+import {
+ ProviderInstanceId,
+ resolveProviderInstanceEnabled,
+ type ProviderDriverKind,
+ type ProviderInstanceConfig,
+ type SkillAgentAccess,
+ type SkillAgentState,
+ type SkillCopy,
+ type SkillFile,
+ type SkillFolderProblem,
+ type SkillGetInput,
+ type SkillGetResult,
+ type SkillListInput,
+ type SkillListResult,
+ type SkillScope,
+ type SkillSummary,
+ SkillRequestError,
+} from "@t3tools/contracts";
+import * as HostProcess from "@t3tools/shared/HostProcess";
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+import * as Stream from "effect/Stream";
+
+import {
+ AGENT_SKILL_FOLDERS,
+ STANDARD_SKILL_FOLDER,
+ ownProjectFolderFor,
+ skillCollisionFor,
+ skillFoldersFor,
+ skillRootsFor,
+ type AgentSkillFolderList,
+ type SkillCollision,
+} from "@t3tools/provider-core/server/AgentSkillFolders";
+import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
+
+import { parseSkillFrontmatter } from "../provider/Drivers/ClaudeSkills.ts";
+import * as ProjectService from "../project/ProjectService.ts";
+import { deriveProviderInstanceConfigMap } from "../provider/ProviderInstanceRegistryHydration.ts";
+import * as Settings from "../serverSettings.ts";
+import { resolveAgentConfigHome } from "./AgentConfigHome.ts";
+import {
+ loadSkillSwitches,
+ skillSwitchKind,
+ type SkillSwitchContext,
+ type SkillSwitchView,
+ type SwitchedSkill,
+} from "./AgentSkillSettings.ts";
+import { codexSettingsHome } from "./CodexSkillSettings.ts";
+import {
+ LIBRARY_FOLDER,
+ RegisteredProjects,
+ libraryLinksIn,
+ linkLeadsTo,
+ type LibraryLink,
+} from "./SkillLibrary.ts";
+import { readSources } from "./SkillLockFiles.ts";
+import { providedSkillRoots } from "./ProvidedSkills.ts";
+
+const SKILL_FILE = "SKILL.md";
+const MAX_FOLDER_ENTRIES = 1_000;
+const MAX_FILES = 500;
+/** Folders walked inside one skill, and entries looked at in each. */
+const MAX_DIRECTORIES = 200;
+const MAX_DIRECTORY_ENTRIES = 1_000;
+const HEAD_BYTES = 4_096;
+/** A long description can push the closing `---` of the header past the first read. */
+const LONG_HEAD_BYTES = 32_768;
+const MAX_SKILL_BYTES = 1024 * 1024;
+const DESCRIPTION_CHARS = 160;
+const SKIPPED_DIRECTORIES = new Set([".git", "node_modules"]);
+const CONCURRENCY = 16;
+
+const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
+/**
+ * A skill's folder name is whatever the agents' scanners accept, short of what could leave the
+ * folder (`.`, `..`, separators, NUL) or hides it (a leading dot).
+ */
+const isSkillFolderName = (name: string) =>
+ name !== "" && !name.startsWith(".") && !/[\\/\0]/.test(name);
+
+const capDescription = (description: string) => {
+ const chars = [...description];
+ return chars.length > DESCRIPTION_CHARS
+ ? `${chars.slice(0, DESCRIPTION_CHARS).join("").trimEnd()}…`
+ : description;
+};
+
+/** A folder an agent reads skills from. */
+interface ReadRoot {
+ readonly scope: SkillScope;
+ readonly directory: string;
+ /** `~/.claude/skills` or `.agents/skills`, as shown to the user. */
+ readonly label: string;
+ /** The folder most agents share. */
+ readonly standard: boolean;
+ /** The library of Global skills used in only some projects, which no agent reads. */
+ readonly library?: boolean;
+ /**
+ * The folder holds skills that come with an agent or one of its plugins. A plugin's skills are
+ * named `:`.
+ */
+ readonly provided?: { readonly kind: "agent" | "plugin"; readonly plugin?: string | undefined };
+}
+
+/** What an agent invokes the skill in a folder by. */
+const skillNameIn = (root: Pick, folder: string) =>
+ root.provided?.plugin === undefined ? folder : `${root.provided.plugin}:${folder}`;
+
+/** The folder in a root that a skill of this name would be in, or undefined when it can't be. */
+const folderIn = (root: Pick, name: string) => {
+ const plugin = root.provided?.plugin;
+ if (plugin === undefined) return name;
+ return name.startsWith(`${plugin}:`) ? name.slice(plugin.length + 1) : undefined;
+};
+
+const rootKey = (root: Pick) => `${root.scope}\0${root.directory}`;
+
+/** An enabled provider instance and the folders it reads, in the order it looks. */
+interface AgentInstance {
+ readonly instanceId: ProviderInstanceId;
+ readonly driver: ProviderDriverKind;
+ readonly reads: readonly ReadRoot[];
+ /** What it takes to read and write the agent's own skill settings. */
+ readonly switches: SkillSwitchContext;
+ /** The folders of skills that come with it, and whether its settings turn each one off. */
+ readonly provided: ReadonlyArray<{ readonly root: ReadRoot; readonly off: boolean }>;
+}
+
+/** One folder entry that holds a skill: a real directory, or a link to one. */
+interface FolderEntry {
+ readonly root: ReadRoot;
+ readonly name: string;
+ /** What the link points at, as written; undefined for a real directory. */
+ readonly target: string | undefined;
+ /** Absolute path after following links. */
+ readonly home: string;
+ /** A project's link to a library skill, which is that Global skill and not one of the project's. */
+ readonly libraryLink?: boolean;
+}
+
+interface SkillHeader {
+ /** The `name` in the header, which Codex names a skill by. */
+ readonly declaredName: string | undefined;
+ readonly description: string;
+ /** Claude Code can't read the header, so it skips the skill. */
+ readonly invalid: boolean;
+}
+
+/** The same skill reached through several folders. */
+interface SkillGroup {
+ readonly scope: SkillScope;
+ readonly name: string;
+ readonly home: string;
+ readonly entries: readonly FolderEntry[];
+ readonly header: SkillHeader;
+}
+
+/** What a skill comes with, when every entry that reaches it is in a folder an agent ships. */
+const providedOf = (group: Pick) => {
+ const [first, ...rest] = group.entries;
+ const kind = first?.root.provided?.kind;
+ return kind !== undefined && rest.every((entry) => entry.root.provided !== undefined)
+ ? kind
+ : undefined;
+};
+
+/** `provided`, for a skill that comes with an agent, to spread into what is returned for it. */
+const providedField = (group: Pick) => {
+ const provided = providedOf(group);
+ return provided === undefined ? {} : { provided };
+};
+
+/**
+ * One skill as the folders hold it, with what it takes to change who reads it. `list` shows the
+ * same facts as a summary.
+ */
+export interface ResolvedSkill {
+ readonly scope: SkillScope;
+ readonly name: string;
+ /** The same display path as `SkillSummary.home`. */
+ readonly displayHome: string;
+ /** The `name` in the skill's header, when it has one. */
+ readonly declaredName?: string | undefined;
+ /** Absolute path of the skill's folder, after following links. */
+ readonly home: string;
+ /**
+ * The home is a real folder in one of the agents' skill folders, reached without a link on the
+ * way. Only such a skill is T3 Code's to move or delete; a synced library's skill is not.
+ */
+ readonly own: boolean;
+ /** Set when the skill comes with an agent or one of its plugins (see `SkillSummary.provided`). */
+ readonly provided?: "agent" | "plugin";
+ /** The shared folder of each scope, where a moved skill lands; a project's needs `cwd`. */
+ readonly standardFolders: Readonly>;
+ /**
+ * Set when the skill is kept in the library (`SkillLibrary`): its entry there, what that links
+ * to when it is a link to a synced folder, and the links to it in the registered projects'
+ * folders. Such a skill reaches an agent through those links, whichever project the list is for,
+ * so its `agents` say what the links give each agent across all of them.
+ */
+ readonly library?: {
+ readonly entry: string;
+ readonly target: string | undefined;
+ readonly links: ReadonlyArray;
+ };
+ /** Every entry in the agents' folders that reaches the skill: a real folder, or a link. */
+ readonly entries: ReadonlyArray<{
+ readonly path: string;
+ /** The folder the entry is in. */
+ readonly directory: string;
+ /** What the link points at, as written; undefined for a real folder. */
+ readonly target: string | undefined;
+ }>;
+ readonly agents: ReadonlyArray<{
+ readonly instanceId: ProviderInstanceId;
+ readonly driver: ProviderDriverKind;
+ readonly collision: SkillCollision;
+ readonly state: SkillAgentState;
+ /** Paths of the entries it loads the skill from; empty when `state` is `none`. */
+ readonly via: readonly string[];
+ /** T3 Code can't switch this agent for this skill (see `SkillAgentAccess.fixed`). */
+ readonly fixed?: boolean;
+ /** The agent's own settings switch the skill off, whether or not it can see the skill. */
+ readonly switchedOff?: boolean;
+ /** Set when the agent has a settings switch for this skill, to read and write it. */
+ readonly settings?: SkillSwitchContext | undefined;
+ /** The folders it reads, in the order it looks, across both scopes. */
+ readonly reads: ReadonlyArray<{
+ readonly scope: SkillScope;
+ readonly directory: string;
+ readonly label: string;
+ readonly standard: boolean;
+ /** The agent would load a different skill with this name from here. */
+ readonly rival: boolean;
+ }>;
+ }>;
+}
+
+const NOT_FOUND: SkillGetResult = {
+ home: null,
+ description: "",
+ contents: null,
+ files: [],
+ filesTruncated: false,
+};
+
+export class SkillCatalog extends Context.Service<
+ SkillCatalog,
+ {
+ /**
+ * One compact record per skill home, in the project (when `cwd` is given) and in the user's
+ * home folder. A `cwd` that isn't a registered project's workspace root is refused.
+ */
+ readonly list: (input: SkillListInput) => Effect.Effect;
+ /** The full SKILL.md text and the file list of one skill from `list`. */
+ readonly get: (input: SkillGetInput) => Effect.Effect;
+ /**
+ * Every skill in the agents' folders with this scope and name, as the folders hold it now.
+ * A project skill needs `cwd`, which must be a registered project's workspace root like the
+ * one `list` takes. Nothing is written.
+ */
+ readonly resolve: (input: {
+ readonly cwd?: string | undefined;
+ readonly skills: ReadonlyArray<{ readonly scope: SkillScope; readonly name: string }>;
+ }) => Effect.Effect, SkillRequestError>;
+ /**
+ * Every folder the list reads in one scope: the shared one, the library for Global, and each
+ * one an enabled agent reads. Project folders need `cwd`, a registered project's root.
+ */
+ readonly folders: (input: {
+ readonly cwd?: string | undefined;
+ readonly scope: SkillScope;
+ }) => Effect.Effect, SkillRequestError>;
+ }
+>()("t3/skills/SkillCatalog") {}
+
+const make = Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const environment = yield* HostProcess.Environment;
+ const homeDirectory = yield* HostProcess.HomeDirectory;
+ const serverSettings = yield* Settings.ServerSettingsService;
+ const projects = yield* ProjectService.ProjectService;
+ const registeredProjects = yield* RegisteredProjects;
+ // The lock and library readers take the filesystem from their environment.
+ const filesystemContext = yield* Effect.context();
+ const libraryDirectory = path.join(homeDirectory, LIBRARY_FOLDER);
+
+ /** The text at the start of a regular file, at most `maxBytes` of it. */
+ const readPrefix = Effect.fnUntraced(function* (file: string, maxBytes: number) {
+ const info = yield* fileSystem.stat(file).pipe(Effect.orElseSucceed(() => undefined));
+ if (info?.type !== "File") return undefined;
+ const size = Number(info.size);
+ if (size === 0) return { text: "", truncated: false };
+ const chunks = yield* fileSystem.stream(file, { bytesToRead: Math.min(size, maxBytes) }).pipe(
+ Stream.runCollect,
+ Effect.orElseSucceed(() => undefined),
+ );
+ return chunks
+ ? { text: Buffer.concat(chunks).toString("utf8"), truncated: size > maxBytes }
+ : undefined;
+ });
+
+ /** SKILL.md under a skill's real folder, unless it is a link that leaves the folder. */
+ const readSkillFile = Effect.fnUntraced(function* (home: string, maxBytes: number) {
+ const file = path.join(home, SKILL_FILE);
+ const real = yield* fileSystem.realPath(file).pipe(Effect.orElseSucceed(() => undefined));
+ if (real === undefined || !real.startsWith(`${home}${path.sep}`)) return undefined;
+ return yield* readPrefix(real, maxBytes);
+ });
+
+ /** The skill's header, or undefined when the folder has no readable SKILL.md. */
+ const readHeader = Effect.fnUntraced(function* (home: string) {
+ const head = yield* readSkillFile(home, HEAD_BYTES);
+ if (!head) return undefined;
+ const longer =
+ head.truncated && head.text.startsWith("---") && !FRONTMATTER.test(head.text)
+ ? yield* readSkillFile(home, LONG_HEAD_BYTES)
+ : undefined;
+ const header = parseSkillFrontmatter((longer ?? head).text);
+ return {
+ declaredName: header.kind === "parsed" ? header.name : undefined,
+ description:
+ header.kind === "parsed" ? (header.description ?? "").replace(/\s+/g, " ").trim() : "",
+ invalid: header.kind === "malformed",
+ } satisfies SkillHeader;
+ });
+
+ /** The folder `name` in a root, when it is a directory or a link to one. */
+ const entryAt = Effect.fnUntraced(function* (root: ReadRoot, name: string) {
+ const entryPath = path.join(root.directory, name);
+ const info = yield* fileSystem.stat(entryPath).pipe(Effect.orElseSucceed(() => undefined));
+ if (info?.type !== "Directory") return undefined;
+ const home = yield* fileSystem.realPath(entryPath).pipe(Effect.orElseSucceed(() => undefined));
+ if (home === undefined) return undefined;
+ const target = yield* fileSystem.readLink(entryPath).pipe(
+ Effect.map((value): string | undefined => value),
+ Effect.orElseSucceed(() => undefined),
+ );
+ const libraryLink =
+ root.scope === "project" &&
+ target !== undefined &&
+ linkLeadsTo(path, { path: entryPath, target }, path.join(libraryDirectory, name));
+ return {
+ root,
+ name,
+ target,
+ home,
+ ...(libraryLink ? { libraryLink } : {}),
+ } satisfies FolderEntry;
+ });
+
+ /**
+ * The skill folders in a root, or only those named in `only`. A root that is missing is empty;
+ * one that can't be read says so.
+ */
+ const scanRoot = Effect.fnUntraced(function* (root: ReadRoot, only?: ReadonlySet) {
+ const listed = yield* fileSystem.readDirectory(root.directory).pipe(
+ Effect.map((names) => ({ names, unreadable: false })),
+ Effect.catchTags({
+ PlatformError: (error) =>
+ Effect.succeed({ names: [] as string[], unreadable: error.reason._tag !== "NotFound" }),
+ }),
+ );
+ const entries = yield* Effect.forEach(
+ listed.names
+ .filter(
+ (name) =>
+ isSkillFolderName(name) && (only === undefined || only.has(skillNameIn(root, name))),
+ )
+ .toSorted()
+ .slice(0, MAX_FOLDER_ENTRIES),
+ (name) => entryAt(root, name),
+ { concurrency: CONCURRENCY },
+ );
+ return { root, unreadable: listed.unreadable, entries: entries.filter((e) => e !== undefined) };
+ });
+
+ /** A folder as given and as it really is, since either can prefix a real path. */
+ const rootsOf = Effect.fnUntraced(function* (directory: string) {
+ const real = yield* fileSystem.realPath(directory).pipe(Effect.orElseSucceed(() => directory));
+ return [...new Set([real, directory])];
+ });
+
+ /** The roots paths are shown against. */
+ const displayRootsOf = Effect.fnUntraced(function* (cwd: string | undefined) {
+ return {
+ project: cwd ? yield* rootsOf(cwd) : [],
+ home: yield* rootsOf(homeDirectory),
+ };
+ });
+
+ /** Relative to the project, or `~/...` under the home directory. */
+ const displayPath = (
+ absolute: string,
+ roots: { readonly project: readonly string[]; readonly home: readonly string[] },
+ ) => {
+ for (const root of roots.project) {
+ if (absolute === root) return ".";
+ if (absolute.startsWith(`${root}${path.sep}`)) {
+ return path.relative(root, absolute).replaceAll("\\", "/");
+ }
+ }
+ for (const root of roots.home) {
+ if (absolute === root) return "~";
+ if (absolute.startsWith(`${root}${path.sep}`)) {
+ return `~/${path.relative(root, absolute).replaceAll("\\", "/")}`;
+ }
+ }
+ return absolute;
+ };
+
+ /**
+ * A project's folders are read only when `cwd` is the workspace root of a project the
+ * environment knows, so a request can't have the server walk skill folders under any path on
+ * the machine. Without a `cwd` only the Global folders are read.
+ */
+ const requireProject = Effect.fnUntraced(function* (cwd: string | undefined) {
+ if (cwd === undefined) return undefined;
+ // The lookup resolves a relative path against the server's own folder, so it never sees one.
+ const project = path.isAbsolute(cwd)
+ ? yield* projects.getByWorkspaceRoot(cwd).pipe(
+ Effect.catchTags({
+ // A folder that is gone or isn't a folder can't be a project's root.
+ ProjectOperationError: (error) =>
+ error.operation === "normalize-workspace"
+ ? Effect.succeed(Option.none())
+ : Effect.die(error),
+ }),
+ )
+ : Option.none();
+ if (Option.isNone(project)) {
+ return yield* new SkillRequestError({ reason: "projectNotRegistered" });
+ }
+ return cwd;
+ });
+
+ /** A global folder as shown to the user: `~/...` under the home directory, else its path. */
+ const globalLabel = (directory: string) => {
+ const relative = path.relative(homeDirectory, directory);
+ if (relative === "") return "~";
+ return relative.startsWith("..") || path.isAbsolute(relative)
+ ? directory
+ : `~/${relative.replaceAll("\\", "/")}`;
+ };
+
+ /**
+ * Where an instance keeps its config, which its own global skill folder lives under; an agent
+ * without a setting or variable that moves it stays at its default folder under the home
+ * directory.
+ */
+ const configHomeOf = (
+ instance: ProviderInstanceConfig,
+ table: AgentSkillFolderList,
+ cwd: string | undefined,
+ ) =>
+ resolveAgentConfigHome({
+ instance,
+ fallback: path.join(homeDirectory, table.configHome ?? ""),
+ environment,
+ cwd,
+ }).pipe(
+ Effect.provideService(Path.Path, path),
+ Effect.provideService(HostProcess.HomeDirectory, homeDirectory),
+ );
+
+ /** The enabled provider instances whose folders T3 Code knows, in the table's order. */
+ const loadInstances = Effect.fnUntraced(function* (cwd: string | undefined) {
+ const settings = yield* serverSettings.getSettings.pipe(Effect.option);
+ if (Option.isNone(settings)) return [];
+ const configs = Object.entries(deriveProviderInstanceConfigMap(settings.value));
+ const instances: AgentInstance[] = [];
+ for (const table of AGENT_SKILL_FOLDERS) {
+ for (const [instanceId, config] of configs) {
+ if (config.driver !== table.agent || !resolveProviderInstanceEnabled(config)) continue;
+ const configHome = yield* configHomeOf(config, table, cwd);
+ const reads = skillRootsFor(table.agent).flatMap((root): ReadRoot[] => {
+ const standard = root.folder === STANDARD_SKILL_FOLDER;
+ if (root.scope === "project") {
+ return cwd
+ ? [
+ {
+ scope: "project",
+ directory: path.join(cwd, root.folder),
+ label: root.folder,
+ standard,
+ },
+ ]
+ : [];
+ }
+ const prefix = table.configHome === undefined ? undefined : `${table.configHome}/`;
+ const directory =
+ prefix !== undefined && root.folder.startsWith(prefix)
+ ? path.join(configHome, root.folder.slice(prefix.length))
+ : path.join(homeDirectory, root.folder);
+ return [{ scope: "global", directory, label: globalLabel(directory), standard }];
+ });
+ const instanceEnvironment = yield* mergeProviderInstanceEnvironment(
+ config.environment,
+ environment,
+ ).pipe(Effect.provideService(HostProcess.HomeDirectory, homeDirectory));
+ const provided = yield* providedSkillRoots({
+ driver: table.agent,
+ configHome,
+ cwd,
+ environment: instanceEnvironment,
+ }).pipe(
+ Effect.provideService(FileSystem.FileSystem, fileSystem),
+ Effect.provideService(Path.Path, path),
+ );
+ instances.push({
+ instanceId: ProviderInstanceId.make(instanceId),
+ driver: table.agent,
+ reads,
+ switches: {
+ driver: table.agent,
+ // The skill folders follow the instance's home; its settings file is the one its
+ // Codex runs with, which is the shadow home's when it has one.
+ configHome:
+ table.agent === "codex"
+ ? codexSettingsHome(path, config.config, configHome, homeDirectory)
+ : configHome,
+ homeDirectory,
+ environment: instanceEnvironment,
+ cwd,
+ },
+ provided: provided.map((item) => ({
+ root: {
+ scope: item.scope,
+ directory: item.directory,
+ label: globalLabel(item.directory),
+ standard: false,
+ provided: { kind: item.kind, plugin: item.plugin },
+ },
+ off: item.off,
+ })),
+ });
+ }
+ }
+ return instances;
+ });
+
+ /**
+ * The shared folders, then every folder an enabled instance reads, then the folders of skills
+ * that come with an agent, each once.
+ */
+ const rootsFor = (cwd: string | undefined, instances: readonly AgentInstance[]) => {
+ const standard: ReadRoot[] = [
+ {
+ scope: "global",
+ directory: path.join(homeDirectory, STANDARD_SKILL_FOLDER),
+ label: globalLabel(path.join(homeDirectory, STANDARD_SKILL_FOLDER)),
+ standard: true,
+ },
+ // Listed as Global skills, but read by no agent: a skill here is used where it is linked.
+ {
+ scope: "global",
+ directory: libraryDirectory,
+ label: globalLabel(libraryDirectory),
+ standard: false,
+ library: true,
+ },
+ ...(cwd
+ ? [
+ {
+ scope: "project" as const,
+ directory: path.join(cwd, STANDARD_SKILL_FOLDER),
+ label: STANDARD_SKILL_FOLDER,
+ standard: true,
+ },
+ ]
+ : []),
+ ];
+ const byKey = new Map();
+ for (const root of [
+ ...standard,
+ ...instances.flatMap((instance) => instance.reads),
+ ...instances.flatMap((instance) => instance.provided.map((item) => item.root)),
+ ]) {
+ if (!byKey.has(rootKey(root))) byKey.set(rootKey(root), root);
+ }
+ return [...byKey.values()];
+ };
+
+ /** SKILL.md text of each skill, read once, and `undefined` when it can't be shown whole. */
+ const makeTextReader = () => {
+ const texts = new Map();
+ return Effect.fnUntraced(function* (home: string) {
+ if (!texts.has(home)) {
+ const file = yield* readSkillFile(home, MAX_SKILL_BYTES);
+ texts.set(home, file && !file.truncated ? file.text : undefined);
+ }
+ return texts.get(home);
+ });
+ };
+
+ /** The other skills that share a name with each skill, and whether their text is identical. */
+ const compareCopies = Effect.fnUntraced(function* (
+ groups: readonly SkillGroup[],
+ roots: { readonly project: readonly string[]; readonly home: readonly string[] },
+ ) {
+ const textOf = makeTextReader();
+ const result = new Map();
+ // A skill that comes with an agent is that agent's, whatever the user has under its name.
+ const own = groups.filter((group) => providedOf(group) === undefined);
+ for (const members of Map.groupBy(own, (group) => group.name).values()) {
+ for (const group of members) {
+ const copies: SkillCopy[] = [];
+ for (const other of members.filter((member) => member !== group)) {
+ let same = other.home === group.home;
+ if (!same) {
+ const [left, right] = yield* Effect.all([textOf(group.home), textOf(other.home)]);
+ same = left !== undefined && left === right;
+ }
+ copies.push({ scope: other.scope, home: displayPath(other.home, roots), same });
+ }
+ if (copies.length > 0) {
+ result.set(
+ group,
+ copies.toSorted(
+ (a, b) =>
+ Number(b.scope === "project") - Number(a.scope === "project") ||
+ a.home.localeCompare(b.home),
+ ),
+ );
+ }
+ }
+ }
+ return result;
+ });
+
+ /**
+ * What the agents' folders hold, grouped by what each folder really holds, and how each
+ * instance reaches every group. `only` narrows the scan to skills with those names.
+ */
+ const scanSkills = Effect.fnUntraced(function* (
+ cwd: string | undefined,
+ only?: ReadonlySet,
+ ) {
+ const displayRoots = yield* displayRootsOf(cwd);
+ const instances = yield* loadInstances(cwd);
+ const roots = rootsFor(cwd, instances);
+ const scanned = yield* Effect.forEach(roots, (root) => scanRoot(root, only), {
+ concurrency: CONCURRENCY,
+ });
+
+ // A folder is plain when it is where its path says, with no link on the way below the base.
+ const bases = {
+ project: cwd === undefined ? undefined : { given: cwd, real: displayRoots.project[0] ?? cwd },
+ global: { given: homeDirectory, real: displayRoots.home[0] ?? homeDirectory },
+ };
+ const plainRoots = new Set();
+ yield* Effect.forEach(
+ roots,
+ (root) =>
+ Effect.gen(function* () {
+ const base = bases[root.scope];
+ const real = yield* fileSystem
+ .realPath(root.directory)
+ .pipe(Effect.orElseSucceed(() => undefined));
+ if (base === undefined || real === undefined) return;
+ const relative = path.relative(base.given, root.directory);
+ const inside = !relative.startsWith("..") && !path.isAbsolute(relative);
+ if (real === (inside ? path.join(base.real, relative) : root.directory)) {
+ plainRoots.add(rootKey(root));
+ }
+ }),
+ { concurrency: CONCURRENCY, discard: true },
+ );
+ const isOwn = (group: Pick) =>
+ group.entries.some(
+ (entry) =>
+ entry.target === undefined &&
+ entry.root.provided === undefined &&
+ plainRoots.has(rootKey(entry.root)),
+ );
+
+ // Group by what is really on disk: the same folder reached through several links is one skill.
+ const grouped = new Map>();
+ for (const { entries } of scanned) {
+ for (const entry of entries) {
+ // A project's link to a library skill is the Global skill itself.
+ const scope = entry.libraryLink ? "global" : entry.root.scope;
+ const name = skillNameIn(entry.root, entry.name);
+ const key = `${scope}\0${name}\0${entry.home}`;
+ const existing = grouped.get(key);
+ grouped.set(
+ key,
+ existing
+ ? { ...existing, entries: [...existing.entries, entry] }
+ : { scope, name, home: entry.home, entries: [entry] },
+ );
+ }
+ }
+
+ const headers = new Map(
+ yield* Effect.forEach(
+ new Set([...grouped.values()].map((group) => group.home)),
+ (home) => readHeader(home).pipe(Effect.map((header) => [home, header] as const)),
+ { concurrency: CONCURRENCY },
+ ),
+ );
+ // A folder without a SKILL.md isn't a skill, whatever links to it.
+ const groups = [...grouped.values()].flatMap((group): SkillGroup[] => {
+ const header = headers.get(group.home);
+ return header === undefined ? [] : [{ ...group, header }];
+ });
+ const groupOf = new Map(
+ groups.flatMap((group) => group.entries.map((e) => [e, group] as const)),
+ );
+ const entryAtRoot = new Map(
+ scanned.map(
+ ({ root, entries }) => [rootKey(root), new Map(entries.map((e) => [e.name, e]))] as const,
+ ),
+ );
+
+ // What each agent's own settings switch off. Read once for the scan, from files only.
+ const views = new Map(
+ groups.length === 0
+ ? []
+ : yield* Effect.forEach(
+ instances.filter((instance) =>
+ (["global", "project"] as const).some(
+ (scope) => skillSwitchKind(instance.driver, scope) !== undefined,
+ ),
+ ),
+ (instance) =>
+ loadSkillSwitches(instance.switches).pipe(
+ Effect.provideService(FileSystem.FileSystem, fileSystem),
+ Effect.provideService(Path.Path, path),
+ Effect.map((view) => [instance.instanceId, view] as const),
+ ),
+ { concurrency: CONCURRENCY },
+ ),
+ );
+ const switchedSkillOf = (group: SkillGroup): SwitchedSkill => ({
+ scope: group.scope,
+ name: group.name,
+ declaredName: group.header.declaredName,
+ home: group.home,
+ entryPaths: group.entries.map((entry) => path.join(entry.root.directory, entry.name)),
+ });
+
+ /** What an instance would load from one folder for this name, if anything. */
+ const loadableAt = (group: SkillGroup, instance: AgentInstance, root: ReadRoot) => {
+ const entry = entryAtRoot.get(rootKey(root))?.get(group.name);
+ const owner = entry && groupOf.get(entry);
+ // Claude skips a skill whose header it can't read, and it doesn't shadow a later one.
+ const skipped = instance.driver === "claudeAgent" && owner?.header.invalid === true;
+ return entry && owner && !skipped ? { entry, owner } : undefined;
+ };
+
+ // The registered projects' links to each library skill, read only when there are any.
+ const libraryEntries = new Map(
+ groups.flatMap((group) => {
+ const entry = group.entries.find((item) => item.root.library === true);
+ return entry === undefined
+ ? []
+ : [[group.name, path.join(entry.root.directory, entry.name)] as const];
+ }),
+ );
+ const libraryLinks =
+ libraryEntries.size === 0
+ ? new Map()
+ : yield* libraryLinksIn({
+ roots: yield* registeredProjects,
+ entries: libraryEntries,
+ }).pipe(Effect.provideContext(filesystemContext));
+ const isLibrary = (group: SkillGroup) =>
+ group.entries.some((entry) => entry.root.library === true);
+
+ /**
+ * How an instance reaches a library skill: through its links in the projects' folders, across
+ * all of them, whichever project the list is for. An agent that reads the shared folder has
+ * the link every project using the skill has; one that doesn't needs a link in its own folder.
+ */
+ const libraryAccessFor = (group: SkillGroup, instance: AgentInstance) => {
+ const folders = skillFoldersFor(instance.driver, "project");
+ const seen = (libraryLinks.get(group.name) ?? []).filter((link) =>
+ folders.includes(link.folder),
+ );
+ const settings =
+ skillSwitchKind(instance.driver, group.scope, "projects") === undefined
+ ? undefined
+ : instance.switches;
+ const switchedOff =
+ settings !== undefined &&
+ views.get(instance.instanceId)?.off(switchedSkillOf(group)) === true;
+ const shared = seen.some((link) => link.folder === STANDARD_SKILL_FOLDER);
+ const folder =
+ (shared ? STANDARD_SKILL_FOLDER : seen[0]?.folder) ??
+ ownProjectFolderFor(instance.driver) ??
+ STANDARD_SKILL_FOLDER;
+ const fixed = seen.length > 0 && shared && !switchedOff && settings === undefined;
+ return {
+ loadedEntries: [] as FolderEntry[],
+ switchedOff,
+ settings,
+ access: {
+ instanceId: instance.instanceId,
+ driver: instance.driver,
+ state: seen.length === 0 ? "none" : switchedOff ? "off" : shared ? "direct" : "link",
+ folder,
+ ...(fixed ? { fixed } : {}),
+ } satisfies SkillAgentAccess,
+ };
+ };
+
+ /**
+ * How an instance has a skill that comes with an agent: it is that agent's, on unless its
+ * settings switch the skill or its plugin off. Any other agent can't have it.
+ */
+ const providedAccessFor = (group: SkillGroup, instance: AgentInstance) => {
+ const keys = new Set(group.entries.map((entry) => rootKey(entry.root)));
+ const mine = instance.provided.find((item) => keys.has(rootKey(item.root)));
+ const entry = mine && group.entries.find((item) => rootKey(item.root) === rootKey(mine.root));
+ if (mine === undefined || entry === undefined) {
+ return {
+ loadedEntries: [] as FolderEntry[],
+ switchedOff: false,
+ settings: undefined,
+ access: {
+ instanceId: instance.instanceId,
+ driver: instance.driver,
+ state: "none",
+ folder: group.entries[0]?.root.label ?? "",
+ fixed: true,
+ } satisfies SkillAgentAccess,
+ };
+ }
+ // A plugin's skills are switched with the plugin, which T3 Code leaves to the agent.
+ const settings =
+ mine.root.provided?.kind === "agent" &&
+ skillSwitchKind(instance.driver, group.scope) !== undefined
+ ? instance.switches
+ : undefined;
+ const switchedOff =
+ settings !== undefined &&
+ views.get(instance.instanceId)?.off(switchedSkillOf(group)) === true;
+ return {
+ loadedEntries: mine.off ? [] : [entry],
+ switchedOff,
+ settings,
+ access: {
+ instanceId: instance.instanceId,
+ driver: instance.driver,
+ state: mine.off || switchedOff ? "off" : "direct",
+ folder: mine.root.label,
+ ...(settings === undefined ? { fixed: true } : {}),
+ } satisfies SkillAgentAccess,
+ };
+ };
+
+ /** How one instance reaches a skill: through the folders it loads it from, else `none`. */
+ const accessFor = (group: SkillGroup, instance: AgentInstance) => {
+ if (providedOf(group) !== undefined) return providedAccessFor(group, instance);
+ if (isLibrary(group)) return libraryAccessFor(group, instance);
+ const found = instance.reads.flatMap((root) => {
+ const loadable = loadableAt(group, instance, root);
+ return loadable ? [loadable] : [];
+ });
+ // A first-wins agent loads only the first copy in its order; the others load every copy.
+ const firstWins = skillCollisionFor(instance.driver) === "first-wins";
+ const loaded =
+ firstWins && found[0]?.owner !== group ? [] : found.filter((f) => f.owner === group);
+ // One copy can be reached through several of the agent's folders; the shared one is shown.
+ const via = (loaded.find((f) => f.entry.root.standard) ?? loaded[0])?.entry;
+ const loadedEntries = loaded.map((f) => f.entry);
+ // The agent's own settings, where T3 Code knows how to write them for this skill.
+ const settings =
+ skillSwitchKind(instance.driver, group.scope) === undefined ? undefined : instance.switches;
+ const switchedOff =
+ settings !== undefined &&
+ views.get(instance.instanceId)?.off(switchedSkillOf(group)) === true;
+ if (via) {
+ // A link T3 Code made can be taken away; a folder the agent reads itself can't.
+ const reachedDirectly = via.root.standard || via.target === undefined;
+ const fixed = reachedDirectly && !switchedOff && settings === undefined;
+ return {
+ loadedEntries,
+ switchedOff,
+ settings,
+ access: {
+ instanceId: instance.instanceId,
+ driver: instance.driver,
+ state: switchedOff ? "off" : reachedDirectly ? "direct" : "link",
+ folder: via.root.label,
+ ...(fixed ? { fixed } : {}),
+ } satisfies SkillAgentAccess,
+ };
+ }
+ const looksIn = instance.reads.find((root) => root.scope === group.scope);
+ return {
+ loadedEntries,
+ switchedOff,
+ settings,
+ access: {
+ instanceId: instance.instanceId,
+ driver: instance.driver,
+ state: "none",
+ folder:
+ looksIn?.label ??
+ (group.scope === "global"
+ ? globalLabel(path.join(homeDirectory, STANDARD_SKILL_FOLDER))
+ : STANDARD_SKILL_FOLDER),
+ } satisfies SkillAgentAccess,
+ };
+ };
+
+ return {
+ displayRoots,
+ instances,
+ scanned,
+ groups,
+ accessFor,
+ loadableAt,
+ isOwn,
+ roots,
+ libraryLinks,
+ };
+ });
+
+ const list: SkillCatalog["Service"]["list"] = Effect.fn("SkillCatalog.list")(function* (input) {
+ const cwd = yield* requireProject(input.cwd);
+ const { displayRoots, instances, scanned, groups, accessFor, isOwn, libraryLinks } =
+ yield* scanSkills(cwd);
+ const copies = yield* compareCopies(groups, displayRoots);
+ const sources = yield* readSources({
+ environment,
+ home: homeDirectory,
+ projectRoot: cwd,
+ }).pipe(Effect.provideContext(filesystemContext));
+
+ const skills = groups.map((group): SkillSummary => {
+ const provided = providedOf(group);
+ if (provided !== undefined) {
+ return {
+ name: group.name,
+ scope: group.scope,
+ home: displayPath(group.home, displayRoots),
+ description: capDescription(group.header.description),
+ ...(group.header.invalid ? { invalidHeader: true } : {}),
+ copies: [],
+ // Only the agents that have it: no other one can.
+ access: instances
+ .map((instance) => accessFor(group, instance).access)
+ .filter((access) => access.state !== "none"),
+ provided,
+ };
+ }
+ const source = (group.scope === "project" ? sources.project : sources.global).get(group.name);
+ // The projects a library skill is used in: where the shared folder has its link.
+ const using = group.entries.some((item) => item.root.library === true)
+ ? [
+ ...new Set(
+ (libraryLinks.get(group.name) ?? [])
+ .filter((link) => link.folder === STANDARD_SKILL_FOLDER)
+ .map((link) => link.project),
+ ),
+ ]
+ : [];
+ return {
+ name: group.name,
+ scope: group.scope,
+ home: displayPath(group.home, displayRoots),
+ description: capDescription(group.header.description),
+ ...(group.header.invalid ? { invalidHeader: true } : {}),
+ ...(isOwn(group) ? { realFolder: true } : {}),
+ copies: copies.get(group) ?? [],
+ access: instances.map((instance) => accessFor(group, instance).access),
+ ...(source === undefined ? {} : { source }),
+ ...(using.length === 0 ? {} : { projects: using }),
+ };
+ });
+
+ const unreadable = new Map();
+ for (const { root, unreadable: failed } of scanned) {
+ // An agent's own folders are its business; a broken one only hides what it ships.
+ if (failed && root.provided === undefined)
+ unreadable.set(`${root.scope}\0${root.label}`, { scope: root.scope, folder: root.label });
+ }
+
+ return {
+ skills: skills.toSorted(
+ (a, b) =>
+ a.name.localeCompare(b.name) ||
+ Number(b.scope === "project") - Number(a.scope === "project"),
+ ),
+ unreadable: [...unreadable.values()],
+ };
+ });
+
+ /** Where a group is kept in the library, when it is, and the projects' links to it. */
+ const libraryOf = (group: SkillGroup, links: ReadonlyMap) => {
+ const entry = group.entries.find((item) => item.root.library === true);
+ return entry === undefined
+ ? {}
+ : {
+ library: {
+ entry: path.join(entry.root.directory, entry.name),
+ target: entry.target,
+ links: links.get(group.name) ?? [],
+ },
+ };
+ };
+
+ const resolve: SkillCatalog["Service"]["resolve"] = Effect.fn("SkillCatalog.resolve")(
+ function* (input) {
+ const cwd = yield* requireProject(input.cwd);
+ const wanted = input.skills.filter(
+ (skill) => isSkillFolderName(skill.name) && (skill.scope === "global" || cwd !== undefined),
+ );
+ if (wanted.length === 0) return [];
+ const { displayRoots, instances, groups, accessFor, loadableAt, isOwn, roots, libraryLinks } =
+ yield* scanSkills(cwd, new Set(wanted.map((skill) => skill.name)));
+ const standardFolders = {
+ project: roots.find((root) => root.scope === "project" && root.standard)?.directory,
+ global: roots.find((root) => root.scope === "global" && root.standard)?.directory,
+ };
+ const wantedKeys = new Set(wanted.map((skill) => `${skill.scope}\0${skill.name}`));
+ return groups
+ .filter((group) => wantedKeys.has(`${group.scope}\0${group.name}`))
+ .map((group): ResolvedSkill => ({
+ scope: group.scope,
+ name: group.name,
+ displayHome: displayPath(group.home, displayRoots),
+ declaredName: group.header.declaredName,
+ home: group.home,
+ own: isOwn(group),
+ ...providedField(group),
+ standardFolders,
+ ...libraryOf(group, libraryLinks),
+ entries: group.entries.map((entry) => ({
+ path: path.join(entry.root.directory, entry.name),
+ directory: entry.root.directory,
+ target: entry.target,
+ })),
+ agents: instances.map((instance) => {
+ const { access, loadedEntries, switchedOff, settings } = accessFor(group, instance);
+ return {
+ instanceId: instance.instanceId,
+ driver: instance.driver,
+ collision: skillCollisionFor(instance.driver),
+ state: access.state,
+ via: loadedEntries.map((entry) => path.join(entry.root.directory, entry.name)),
+ ...("fixed" in access ? { fixed: true } : {}),
+ ...(switchedOff ? { switchedOff } : {}),
+ settings,
+ reads: instance.reads.map((root) => {
+ const loadable = loadableAt(group, instance, root);
+ return {
+ scope: root.scope,
+ directory: root.directory,
+ label: root.label,
+ standard: root.standard,
+ rival: loadable !== undefined && loadable.owner !== group,
+ };
+ }),
+ };
+ }),
+ }));
+ },
+ );
+
+ /**
+ * Relative paths and sizes of the files under a skill's folder, breadth first. It stops at the
+ * file limit, and bounds the folders it enters and the entries it looks at in each, so a skill
+ * with a huge or deeply branching tree costs a fixed amount of work.
+ */
+ const walkSkillFiles = Effect.fnUntraced(function* (root: string) {
+ const files: SkillFile[] = [];
+ const pending = [""];
+ let visited = 0;
+ let truncated = false;
+ let full = false;
+ while (pending.length > 0 && !full) {
+ const relative = pending.shift() ?? "";
+ visited += 1;
+ const names = (yield* fileSystem
+ .readDirectory(path.join(root, relative))
+ .pipe(Effect.orElseSucceed((): string[] => []))).toSorted();
+ if (names.length > MAX_DIRECTORY_ENTRIES) truncated = true;
+ const looked = names.slice(0, MAX_DIRECTORY_ENTRIES);
+ for (let start = 0; start < looked.length && !full; start += CONCURRENCY) {
+ const children = yield* Effect.forEach(
+ looked.slice(start, start + CONCURRENCY),
+ (name) =>
+ Effect.gen(function* () {
+ const absolute = path.join(root, relative, name);
+ const link = yield* fileSystem.readLink(absolute).pipe(
+ Effect.as(true),
+ Effect.orElseSucceed(() => false),
+ );
+ const info = link
+ ? undefined
+ : yield* fileSystem.stat(absolute).pipe(Effect.orElseSucceed(() => undefined));
+ return { name, link, info };
+ }),
+ { concurrency: CONCURRENCY },
+ );
+ for (const { name, link, info } of children) {
+ const childPath = relative ? `${relative}/${name}` : name;
+ if (info?.type === "Directory") {
+ if (SKIPPED_DIRECTORIES.has(name)) continue;
+ if (visited + pending.length >= MAX_DIRECTORIES) truncated = true;
+ else pending.push(childPath);
+ continue;
+ }
+ // Links count as files and are never followed; other special files aren't shown.
+ if (!link && info?.type !== "File") continue;
+ if (files.length >= MAX_FILES) {
+ truncated = true;
+ full = true;
+ break;
+ }
+ files.push({
+ path: childPath,
+ size: info ? Number(info.size) : 0,
+ executable: info !== undefined && (info.mode & 0o111) !== 0,
+ });
+ }
+ }
+ }
+ return {
+ files: files.toSorted((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
+ truncated,
+ };
+ });
+
+ const get: SkillCatalog["Service"]["get"] = Effect.fn("SkillCatalog.get")(function* (input) {
+ const cwd = yield* requireProject(input.cwd);
+ const base = input.scope === "project" ? cwd : homeDirectory;
+ if (!base || !isSkillFolderName(input.name)) return NOT_FOUND;
+ // Only the folders agents read are looked in, so the request can't name an arbitrary path.
+ const roots = rootsFor(cwd, yield* loadInstances(cwd)).filter(
+ (root) => root.scope === input.scope,
+ );
+ const candidates = yield* Effect.forEach(
+ roots,
+ (root) => {
+ const folder = folderIn(root, input.name);
+ return folder === undefined || !isSkillFolderName(folder)
+ ? Effect.succeed(undefined)
+ : entryAt(root, folder);
+ },
+ { concurrency: CONCURRENCY },
+ );
+ const displayRoots = yield* displayRootsOf(cwd);
+ const chosen = candidates.find(
+ (entry) => entry !== undefined && displayPath(entry.home, displayRoots) === input.home,
+ );
+ if (!chosen) return NOT_FOUND;
+ const header = yield* readHeader(chosen.home);
+ const skillFile = yield* readSkillFile(chosen.home, MAX_SKILL_BYTES);
+ const { files, truncated } = yield* walkSkillFiles(chosen.home);
+ return {
+ home: chosen.home,
+ description: header?.description ?? "",
+ contents: skillFile && !skillFile.truncated ? skillFile.text : null,
+ files,
+ filesTruncated: truncated,
+ };
+ });
+
+ const folders: SkillCatalog["Service"]["folders"] = Effect.fn("SkillCatalog.folders")(
+ function* (input) {
+ const cwd = yield* requireProject(input.cwd);
+ return rootsFor(cwd, yield* loadInstances(cwd))
+ .filter((root) => root.scope === input.scope)
+ .map((root) => root.directory);
+ },
+ );
+
+ return SkillCatalog.of({ list, get, resolve, folders });
+});
+
+export const layer = Layer.effect(SkillCatalog, make);
diff --git a/apps/server/src/skills/SkillCreate.test.ts b/apps/server/src/skills/SkillCreate.test.ts
new file mode 100644
index 000000000000..e19f8f649109
--- /dev/null
+++ b/apps/server/src/skills/SkillCreate.test.ts
@@ -0,0 +1,65 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { describe, expect, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+
+import { parseSkillFrontmatter } from "../provider/Drivers/ClaudeSkills.ts";
+import { skillFileText, writeNewSkill } from "./SkillCreate.ts";
+
+describe("skillFileText", () => {
+ // Each is text YAML would read as something else, or not at all, if it were written bare.
+ it.each([
+ "Review a pull request.",
+ "Use when: the diff touches the API",
+ "Say \"hello\" and 'goodbye'",
+ "# not a comment",
+ "- not a list item",
+ "{ not: a map }",
+ "yes",
+ "null",
+ "1024",
+ "a tab\there and a back\\slash",
+ `Ünïcode, emoji 🚀 and a line separator ${String.fromCharCode(0x2028)} inside`,
+ ])("reads back %j as the description, as Claude Code reads the header", (description) => {
+ expect(parseSkillFrontmatter(skillFileText("review-code", description))).toEqual({
+ kind: "parsed",
+ name: "review-code",
+ description,
+ });
+ });
+});
+
+it.layer(NodeServices.layer, { excludeTestServices: true })("writeNewSkill", (it) => {
+ const makeFolder = Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-create-" });
+ return { fs, path, folder: path.join(root, ".agents/skills") };
+ });
+
+ it.effect("makes the folder it goes in, and leaves nothing else behind", () =>
+ Effect.gen(function* () {
+ const { fs, path, folder } = yield* makeFolder;
+ const result = yield* writeNewSkill({ folder, name: "ship-it", description: "Ship it." });
+ expect(result).toBe("created");
+ expect(yield* fs.readDirectory(folder)).toEqual(["ship-it"]);
+ expect(yield* fs.readDirectory(path.join(folder, "ship-it"))).toEqual(["SKILL.md"]);
+ }),
+ );
+
+ it.effect("leaves a file or a folder with that name as it was", () =>
+ Effect.gen(function* () {
+ const { fs, path, folder } = yield* makeFolder;
+ yield* fs.makeDirectory(path.join(folder, "notes"), { recursive: true });
+ yield* fs.writeFileString(path.join(folder, "notes/todo.md"), "keep me");
+ yield* fs.writeFileString(path.join(folder, "plain"), "keep me too");
+
+ expect(yield* writeNewSkill({ folder, name: "notes", description: "x" })).toBe("taken");
+ expect(yield* writeNewSkill({ folder, name: "plain", description: "x" })).toBe("taken");
+ expect((yield* fs.readDirectory(folder)).toSorted()).toEqual(["notes", "plain"]);
+ expect(yield* fs.readDirectory(path.join(folder, "notes"))).toEqual(["todo.md"]);
+ expect(yield* fs.readFileString(path.join(folder, "plain"))).toBe("keep me too");
+ }),
+ );
+});
diff --git a/apps/server/src/skills/SkillCreate.ts b/apps/server/src/skills/SkillCreate.ts
new file mode 100644
index 000000000000..a16afe497b25
--- /dev/null
+++ b/apps/server/src/skills/SkillCreate.ts
@@ -0,0 +1,71 @@
+/**
+ * SkillCreate - writes a new skill's folder: a SKILL.md with the header agents read and a line
+ * for the person to replace.
+ *
+ * The folder is made whole in a hidden folder beside where it goes, which no agent reads, and
+ * then renamed into place. An agent never sees half a skill, and nothing already there is
+ * replaced: a rename stops at a file, a link or a folder with something in it.
+ *
+ * @module SkillCreate
+ */
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+
+/**
+ * SKILL.md for a new skill. The name is one agents accept, which YAML reads as plain text; the
+ * description is written as a JSON string, which YAML reads back as the same text whatever it holds.
+ */
+export const skillFileText = (name: string, description: string) =>
+ [
+ "---",
+ `name: ${name}`,
+ `description: ${JSON.stringify(description)}`,
+ "---",
+ "",
+ `# ${name}`,
+ "",
+ "Write the steps the agent should follow when it uses this skill.",
+ "",
+ ].join("\n");
+
+/**
+ * Makes `//SKILL.md`, making `folder` if it is missing. `taken` when something is
+ * already at `/`; nothing is changed then.
+ */
+export const writeNewSkill = Effect.fn("SkillCreate.writeNewSkill")(function* (input: {
+ readonly folder: string;
+ readonly name: string;
+ readonly description: string;
+}) {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const destination = path.join(input.folder, input.name);
+ yield* fs.makeDirectory(input.folder, { recursive: true });
+ const staging = yield* fs.makeTempDirectory({ directory: input.folder, prefix: ".t3-new-" });
+ // Made inside the private staging folder, the skill's own folder gets the usual permissions.
+ const made = path.join(staging, input.name);
+ return yield* Effect.gen(function* () {
+ yield* fs.makeDirectory(made);
+ yield* fs.writeFileString(
+ path.join(made, "SKILL.md"),
+ skillFileText(input.name, input.description),
+ );
+ return yield* fs.rename(made, destination).pipe(
+ Effect.as("created" as const),
+ Effect.catchTags({
+ PlatformError: (error) => {
+ const cause: unknown = error.reason.cause;
+ const code =
+ typeof cause === "object" && cause !== null && "code" in cause ? cause.code : undefined;
+ return error.reason._tag === "AlreadyExists" || code === "ENOTEMPTY" || code === "ENOTDIR"
+ ? Effect.succeed("taken" as const)
+ : Effect.fail(error);
+ },
+ }),
+ );
+ }).pipe(
+ // The staging folder is this request's own; whatever stopped the write, it goes.
+ Effect.onExit(() => fs.remove(staging, { recursive: true }).pipe(Effect.ignore)),
+ );
+});
diff --git a/apps/server/src/skills/SkillGitExclude.test.ts b/apps/server/src/skills/SkillGitExclude.test.ts
new file mode 100644
index 000000000000..2bfe1ca39224
--- /dev/null
+++ b/apps/server/src/skills/SkillGitExclude.test.ts
@@ -0,0 +1,282 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { describe, expect, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+
+import * as ProcessRunner from "../processRunner.ts";
+import * as VcsProcess from "../vcs/VcsProcess.ts";
+import {
+ EXCLUDE_BLOCK_END,
+ EXCLUDE_BLOCK_START,
+ editExcludeBlock,
+ excludeNewFile,
+ updateExclude,
+} from "./SkillGitExclude.ts";
+
+const block = (...lines: string[]) => [EXCLUDE_BLOCK_START, ...lines, EXCLUDE_BLOCK_END].join("\n");
+
+const git = (cwd: string, args: ReadonlyArray) =>
+ Effect.gen(function* () {
+ const runner = yield* ProcessRunner.ProcessRunner;
+ return yield* runner.run({
+ command: "git",
+ args: ["-C", cwd, "-c", "user.name=Test", "-c", "user.email=test@example.com", ...args],
+ });
+ }).pipe(Effect.provide(ProcessRunner.layer));
+
+const run = (effect: Effect.Effect) =>
+ effect.pipe(Effect.provide(VcsProcess.layer));
+
+const makeRepo = Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const root = yield* fs.realPath(yield* fs.makeTempDirectoryScoped({ prefix: "t3code-exclude-" }));
+ const repo = path.join(root, "acme-web");
+ yield* fs.makeDirectory(repo, { recursive: true });
+ yield* git(repo, ["init", "-q", "-b", "main"]);
+ // Not the machine's own global ignore file, which may already name what a test creates.
+ yield* git(repo, ["config", "core.excludesFile", path.join(root, "global-ignore")]);
+ yield* fs.writeFileString(path.join(repo, "README.md"), "# acme-web\n");
+ yield* git(repo, ["add", "-A"]);
+ yield* git(repo, ["commit", "-q", "-m", "init"]);
+ return { fs, path, root, repo };
+});
+
+it.layer(NodeServices.layer, { excludeTestServices: true })("SkillGitExclude", (it) => {
+ describe("editExcludeBlock", () => {
+ it("starts a block at the end and leaves the user's lines alone", () => {
+ expect(editExcludeBlock("*.log\n", { add: ["/.agents/skills/a"], remove: [] })).toBe(
+ `*.log\n${block("/.agents/skills/a")}\n`,
+ );
+ expect(editExcludeBlock("*.log", { add: ["/.agents/skills/a"], remove: [] })).toBe(
+ `*.log\n${block("/.agents/skills/a")}\n`,
+ );
+ expect(editExcludeBlock("", { add: ["/.agents/skills/a"], remove: [] })).toBe(
+ `${block("/.agents/skills/a")}\n`,
+ );
+ });
+
+ it("adds to and removes from the block without repeating a line", () => {
+ const start = `# mine\n${block("/a", "/b")}\n# after\n`;
+
+ expect(editExcludeBlock(start, { add: ["/b", "/c"], remove: [] })).toBe(
+ `# mine\n${block("/a", "/b", "/c")}\n# after\n`,
+ );
+ expect(editExcludeBlock(start, { add: [], remove: ["/a"] })).toBe(
+ `# mine\n${block("/b")}\n# after\n`,
+ );
+ });
+
+ it("removes the whole block when its last line goes, and gives back the file it started from", () => {
+ const original = "# mine\n*.log\n";
+ const withBlock = editExcludeBlock(original, { add: ["/a", "/b"], remove: [] });
+
+ expect(editExcludeBlock(withBlock, { add: [], remove: ["/a", "/b"] })).toBe(original);
+ expect(editExcludeBlock(`${block("/a")}\n`, { add: [], remove: ["/a"] })).toBe("");
+ expect(editExcludeBlock(original, { add: [], remove: ["/never-there"] })).toBe(original);
+ });
+
+ it("leaves an unfinished block as it found it", () => {
+ const broken = `*.log\n${EXCLUDE_BLOCK_START}\n/kept\n`;
+
+ expect(editExcludeBlock(broken, { add: ["/a"], remove: [] })).toBe(broken);
+ expect(editExcludeBlock(broken, { add: [], remove: ["/kept"] })).toBe(broken);
+ });
+ });
+
+ describe("updateExclude", () => {
+ it.effect("keeps links out of git status, and takes the lines out again", () =>
+ Effect.gen(function* () {
+ const { fs, path, repo } = yield* makeRepo;
+ const link = path.join(repo, ".agents/skills/db-migrations");
+ yield* fs.makeDirectory(path.dirname(link), { recursive: true });
+ yield* fs.symlink(path.join(repo, "README.md"), link);
+ expect((yield* git(repo, ["status", "--porcelain"])).stdout).toContain("?? .agents/");
+
+ yield* run(updateExclude({ projectRoot: repo, links: [link], action: "add" }));
+ const exclude = path.join(repo, ".git/info/exclude");
+ expect(yield* fs.readFileString(exclude)).toContain(
+ `${EXCLUDE_BLOCK_START}\n/.agents/skills/db-migrations\n${EXCLUDE_BLOCK_END}\n`,
+ );
+ expect((yield* git(repo, ["status", "--porcelain"])).stdout).toBe("");
+
+ yield* run(updateExclude({ projectRoot: repo, links: [link], action: "remove" }));
+ expect(yield* fs.readFileString(exclude)).not.toContain("T3 Code");
+ expect((yield* git(repo, ["status", "--porcelain"])).stdout).toContain("?? .agents/");
+ }),
+ );
+
+ it.effect("anchors the lines at the repository when the project is a folder inside it", () =>
+ Effect.gen(function* () {
+ const { fs, path, repo } = yield* makeRepo;
+ const project = path.join(repo, "packages/web");
+ const link = path.join(project, ".agents/skills/db-migrations");
+ yield* fs.makeDirectory(path.dirname(link), { recursive: true });
+ yield* fs.symlink(path.join(repo, "README.md"), link);
+
+ yield* run(updateExclude({ projectRoot: project, links: [link], action: "add" }));
+
+ expect(yield* fs.readFileString(path.join(repo, ".git/info/exclude"))).toContain(
+ "\n/packages/web/.agents/skills/db-migrations\n",
+ );
+ expect((yield* git(repo, ["status", "--porcelain"])).stdout).toBe("");
+ }),
+ );
+
+ it.effect("writes to the common git dir, so every worktree shares the lines", () =>
+ Effect.gen(function* () {
+ const { fs, path, root, repo } = yield* makeRepo;
+ const worktree = path.join(root, "acme-web-feature");
+ yield* git(repo, ["worktree", "add", "-q", "-b", "feature", worktree]);
+ const link = path.join(worktree, ".agents/skills/db-migrations");
+ yield* fs.makeDirectory(path.dirname(link), { recursive: true });
+ yield* fs.symlink(path.join(repo, "README.md"), link);
+
+ yield* run(updateExclude({ projectRoot: worktree, links: [link], action: "add" }));
+
+ expect(yield* fs.readFileString(path.join(repo, ".git/info/exclude"))).toContain(
+ "\n/.agents/skills/db-migrations\n",
+ );
+ expect((yield* git(worktree, ["status", "--porcelain"])).stdout).toBe("");
+ expect((yield* git(repo, ["status", "--porcelain"])).stdout).toBe("");
+ }),
+ );
+
+ it.effect("quotes a skill name that git would read as a pattern", () =>
+ Effect.gen(function* () {
+ const { fs, path, repo } = yield* makeRepo;
+ const link = path.join(repo, ".agents/skills/[draft]*");
+ yield* fs.makeDirectory(path.dirname(link), { recursive: true });
+ yield* fs.symlink(path.join(repo, "README.md"), link);
+ const other = path.join(repo, ".agents/skills/d");
+ yield* fs.symlink(path.join(repo, "README.md"), other);
+
+ yield* run(updateExclude({ projectRoot: repo, links: [link], action: "add" }));
+
+ // Only the link named, not the one the unquoted pattern would also match.
+ expect((yield* git(repo, ["status", "--porcelain", "-uall"])).stdout).toBe(
+ "?? .agents/skills/d\n",
+ );
+ }),
+ );
+
+ it.effect("does nothing for a project that isn't in a git repository", () =>
+ Effect.gen(function* () {
+ const { fs, path, root } = yield* makeRepo;
+ const loose = path.join(root, "marketing-site");
+ const link = path.join(loose, ".agents/skills/db-migrations");
+ yield* fs.makeDirectory(path.dirname(link), { recursive: true });
+ yield* fs.symlink(path.join(root, "acme-web/README.md"), link);
+
+ yield* run(updateExclude({ projectRoot: loose, links: [link], action: "add" }));
+
+ expect(yield* fs.exists(path.join(loose, ".git"))).toBe(false);
+ expect(yield* fs.readDirectory(path.join(loose, ".agents"))).toEqual(["skills"]);
+ }),
+ );
+
+ it.effect("leaves an unfinished block alone, and goes on without failing", () =>
+ Effect.gen(function* () {
+ const { fs, path, repo } = yield* makeRepo;
+ const link = path.join(repo, ".agents/skills/db-migrations");
+ yield* fs.makeDirectory(path.dirname(link), { recursive: true });
+ yield* fs.symlink(path.join(repo, "README.md"), link);
+ const exclude = path.join(repo, ".git/info/exclude");
+ const broken = `*.log\n${EXCLUDE_BLOCK_START}\n/kept\n`;
+ yield* fs.writeFileString(exclude, broken);
+
+ yield* run(updateExclude({ projectRoot: repo, links: [link], action: "add" }));
+ yield* run(updateExclude({ projectRoot: repo, links: [link], action: "remove" }));
+
+ // No second block is added, so a later edit never pairs the dangling start with a new end.
+ expect(yield* fs.readFileString(exclude)).toBe(broken);
+ expect((yield* git(repo, ["status", "--porcelain"])).stdout).toContain("?? .agents/");
+ }),
+ );
+
+ it.effect("fails, and writes nothing, when the exclude file can't be written", () =>
+ Effect.gen(function* () {
+ const { fs, path, repo } = yield* makeRepo;
+ const link = path.join(repo, ".agents/skills/db-migrations");
+ yield* fs.makeDirectory(path.dirname(link), { recursive: true });
+ yield* fs.symlink(path.join(repo, "README.md"), link);
+ // A file where the info folder goes.
+ yield* fs.remove(path.join(repo, ".git/info"), { recursive: true });
+ yield* fs.writeFileString(path.join(repo, ".git/info"), "not a folder");
+
+ const failure = yield* run(
+ updateExclude({ projectRoot: repo, links: [link], action: "add" }),
+ ).pipe(Effect.flip);
+
+ expect(failure).toBeDefined();
+ expect(yield* fs.readFileString(path.join(repo, ".git/info"))).toBe("not a folder");
+ }),
+ );
+ });
+
+ describe("excludeNewFile", () => {
+ const LOCAL_BLOCK =
+ "# T3 Code: local settings\n/.claude/settings.local.json\n# End T3 Code: local settings\n";
+
+ it.effect("keeps a file that was just created out of git, in a block of its own", () =>
+ Effect.gen(function* () {
+ const { fs, path, repo } = yield* makeRepo;
+ const file = path.join(repo, ".claude/settings.local.json");
+ yield* fs.makeDirectory(path.dirname(file), { recursive: true });
+ yield* fs.writeFileString(file, "{}\n");
+ expect((yield* git(repo, ["status", "--porcelain"])).stdout).toBe("?? .claude/\n");
+
+ yield* run(excludeNewFile({ projectRoot: repo, file }));
+
+ expect(yield* fs.readFileString(path.join(repo, ".git/info/exclude"))).toContain(
+ LOCAL_BLOCK,
+ );
+ expect((yield* git(repo, ["status", "--porcelain", "-uall"])).stdout).toBe("");
+ // Doing it again changes nothing.
+ const before = yield* fs.readFileString(path.join(repo, ".git/info/exclude"));
+ yield* run(excludeNewFile({ projectRoot: repo, file }));
+ expect(yield* fs.readFileString(path.join(repo, ".git/info/exclude"))).toBe(before);
+ }),
+ );
+
+ it.effect("leaves a file the repository ignores already, or tracks, alone", () =>
+ Effect.gen(function* () {
+ const { fs, path, repo } = yield* makeRepo;
+ const exclude = path.join(repo, ".git/info/exclude");
+ const before = yield* fs.readFileString(exclude);
+ const ignored = path.join(repo, ".claude/settings.local.json");
+ yield* fs.makeDirectory(path.dirname(ignored), { recursive: true });
+ yield* fs.writeFileString(ignored, "{}\n");
+ yield* fs.writeFileString(
+ path.join(repo, ".gitignore"),
+ "**/.claude/settings.local.json\n",
+ );
+
+ yield* run(excludeNewFile({ projectRoot: repo, file: ignored }));
+ expect(yield* fs.readFileString(exclude)).toBe(before);
+
+ // Tracked, whatever ignores it.
+ yield* fs.remove(path.join(repo, ".gitignore"));
+ yield* git(repo, ["add", "-f", ".claude/settings.local.json"]);
+ yield* git(repo, ["commit", "-q", "-m", "track it"]);
+ yield* run(excludeNewFile({ projectRoot: repo, file: ignored }));
+ expect(yield* fs.readFileString(exclude)).toBe(before);
+ }),
+ );
+
+ it.effect("does nothing for a project that isn't in a git repository", () =>
+ Effect.gen(function* () {
+ const { fs, path, root } = yield* makeRepo;
+ const loose = path.join(root, "marketing-site");
+ const file = path.join(loose, ".claude/settings.local.json");
+ yield* fs.makeDirectory(path.dirname(file), { recursive: true });
+ yield* fs.writeFileString(file, "{}\n");
+
+ yield* run(excludeNewFile({ projectRoot: loose, file }));
+
+ expect(yield* fs.exists(path.join(loose, ".git"))).toBe(false);
+ }),
+ );
+ });
+});
diff --git a/apps/server/src/skills/SkillGitExclude.ts b/apps/server/src/skills/SkillGitExclude.ts
new file mode 100644
index 000000000000..c34f173173fc
--- /dev/null
+++ b/apps/server/src/skills/SkillGitExclude.ts
@@ -0,0 +1,207 @@
+/**
+ * SkillGitExclude - keeps the links to library skills out of a project's git status.
+ *
+ * Those links are the user's own wiring, not part of the project, so they are listed in the
+ * repository's `info/exclude` (in the common git dir, so every worktree of the repository shares
+ * it) instead of a `.gitignore` that gets committed. T3 Code owns one marked block there and
+ * leaves every other line alone; the block goes when its last line does. A project that isn't in a
+ * git repository has no exclude file, so its links need nothing. The same repository's other
+ * worktrees (`worktreesOf`) hold the links the worktree hook made in them.
+ *
+ * A file T3 Code creates in a project that isn't meant to be committed, Claude's
+ * `.claude/settings.local.json`, is kept out of git the same way (`excludeNewFile`), in a block of
+ * its own.
+ *
+ * @module SkillGitExclude
+ */
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
+
+import * as VcsProcess from "../vcs/VcsProcess.ts";
+
+export const EXCLUDE_BLOCK_START = "# T3 Code: skills used from Global";
+export const EXCLUDE_BLOCK_END = "# End T3 Code: skills used from Global";
+
+/** The lines T3 Code owns in the exclude file are between a start and an end marker. */
+export interface ExcludeBlock {
+ readonly start: string;
+ readonly end: string;
+}
+
+const LIBRARY_BLOCK: ExcludeBlock = { start: EXCLUDE_BLOCK_START, end: EXCLUDE_BLOCK_END };
+const LOCAL_SETTINGS_BLOCK: ExcludeBlock = {
+ start: "# T3 Code: local settings",
+ end: "# End T3 Code: local settings",
+};
+
+/** A path as one exclude line: anchored at the repository root, with its glob characters quoted. */
+const excludeLine = (relative: string) =>
+ `/${relative.replace(/[\\*?[\]]/g, "\\$&").replace(/ +$/, (spaces) => "\\ ".repeat(spaces.length))}`;
+
+/**
+ * `text` with the lines in `add` in T3 Code's block and those in `remove` out of it. A block left
+ * empty is removed whole. An unfinished block (a start without its end) is left as it is, and so
+ * is the text around it: a second block would pair the dangling start with the new end, and T3
+ * Code could no longer tell which lines are its own.
+ */
+export const editExcludeBlock = (
+ text: string,
+ change: { readonly add: readonly string[]; readonly remove: readonly string[] },
+ block: ExcludeBlock = LIBRARY_BLOCK,
+) => {
+ const lines = text === "" ? [] : text.split("\n");
+ if (lines.at(-1) === "") lines.pop();
+ const start = lines.indexOf(block.start);
+ const end = start < 0 ? -1 : lines.indexOf(block.end, start + 1);
+ if (start >= 0 && end < 0) return text;
+ const kept = start >= 0 ? lines.slice(start + 1, end) : [];
+ const removed = new Set(change.remove);
+ const inBlock = [...kept.filter((line) => !removed.has(line)), ...change.add].filter(
+ (line, index, all) => all.indexOf(line) === index,
+ );
+ const marked = inBlock.length === 0 ? [] : [block.start, ...inBlock, block.end];
+ const next =
+ start >= 0
+ ? [...lines.slice(0, start), ...marked, ...lines.slice(end + 1)]
+ : [...lines, ...marked];
+ return next.length === 0 ? "" : `${next.join("\n")}\n`;
+};
+
+/**
+ * Adds the links to, or removes them from, the block in the repository's exclude file. `links`
+ * are absolute paths inside `projectRoot`. Nothing happens outside a git repository, and a repo
+ * whose exclude file can't be written fails.
+ */
+export const updateExclude = Effect.fn("SkillGitExclude.updateExclude")(function* (input: {
+ readonly projectRoot: string;
+ readonly links: ReadonlyArray;
+ readonly action: "add" | "remove";
+ /** The block the lines are kept in; the one for skill links by default. */
+ readonly block?: ExcludeBlock;
+}) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const vcs = yield* VcsProcess.VcsProcess;
+ if (input.links.length === 0) return;
+ const result = yield* vcs
+ .run({
+ operation: "SkillGitExclude.updateExclude",
+ command: "git",
+ args: ["rev-parse", "--git-common-dir", "--show-prefix"],
+ cwd: input.projectRoot,
+ allowNonZeroExit: true,
+ timeoutMs: 5_000,
+ maxOutputBytes: 16 * 1024,
+ })
+ .pipe(Effect.orElseSucceed(() => undefined));
+ if (result === undefined || result.exitCode !== 0) return;
+ const [commonDir = "", prefix = ""] = result.stdout.split("\n");
+ if (commonDir === "") return;
+ const file = path.join(path.resolve(input.projectRoot, commonDir), "info", "exclude");
+ const lines = input.links.map((link) =>
+ excludeLine(`${prefix}${path.relative(input.projectRoot, link).replaceAll("\\", "/")}`),
+ );
+ const text = yield* fileSystem.readFileString(file).pipe(
+ Effect.catchTags({
+ PlatformError: (error) =>
+ error.reason._tag === "NotFound" ? Effect.succeed("") : Effect.fail(error),
+ }),
+ );
+ const next = editExcludeBlock(
+ text,
+ input.action === "add" ? { add: lines, remove: [] } : { add: [], remove: lines },
+ input.block,
+ );
+ if (next === text || (text === "" && next === "")) return;
+ yield* writeFileStringAtomically({ filePath: file, contents: next });
+});
+
+/**
+ * The checkouts of the repository a project is in, the project's own among them: the paths
+ * `git worktree list --porcelain` names. Empty outside a git repository.
+ */
+export const worktreesOf = Effect.fn("SkillGitExclude.worktreesOf")(function* (
+ projectRoot: string,
+) {
+ const vcs = yield* VcsProcess.VcsProcess;
+ const result = yield* vcs
+ .run({
+ operation: "SkillGitExclude.worktreesOf",
+ command: "git",
+ args: ["worktree", "list", "--porcelain"],
+ cwd: projectRoot,
+ allowNonZeroExit: true,
+ timeoutMs: 5_000,
+ maxOutputBytes: 256 * 1024,
+ })
+ .pipe(Effect.orElseSucceed(() => undefined));
+ if (result === undefined || result.exitCode !== 0) return [];
+ return result.stdout
+ .split("\n")
+ .filter((line) => line.startsWith("worktree ") && line.length > "worktree ".length)
+ .map((line) => line.slice("worktree ".length));
+});
+
+/**
+ * The project's folder relative to its repository's root, as `git rev-parse --show-prefix` says:
+ * empty when the project is the root, and outside a git repository. A checkout of the repository
+ * has the project at that path under its own root.
+ */
+export const projectPrefixOf = Effect.fn("SkillGitExclude.projectPrefixOf")(function* (
+ projectRoot: string,
+) {
+ const vcs = yield* VcsProcess.VcsProcess;
+ const result = yield* vcs
+ .run({
+ operation: "SkillGitExclude.projectPrefixOf",
+ command: "git",
+ args: ["rev-parse", "--show-prefix"],
+ cwd: projectRoot,
+ allowNonZeroExit: true,
+ timeoutMs: 5_000,
+ maxOutputBytes: 16 * 1024,
+ })
+ .pipe(Effect.orElseSucceed(() => undefined));
+ return result === undefined || result.exitCode !== 0 ? "" : result.stdout.trim();
+});
+
+/**
+ * Keeps a file T3 Code has just created in a project out of git: Claude Code's own
+ * `.claude/settings.local.json`, which is the user's and not the repository's. Claude Code does
+ * this itself when it creates the file: "Claude Code keeps it out of git when it creates the file"
+ * (https://code.claude.com/docs/en/settings, "Settings files"), by adding it to the global git
+ * excludes the first time it writes the file in a repository that doesn't already ignore it. T3
+ * Code follows that rule, but in the repository's own `info/exclude`, since it doesn't edit the
+ * user's global git configuration. A file the repository already ignores or tracks is left alone,
+ * and so is a project that isn't in a git repository.
+ */
+export const excludeNewFile = Effect.fn("SkillGitExclude.excludeNewFile")(function* (input: {
+ readonly projectRoot: string;
+ readonly file: string;
+}) {
+ const vcs = yield* VcsProcess.VcsProcess;
+ const asked = (args: ReadonlyArray) =>
+ vcs
+ .run({
+ operation: "SkillGitExclude.excludeNewFile",
+ command: "git",
+ args,
+ cwd: input.projectRoot,
+ allowNonZeroExit: true,
+ timeoutMs: 5_000,
+ maxOutputBytes: 16 * 1024,
+ })
+ .pipe(Effect.orElseSucceed(() => undefined));
+ // `check-ignore` is 0 for an ignored file; `ls-files` is 0 for a tracked one.
+ const ignored = yield* asked(["check-ignore", "-q", "--", input.file]);
+ const tracked = yield* asked(["ls-files", "--error-unmatch", "--", input.file]);
+ if (ignored?.exitCode === 0 || tracked?.exitCode === 0) return;
+ yield* updateExclude({
+ projectRoot: input.projectRoot,
+ links: [input.file],
+ action: "add",
+ block: LOCAL_SETTINGS_BLOCK,
+ });
+});
diff --git a/apps/server/src/skills/SkillLibrary.test.ts b/apps/server/src/skills/SkillLibrary.test.ts
new file mode 100644
index 000000000000..f11f2aa3537e
--- /dev/null
+++ b/apps/server/src/skills/SkillLibrary.test.ts
@@ -0,0 +1,346 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { describe, expect, it } from "@effect/vitest";
+import { ProjectId, SkillListResult, type Project, type SkillSummary } from "@t3tools/contracts";
+import * as HostProcess from "@t3tools/shared/HostProcess";
+import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+
+import * as ProjectService from "../project/ProjectService.ts";
+import * as Settings from "../serverSettings.ts";
+import * as SkillCatalog from "./SkillCatalog.ts";
+import { RegisteredProjects, restoreLibraryLinks } from "./SkillLibrary.ts";
+
+const encodeList = Schema.encodeUnknownEffect(SkillListResult);
+
+const skillFile = (name: string) => `---\nname: ${name}\ndescription: The ${name} skill.\n---\n`;
+
+/**
+ * A machine with a library holding `db-migrations` (a real folder) and `alpha` (a link to a synced
+ * folder), three projects that link to some of them, and a project that isn't registered.
+ */
+const makeMachine = Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const home = yield* fs.realPath(yield* fs.makeTempDirectoryScoped({ prefix: "t3code-library-" }));
+ const write = (relative: string, contents: string) =>
+ Effect.gen(function* () {
+ const target = path.join(home, relative);
+ yield* fs.makeDirectory(path.dirname(target), { recursive: true });
+ yield* fs.writeFileString(target, contents);
+ });
+ const link = (target: string, from: string) =>
+ Effect.gen(function* () {
+ yield* fs.makeDirectory(path.dirname(path.join(home, from)), { recursive: true });
+ yield* fs.symlink(target, path.join(home, from));
+ });
+ const library = path.join(home, ".agents/skill-library");
+ const projects = ["acme-web", "acme-api", "marketing-site", "stranger"].map((name) =>
+ path.join(home, "repos", name),
+ );
+ const [web, api, marketing, stranger] = projects as [string, string, string, string];
+
+ yield* write(".agents/skill-library/db-migrations/SKILL.md", skillFile("db-migrations"));
+ yield* write("Knowledge/skills/alpha/SKILL.md", skillFile("alpha"));
+ yield* link(path.join(home, "Knowledge/skills/alpha"), ".agents/skill-library/alpha");
+ yield* write("repos/acme-web/elsewhere/solo/SKILL.md", skillFile("solo"));
+
+ // web and api use db-migrations; web also uses alpha; stranger isn't registered.
+ for (const project of [web, api, stranger]) {
+ yield* link(
+ path.join(library, "db-migrations"),
+ path.relative(home, path.join(project, ".agents/skills/db-migrations")),
+ );
+ }
+ yield* link(
+ path.join(library, "alpha"),
+ path.relative(home, path.join(web, ".agents/skills/alpha")),
+ );
+ // A link to a skill that isn't the library's is the project's own.
+ yield* link(
+ path.join(web, "elsewhere/solo"),
+ path.relative(home, path.join(web, ".agents/skills/solo")),
+ );
+ yield* fs.makeDirectory(marketing, { recursive: true });
+ return { fs, path, home, write, library, web, api, marketing, stranger };
+});
+
+const makeProject = (workspaceRoot: string): Project => ({
+ id: ProjectId.make("project-skill-library"),
+ title: "App",
+ workspaceRoot,
+ repositoryIdentity: null,
+ faviconPath: null,
+ projectIcon: null,
+ defaultModelSelection: null,
+ defaultThreadEnvMode: null,
+ autoPull: false,
+ scripts: [],
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ deletedAt: null,
+});
+
+const onMachine = (
+ home: string,
+ registered: readonly string[],
+ use: (catalog: SkillCatalog.SkillCatalog["Service"]) => Effect.Effect,
+ environment: NodeJS.ProcessEnv = {},
+) =>
+ Effect.gen(function* () {
+ return yield* use(yield* SkillCatalog.SkillCatalog);
+ }).pipe(
+ Effect.provide(
+ SkillCatalog.layer.pipe(
+ Layer.provide(Settings.layerTest({})),
+ Layer.provide(
+ Layer.mock(ProjectService.ProjectService)({
+ getByWorkspaceRoot: (root) =>
+ Effect.succeed(
+ registered.includes(root) ? Option.some(makeProject(root)) : Option.none(),
+ ),
+ }),
+ ),
+ ),
+ ),
+ Effect.provideService(HostProcess.Environment, { HOME: home, ...environment }),
+ Effect.provideService(HostProcess.HomeDirectory, home),
+ Effect.provideService(RegisteredProjects, Effect.succeed(registered)),
+ );
+
+const rowOf = (skills: readonly SkillSummary[], scope: SkillSummary["scope"], name: string) =>
+ skills.find((skill) => skill.scope === scope && skill.name === name);
+
+const statesOf = (row: SkillSummary | undefined) =>
+ row === undefined
+ ? undefined
+ : Object.fromEntries(row.access.map((access) => [access.instanceId, access.state]));
+
+it.layer(NodeServices.layer, { excludeTestServices: true })("SkillLibrary", (it) => {
+ describe("the list", () => {
+ it.effect.skipIf(!symlinksSupported)(
+ "shows library skills as Global, with the registered projects that link to them",
+ () =>
+ Effect.gen(function* () {
+ const { home, web, api, marketing } = yield* makeMachine;
+ yield* onMachine(home, [marketing, api, web], (catalog) =>
+ Effect.gen(function* () {
+ const result = yield* catalog.list({});
+ yield* encodeList(result);
+
+ const migrations = rowOf(result.skills, "global", "db-migrations");
+ expect(migrations).toMatchObject({
+ home: "~/.agents/skill-library/db-migrations",
+ realFolder: true,
+ });
+ // In the order the projects were given; the unregistered one isn't named.
+ expect(migrations?.projects).toEqual([api, web]);
+ expect(rowOf(result.skills, "global", "alpha")?.projects).toEqual([web]);
+ // A synced skill's folder stays where it is, so T3 Code can't move or delete it.
+ expect(rowOf(result.skills, "global", "alpha")).toMatchObject({
+ home: "~/Knowledge/skills/alpha",
+ });
+ expect(rowOf(result.skills, "global", "alpha")?.realFolder).toBeUndefined();
+ // No agent reads the library; each has what the projects' links give it, across all
+ // of them. Codex reads the shared folder the links are in. Claude reads its own,
+ // where nothing is linked yet.
+ for (const name of ["db-migrations", "alpha"]) {
+ expect(statesOf(rowOf(result.skills, "global", name))).toEqual({
+ claudeAgent: "none",
+ codex: "direct",
+ });
+ }
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "shows a project's link to a library skill as that Global skill, not as the project's own",
+ () =>
+ Effect.gen(function* () {
+ const { home, web, api, marketing } = yield* makeMachine;
+ yield* onMachine(home, [web, api, marketing], (catalog) =>
+ Effect.gen(function* () {
+ const inWeb = (yield* catalog.list({ cwd: web })).skills;
+
+ expect(
+ inWeb.filter((skill) => skill.name === "db-migrations").map((skill) => skill.scope),
+ ).toEqual(["global"]);
+ expect(rowOf(inWeb, "global", "db-migrations")?.projects).toEqual([web, api]);
+ // The project reads its folder, so the agents that read it have the skill here.
+ expect(
+ rowOf(inWeb, "global", "db-migrations")?.access.find(
+ (access) => access.instanceId === "codex",
+ )?.state,
+ ).toBe("direct");
+ // Its own skill, linked the same way, is still its own.
+ expect(rowOf(inWeb, "project", "solo")).toBeDefined();
+
+ // A project that doesn't link to it sees the same Global skill, with the same
+ // agents: they are the skill's, not the project's.
+ const inMarketing = (yield* catalog.list({ cwd: marketing })).skills;
+ expect(rowOf(inMarketing, "global", "db-migrations")?.projects).toEqual([web, api]);
+ expect(statesOf(rowOf(inMarketing, "global", "db-migrations"))).toEqual(
+ statesOf(rowOf(inWeb, "global", "db-migrations")),
+ );
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "names no projects when none is registered or none links to the skill",
+ () =>
+ Effect.gen(function* () {
+ const { home, marketing } = yield* makeMachine;
+ yield* onMachine(home, [marketing], (catalog) =>
+ Effect.gen(function* () {
+ const { skills } = yield* catalog.list({});
+
+ expect(rowOf(skills, "global", "db-migrations")).toBeDefined();
+ expect(rowOf(skills, "global", "db-migrations")?.projects).toBeUndefined();
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "gives a skill its source from the skills CLI's lock, in the project and in Global",
+ () =>
+ Effect.gen(function* () {
+ const { home, web, write } = yield* makeMachine;
+ yield* write(
+ "repos/acme-web/skills-lock.json",
+ JSON.stringify({
+ version: 1,
+ skills: {
+ solo: { source: "acme/skills", sourceType: "github", computedHash: "x" },
+ },
+ }),
+ );
+ yield* write(
+ "state/skills/.skill-lock.json",
+ JSON.stringify({
+ version: 3,
+ skills: {
+ "db-migrations": {
+ source: "acme/migrations",
+ sourceType: "github",
+ sourceUrl: "https://github.com/acme/migrations.git",
+ skillFolderHash: "",
+ installedAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ },
+ },
+ }),
+ );
+ yield* onMachine(
+ home,
+ [web],
+ (catalog) =>
+ Effect.gen(function* () {
+ const { skills } = yield* catalog.list({ cwd: web });
+
+ expect(rowOf(skills, "project", "solo")?.source).toBe("acme/skills");
+ expect(rowOf(skills, "global", "db-migrations")?.source).toBe("acme/migrations");
+ expect(rowOf(skills, "global", "alpha")?.source).toBeUndefined();
+ }),
+ { XDG_STATE_HOME: `${home}/state` },
+ );
+ }),
+ );
+ });
+
+ describe("restoreLibraryLinks", () => {
+ it.effect.skipIf(!symlinksSupported)(
+ "makes the same links in a worktree, keeping out of the way of what is there",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, library, web } = yield* makeMachine;
+ const worktree = path.join(home, "worktrees/acme-web-feature");
+ yield* fs.makeDirectory(path.join(worktree, ".agents/skills/alpha"), { recursive: true });
+ yield* fs.writeFileString(path.join(worktree, ".agents/skills/alpha/SKILL.md"), "kept");
+
+ yield* restoreLibraryLinks({ project: web, worktree, prefix: "" }).pipe(
+ Effect.provideService(HostProcess.HomeDirectory, home),
+ );
+
+ expect(yield* fs.readLink(path.join(worktree, ".agents/skills/db-migrations"))).toBe(
+ path.join(library, "db-migrations"),
+ );
+ // Something already there stays, and the project's own links aren't copied.
+ expect(
+ yield* fs.readFileString(path.join(worktree, ".agents/skills/alpha/SKILL.md")),
+ ).toBe("kept");
+ expect(yield* fs.exists(path.join(worktree, ".agents/skills/solo"))).toBe(false);
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "makes the links in the project's own folder of a worktree of the whole repository",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, library, web } = yield* makeMachine;
+ const worktree = path.join(home, "worktrees/monorepo-feature");
+
+ // The project is `apps/web` of its repository, and the worktree is the repository.
+ yield* restoreLibraryLinks({ project: web, worktree, prefix: "apps/web/" }).pipe(
+ Effect.provideService(HostProcess.HomeDirectory, home),
+ );
+
+ expect(
+ yield* fs.readLink(path.join(worktree, "apps/web/.agents/skills/db-migrations")),
+ ).toBe(path.join(library, "db-migrations"));
+ expect(yield* fs.exists(path.join(worktree, ".agents"))).toBe(false);
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "writes a relative project link as the library skill's own path",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, library } = yield* makeMachine;
+ const project = path.join(home, "repos/relative");
+ const link = path.join(project, ".agents/skills/db-migrations");
+ yield* fs.makeDirectory(path.dirname(link), { recursive: true });
+ yield* fs.symlink(
+ path.relative(path.dirname(link), path.join(library, "db-migrations")),
+ link,
+ );
+ // A depth where the project link's relative target would lead somewhere else.
+ const worktree = path.join(home, "worktrees/deeper/still/relative-feature");
+
+ yield* restoreLibraryLinks({ project, worktree, prefix: "" }).pipe(
+ Effect.provideService(HostProcess.HomeDirectory, home),
+ );
+
+ const created = path.join(worktree, ".agents/skills/db-migrations");
+ expect(yield* fs.readLink(created)).toBe(path.join(library, "db-migrations"));
+ expect(yield* fs.exists(path.join(created, "SKILL.md"))).toBe(true);
+ }),
+ );
+
+ it.effect("does nothing for a project without links, or one that has gone", () =>
+ Effect.gen(function* () {
+ const { fs, path, home, marketing } = yield* makeMachine;
+ const worktree = path.join(home, "worktrees/marketing-feature");
+
+ yield* restoreLibraryLinks({ project: marketing, worktree, prefix: "" }).pipe(
+ Effect.provideService(HostProcess.HomeDirectory, home),
+ );
+ yield* restoreLibraryLinks({
+ project: path.join(home, "repos/gone"),
+ worktree,
+ prefix: "",
+ }).pipe(Effect.provideService(HostProcess.HomeDirectory, home));
+
+ expect(yield* fs.exists(worktree)).toBe(false);
+ }),
+ );
+ });
+});
diff --git a/apps/server/src/skills/SkillLibrary.ts b/apps/server/src/skills/SkillLibrary.ts
new file mode 100644
index 000000000000..253dcfb6258b
--- /dev/null
+++ b/apps/server/src/skills/SkillLibrary.ts
@@ -0,0 +1,192 @@
+/**
+ * SkillLibrary - the one copy behind a Global skill that is used in only some projects.
+ *
+ * Such a skill lives in `~/.agents/skill-library/`, and each project that uses it has an
+ * absolute link to that at `/.agents/skills/` (and one in another agent's own folder
+ * when that agent needs it). An edit to the skill shows up in every project, and the links sit
+ * outside git (see `SkillGitExclude`).
+ *
+ * No agent scans the library folder, which is why a skill kept there is used only where it is
+ * linked. Each agent reads a folder named `skills` inside `.agents` or its own folder, never a
+ * neighbour of it, and none of the patterns below matches `skill-library`:
+ * - Claude Code reads `/skills` and `/.claude/skills` (`ClaudeSkills.ts`).
+ * - Codex's roots are `/.agents/skills` (`roots_from_layer_stack`) and each ancestor's
+ * `.agents/skills` (`repo_agents_skill_roots`), `AGENTS_DIR_NAME` and `SKILLS_DIR_NAME` in
+ * codex-rs/ext/skills/src/host_roots.rs (openai/codex@8e23d1836f).
+ * - OpenCode scans `skills/**\/SKILL.md` under `~/.agents` and the project's `.agents`
+ * (`EXTERNAL_SKILL_PATTERN` in packages/opencode/src/skill/index.ts, anomalyco/opencode@4ac0d9c3d1).
+ * - Pi reads `~/.agents/skills` and `.agents/skills` (`userAgentsSkillsDir` in
+ * packages/coding-agent/src/core/package-manager.ts and docs/skills.md, earendil-works/pi@43d3763991).
+ * - Cursor, Grok and Antigravity read the folders in `AgentSkillFolders.ts`, none of them this one.
+ *
+ * @module SkillLibrary
+ */
+import * as HostProcess from "@t3tools/shared/HostProcess";
+
+import * as Cause from "effect/Cause";
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import {
+ AGENT_SKILL_FOLDERS,
+ STANDARD_SKILL_FOLDER,
+} from "@t3tools/provider-core/server/AgentSkillFolders";
+
+/** Under the home folder; the shared folder's neighbour, not inside it. */
+export const LIBRARY_FOLDER = ".agents/skill-library";
+
+/** Every folder an agent reads skills from inside a project, the shared one first. */
+const PROJECT_SKILL_FOLDERS: readonly string[] = [
+ ...new Set([
+ STANDARD_SKILL_FOLDER,
+ ...AGENT_SKILL_FOLDERS.flatMap((agent) =>
+ agent.reads.filter((root) => root.scope === "project").map((root) => root.folder),
+ ),
+ ]),
+];
+
+/**
+ * The workspace roots of this environment's registered projects. The server provides the real
+ * list to the skill catalog; without one, no project is known and no library skill shows the
+ * projects it is used in.
+ */
+export const RegisteredProjects = Context.Reference>>(
+ "t3/skills/RegisteredProjects",
+ { defaultValue: () => Effect.succeed([]) },
+);
+
+/** Whether a link at `linkPath` with this target, as written, leads to `entry`. */
+export const linkLeadsTo = (
+ path: Path.Path,
+ link: { readonly path: string; readonly target: string },
+ entry: string,
+) => path.resolve(path.dirname(link.path), link.target) === entry;
+
+/** A link in one project's skill folder that leads to a library entry. */
+export interface LibraryLink {
+ readonly project: string;
+ readonly path: string;
+ /** What the link points at, as written. */
+ readonly target: string;
+ /** The folder it is in, relative to the project. */
+ readonly folder: string;
+}
+
+/**
+ * Every link, in any agent's project folder in these projects, that leads to a library entry,
+ * for each skill in `entries` (its name, then its library entry's path). A folder is read once
+ * per project whatever the number of skills, and only a name that is a library skill is looked at
+ * further. The links of a skill come in the order of the projects, then of the folders.
+ */
+export const libraryLinksIn = Effect.fn("SkillLibrary.libraryLinksIn")(function* (input: {
+ readonly roots: ReadonlyArray;
+ readonly entries: ReadonlyMap;
+}) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const found = new Map();
+ if (input.entries.size === 0) return found;
+ const perProject = yield* Effect.forEach(
+ input.roots,
+ (project) =>
+ Effect.gen(function* () {
+ const links: Array = [];
+ for (const folder of PROJECT_SKILL_FOLDERS) {
+ const names = yield* fileSystem
+ .readDirectory(path.join(project, folder))
+ .pipe(Effect.orElseSucceed((): string[] => []));
+ for (const name of names) {
+ const entry = input.entries.get(name);
+ if (entry === undefined) continue;
+ const linkPath = path.join(project, folder, name);
+ const target = yield* fileSystem.readLink(linkPath).pipe(
+ Effect.map((value): string | undefined => value),
+ Effect.orElseSucceed(() => undefined),
+ );
+ if (target !== undefined && linkLeadsTo(path, { path: linkPath, target }, entry)) {
+ links.push([name, { project, path: linkPath, target, folder }]);
+ }
+ }
+ }
+ return links;
+ }),
+ { concurrency: 8 },
+ );
+ for (const links of perProject) {
+ for (const [name, link] of links) found.set(name, [...(found.get(name) ?? []), link]);
+ }
+ return found;
+});
+
+/** Every link, in any agent's project folder in these projects, that leads to `entry`. */
+export const libraryLinksOf = Effect.fn("SkillLibrary.libraryLinksOf")(function* (input: {
+ readonly roots: ReadonlyArray;
+ readonly name: string;
+ readonly entry: string;
+}) {
+ const found = yield* libraryLinksIn({
+ roots: input.roots,
+ entries: new Map([[input.name, input.entry]]),
+ });
+ return found.get(input.name) ?? [];
+});
+
+/**
+ * Links to a project's library skills into a worktree that was just made from it. The links sit
+ * outside git, so a worktree has none until they are made. Nothing in the way is replaced, and a
+ * failure is logged and goes no further: a worktree without the links is still a worktree.
+ *
+ * A worktree is a checkout of the whole repository, so a project that is a folder inside its
+ * repository is at `prefix` under the worktree's root.
+ */
+export const restoreLibraryLinks = Effect.fn("SkillLibrary.restoreLibraryLinks")(
+ function* (input: {
+ readonly project: string;
+ readonly worktree: string;
+ /** The project's folder relative to its repository's root, empty when it is the root. */
+ readonly prefix: string;
+ }) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const home = yield* HostProcess.HomeDirectory;
+ const library = path.join(home, LIBRARY_FOLDER);
+ for (const folder of PROJECT_SKILL_FOLDERS) {
+ const names = yield* fileSystem
+ .readDirectory(path.join(input.project, folder))
+ .pipe(Effect.orElseSucceed((): string[] => []));
+ for (const name of names) {
+ const linkPath = path.join(input.project, folder, name);
+ const target = yield* fileSystem.readLink(linkPath).pipe(
+ Effect.map((value): string | undefined => value),
+ Effect.orElseSucceed(() => undefined),
+ );
+ if (target === undefined) continue;
+ // A relative link would lead somewhere else from the worktree's own depth, so the new
+ // link names the library skill's absolute path.
+ const entry = path.resolve(path.dirname(linkPath), target);
+ if (path.dirname(entry) !== library) continue;
+ const created = path.join(input.worktree, input.prefix, folder, name);
+ yield* fileSystem.makeDirectory(path.dirname(created), { recursive: true });
+ // A bare create: something already there, such as a skill the project commits, stays.
+ yield* fileSystem.symlink(entry, created).pipe(
+ Effect.catchTags({
+ PlatformError: (error) =>
+ error.reason._tag === "AlreadyExists" ? Effect.void : Effect.fail(error),
+ }),
+ );
+ }
+ }
+ },
+ (effect, input) =>
+ effect.pipe(
+ Effect.catchCause((cause) =>
+ Cause.hasInterruptsOnly(cause)
+ ? Effect.interrupt
+ : Effect.logWarning("could not link library skills into the new worktree", {
+ worktree: input.worktree,
+ cause: Cause.pretty(cause),
+ }),
+ ),
+ ),
+);
diff --git a/apps/server/src/skills/SkillLinks.test.ts b/apps/server/src/skills/SkillLinks.test.ts
new file mode 100644
index 000000000000..31aa11377718
--- /dev/null
+++ b/apps/server/src/skills/SkillLinks.test.ts
@@ -0,0 +1,288 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { describe, expect, it } from "@effect/vitest";
+import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+
+import { createLink, linkSpec, removeLink, SkillLinkError } from "./SkillLinks.ts";
+
+/** A temp folder holding one project and one library folder, with their paths made real. */
+const makeFolders = Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const root = yield* fs.realPath(yield* fs.makeTempDirectoryScoped({ prefix: "t3code-links-" }));
+ const project = path.join(root, "app");
+ const library = path.join(root, "library");
+ const skill = (folder: string, name: string) =>
+ Effect.gen(function* () {
+ const home = path.join(folder, name);
+ yield* fs.makeDirectory(home, { recursive: true });
+ yield* fs.writeFileString(path.join(home, "SKILL.md"), `---\nname: ${name}\n---\n`);
+ yield* fs.writeFileString(path.join(home, "notes.txt"), "keep me");
+ return home;
+ });
+ return { fs, path, root, project, library, skill };
+});
+
+describe("linkSpec", () => {
+ const home = "/data/skills/review";
+
+ it.each([
+ {
+ name: "a project link on any system is relative when the skill is in the project",
+ input: { platform: "linux", scope: "project", home, relative: "../../.agents/skills/review" },
+ expected: { type: "dir", target: "../../.agents/skills/review" },
+ },
+ {
+ name: "a project link is absolute when the skill is outside the project",
+ input: { platform: "linux", scope: "project", home, relative: undefined },
+ expected: { type: "dir", target: home },
+ },
+ {
+ name: "a global link is absolute",
+ input: { platform: "darwin", scope: "global", home, relative: undefined },
+ expected: { type: "dir", target: home },
+ },
+ {
+ name: "a global link on Windows is a junction, which needs no privilege and an absolute path",
+ input: { platform: "win32", scope: "global", home, relative: undefined },
+ expected: { type: "junction", target: home },
+ },
+ {
+ name: "a project link on Windows stays a relative symlink, since a junction can't be committed",
+ input: { platform: "win32", scope: "project", home, relative: "../review" },
+ expected: { type: "dir", target: "../review" },
+ },
+ ] as const)("$name", ({ input, expected }) => {
+ expect(linkSpec(input)).toEqual(expected);
+ });
+});
+
+it.layer(NodeServices.layer, { excludeTestServices: true })("SkillLinks", (it) => {
+ describe("createLink", () => {
+ it.effect.skipIf(!symlinksSupported)(
+ "makes a project link relative, creating the folder it goes in",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, project, skill } = yield* makeFolders;
+ const home = yield* skill(path.join(project, ".agents/skills"), "review");
+ const link = path.join(project, ".claude/skills/review");
+
+ const result = yield* createLink({
+ link,
+ home,
+ scope: "project",
+ platform: "linux",
+ projectRoot: project,
+ });
+
+ expect(result).toBe("created");
+ expect(yield* fs.readLink(link)).toBe("../../.agents/skills/review");
+ expect(yield* fs.realPath(link)).toBe(home);
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)("makes a global link absolute", () =>
+ Effect.gen(function* () {
+ const { fs, path, root, library, skill } = yield* makeFolders;
+ const home = yield* skill(library, "review");
+ const link = path.join(root, "home/.claude/skills/review");
+
+ expect(yield* createLink({ link, home, scope: "global", platform: "linux" })).toBe(
+ "created",
+ );
+ expect(yield* fs.readLink(link)).toBe(home);
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "makes a global link on Windows the way it would there: absolute, to the same folder",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, root, library, skill } = yield* makeFolders;
+ const home = yield* skill(library, "review");
+ const link = path.join(root, "home/.claude/skills/review");
+
+ // Node ignores the `junction` type away from Windows, so this makes a plain symlink.
+ expect(yield* createLink({ link, home, scope: "global", platform: "win32" })).toBe(
+ "created",
+ );
+ expect(yield* fs.readLink(link)).toBe(home);
+ expect(yield* fs.realPath(link)).toBe(home);
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "keeps a project link working when the project folder is moved",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, root, project, skill } = yield* makeFolders;
+ const home = yield* skill(path.join(project, ".agents/skills"), "review");
+ yield* createLink({
+ link: path.join(project, ".claude/skills/review"),
+ home,
+ scope: "project",
+ platform: "linux",
+ projectRoot: project,
+ });
+
+ const moved = path.join(root, "app-renamed");
+ yield* fs.rename(project, moved);
+
+ expect(yield* fs.realPath(path.join(moved, ".claude/skills/review"))).toBe(
+ path.join(moved, ".agents/skills/review"),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "reads a relative target from where the link's folder really is",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, root, project, skill } = yield* makeFolders;
+ const home = yield* skill(path.join(project, ".agents/skills"), "review");
+ // `.claude` is a link to a dotfiles folder elsewhere, so `../..` means something else there.
+ const dotfiles = path.join(root, "dotfiles/claude");
+ yield* fs.makeDirectory(path.join(dotfiles, "skills"), { recursive: true });
+ yield* fs.symlink(dotfiles, path.join(project, ".claude"));
+ const link = path.join(project, ".claude/skills/review");
+
+ expect(
+ yield* createLink({
+ link,
+ home,
+ scope: "project",
+ platform: "linux",
+ projectRoot: project,
+ }),
+ ).toBe("created");
+ expect(yield* fs.realPath(link)).toBe(home);
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)("leaves a real folder alone and says it is taken", () =>
+ Effect.gen(function* () {
+ const { fs, path, root, library, skill } = yield* makeFolders;
+ const home = yield* skill(library, "review");
+ const link = path.join(root, "home/.claude/skills/review");
+ yield* fs.makeDirectory(link, { recursive: true });
+ yield* fs.writeFileString(path.join(link, "mine.md"), "my own notes");
+
+ expect(yield* createLink({ link, home, scope: "global", platform: "linux" })).toBe("taken");
+
+ expect(yield* fs.readLink(link).pipe(Effect.flip)).toBeDefined();
+ expect(yield* fs.readFileString(path.join(link, "mine.md"))).toBe("my own notes");
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "leaves a link to another folder alone and says it is taken",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, root, library, skill } = yield* makeFolders;
+ const home = yield* skill(library, "review");
+ const other = yield* skill(library, "review-copy");
+ const link = path.join(root, "home/.claude/skills/review");
+ yield* fs.makeDirectory(path.dirname(link), { recursive: true });
+ yield* fs.symlink(other, link);
+
+ expect(yield* createLink({ link, home, scope: "global", platform: "linux" })).toBe(
+ "taken",
+ );
+ expect(yield* fs.readLink(link)).toBe(other);
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)("says a link that is already there is unchanged", () =>
+ Effect.gen(function* () {
+ const { path, root, library, skill } = yield* makeFolders;
+ const home = yield* skill(library, "review");
+ const link = path.join(root, "home/.claude/skills/review");
+
+ const input = { link, home, scope: "global", platform: "linux" } as const;
+ expect(yield* createLink(input)).toBe("created");
+ expect(yield* createLink(input)).toBe("unchanged");
+ }),
+ );
+ });
+
+ describe("removeLink", () => {
+ it.effect.skipIf(!symlinksSupported)(
+ "removes the link and leaves the folder it pointed at",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, root, library, skill } = yield* makeFolders;
+ const home = yield* skill(library, "review");
+ const link = path.join(root, "home/.claude/skills/review");
+ yield* createLink({ link, home, scope: "global", platform: "linux" });
+
+ expect(yield* removeLink({ path: link, expectedTarget: home })).toBe("removed");
+
+ expect(yield* fs.exists(link)).toBe(false);
+ expect(yield* fs.readFileString(path.join(home, "notes.txt"))).toBe("keep me");
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)("says nothing to remove when the link is gone", () =>
+ Effect.gen(function* () {
+ const { path, root } = yield* makeFolders;
+ expect(
+ yield* removeLink({ path: path.join(root, "nothing-here"), expectedTarget: "/x" }),
+ ).toBe("gone");
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)("leaves a real folder alone, whatever it holds", () =>
+ Effect.gen(function* () {
+ const { fs, path, library, skill } = yield* makeFolders;
+ const home = yield* skill(library, "review");
+
+ expect(yield* removeLink({ path: home, expectedTarget: home })).toBe("changed");
+
+ expect(yield* fs.readFileString(path.join(home, "notes.txt"))).toBe("keep me");
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)("leaves a link that points somewhere else", () =>
+ Effect.gen(function* () {
+ const { fs, path, root, library, skill } = yield* makeFolders;
+ const home = yield* skill(library, "review");
+ const other = yield* skill(library, "review-copy");
+ const link = path.join(root, "home/.claude/skills/review");
+ yield* fs.makeDirectory(path.dirname(link), { recursive: true });
+ yield* fs.symlink(other, link);
+
+ // The link was inspected when it pointed at `home`; it has been repointed since.
+ expect(yield* removeLink({ path: link, expectedTarget: home })).toBe("changed");
+
+ expect(yield* fs.readLink(link)).toBe(other);
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "fails instead of deleting when a folder takes the link's place right after the check",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, library, skill } = yield* makeFolders;
+ const home = yield* skill(library, "review");
+ // The check sees the link it expected; by the time of the remove it is a folder.
+ const swapped = FileSystem.FileSystem.of({
+ ...fs,
+ readLink: (target) =>
+ target === home ? Effect.succeed("expected-target") : fs.readLink(target),
+ });
+
+ const error = yield* removeLink({ path: home, expectedTarget: "expected-target" }).pipe(
+ Effect.provideService(FileSystem.FileSystem, swapped),
+ Effect.flip,
+ );
+
+ expect(error).toBeInstanceOf(SkillLinkError);
+ expect(error.operation).toBe("remove");
+ expect(yield* fs.readFileString(path.join(home, "notes.txt"))).toBe("keep me");
+ expect(yield* fs.exists(path.join(home, "SKILL.md"))).toBe(true);
+ }),
+ );
+ });
+});
diff --git a/apps/server/src/skills/SkillLinks.ts b/apps/server/src/skills/SkillLinks.ts
new file mode 100644
index 000000000000..dbf529eb8cca
--- /dev/null
+++ b/apps/server/src/skills/SkillLinks.ts
@@ -0,0 +1,214 @@
+/**
+ * SkillLinks - the two filesystem writes that give an agent a skill: making a link in the agent's
+ * own folder, and removing one.
+ *
+ * Both are built so the operating system, not an earlier check, is the last guard:
+ * - A link is made with a bare create. Something already at the path makes it fail; it is never
+ * removed first, so a real folder or another skill's link can't be replaced.
+ * - A link is removed only after it is read again and found to be the one that was inspected, and
+ * with a non-recursive remove. If a folder took its place in between, the remove fails.
+ *
+ * @module SkillLinks
+ */
+// @effect-diagnostics-next-line nodeBuiltinImport:off - Effect's symlink has no type argument, and Windows needs a junction to link without elevation.
+import * as NodeFSP from "node:fs/promises";
+
+import type { SkillScope } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import type * as PlatformError from "effect/PlatformError";
+import * as Schema from "effect/Schema";
+
+export class SkillLinkError extends Schema.TaggedError()("SkillLinkError", {
+ operation: Schema.Literals([
+ "makeDirectory",
+ "realPath",
+ "symlink",
+ "verify",
+ "readLink",
+ "remove",
+ ]),
+ path: Schema.String,
+ cause: Schema.optional(Schema.Defect()),
+}) {
+ override get message(): string {
+ return `Skill link operation '${this.operation}' failed.`;
+ }
+}
+
+/**
+ * The kind of link to make and what it points at.
+ *
+ * A project's links are meant to be committed, so they point at a path relative to the link's own
+ * folder and survive a clone or a move. A global link points at an absolute path: nobody clones
+ * `~/.claude`, and the skill often lives outside the home folder. Windows can't make a symlink
+ * without Developer Mode or elevation, but a junction needs neither, so a global link there is one;
+ * a junction can't be committed as a link, so a project's stays a symlink and is refused without
+ * the privilege.
+ */
+export const linkSpec = (input: {
+ readonly platform: NodeJS.Platform;
+ readonly scope: SkillScope;
+ readonly home: string;
+ /** From the link's real folder to the home, when the home is inside the project. */
+ readonly relative: string | undefined;
+}) => {
+ const junction = input.platform === "win32" && input.scope === "global";
+ return {
+ type: junction ? "junction" : "dir",
+ target: !junction && input.scope === "project" ? (input.relative ?? input.home) : input.home,
+ } as const;
+};
+
+export type CreateLinkResult =
+ /** The link was made. */
+ | "created"
+ /** What is there already is the skill's folder. */
+ | "unchanged"
+ /** Something else is there. It was left alone. */
+ | "taken"
+ /** The system doesn't allow links here. */
+ | "notAllowed";
+
+const NOT_ALLOWED_CODES = new Set(["EPERM", "EACCES", "EROFS"]);
+
+const errorCode = (error: unknown) =>
+ typeof error === "object" && error !== null && "code" in error ? error.code : undefined;
+
+/** What a path is, as far as links go. `stat` follows links, so only `readLink` can tell. */
+export const readLinkTarget = Effect.fnUntraced(function* (link: string) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ return yield* fileSystem.readLink(link).pipe(
+ Effect.map((target): { readonly _tag: "Link"; readonly target: string } => ({
+ _tag: "Link",
+ target,
+ })),
+ Effect.catchTags({
+ PlatformError: (error) =>
+ error.reason._tag === "NotFound"
+ ? Effect.succeed({ _tag: "Missing" } as const)
+ : isNotLinkError(error)
+ ? Effect.succeed({ _tag: "NotLink" } as const)
+ : Effect.fail(new SkillLinkError({ operation: "readLink", path: link, cause: error })),
+ }),
+ );
+});
+
+/** Reading a path that isn't a link fails with EINVAL; this is how CodexHomeLayout tells too. */
+function isNotLinkError(error: PlatformError.PlatformError) {
+ return error.reason._tag === "Unknown" && errorCode(error.reason.cause) === "EINVAL";
+}
+
+const isInside = (path: Path.Path, folder: string, inner: string) => {
+ const relative = path.relative(folder, inner);
+ return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
+};
+
+export type RemoveLinkResult =
+ /** The link was removed. */
+ | "removed"
+ /** Nothing was there. */
+ | "gone"
+ /** What is there isn't the link that was inspected: a folder, a file or a link to elsewhere. */
+ | "changed";
+
+/**
+ * Removes `path` only if it is still a link with the target it was inspected with. The remove is
+ * not recursive, so a folder that took the link's place makes it fail instead of being deleted.
+ */
+export const removeLink = Effect.fn("SkillLinks.removeLink")(function* (input: {
+ readonly path: string;
+ readonly expectedTarget: string;
+}) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const state = yield* readLinkTarget(input.path);
+ if (state._tag === "Missing") return "gone" as const satisfies RemoveLinkResult;
+ if (state._tag === "NotLink" || state.target !== input.expectedTarget) {
+ return "changed" as const satisfies RemoveLinkResult;
+ }
+ yield* fileSystem
+ .remove(input.path)
+ .pipe(
+ Effect.mapError(
+ (cause) => new SkillLinkError({ operation: "remove", path: input.path, cause }),
+ ),
+ );
+ return "removed" as const satisfies RemoveLinkResult;
+});
+
+/**
+ * Makes `link` point at `home`. Nothing is replaced: an existing entry fails the create, and it
+ * counts as `unchanged` only when it already is the skill's folder.
+ */
+export const createLink = Effect.fn("SkillLinks.createLink")(function* (input: {
+ readonly link: string;
+ /** Absolute and real: the skill's folder after following links. */
+ readonly home: string;
+ readonly scope: SkillScope;
+ readonly platform: NodeJS.Platform;
+ /** The project's real folder, when a link may be written relative to it. */
+ readonly projectRoot?: string | undefined;
+ /**
+ * What the link says instead of the home, for a link that goes through another link on its way
+ * there, such as a project's link to the library. It has to lead to the home all the same.
+ */
+ readonly target?: string | undefined;
+}) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const parent = path.dirname(input.link);
+ yield* fileSystem
+ .makeDirectory(parent, { recursive: true })
+ .pipe(
+ Effect.mapError(
+ (cause) => new SkillLinkError({ operation: "makeDirectory", path: parent, cause }),
+ ),
+ );
+ // A relative target is read from where the link's folder really is, not from the path it was reached by.
+ const realParent = yield* fileSystem
+ .realPath(parent)
+ .pipe(
+ Effect.mapError(
+ (cause) => new SkillLinkError({ operation: "realPath", path: parent, cause }),
+ ),
+ );
+ const computed = linkSpec({
+ platform: input.platform,
+ scope: input.scope,
+ home: input.home,
+ relative:
+ input.projectRoot !== undefined && isInside(path, input.projectRoot, input.home)
+ ? path.relative(realParent, input.home)
+ : undefined,
+ });
+ const spec = input.target === undefined ? computed : { ...computed, target: input.target };
+
+ const outcome = yield* Effect.tryPromise({
+ try: () => NodeFSP.symlink(spec.target, input.link, spec.type),
+ catch: (cause) => new SkillLinkError({ operation: "symlink", path: input.link, cause }),
+ }).pipe(
+ Effect.as("created" as CreateLinkResult),
+ Effect.catchTags({
+ SkillLinkError: (error) => {
+ const code = errorCode(error.cause);
+ if (code === "EEXIST") {
+ return fileSystem.realPath(input.link).pipe(
+ Effect.map((real): CreateLinkResult => (real === input.home ? "unchanged" : "taken")),
+ Effect.orElseSucceed((): CreateLinkResult => "taken"),
+ );
+ }
+ return typeof code === "string" && NOT_ALLOWED_CODES.has(code)
+ ? Effect.succeed("notAllowed" as CreateLinkResult)
+ : Effect.fail(error);
+ },
+ }),
+ );
+ if (outcome !== "created") return outcome;
+
+ // The link has to lead where it was meant to; if it doesn't, take back what was just made.
+ const real = yield* fileSystem.realPath(input.link).pipe(Effect.orElseSucceed(() => undefined));
+ if (real === input.home) return outcome;
+ yield* removeLink({ path: input.link, expectedTarget: spec.target }).pipe(Effect.ignore);
+ return yield* new SkillLinkError({ operation: "verify", path: input.link });
+});
diff --git a/apps/server/src/skills/SkillLockFiles.test.ts b/apps/server/src/skills/SkillLockFiles.test.ts
new file mode 100644
index 000000000000..c5bc069dee68
--- /dev/null
+++ b/apps/server/src/skills/SkillLockFiles.test.ts
@@ -0,0 +1,477 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { describe, expect, it } from "@effect/vitest";
+import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+
+import * as ProcessRunner from "../processRunner.ts";
+import { hashSkillFolder, moveRecord, readSources } from "./SkillLockFiles.ts";
+
+/**
+ * A skill folder and the hashes the real tools give it: `computedHash` is what the skills CLI's
+ * own `computeSkillFolderHash` (vercel-labs/skills v1.7.0, src/local-lock.ts) returned for this
+ * folder, and the tree SHA is what `git write-tree` gives. "SKILL.md" sorts before
+ * "references/x.md" by bytes and after it by locale, so the sort order matters to the hash.
+ */
+const GOLDEN = {
+ computedHash: "a7f77818fb1962dfbb40da69550e2c9c0c035e97a11012946465db99bad816c0",
+ treeSha: "18071366eef226103eef569e462d6a3e8e11fac7",
+};
+
+const SKILL_FILE =
+ "---\nname: db-migrations\ndescription: Plan and run database migrations.\n---\n\n# Migrations\n";
+
+const git = (cwd: string, args: ReadonlyArray) =>
+ Effect.gen(function* () {
+ const runner = yield* ProcessRunner.ProcessRunner;
+ return yield* runner.run({
+ command: "git",
+ args: [
+ "-C",
+ cwd,
+ "-c",
+ "user.name=Test",
+ "-c",
+ "user.email=test@example.com",
+ // The mode is part of a tree, and git only reads it where the filesystem keeps it.
+ "-c",
+ "core.fileMode=true",
+ ...args,
+ ],
+ });
+ }).pipe(Effect.provide(ProcessRunner.layer));
+
+/** A temp home with a skill folder written the way the golden hashes were taken. */
+const makeMachine = Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const home = yield* fs.realPath(yield* fs.makeTempDirectoryScoped({ prefix: "t3code-locks-" }));
+ const write = (relative: string, contents: string, mode?: number) =>
+ Effect.gen(function* () {
+ const target = path.join(home, relative);
+ yield* fs.makeDirectory(path.dirname(target), { recursive: true });
+ yield* fs.writeFileString(target, contents);
+ if (mode !== undefined) yield* fs.chmod(target, mode);
+ });
+ const skill = "repos/acme-web/.agents/skills/db-migrations";
+ yield* write(`${skill}/SKILL.md`, SKILL_FILE);
+ yield* write(`${skill}/references/x.md`, "Read this first.\n");
+ yield* write(`${skill}/scripts/run.sh`, "#!/bin/sh\necho migrate\n", 0o755);
+ const project = path.join(home, "repos/acme-web");
+ return { fs, path, home, project, write, skill: path.join(home, skill) };
+});
+
+const environment = (home: string, extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv => ({
+ HOME: home,
+ ...extra,
+});
+
+const projectEntry = {
+ source: "acme/skills",
+ sourceType: "github",
+ skillPath: "skills/db-migrations/SKILL.md",
+ computedHash: "0".repeat(64),
+};
+
+const globalEntry = {
+ source: "acme/skills",
+ sourceType: "github",
+ sourceUrl: "https://github.com/acme/skills.git",
+ skillPath: "skills/db-migrations/SKILL.md",
+ skillFolderHash: GOLDEN.treeSha,
+ installedAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-02T00:00:00.000Z",
+};
+
+/** The project lock as the CLI writes it: skills sorted by name, two-space indent, final newline. */
+const projectLockText = (skills: Record) =>
+ `${JSON.stringify({ version: 1, skills }, null, 2)}\n`;
+
+/** The global lock as the CLI writes it: no final newline. */
+const globalLockText = (skills: Record, indent: number | string = 2) =>
+ JSON.stringify({ version: 3, skills, dismissed: { findSkillsPrompt: true } }, null, indent);
+
+const it_ = it.layer(NodeServices.layer, { excludeTestServices: true });
+
+it_("SkillLockFiles", (it) => {
+ describe("readSources", () => {
+ it.effect("reads owner/repo from a v1 project lock and a v3 global lock", () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ yield* write(
+ "repos/acme-web/skills-lock.json",
+ projectLockText({
+ "db-migrations": projectEntry,
+ "from-npm": { source: "left-pad", sourceType: "node_modules", computedHash: "x" },
+ "from-disk": { source: "./skills/local", sourceType: "local", computedHash: "x" },
+ }),
+ );
+ yield* write(
+ ".agents/.skill-lock.json",
+ globalLockText({
+ "global-one": { ...globalEntry, source: "acme/other" },
+ "odd-source": { ...globalEntry, source: "not a repo" },
+ }),
+ );
+
+ const sources = yield* readSources({
+ environment: environment(home),
+ home,
+ projectRoot: project,
+ });
+
+ expect([...sources.project]).toEqual([["db-migrations", "acme/skills"]]);
+ expect([...sources.global]).toEqual([["global-one", "acme/other"]]);
+ }),
+ );
+
+ it.effect("follows XDG_STATE_HOME for the global lock, as the CLI does", () =>
+ Effect.gen(function* () {
+ const { home, write } = yield* makeMachine;
+ yield* write(".agents/.skill-lock.json", globalLockText({ "in-home": globalEntry }));
+ yield* write(
+ "state/skills/.skill-lock.json",
+ globalLockText({ "in-state": { ...globalEntry, source: "acme/state" } }),
+ );
+
+ const sources = yield* readSources({
+ environment: environment(home, { XDG_STATE_HOME: `${home}/state` }),
+ home,
+ });
+
+ expect([...sources.global]).toEqual([["in-state", "acme/state"]]);
+ }),
+ );
+
+ it.effect("reads nothing from a lock that doesn't parse, or has no skills", () =>
+ Effect.gen(function* () {
+ const { home, project, write } = yield* makeMachine;
+ yield* write("repos/acme-web/skills-lock.json", '<<<<<<< HEAD\n{"version":1}\n>>>>>>>\n');
+ yield* write(".agents/.skill-lock.json", '{"version":3}');
+
+ const sources = yield* readSources({
+ environment: environment(home),
+ home,
+ projectRoot: project,
+ });
+
+ expect(sources.project.size).toBe(0);
+ expect(sources.global.size).toBe(0);
+ }),
+ );
+ });
+
+ describe("hashSkillFolder", () => {
+ it.effect("gives the hashes the skills CLI and git give the same folder", () =>
+ Effect.gen(function* () {
+ const { fs, path, skill } = yield* makeMachine;
+ expect(yield* hashSkillFolder(skill)).toEqual(GOLDEN);
+
+ // The CLI leaves node_modules out of its hash; git's tree would hold it.
+ yield* fs.makeDirectory(path.join(skill, "node_modules"));
+ yield* fs.writeFileString(path.join(skill, "node_modules/ignored.js"), "ignored\n");
+ const withModules = yield* hashSkillFolder(skill);
+ expect(withModules?.computedHash).toBe(GOLDEN.computedHash);
+ expect(withModules?.treeSha).not.toBe(GOLDEN.treeSha);
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "matches git's tree SHA for a folder with a link and an empty folder",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, skill } = yield* makeMachine;
+ yield* fs.symlink("SKILL.md", path.join(skill, "alias.md"));
+ yield* fs.makeDirectory(path.join(skill, "empty"));
+ yield* git(skill, ["init", "-q", "."]);
+ yield* git(skill, ["add", "-A"]);
+ const expected = (yield* git(skill, ["write-tree"])).stdout.trim();
+ yield* fs.remove(path.join(skill, ".git"), { recursive: true });
+
+ expect((yield* hashSkillFolder(skill))?.treeSha).toBe(expected);
+ }),
+ );
+ });
+
+ describe("moveRecord", () => {
+ const lockPaths = (home: string) => ({
+ project: `${home}/repos/acme-web/skills-lock.json`,
+ global: `${home}/.agents/.skill-lock.json`,
+ });
+
+ it.effect("moves a project's record to the global lock as one that is never overwritten", () =>
+ Effect.gen(function* () {
+ const { fs, home, project, skill, write } = yield* makeMachine;
+ const paths = lockPaths(home);
+ yield* write(
+ "repos/acme-web/skills-lock.json",
+ projectLockText({
+ aaa: { ...projectEntry, source: "acme/first" },
+ "db-migrations": { ...projectEntry, ref: "v2" },
+ }),
+ );
+ // Four-space indent, no final newline: kept as the file had it.
+ yield* write(".agents/.skill-lock.json", globalLockText({ existing: globalEntry }, 4));
+
+ const result = yield* moveRecord({
+ name: "db-migrations",
+ from: { kind: "project", root: project },
+ to: { kind: "global" },
+ folder: skill,
+ environment: environment(home),
+ home,
+ });
+
+ expect(result).toBe("moved");
+ const global = yield* fs.readFileString(paths.global);
+ expect(global.endsWith("}")).toBe(true);
+ expect(global.startsWith('{\n "version": 3')).toBe(true);
+ const parsed = JSON.parse(global);
+ expect(Object.keys(parsed.skills)).toEqual(["existing", "db-migrations"]);
+ expect(parsed.skills["db-migrations"]).toEqual({
+ source: "acme/skills",
+ sourceType: "github",
+ sourceUrl: "https://github.com/acme/skills.git",
+ ref: "v2",
+ skillPath: "skills/db-migrations/SKILL.md",
+ skillFolderHash: "",
+ installedAt: expect.any(String),
+ updatedAt: expect.any(String),
+ });
+ expect(parsed.skills.existing).toEqual(globalEntry);
+ expect(parsed.dismissed).toEqual({ findSkillsPrompt: true });
+ // The project lock lost only that entry and kept its form.
+ expect(yield* fs.readFileString(paths.project)).toBe(
+ projectLockText({ aaa: { ...projectEntry, source: "acme/first" } }),
+ );
+ }),
+ );
+
+ it.effect("creates the global lock in the CLI's empty shape when there is none", () =>
+ Effect.gen(function* () {
+ const { fs, home, project, skill, write } = yield* makeMachine;
+ yield* write(
+ "repos/acme-web/skills-lock.json",
+ projectLockText({ "db-migrations": projectEntry }),
+ );
+
+ yield* moveRecord({
+ name: "db-migrations",
+ from: { kind: "project", root: project },
+ to: { kind: "global" },
+ folder: skill,
+ environment: environment(home, { XDG_STATE_HOME: `${home}/state` }),
+ home,
+ });
+
+ const created = JSON.parse(
+ yield* fs.readFileString(`${home}/state/skills/.skill-lock.json`),
+ );
+ expect(Object.keys(created)).toEqual(["version", "skills", "dismissed"]);
+ expect(created.version).toBe(3);
+ expect(created.skills["db-migrations"].skillFolderHash).toBe("");
+ expect(yield* fs.readFileString(lockPaths(home).project)).toBe(projectLockText({}));
+ }),
+ );
+
+ it.effect(
+ "moves a global record to a project when the folder is exactly the recorded tree",
+ () =>
+ Effect.gen(function* () {
+ const { fs, home, project, skill, write } = yield* makeMachine;
+ const paths = lockPaths(home);
+ yield* write(
+ ".agents/.skill-lock.json",
+ globalLockText({ "db-migrations": globalEntry, other: globalEntry }),
+ );
+ yield* write("repos/acme-web/skills-lock.json", projectLockText({ zzz: projectEntry }));
+
+ const result = yield* moveRecord({
+ name: "db-migrations",
+ from: { kind: "global" },
+ to: { kind: "project", root: project },
+ folder: skill,
+ environment: environment(home),
+ home,
+ });
+
+ expect(result).toBe("moved");
+ // Sorted as the CLI writes a project lock, the default source URL left out.
+ expect(yield* fs.readFileString(paths.project)).toBe(
+ projectLockText({
+ "db-migrations": {
+ source: "acme/skills",
+ sourceType: "github",
+ skillPath: "skills/db-migrations/SKILL.md",
+ computedHash: GOLDEN.computedHash,
+ },
+ zzz: projectEntry,
+ }),
+ );
+ const global = JSON.parse(yield* fs.readFileString(paths.global));
+ expect(Object.keys(global.skills)).toEqual(["other"]);
+ }),
+ );
+
+ it.effect("drops a global record whose folder isn't exactly what was recorded", () =>
+ Effect.gen(function* () {
+ const { fs, home, project, skill, write } = yield* makeMachine;
+ const paths = lockPaths(home);
+ yield* write(".agents/.skill-lock.json", globalLockText({ "db-migrations": globalEntry }));
+ yield* write("repos/acme-web/skills-lock.json", projectLockText({}));
+ // An edit: the CLI couldn't tell what hash upstream's folder has.
+ yield* fs.writeFileString(`${skill}/references/x.md`, "Edited.\n");
+
+ const result = yield* moveRecord({
+ name: "db-migrations",
+ from: { kind: "global" },
+ to: { kind: "project", root: project },
+ folder: skill,
+ environment: environment(home),
+ home,
+ });
+
+ expect(result).toBe("dropped");
+ expect(JSON.parse(yield* fs.readFileString(paths.global)).skills).toEqual({});
+ expect(yield* fs.readFileString(paths.project)).toBe(projectLockText({}));
+ }),
+ );
+
+ it.effect(
+ "drops a record that was never version-tracked, or that has no meaning elsewhere",
+ () =>
+ Effect.gen(function* () {
+ const { fs, home, project, skill, write } = yield* makeMachine;
+ yield* write(
+ ".agents/.skill-lock.json",
+ globalLockText({ "db-migrations": { ...globalEntry, skillFolderHash: "" } }),
+ );
+ const untracked = yield* moveRecord({
+ name: "db-migrations",
+ from: { kind: "global" },
+ to: { kind: "project", root: project },
+ folder: skill,
+ environment: environment(home),
+ home,
+ });
+
+ yield* write(
+ "repos/acme-web/skills-lock.json",
+ projectLockText({
+ "db-migrations": { source: "./skills", sourceType: "local", computedHash: "x" },
+ }),
+ );
+ const local = yield* moveRecord({
+ name: "db-migrations",
+ from: { kind: "project", root: project },
+ to: { kind: "global" },
+ folder: skill,
+ environment: environment(home),
+ home,
+ });
+
+ expect([untracked, local]).toEqual(["dropped", "dropped"]);
+ expect(JSON.parse(yield* fs.readFileString(lockPaths(home).global)).skills).toEqual({});
+ }),
+ );
+
+ it.effect("never writes a lock it can't parse or whose version it doesn't know", () =>
+ Effect.gen(function* () {
+ const { fs, home, project, skill, write } = yield* makeMachine;
+ const paths = lockPaths(home);
+ const sourceText = projectLockText({ "db-migrations": projectEntry });
+ yield* write("repos/acme-web/skills-lock.json", sourceText);
+
+ for (const bad of [
+ '<<<<<<< HEAD\n{"version":3,"skills":{}}\n=======\n{}\n>>>>>>> main\n',
+ JSON.stringify({ version: 2, skills: {} }),
+ JSON.stringify({ version: 4, skills: {} }),
+ JSON.stringify({ version: 3 }),
+ ]) {
+ yield* write(".agents/.skill-lock.json", bad);
+
+ const result = yield* moveRecord({
+ name: "db-migrations",
+ from: { kind: "project", root: project },
+ to: { kind: "global" },
+ folder: skill,
+ environment: environment(home),
+ home,
+ });
+
+ expect(result).toBe("untouched");
+ // Neither lock changed, so the record is still somewhere.
+ expect(yield* fs.readFileString(paths.global)).toBe(bad);
+ expect(yield* fs.readFileString(paths.project)).toBe(sourceText);
+ }
+
+ // The same for a source lock that is a newer version than this writes.
+ const newer = JSON.stringify({ version: 2, skills: { "db-migrations": projectEntry } });
+ yield* write("repos/acme-web/skills-lock.json", newer);
+ yield* write(".agents/.skill-lock.json", globalLockText({}));
+ const result = yield* moveRecord({
+ name: "db-migrations",
+ from: { kind: "project", root: project },
+ to: { kind: "global" },
+ folder: skill,
+ environment: environment(home),
+ home,
+ });
+ expect(result).toBe("untouched");
+ expect(yield* fs.readFileString(paths.project)).toBe(newer);
+ }),
+ );
+
+ it.effect("says there is nothing to move when the skill has no record", () =>
+ Effect.gen(function* () {
+ const { home, project, skill, write } = yield* makeMachine;
+ yield* write("repos/acme-web/skills-lock.json", projectLockText({ other: projectEntry }));
+
+ const result = yield* moveRecord({
+ name: "db-migrations",
+ from: { kind: "project", root: project },
+ to: { kind: "global" },
+ folder: skill,
+ environment: environment(home),
+ home,
+ });
+
+ expect(result).toBe("none");
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "rewrites the file behind a link, as with a lock kept in dotfiles",
+ () =>
+ Effect.gen(function* () {
+ const { fs, home, project, skill, write } = yield* makeMachine;
+ yield* write("dotfiles/skill-lock.json", globalLockText({}));
+ yield* fs.makeDirectory(`${home}/.agents`, { recursive: true });
+ yield* fs.symlink(`${home}/dotfiles/skill-lock.json`, `${home}/.agents/.skill-lock.json`);
+ yield* write(
+ "repos/acme-web/skills-lock.json",
+ projectLockText({ "db-migrations": projectEntry }),
+ );
+
+ yield* moveRecord({
+ name: "db-migrations",
+ from: { kind: "project", root: project },
+ to: { kind: "global" },
+ folder: skill,
+ environment: environment(home),
+ home,
+ });
+
+ expect(yield* fs.readLink(`${home}/.agents/.skill-lock.json`)).toBe(
+ `${home}/dotfiles/skill-lock.json`,
+ );
+ expect(
+ Object.keys(
+ JSON.parse(yield* fs.readFileString(`${home}/dotfiles/skill-lock.json`)).skills,
+ ),
+ ).toEqual(["db-migrations"]);
+ }),
+ );
+ });
+});
diff --git a/apps/server/src/skills/SkillLockFiles.ts b/apps/server/src/skills/SkillLockFiles.ts
new file mode 100644
index 000000000000..42c027c840f3
--- /dev/null
+++ b/apps/server/src/skills/SkillLockFiles.ts
@@ -0,0 +1,566 @@
+/**
+ * SkillLockFiles - the `skills` CLI's records of where an installed skill came from.
+ *
+ * The CLI keeps one record per skill: a global lock (v3) for skills in the home folder and
+ * `skills-lock.json` (v1) in a project. T3 Code reads them to group skills by their source, and
+ * rewrites them only when a skill moves between a project and Global, so the record goes with it.
+ * The format and the CLI's own handling follow vercel-labs/skills v1.7.0 (14cf84aa), `src/skill-lock.ts`
+ * and `src/local-lock.ts`:
+ * - The CLI treats a file it can't parse, or one with a version older than it knows, as empty, and
+ * its next write replaces the file with one entry. So a lock that doesn't parse, or whose version
+ * isn't the one this module writes, is never written here (a newer version is still read).
+ * - The global lock is at `$XDG_STATE_HOME/skills/.skill-lock.json` when that is set, else
+ * `~/.agents/.skill-lock.json`; it is `JSON.stringify(lock, null, 2)` with no trailing newline.
+ * The project lock has its skills sorted by name and ends with a newline. A rewrite keeps the
+ * indent, the trailing newline and the order the file already had.
+ * - A global entry's `skillFolderHash` is the GitHub tree SHA of the skill's folder. An empty one is
+ * the CLI's "not version-tracked" value, so the CLI never overwrites such a skill. A project
+ * entry's `computedHash` is SHA-256 over the folder's files, `computeSkillFolderHash` in the CLI.
+ * Because only the CLI's own download can say what `computedHash` the upstream folder has, a
+ * record is moved to a project only when the folder is byte for byte that tree (its git tree SHA
+ * is the recorded `skillFolderHash`); otherwise it is dropped.
+ *
+ * Nothing here is cached; each call reads the files as they are.
+ *
+ * @module SkillLockFiles
+ */
+// @effect-diagnostics-next-line nodeBuiltinImport:off - `computedHash` has to match the skills CLI's own SHA-256 over buffers, computed synchronously.
+import * as NodeCrypto from "node:crypto";
+
+import * as DateTime from "effect/DateTime";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
+
+import { gitBlobSha, treeShaOfEntries } from "./SkillUpdatePlan.ts";
+
+const GLOBAL_LOCK_VERSION = 3;
+const PROJECT_LOCK_VERSION = 1;
+const GLOBAL_LOCK_FILE = ".skill-lock.json";
+const PROJECT_LOCK_FILE = "skills-lock.json";
+const MAX_LOCK_BYTES = 4 * 1024 * 1024;
+const SOURCE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
+/** Folders the CLI leaves out of `computedHash`. */
+const HASH_SKIPPED_DIRECTORIES = new Set([".git", "node_modules"]);
+const MAX_HASHED_FILES = 500;
+const MAX_HASHED_BYTES = 16 * 1024 * 1024;
+
+/** The lock a skill's record lives in. */
+export type LockScope =
+ | { readonly kind: "global" }
+ | { readonly kind: "project"; readonly root: string };
+
+type Json = Record;
+
+const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown));
+
+const isRecord = (value: unknown): value is Json =>
+ typeof value === "object" && value !== null && !Array.isArray(value);
+
+interface FoundLock {
+ readonly _tag: "Found";
+ readonly path: string;
+ readonly version: number;
+ readonly data: Json & { skills: Json };
+ /** What the file used to indent with, and whether it ended with a newline. */
+ readonly indent: string;
+ readonly trailingNewline: boolean;
+ /** The skills were sorted by name, as the CLI writes a project lock. */
+ readonly sorted: boolean;
+}
+
+type ReadLock = { readonly _tag: "Missing" } | { readonly _tag: "Unusable" } | FoundLock;
+
+/** The global lock's path, which follows `XDG_STATE_HOME` as the CLI does. */
+const globalLockPath = (
+ path: Path.Path,
+ input: { readonly environment: NodeJS.ProcessEnv; readonly home: string },
+) => {
+ const state = input.environment.XDG_STATE_HOME;
+ return state
+ ? path.join(state, "skills", GLOBAL_LOCK_FILE)
+ : path.join(input.home, ".agents", GLOBAL_LOCK_FILE);
+};
+
+const lockPathOf = (
+ path: Path.Path,
+ scope: LockScope,
+ context: { readonly environment: NodeJS.ProcessEnv; readonly home: string },
+) =>
+ scope.kind === "global"
+ ? globalLockPath(path, context)
+ : path.join(scope.root, PROJECT_LOCK_FILE);
+
+/** The version a lock of this kind is written with. */
+const writtenVersion = (scope: LockScope) =>
+ scope.kind === "global" ? GLOBAL_LOCK_VERSION : PROJECT_LOCK_VERSION;
+
+const isSorted = (names: readonly string[]) =>
+ names.every((name, index) => index === 0 || (names[index - 1] ?? "") <= name);
+
+const readLock = Effect.fnUntraced(function* (file: string) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const info = yield* fileSystem.stat(file).pipe(
+ Effect.map((value) => ({ _tag: "Exists" as const, value })),
+ Effect.catchTags({
+ PlatformError: (error) =>
+ Effect.succeed(
+ error.reason._tag === "NotFound"
+ ? ({ _tag: "Missing" } as const)
+ : ({ _tag: "Unusable" } as const),
+ ),
+ }),
+ );
+ if (info._tag !== "Exists") return info satisfies ReadLock;
+ if (info.value.type !== "File" || Number(info.value.size) > MAX_LOCK_BYTES) {
+ return { _tag: "Unusable" } as const satisfies ReadLock;
+ }
+ const text = yield* fileSystem.readFileString(file).pipe(Effect.orElseSucceed(() => undefined));
+ if (text === undefined) return { _tag: "Unusable" } as const satisfies ReadLock;
+ const parsed = Option.getOrUndefined(decodeJson(text));
+ if (!isRecord(parsed) || typeof parsed.version !== "number" || !isRecord(parsed.skills)) {
+ return { _tag: "Unusable" } as const satisfies ReadLock;
+ }
+ return {
+ _tag: "Found",
+ path: file,
+ version: parsed.version,
+ data: parsed as FoundLock["data"],
+ indent: /^([ \t]+)"/m.exec(text)?.[1] ?? " ",
+ trailingNewline: text.endsWith("\n"),
+ sorted: isSorted(Object.keys(parsed.skills)),
+ } satisfies ReadLock;
+});
+
+const sourceOf = (entry: unknown) =>
+ isRecord(entry) &&
+ entry.sourceType === "github" &&
+ typeof entry.source === "string" &&
+ SOURCE.test(entry.source)
+ ? entry.source
+ : undefined;
+
+/**
+ * The `owner/repo` each skill was installed from, by skill name, for GitHub sources. The global
+ * lock answers for Global skills and `projectRoot`'s lock for that project's. A lock that can't be
+ * read says nothing.
+ */
+export const readSources = Effect.fn("SkillLockFiles.readSources")(function* (input: {
+ readonly environment: NodeJS.ProcessEnv;
+ readonly home: string;
+ readonly projectRoot?: string | undefined;
+}) {
+ const path = yield* Path.Path;
+ const sourcesIn = Effect.fnUntraced(function* (scope: LockScope, oldest: number) {
+ const lock = yield* readLock(lockPathOf(path, scope, input));
+ const sources = new Map();
+ if (lock._tag !== "Found" || lock.version < oldest) return sources;
+ for (const [name, entry] of Object.entries(lock.data.skills)) {
+ const source = sourceOf(entry);
+ if (source !== undefined) sources.set(name, source);
+ }
+ return sources;
+ });
+ return {
+ global: yield* sourcesIn({ kind: "global" }, GLOBAL_LOCK_VERSION),
+ project:
+ input.projectRoot === undefined
+ ? new Map()
+ : yield* sourcesIn({ kind: "project", root: input.projectRoot }, PROJECT_LOCK_VERSION),
+ };
+});
+
+/** One regular file in a skill's folder, or a link in it. */
+export interface HashedEntry {
+ readonly relative: string;
+ readonly kind: "file" | "link";
+ readonly bytes: Uint8Array;
+ readonly executable: boolean;
+}
+
+/**
+ * Everything in a skill's folder, bounded, or undefined when it can't be read or is too large.
+ * Folders named in `skip` aren't entered.
+ */
+export const readFolder = Effect.fnUntraced(function* (
+ root: string,
+ skip: ReadonlySet = new Set(),
+) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const entries: HashedEntry[] = [];
+ let total = 0;
+ const pending = [""];
+ for (let folder = pending.shift(); folder !== undefined; folder = pending.shift()) {
+ const names = yield* fileSystem
+ .readDirectory(path.join(root, folder))
+ .pipe(Effect.orElseSucceed(() => undefined));
+ if (names === undefined) return undefined;
+ for (const name of names) {
+ const relative = folder === "" ? name : `${folder}/${name}`;
+ const absolute = path.join(root, relative);
+ const target = yield* fileSystem.readLink(absolute).pipe(
+ Effect.map((value): string | undefined => value),
+ Effect.orElseSucceed(() => undefined),
+ );
+ if (target !== undefined) {
+ entries.push({
+ relative,
+ kind: "link",
+ bytes: new TextEncoder().encode(target),
+ executable: false,
+ });
+ continue;
+ }
+ const info = yield* fileSystem.stat(absolute).pipe(Effect.orElseSucceed(() => undefined));
+ if (info === undefined) return undefined;
+ if (info.type === "Directory") {
+ if (!skip.has(name)) pending.push(relative);
+ continue;
+ }
+ if (info.type !== "File") return undefined;
+ total += Number(info.size);
+ if (entries.length >= MAX_HASHED_FILES || total > MAX_HASHED_BYTES) return undefined;
+ const bytes = yield* fileSystem
+ .readFile(absolute)
+ .pipe(Effect.orElseSucceed(() => undefined));
+ if (bytes === undefined) return undefined;
+ entries.push({ relative, kind: "file", bytes, executable: (info.mode & 0o111) !== 0 });
+ }
+ }
+ return entries;
+});
+
+/** The git tree SHA of the entries, the way GitHub reports a folder's `skillFolderHash`. */
+const gitTreeSha = (entries: readonly HashedEntry[]) =>
+ treeShaOfEntries(
+ entries.map((entry) => ({
+ path: entry.relative,
+ mode: entry.kind === "link" ? "120000" : entry.executable ? "100755" : "100644",
+ sha: gitBlobSha(entry.bytes),
+ })),
+ );
+
+/**
+ * The CLI's `computeSkillFolderHash`: SHA-256 over each regular file's path and bytes, sorted by
+ * path, leaving out `.git` and `node_modules`.
+ */
+export const computedHash = (
+ entries: ReadonlyArray>,
+) => {
+ const hash = NodeCrypto.createHash("sha256");
+ const files = entries
+ .filter(
+ (entry) =>
+ entry.kind === "file" &&
+ !entry.relative
+ .split("/")
+ .slice(0, -1)
+ .some((part) => HASH_SKIPPED_DIRECTORIES.has(part)),
+ )
+ .toSorted((a, b) => a.relative.localeCompare(b.relative));
+ for (const file of files) {
+ hash.update(file.relative);
+ hash.update(file.bytes);
+ }
+ return hash.digest("hex");
+};
+
+/** What the CLI hashes a skill's folder to, for a project lock; undefined if it can't be read. */
+export const hashSkillFolder = Effect.fn("SkillLockFiles.hashSkillFolder")(function* (
+ folder: string,
+) {
+ const entries = yield* readFolder(folder);
+ return entries === undefined
+ ? undefined
+ : { treeSha: gitTreeSha(entries), computedHash: computedHash(entries) };
+});
+
+const DEFAULT_GITHUB_URL = (source: string) => `https://github.com/${source}.git`;
+
+/**
+ * The record as the other lock keeps it, or undefined when it can't be carried over. A path-based
+ * source (`local`, `node_modules`) means nothing in another place, and a global record needs the
+ * URL the CLI reinstalls from.
+ */
+const convertRecord = (
+ entry: unknown,
+ from: LockScope,
+ to: LockScope,
+ folderHashes: { readonly treeSha: string; readonly computedHash: string } | undefined,
+ now: string,
+): Json | undefined => {
+ if (
+ !isRecord(entry) ||
+ typeof entry.source !== "string" ||
+ typeof entry.sourceType !== "string"
+ ) {
+ return undefined;
+ }
+ if (entry.sourceType === "local" || entry.sourceType === "node_modules") return undefined;
+ const optional = (key: string) => (typeof entry[key] === "string" ? { [key]: entry[key] } : {});
+ if (from.kind === "project" && to.kind === "project") return { ...entry };
+ if (from.kind === "project") {
+ const sourceUrl =
+ typeof entry.sourceUrl === "string"
+ ? entry.sourceUrl
+ : entry.sourceType === "github"
+ ? DEFAULT_GITHUB_URL(entry.source)
+ : undefined;
+ if (sourceUrl === undefined) return undefined;
+ return {
+ source: entry.source,
+ sourceType: entry.sourceType,
+ sourceUrl,
+ ...optional("ref"),
+ ...optional("skillPath"),
+ // The CLI's "not version-tracked" value, so it never overwrites the skill.
+ skillFolderHash: "",
+ installedAt: now,
+ updatedAt: now,
+ ...optional("wellKnownDigest"),
+ };
+ }
+ // Global to a project: only a folder that is exactly the recorded tree has a known hash.
+ if (
+ entry.sourceType !== "github" ||
+ typeof entry.skillFolderHash !== "string" ||
+ entry.skillFolderHash === "" ||
+ folderHashes === undefined ||
+ folderHashes.treeSha !== entry.skillFolderHash
+ ) {
+ return undefined;
+ }
+ return {
+ source: entry.source,
+ ...(typeof entry.sourceUrl === "string" && entry.sourceUrl !== DEFAULT_GITHUB_URL(entry.source)
+ ? { sourceUrl: entry.sourceUrl }
+ : {}),
+ ...optional("ref"),
+ sourceType: entry.sourceType,
+ ...optional("skillPath"),
+ computedHash: folderHashes.computedHash,
+ ...optional("wellKnownDigest"),
+ };
+};
+
+const render = (lock: FoundLock, skills: Json, scope: LockScope) => {
+ const ordered =
+ scope.kind === "project" && lock.sorted
+ ? Object.fromEntries(
+ Object.entries(skills).toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
+ )
+ : skills;
+ const text = JSON.stringify({ ...lock.data, skills: ordered }, null, lock.indent);
+ return lock.trailingNewline ? `${text}\n` : text;
+};
+
+export type MoveRecordResult =
+ /** The record is in the other lock and out of this one. */
+ | "moved"
+ /** The record couldn't be carried over, so it is out of this lock and in no other. */
+ | "dropped"
+ /** The skill has no record here. */
+ | "none"
+ /** A lock couldn't be read or written safely; no lock was changed. */
+ | "untouched";
+
+/**
+ * Takes a skill's record out of `from`'s lock and puts it into `to`'s, in the shape that lock
+ * keeps. A lock that doesn't parse, or has a version this module doesn't write, is left exactly as
+ * it is, and then the other lock is left alone too so a record is never lost. `folder` is where
+ * the skill's files are now.
+ */
+export const moveRecord = Effect.fn("SkillLockFiles.moveRecord")(function* (input: {
+ readonly name: string;
+ readonly from: LockScope;
+ readonly to: LockScope;
+ readonly folder: string;
+ readonly environment: NodeJS.ProcessEnv;
+ readonly home: string;
+}) {
+ const path = yield* Path.Path;
+ const source = yield* readLock(lockPathOf(path, input.from, input));
+ if (source._tag !== "Found" || !(input.name in source.data.skills)) {
+ return (source._tag === "Unusable" ? "untouched" : "none") satisfies MoveRecordResult;
+ }
+ if (source.version !== writtenVersion(input.from)) return "untouched" as const;
+
+ const target = yield* readLock(lockPathOf(path, input.to, input));
+ if (target._tag === "Unusable") return "untouched" as const;
+ if (target._tag === "Found" && target.version !== writtenVersion(input.to)) {
+ return "untouched" as const;
+ }
+
+ const now = DateTime.formatIso(yield* DateTime.now);
+ const hashes =
+ input.from.kind === "global" && input.to.kind === "project"
+ ? yield* hashSkillFolder(input.folder)
+ : undefined;
+ const converted = convertRecord(
+ source.data.skills[input.name],
+ input.from,
+ input.to,
+ hashes,
+ now,
+ );
+
+ if (converted !== undefined) {
+ const fresh: FoundLock = {
+ _tag: "Found",
+ path: lockPathOf(path, input.to, input),
+ version: writtenVersion(input.to),
+ data:
+ input.to.kind === "global"
+ ? { version: GLOBAL_LOCK_VERSION, skills: {}, dismissed: {} }
+ : { version: PROJECT_LOCK_VERSION, skills: {} },
+ indent: " ",
+ trailingNewline: input.to.kind === "project",
+ sorted: true,
+ };
+ const destination = target._tag === "Found" ? target : fresh;
+ yield* writeFileStringAtomically({
+ filePath: destination.path,
+ contents: render(
+ destination,
+ { ...destination.data.skills, [input.name]: converted },
+ input.to,
+ ),
+ });
+ }
+
+ const { [input.name]: _removed, ...rest } = source.data.skills;
+ yield* writeFileStringAtomically({
+ filePath: source.path,
+ contents: render(source, rest, input.from),
+ });
+ return (converted === undefined ? "dropped" : "moved") satisfies MoveRecordResult;
+});
+
+/** A skill the CLI installed from GitHub, as its lock records it. */
+export interface LockedSkill {
+ readonly scope: LockScope;
+ readonly name: string;
+ /** `owner/repo`. */
+ readonly source: string;
+ /** The branch or tag it was installed from; the default branch when absent. */
+ readonly ref: string | undefined;
+ /** Where SKILL.md was in the repository, when the record says. */
+ readonly skillPath: string | undefined;
+ /**
+ * The version the local copy was installed from: the folder's git tree SHA (global lock) or the
+ * CLI's `computedHash` of its files (project lock). Absent when the record has none, as for a
+ * global record that isn't version-tracked.
+ */
+ readonly baseline:
+ | { readonly kind: "tree"; readonly sha: string }
+ | { readonly kind: "content"; readonly hash: string }
+ | undefined;
+ /** The lock can be rewritten: it parses and has the version this module writes. */
+ readonly writable: boolean;
+}
+
+const TREE_SHA = /^[0-9a-f]{40}$/;
+const CONTENT_HASH = /^[0-9a-f]{64}$/;
+
+const lockedSkillOf = (
+ scope: LockScope,
+ name: string,
+ entry: unknown,
+ writable: boolean,
+): LockedSkill | undefined => {
+ const source = sourceOf(entry);
+ if (source === undefined || !isRecord(entry)) return undefined;
+ // A GitHub record names github.com unless its URL says otherwise; the CLI pins the host too.
+ if (
+ typeof entry.sourceUrl === "string" &&
+ !/^(?:https:\/\/|git@)github\.com[/:]/i.test(entry.sourceUrl)
+ ) {
+ return undefined;
+ }
+ const text = (key: string) =>
+ typeof entry[key] === "string" && entry[key] !== "" ? entry[key] : undefined;
+ const folderHash = text("skillFolderHash");
+ const contentHash = text("computedHash");
+ return {
+ scope,
+ name,
+ source,
+ ref: text("ref"),
+ skillPath: text("skillPath"),
+ baseline:
+ scope.kind === "global"
+ ? folderHash !== undefined && TREE_SHA.test(folderHash)
+ ? { kind: "tree", sha: folderHash }
+ : undefined
+ : contentHash !== undefined && CONTENT_HASH.test(contentHash)
+ ? { kind: "content", hash: contentHash }
+ : undefined,
+ writable,
+ };
+};
+
+/**
+ * The skills the CLI installed from GitHub: Global ones from the global lock and, with
+ * `projectRoot`, that project's. A lock that can't be read says nothing.
+ */
+export const readLockedSkills = Effect.fn("SkillLockFiles.readLockedSkills")(function* (input: {
+ readonly environment: NodeJS.ProcessEnv;
+ readonly home: string;
+ readonly projectRoot?: string | undefined;
+}) {
+ const path = yield* Path.Path;
+ const skillsIn = Effect.fnUntraced(function* (scope: LockScope) {
+ const lock = yield* readLock(lockPathOf(path, scope, input));
+ if (lock._tag !== "Found" || lock.version < writtenVersion(scope)) return [];
+ const writable = lock.version === writtenVersion(scope);
+ return Object.entries(lock.data.skills).flatMap(([name, entry]) => {
+ const skill = lockedSkillOf(scope, name, entry, writable);
+ return skill === undefined ? [] : [skill];
+ });
+ });
+ return [
+ ...(yield* skillsIn({ kind: "global" })),
+ ...(input.projectRoot === undefined
+ ? []
+ : yield* skillsIn({ kind: "project", root: input.projectRoot })),
+ ];
+});
+
+/**
+ * Records that a skill is now based on another version of its source, the way `npx skills
+ * update` does: a global record gets the folder's tree SHA and an `updatedAt`, a project record
+ * the new `computedHash`. Every other field and record, and the file's formatting, stay as they
+ * are. Nothing is written unless the record is still there with the same source and the lock has
+ * the version this module writes; says whether it was written.
+ */
+export const recordBaseline = Effect.fn("SkillLockFiles.recordBaseline")(function* (input: {
+ readonly scope: LockScope;
+ readonly name: string;
+ readonly source: string;
+ readonly baseline: { readonly skillFolderHash: string } | { readonly computedHash: string };
+ readonly environment: NodeJS.ProcessEnv;
+ readonly home: string;
+}) {
+ const path = yield* Path.Path;
+ const lock = yield* readLock(lockPathOf(path, input.scope, input));
+ if (lock._tag !== "Found" || lock.version !== writtenVersion(input.scope)) return false;
+ const entry = lock.data.skills[input.name];
+ if (!isRecord(entry) || sourceOf(entry) !== input.source) return false;
+ const updated =
+ "skillFolderHash" in input.baseline
+ ? {
+ ...entry,
+ skillFolderHash: input.baseline.skillFolderHash,
+ updatedAt: DateTime.formatIso(yield* DateTime.now),
+ }
+ : { ...entry, computedHash: input.baseline.computedHash };
+ yield* writeFileStringAtomically({
+ filePath: lock.path,
+ contents: render(lock, { ...lock.data.skills, [input.name]: updated }, input.scope),
+ });
+ return true;
+});
diff --git a/apps/server/src/skills/SkillManager.test.ts b/apps/server/src/skills/SkillManager.test.ts
new file mode 100644
index 000000000000..d6999299889c
--- /dev/null
+++ b/apps/server/src/skills/SkillManager.test.ts
@@ -0,0 +1,1811 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { describe, expect, it } from "@effect/vitest";
+import {
+ ProjectId,
+ ProviderDriverKind,
+ ProviderInstanceId,
+ SkillBatchResult,
+ SkillCreateError,
+ SkillDeleteInput,
+ SkillDisableInput,
+ SkillEnableInput,
+ SkillListResult,
+ SkillPlaceInput,
+ SkillRequestError,
+ type Project,
+ type SkillRef,
+ type SkillScope,
+ type SkillSummary,
+} from "@t3tools/contracts";
+import * as HostProcess from "@t3tools/shared/HostProcess";
+import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+import * as PlatformError from "effect/PlatformError";
+import * as Queue from "effect/Queue";
+import * as Schema from "effect/Schema";
+
+import * as ProjectService from "../project/ProjectService.ts";
+import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
+import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
+import * as Settings from "../serverSettings.ts";
+import { parseSkillFrontmatter } from "../provider/Drivers/ClaudeSkills.ts";
+import * as VcsProcess from "../vcs/VcsProcess.ts";
+import * as SkillCatalog from "./SkillCatalog.ts";
+import { RegisteredProjects } from "./SkillLibrary.ts";
+import * as SkillManager from "./SkillManager.ts";
+import { planEnable } from "./SkillManager.ts";
+
+const encodeResult = Schema.encodeUnknownEffect(SkillBatchResult);
+const agent = ProviderInstanceId.make;
+const ALL_AGENTS = ["claudeAgent", "codex", "cursor", "grok", "opencode", "antigravity", "pi"].map(
+ (id) => agent(id),
+);
+
+const skillFile = (name: string) => `---\nname: ${name}\ndescription: The ${name} skill.\n---\n`;
+
+/** A made-up machine: a synced library linked into the shared folder, and a project in a repo. */
+const makeMachine = Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const home = yield* fs.realPath(yield* fs.makeTempDirectoryScoped({ prefix: "t3code-manager-" }));
+ const project = path.join(home, "repos/app");
+ const write = (relative: string, contents: string) =>
+ Effect.gen(function* () {
+ const target = path.join(home, relative);
+ yield* fs.makeDirectory(path.dirname(target), { recursive: true });
+ yield* fs.writeFileString(target, contents);
+ });
+ const link = (target: string, from: string) =>
+ Effect.gen(function* () {
+ yield* fs.makeDirectory(path.dirname(path.join(home, from)), { recursive: true });
+ yield* fs.symlink(path.join(home, target), path.join(home, from));
+ });
+ for (const name of ["alpha", "beta"]) {
+ yield* write(`library/skills/${name}/SKILL.md`, skillFile(name));
+ yield* write(`library/skills/${name}/notes.md`, `notes on ${name}`);
+ }
+ yield* link("library/skills/alpha", ".agents/skills/alpha");
+ yield* write(".claude/skills/solo/SKILL.md", skillFile("solo"));
+ yield* write("repos/app/.agents/skills/verify/SKILL.md", skillFile("verify"));
+ yield* write("repos/app/.agents/skills/verify/run.sh", "echo ok");
+ return { fs, path, home, project, write, link };
+});
+
+const makeProject = (workspaceRoot: string): Project => ({
+ id: ProjectId.make("project-skill-manager"),
+ title: "App",
+ workspaceRoot,
+ repositoryIdentity: null,
+ faviconPath: null,
+ projectIcon: null,
+ defaultModelSelection: null,
+ defaultThreadEnvMode: null,
+ autoPull: false,
+ scripts: [],
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ deletedAt: null,
+});
+
+/** A skill-list refresh the manager asked the provider registry for. */
+type Refresh = {
+ readonly instanceId: ProviderInstanceId;
+ /** Absent when only the agent's machine-wide list was refreshed. */
+ readonly cwd: string | undefined;
+ readonly fresh: boolean | undefined;
+};
+
+/**
+ * The manager and catalog on a machine whose home is `home`; only `registered` folders are
+ * projects. The provider registry is a stand-in that queues each refresh it is asked for.
+ */
+const withManager = (
+ home: string,
+ registered: readonly string[],
+ use: (services: {
+ readonly manager: SkillManager.SkillManager["Service"];
+ readonly catalog: SkillCatalog.SkillCatalog["Service"];
+ readonly refreshes: Queue.Queue;
+ }) => Effect.Effect,
+) =>
+ Effect.gen(function* () {
+ const refreshes = yield* Queue.unbounded();
+ const registry = Layer.mock(ProviderRegistry.ProviderRegistry)({
+ refreshInstance: (instanceId) =>
+ Queue.offer(refreshes, { instanceId, cwd: undefined, fresh: undefined }).pipe(
+ Effect.as([]),
+ ),
+ refreshWorkspaceSnapshot: ({ instanceId, cwd, fresh }) =>
+ Queue.offer(refreshes, { instanceId, cwd, fresh }).pipe(Effect.as([])),
+ });
+ // No agent in these tests has a settings writer, so none of them is ever looked up.
+ const instances = Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({
+ getInstance: () => Effect.succeed(undefined),
+ });
+ const projects = Layer.mock(ProjectService.ProjectService)({
+ getByWorkspaceRoot: (root) =>
+ Effect.succeed(registered.includes(root) ? Option.some(makeProject(root)) : Option.none()),
+ listShells: () =>
+ Effect.succeed(registered.map((workspaceRoot) => ({ workspaceRoot }) as never)),
+ });
+ const catalog = SkillCatalog.layer.pipe(
+ Layer.provide(
+ Settings.layerTest({
+ providerInstances: Object.fromEntries(
+ ["cursor", "grok", "opencode", "antigravity", "pi"].map((driver) => [
+ ProviderInstanceId.make(driver),
+ { driver: ProviderDriverKind.make(driver), enabled: true },
+ ]),
+ ),
+ }),
+ ),
+ );
+ return yield* Effect.gen(function* () {
+ return yield* use({
+ manager: yield* SkillManager.SkillManager,
+ catalog: yield* SkillCatalog.SkillCatalog,
+ refreshes,
+ });
+ }).pipe(
+ Effect.provide(
+ SkillManager.layer.pipe(
+ Layer.provideMerge(catalog),
+ Layer.provide(projects),
+ Layer.provide(registry),
+ Layer.provide(instances),
+ Layer.provide(VcsProcess.layer),
+ ),
+ ),
+ );
+ }).pipe(
+ Effect.provideService(HostProcess.Environment, { HOME: home }),
+ Effect.provideService(HostProcess.HomeDirectory, home),
+ Effect.provideService(RegisteredProjects, Effect.succeed(registered)),
+ );
+
+const refOf = (skills: readonly SkillSummary[], scope: SkillScope, name: string): SkillRef => {
+ const skill = skills.find((item) => item.scope === scope && item.name === name);
+ if (!skill) throw new Error(`No ${scope} skill ${name} in the list`);
+ return { scope, name, home: skill.home };
+};
+
+const stateOf = (skills: readonly SkillSummary[], scope: SkillScope, name: string) =>
+ Object.fromEntries(
+ (skills.find((item) => item.scope === scope && item.name === name)?.access ?? []).map(
+ (entry) => [entry.instanceId, entry.state],
+ ),
+ );
+
+it.layer(NodeServices.layer, { excludeTestServices: true })("SkillManager", (it) => {
+ describe("enable", () => {
+ it.effect.skipIf(!symlinksSupported)(
+ "gives one agent a global skill through an absolute link in its own folder",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home } = yield* makeMachine;
+ yield* withManager(home, [], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const { skills } = yield* catalog.list({});
+ expect(stateOf(skills, "global", "alpha").claudeAgent).toBe("none");
+
+ const result = yield* manager.enable({
+ skills: [refOf(skills, "global", "alpha")],
+ agents: [agent("claudeAgent")],
+ });
+
+ expect(result.outcomes).toEqual([
+ {
+ skill: refOf(skills, "global", "alpha"),
+ status: "changed",
+ blocked: [],
+ affected: [],
+ },
+ ]);
+ yield* encodeResult(result);
+ expect(yield* fs.readLink(path.join(home, ".claude/skills/alpha"))).toBe(
+ path.join(home, "library/skills/alpha"),
+ );
+ const after = (yield* catalog.list({})).skills;
+ expect(stateOf(after, "global", "alpha")).toMatchObject({
+ claudeAgent: "link",
+ codex: "direct",
+ antigravity: "none",
+ });
+ // Nobody else's folders changed.
+ expect(yield* fs.exists(path.join(home, ".gemini"))).toBe(false);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "gives an agent a project skill through a relative link, creating the folder",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project } = yield* makeMachine;
+ yield* withManager(home, [project], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const { skills } = yield* catalog.list({ cwd: project });
+ const verify = refOf(skills, "project", "verify");
+
+ const result = yield* manager.enable({
+ cwd: project,
+ skills: [verify],
+ agents: [agent("claudeAgent")],
+ });
+
+ expect(result.outcomes[0]?.status).toBe("changed");
+ const link = path.join(project, ".claude/skills/verify");
+ expect(yield* fs.readLink(link)).toBe("../../.agents/skills/verify");
+ expect(yield* fs.realPath(link)).toBe(path.join(project, ".agents/skills/verify"));
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "keeps a project's links working after the project folder is moved",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project } = yield* makeMachine;
+ yield* withManager(home, [project], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const { skills } = yield* catalog.list({ cwd: project });
+ yield* manager.enable({
+ cwd: project,
+ skills: [refOf(skills, "project", "verify")],
+ agents: [agent("claudeAgent")],
+ });
+ }),
+ );
+
+ const moved = path.join(home, "repos/app-renamed");
+ yield* fs.rename(project, moved);
+
+ expect(yield* fs.realPath(path.join(moved, ".claude/skills/verify"))).toBe(
+ path.join(moved, ".agents/skills/verify"),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "turns on for all agents, one link where agents share a folder, and names who else gained it",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home } = yield* makeMachine;
+ yield* withManager(home, [], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const { skills } = yield* catalog.list({});
+ const solo = refOf(skills, "global", "solo");
+ // `solo` only lives in Claude's folder, which Cursor and OpenCode read too.
+ expect(stateOf(skills, "global", "solo")).toMatchObject({
+ claudeAgent: "direct",
+ cursor: "direct",
+ opencode: "direct",
+ codex: "none",
+ grok: "none",
+ pi: "none",
+ });
+
+ const result = yield* manager.enable({ skills: [solo], agents: ALL_AGENTS });
+
+ // Codex, Grok and Pi share ~/.agents/skills, so a single link serves them.
+ expect(yield* fs.readLink(path.join(home, ".agents/skills/solo"))).toBe(
+ path.join(home, ".claude/skills/solo"),
+ );
+ expect(result.outcomes[0]).toMatchObject({ status: "changed", blocked: [] });
+ // Antigravity reads neither folder, so it gets its own link.
+ expect(yield* fs.readLink(path.join(home, ".gemini/config/skills/solo"))).toBe(
+ path.join(home, ".claude/skills/solo"),
+ );
+ const states = stateOf((yield* catalog.list({})).skills, "global", "solo");
+ expect(Object.values(states).every((state) => state !== "none")).toBe(true);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "says which agents that weren't asked for gained the skill from a shared folder",
+ () =>
+ Effect.gen(function* () {
+ const { home } = yield* makeMachine;
+ yield* withManager(home, [], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const { skills } = yield* catalog.list({});
+
+ const result = yield* manager.enable({
+ skills: [refOf(skills, "global", "solo")],
+ agents: [agent("codex")],
+ });
+
+ expect(result.outcomes[0]?.affected).toEqual([agent("grok"), agent("pi")]);
+ yield* encodeResult(result);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)("is a no-op the second time", () =>
+ Effect.gen(function* () {
+ const { home } = yield* makeMachine;
+ yield* withManager(home, [], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const { skills } = yield* catalog.list({});
+ const input = {
+ skills: [refOf(skills, "global", "alpha")],
+ agents: [agent("claudeAgent")],
+ };
+
+ const first = yield* manager.enable(input);
+ const second = yield* manager.enable(input);
+
+ expect(first.outcomes[0]?.status).toBe("changed");
+ expect(second.outcomes[0]).toMatchObject({ status: "unchanged", blocked: [] });
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "makes one link when two requests ask at once, and neither fails",
+ () =>
+ Effect.gen(function* () {
+ const { home } = yield* makeMachine;
+ yield* withManager(home, [], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const { skills } = yield* catalog.list({});
+ const input = {
+ skills: [refOf(skills, "global", "alpha")],
+ agents: [agent("claudeAgent")],
+ };
+
+ const results = yield* Effect.all([manager.enable(input), manager.enable(input)], {
+ concurrency: "unbounded",
+ });
+
+ expect(results.map((result) => result.outcomes[0]?.status).toSorted()).toEqual([
+ "changed",
+ "unchanged",
+ ]);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "never replaces a real folder or file where the link would go",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, write } = yield* makeMachine;
+ // Claude's folder holds its own `alpha` without a SKILL.md, and a file named `beta`.
+ yield* write(".claude/skills/alpha/mine.md", "my own notes");
+ yield* write(".claude/skills/beta", "a file");
+ yield* withManager(home, [], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const { skills } = yield* catalog.list({});
+ const beta = path.join(home, ".agents/skills/beta");
+ yield* fs.symlink(path.join(home, "library/skills/beta"), beta);
+ const listed = (yield* catalog.list({})).skills;
+
+ const result = yield* manager.enable({
+ skills: [refOf(skills, "global", "alpha"), refOf(listed, "global", "beta")],
+ agents: [agent("claudeAgent")],
+ });
+
+ expect(result.outcomes.map(({ status, blocked }) => ({ status, blocked }))).toEqual([
+ {
+ status: "skipped",
+ blocked: [{ instanceId: "claudeAgent", reason: "entryTaken" }],
+ },
+ {
+ status: "skipped",
+ blocked: [{ instanceId: "claudeAgent", reason: "entryTaken" }],
+ },
+ ]);
+ yield* encodeResult(result);
+ expect(
+ yield* fs.readFileString(path.join(home, ".claude/skills/alpha/mine.md")),
+ ).toBe("my own notes");
+ expect(yield* fs.readFileString(path.join(home, ".claude/skills/beta"))).toBe(
+ "a file",
+ );
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)("never points a link that is there somewhere else", () =>
+ Effect.gen(function* () {
+ const { fs, path, home, link } = yield* makeMachine;
+ // Claude's own `alpha` is already a link, to the other skill.
+ yield* link("library/skills/beta", ".claude/skills/alpha");
+ yield* withManager(home, [], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const { skills } = yield* catalog.list({});
+
+ const result = yield* manager.enable({
+ skills: [refOf(skills, "global", "alpha")],
+ agents: [agent("claudeAgent")],
+ });
+
+ expect(result.outcomes[0]?.blocked).toEqual([
+ { instanceId: "claudeAgent", reason: "entryTaken" },
+ ]);
+ expect(yield* fs.readLink(path.join(home, ".claude/skills/alpha"))).toBe(
+ path.join(home, "library/skills/beta"),
+ );
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "doesn't link a skill the agent would never load because another comes first",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project, write } = yield* makeMachine;
+ // Claude reads its global folder before the project's, so a global `verify` wins.
+ yield* write(".claude/skills/verify/SKILL.md", skillFile("verify"));
+ yield* withManager(home, [project], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const { skills } = yield* catalog.list({ cwd: project });
+
+ const result = yield* manager.enable({
+ cwd: project,
+ skills: [refOf(skills, "project", "verify")],
+ agents: [agent("claudeAgent"), agent("codex")],
+ });
+
+ expect(result.outcomes[0]).toMatchObject({
+ status: "skipped",
+ blocked: [{ instanceId: "claudeAgent", reason: "shadowed" }],
+ });
+ expect(yield* fs.exists(path.join(project, ".claude"))).toBe(false);
+ }),
+ );
+ }),
+ );
+ });
+
+ describe("disable", () => {
+ it.effect.skipIf(!symlinksSupported)(
+ "removes the agent's link and leaves the skill's own folder and every other link",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home } = yield* makeMachine;
+ yield* withManager(home, [], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const { skills } = yield* catalog.list({});
+ const alpha = refOf(skills, "global", "alpha");
+ yield* manager.enable({ skills: [alpha], agents: [agent("claudeAgent")] });
+
+ const result = yield* manager.disable({
+ skills: [alpha],
+ agents: [agent("claudeAgent")],
+ });
+
+ expect(result.outcomes[0]).toEqual({
+ skill: alpha,
+ status: "changed",
+ blocked: [],
+ affected: [],
+ });
+ yield* encodeResult(result);
+ expect(yield* fs.exists(path.join(home, ".claude/skills/alpha"))).toBe(false);
+ expect(
+ yield* fs.readFileString(path.join(home, "library/skills/alpha/notes.md")),
+ ).toBe("notes on alpha");
+ expect(yield* fs.exists(path.join(home, ".agents/skills/alpha"))).toBe(true);
+ expect(stateOf((yield* catalog.list({})).skills, "global", "alpha")).toMatchObject({
+ claudeAgent: "none",
+ codex: "direct",
+ });
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "refuses for an agent that reads the skill's folder, changing nothing",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home } = yield* makeMachine;
+ yield* withManager(home, [], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const { skills } = yield* catalog.list({});
+
+ const result = yield* manager.disable({
+ skills: [refOf(skills, "global", "alpha"), refOf(skills, "global", "solo")],
+ // Cursor reads the shared folder the alpha link is in and Claude's own folder
+ // that solo is in, and has no setting that names a skill.
+ agents: [agent("cursor")],
+ });
+
+ expect(result.outcomes.map(({ status, blocked }) => ({ status, blocked }))).toEqual([
+ {
+ status: "skipped",
+ blocked: [{ instanceId: "cursor", reason: "alwaysOn" }],
+ },
+ {
+ status: "skipped",
+ blocked: [{ instanceId: "cursor", reason: "alwaysOn" }],
+ },
+ ]);
+ expect(yield* fs.exists(path.join(home, ".agents/skills/alpha"))).toBe(true);
+ expect(yield* fs.exists(path.join(home, ".claude/skills/solo/SKILL.md"))).toBe(true);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "names the other agents that lose the skill with the link",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, link } = yield* makeMachine;
+ // `beta` is only linked in Claude's folder, which Cursor and OpenCode read too.
+ yield* link("library/skills/beta", ".claude/skills/beta");
+ yield* withManager(home, [], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const { skills } = yield* catalog.list({});
+ expect(stateOf(skills, "global", "beta")).toMatchObject({
+ claudeAgent: "link",
+ cursor: "link",
+ opencode: "link",
+ });
+
+ const result = yield* manager.disable({
+ skills: [refOf(skills, "global", "beta")],
+ agents: [agent("claudeAgent")],
+ });
+
+ expect(result.outcomes[0]).toMatchObject({
+ status: "changed",
+ affected: [agent("cursor"), agent("opencode")],
+ });
+ expect(yield* fs.exists(path.join(home, "library/skills/beta/SKILL.md"))).toBe(true);
+ }),
+ );
+ }),
+ );
+ });
+
+ describe("place", () => {
+ /** Claude reads a project skill through a link made by turning it on. */
+ const withClaudeOnVerify = (
+ manager: SkillManager.SkillManager["Service"],
+ project: string,
+ verify: SkillRef,
+ ) => manager.enable({ cwd: project, skills: [verify], agents: [agent("claudeAgent")] });
+
+ it.effect.skipIf(!symlinksSupported)(
+ "moves a project skill to Global, and each agent's link follows",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project } = yield* makeMachine;
+ yield* withManager(home, [project], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const verify = refOf(
+ (yield* catalog.list({ cwd: project })).skills,
+ "project",
+ "verify",
+ );
+ yield* withClaudeOnVerify(manager, project, verify);
+ expect(yield* fs.readLink(path.join(project, ".claude/skills/verify"))).toBe(
+ "../../.agents/skills/verify",
+ );
+
+ const result = yield* manager.place({
+ cwd: project,
+ skills: [verify],
+ to: { kind: "global" },
+ });
+
+ // Grok reads the shared global folder but not the project's, so it gets the skill.
+ expect(result.outcomes).toEqual([
+ { skill: verify, status: "changed", blocked: [], affected: [agent("grok")] },
+ ]);
+ yield* encodeResult(result);
+ const moved = path.join(home, ".agents/skills/verify");
+ expect(yield* fs.readFileString(path.join(moved, "run.sh"))).toBe("echo ok");
+ expect(yield* fs.exists(path.join(project, ".agents/skills/verify"))).toBe(false);
+ // The project's link would lead nowhere; the agents that need one get it in Global.
+ expect(yield* fs.readDirectory(path.join(project, ".claude/skills"))).toEqual([]);
+ expect(yield* fs.readLink(path.join(home, ".claude/skills/verify"))).toBe(moved);
+ expect(yield* fs.readLink(path.join(home, ".gemini/config/skills/verify"))).toBe(
+ moved,
+ );
+ const after = (yield* catalog.list({ cwd: project })).skills;
+ expect(
+ after.some((skill) => skill.scope === "project" && skill.name === "verify"),
+ ).toBe(false);
+ expect(stateOf(after, "global", "verify")).toEqual({
+ claudeAgent: "link",
+ codex: "direct",
+ cursor: "direct",
+ grok: "direct",
+ opencode: "direct",
+ antigravity: "link",
+ pi: "direct",
+ });
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "refuses a project that isn't registered, whether it is the list's or a destination",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project } = yield* makeMachine;
+ yield* withManager(home, [project], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const verify = refOf(
+ (yield* catalog.list({ cwd: project })).skills,
+ "project",
+ "verify",
+ );
+ const stranger = path.join(home, "repos/stranger");
+
+ for (const input of [
+ { cwd: stranger, skills: [verify], to: { kind: "global" } },
+ { cwd: project, skills: [verify], to: { kind: "project", cwd: stranger } },
+ {
+ cwd: project,
+ skills: [verify],
+ to: { kind: "projects", cwds: [project, stranger] },
+ },
+ ] as const) {
+ const error = yield* manager.place(input).pipe(Effect.flip);
+ expect(error).toEqual(new SkillRequestError({ reason: "projectNotRegistered" }));
+ }
+ expect(yield* fs.exists(path.join(project, ".agents/skills/verify/SKILL.md"))).toBe(
+ true,
+ );
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "moves a skill in an agent's own folder to this project, and the agent keeps it",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project } = yield* makeMachine;
+ yield* withManager(home, [project], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const solo = refOf((yield* catalog.list({ cwd: project })).skills, "global", "solo");
+
+ const result = yield* manager.place({
+ cwd: project,
+ skills: [solo],
+ to: { kind: "project", cwd: project },
+ });
+
+ expect(result.outcomes[0]).toMatchObject({ status: "changed", blocked: [] });
+ // Codex, Antigravity and Pi read the project's shared folder; they hadn't the skill.
+ expect(result.outcomes[0]?.affected.toSorted()).toEqual(
+ [agent("antigravity"), agent("codex"), agent("pi")].toSorted(),
+ );
+ const moved = path.join(project, ".agents/skills/solo");
+ expect(yield* fs.exists(path.join(moved, "SKILL.md"))).toBe(true);
+ expect(yield* fs.exists(path.join(home, ".claude/skills/solo"))).toBe(false);
+ // A project's link is relative, so it survives a clone.
+ expect(yield* fs.readLink(path.join(project, ".claude/skills/solo"))).toBe(
+ "../../.agents/skills/solo",
+ );
+ expect(
+ stateOf((yield* catalog.list({ cwd: project })).skills, "project", "solo"),
+ ).toMatchObject({ claudeAgent: "link", codex: "direct", opencode: "direct" });
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "never merges into or replaces a skill of the same name in the other scope",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project, write } = yield* makeMachine;
+ yield* withManager(home, [project], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const verify = refOf(
+ (yield* catalog.list({ cwd: project })).skills,
+ "project",
+ "verify",
+ );
+ yield* withClaudeOnVerify(manager, project, verify);
+ // First something that isn't even a skill is in the way, then a real skill.
+ yield* fs.makeDirectory(path.join(home, ".agents/skills/verify"), {
+ recursive: true,
+ });
+
+ const folder = yield* manager.place({
+ cwd: project,
+ skills: [verify],
+ to: { kind: "global" },
+ });
+ yield* write(".agents/skills/verify/SKILL.md", skillFile("theirs"));
+ const skill = yield* manager.place({
+ cwd: project,
+ skills: [verify],
+ to: { kind: "global" },
+ });
+
+ for (const result of [folder, skill]) {
+ expect(result.outcomes[0]).toMatchObject({
+ status: "skipped",
+ reason: "destinationTaken",
+ });
+ }
+ expect(
+ yield* fs.readFileString(path.join(home, ".agents/skills/verify/SKILL.md")),
+ ).toBe(skillFile("theirs"));
+ expect(
+ yield* fs.readFileString(path.join(project, ".agents/skills/verify/run.sh")),
+ ).toBe("echo ok");
+ expect(yield* fs.exists(path.join(project, ".claude/skills/verify"))).toBe(true);
+ expect(yield* fs.exists(path.join(home, ".claude/skills/verify"))).toBe(false);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "leaves a skill alone that is only reached through a link, such as a synced library's",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project } = yield* makeMachine;
+ yield* withManager(home, [project], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const { skills } = yield* catalog.list({ cwd: project });
+ const alpha = refOf(skills, "global", "alpha");
+
+ const result = yield* manager.place({
+ cwd: project,
+ skills: [alpha],
+ to: { kind: "project", cwd: project },
+ });
+
+ expect(result.outcomes[0]).toMatchObject({ status: "skipped", reason: "linked" });
+ expect(yield* fs.readLink(path.join(home, ".agents/skills/alpha"))).toBe(
+ path.join(home, "library/skills/alpha"),
+ );
+ expect(yield* fs.exists(path.join(project, ".agents/skills/alpha"))).toBe(false);
+ expect(yield* fs.exists(path.join(home, "library/skills/alpha/SKILL.md"))).toBe(true);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "tells what happened to each skill in a bulk move, and one that can't move doesn't stop the rest",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project, link } = yield* makeMachine;
+ yield* link("library/skills/beta", "repos/app/.agents/skills/synced");
+ yield* withManager(home, [project], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const { skills } = yield* catalog.list({ cwd: project });
+ const ghost: SkillRef = {
+ scope: "project",
+ name: "ghost",
+ home: ".agents/skills/ghost",
+ };
+
+ const result = yield* manager.place({
+ cwd: project,
+ skills: [
+ refOf(skills, "project", "synced"),
+ ghost,
+ refOf(skills, "project", "verify"),
+ ],
+ to: { kind: "global" },
+ });
+
+ expect(
+ result.outcomes.map(({ skill, status, reason }) => [skill.name, status, reason]),
+ ).toEqual([
+ ["synced", "skipped", "linked"],
+ ["ghost", "skipped", "notFound"],
+ ["verify", "changed", undefined],
+ ]);
+ yield* encodeResult(result);
+ expect(yield* fs.exists(path.join(home, ".agents/skills/verify/SKILL.md"))).toBe(
+ true,
+ );
+ expect(yield* fs.readLink(path.join(project, ".agents/skills/synced"))).toBe(
+ path.join(home, "library/skills/beta"),
+ );
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "refuses a skill that isn't where the list said, and a skill that is there already",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project } = yield* makeMachine;
+ yield* withManager(home, [project], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const { skills } = yield* catalog.list({ cwd: project });
+ const solo = refOf(skills, "global", "solo");
+ const verify = refOf(skills, "project", "verify");
+ // `solo` now leads to another folder, so it is no longer what the list showed.
+ yield* fs.remove(path.join(home, ".claude/skills/solo"), { recursive: true });
+ yield* fs.symlink(
+ path.join(home, "library/skills/beta"),
+ path.join(home, ".claude/skills/solo"),
+ );
+
+ const result = yield* manager.place({
+ cwd: project,
+ skills: [solo, verify],
+ to: { kind: "project", cwd: project },
+ });
+
+ expect(result.outcomes.map(({ status, reason }) => ({ status, reason }))).toEqual([
+ { status: "skipped", reason: "changed" },
+ // It is in this project already.
+ { status: "unchanged", reason: undefined },
+ ]);
+ expect(yield* fs.exists(path.join(project, ".agents/skills/solo"))).toBe(false);
+ expect(yield* fs.exists(path.join(project, ".agents/skills/verify/run.sh"))).toBe(
+ true,
+ );
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "refuses a project folder the environment doesn't know",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project } = yield* makeMachine;
+ yield* withManager(home, [], ({ manager }) =>
+ Effect.gen(function* () {
+ // The list itself refuses a folder that isn't a project, so name the skill as a
+ // client holding an older list would.
+ const verify: SkillRef = {
+ scope: "project",
+ name: "verify",
+ home: ".agents/skills/verify",
+ };
+
+ const error = yield* manager
+ .place({ cwd: project, skills: [verify], to: { kind: "global" } })
+ .pipe(Effect.flip);
+
+ expect(error).toEqual(new SkillRequestError({ reason: "projectNotRegistered" }));
+ expect(yield* fs.exists(path.join(project, ".agents/skills/verify/SKILL.md"))).toBe(
+ true,
+ );
+ expect(yield* fs.exists(path.join(home, ".agents/skills/verify"))).toBe(false);
+ }),
+ );
+ }),
+ );
+
+ describe("across filesystems", () => {
+ /** The skill's own folder can't be renamed, as when the project is on another disk. */
+ const onAnotherDisk = (fs: FileSystem.FileSystem, from: string) =>
+ FileSystem.FileSystem.of({
+ ...fs,
+ rename: (oldPath, newPath) =>
+ oldPath === from
+ ? Effect.fail(
+ PlatformError.systemError({
+ _tag: "Unknown",
+ module: "FileSystem",
+ method: "rename",
+ pathOrDescriptor: oldPath,
+ cause: Object.assign(new Error("EXDEV"), { code: "EXDEV" }),
+ }),
+ )
+ : fs.rename(oldPath, newPath),
+ });
+
+ it.effect.skipIf(!symlinksSupported)(
+ "copies the skill over, and the agents' links follow just the same",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project } = yield* makeMachine;
+ const from = path.join(project, ".agents/skills/verify");
+ yield* withManager(home, [project], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const verify = refOf(
+ (yield* catalog.list({ cwd: project })).skills,
+ "project",
+ "verify",
+ );
+ yield* withClaudeOnVerify(manager, project, verify);
+
+ const result = yield* manager.place({
+ cwd: project,
+ skills: [verify],
+ to: { kind: "global" },
+ });
+
+ expect(result.outcomes[0]).toMatchObject({ status: "changed", blocked: [] });
+ const moved = path.join(home, ".agents/skills/verify");
+ expect(yield* fs.readFileString(path.join(moved, "run.sh"))).toBe("echo ok");
+ expect(yield* fs.exists(from)).toBe(false);
+ expect(yield* fs.readDirectory(path.join(project, ".claude/skills"))).toEqual([]);
+ expect(yield* fs.readLink(path.join(home, ".claude/skills/verify"))).toBe(moved);
+ const left = yield* fs.readDirectory(path.join(home, ".agents/skills"));
+ expect(left.filter((name) => name.startsWith(".t3-moving"))).toEqual([]);
+ }),
+ ).pipe(Effect.provideService(FileSystem.FileSystem, onAnotherDisk(fs, from)));
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "leaves the skill and its links as they were when the copy fails",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project } = yield* makeMachine;
+ const from = path.join(project, ".agents/skills/verify");
+ const failing = FileSystem.FileSystem.of({
+ ...onAnotherDisk(fs, from),
+ copyFile: (source) =>
+ Effect.fail(
+ PlatformError.systemError({
+ _tag: "Unknown",
+ module: "FileSystem",
+ method: "copyFile",
+ pathOrDescriptor: source,
+ cause: Object.assign(new Error("EIO"), { code: "EIO" }),
+ }),
+ ),
+ });
+ yield* withManager(home, [project], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const verify = refOf(
+ (yield* catalog.list({ cwd: project })).skills,
+ "project",
+ "verify",
+ );
+ yield* withClaudeOnVerify(manager, project, verify);
+
+ const result = yield* manager.place({
+ cwd: project,
+ skills: [verify],
+ to: { kind: "global" },
+ });
+
+ expect(result.outcomes[0]).toMatchObject({ status: "skipped", reason: "failed" });
+ expect(yield* fs.readFileString(path.join(from, "run.sh"))).toBe("echo ok");
+ expect(yield* fs.readLink(path.join(project, ".claude/skills/verify"))).toBe(
+ "../../.agents/skills/verify",
+ );
+ expect(yield* fs.exists(path.join(home, ".agents/skills/verify"))).toBe(false);
+ expect(yield* fs.readDirectory(path.join(home, ".agents/skills"))).toEqual([
+ "alpha",
+ ]);
+ }),
+ ).pipe(Effect.provideService(FileSystem.FileSystem, failing));
+ }),
+ );
+ });
+ });
+
+ describe("delete", () => {
+ it.effect.skipIf(!symlinksSupported)(
+ "deletes the skill's folder and every link to it, and nothing else",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project } = yield* makeMachine;
+ yield* withManager(home, [project], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const { skills } = yield* catalog.list({ cwd: project });
+ const verify = refOf(skills, "project", "verify");
+ yield* manager.enable({
+ cwd: project,
+ skills: [verify],
+ agents: [agent("claudeAgent")],
+ });
+
+ const result = yield* manager.delete({ cwd: project, skills: [verify] });
+
+ expect(result.outcomes[0]).toMatchObject({ status: "changed", blocked: [] });
+ expect(result.outcomes[0]?.affected).toContain(agent("claudeAgent"));
+ expect(result.outcomes[0]?.affected).toContain(agent("codex"));
+ yield* encodeResult(result);
+ expect(yield* fs.exists(path.join(project, ".agents/skills/verify"))).toBe(false);
+ expect(yield* fs.readDirectory(path.join(project, ".claude/skills"))).toEqual([]);
+ // The folders around it, and everything else, are as they were.
+ expect(yield* fs.exists(path.join(project, ".agents/skills"))).toBe(true);
+ expect(yield* fs.exists(path.join(home, ".claude/skills/solo/SKILL.md"))).toBe(true);
+ expect(yield* fs.exists(path.join(home, "library/skills/alpha/SKILL.md"))).toBe(true);
+ expect(
+ (yield* catalog.list({ cwd: project })).skills.some(
+ (skill) => skill.name === "verify",
+ ),
+ ).toBe(false);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "takes away the links that lead to a global skill from a project too",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project, link } = yield* makeMachine;
+ yield* link(".claude/skills/solo", "repos/app/.claude/skills/solo");
+ yield* withManager(home, [project], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const solo = refOf((yield* catalog.list({ cwd: project })).skills, "global", "solo");
+
+ const result = yield* manager.delete({ cwd: project, skills: [solo] });
+
+ expect(result.outcomes[0]).toMatchObject({ status: "changed" });
+ expect(yield* fs.exists(path.join(home, ".claude/skills/solo"))).toBe(false);
+ const left = yield* fs.readDirectory(path.join(project, ".claude/skills"));
+ expect(left).not.toContain("solo");
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "doesn't delete what a link leads to: a synced library's skill stays",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home } = yield* makeMachine;
+ yield* withManager(home, [], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const alpha = refOf((yield* catalog.list({})).skills, "global", "alpha");
+
+ const result = yield* manager.delete({ skills: [alpha] });
+
+ expect(result.outcomes[0]).toMatchObject({ status: "skipped", reason: "linked" });
+ expect(
+ yield* fs.readFileString(path.join(home, "library/skills/alpha/notes.md")),
+ ).toBe("notes on alpha");
+ expect(yield* fs.exists(path.join(home, ".agents/skills/alpha"))).toBe(true);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "tells what happened to each skill in a bulk delete, and refuses a stale one",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project } = yield* makeMachine;
+ yield* withManager(home, [project], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const { skills } = yield* catalog.list({ cwd: project });
+ const verify = refOf(skills, "project", "verify");
+ const solo = refOf(skills, "global", "solo");
+ const alpha = refOf(skills, "global", "alpha");
+ const ghost: SkillRef = { scope: "global", name: "ghost", home: "~/ghost" };
+ // `solo` was swapped for a link to another folder since the list was read.
+ yield* fs.remove(path.join(home, ".claude/skills/solo"), { recursive: true });
+ yield* fs.symlink(
+ path.join(home, "library/skills/beta"),
+ path.join(home, ".claude/skills/solo"),
+ );
+
+ const result = yield* manager.delete({
+ cwd: project,
+ skills: [verify, solo, alpha, ghost],
+ });
+
+ expect(
+ result.outcomes.map(({ skill, status, reason }) => [skill.name, status, reason]),
+ ).toEqual([
+ ["verify", "changed", undefined],
+ ["solo", "skipped", "changed"],
+ ["alpha", "skipped", "linked"],
+ ["ghost", "skipped", "notFound"],
+ ]);
+ expect(yield* fs.exists(path.join(home, "library/skills/beta/SKILL.md"))).toBe(true);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "refuses a project folder the environment doesn't know",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project } = yield* makeMachine;
+ yield* withManager(home, [], ({ manager }) =>
+ Effect.gen(function* () {
+ // The list itself refuses a folder that isn't a project, so name the skill as a
+ // client holding an older list would.
+ const verify: SkillRef = {
+ scope: "project",
+ name: "verify",
+ home: ".agents/skills/verify",
+ };
+
+ const error = yield* manager
+ .delete({ cwd: project, skills: [verify] })
+ .pipe(Effect.flip);
+
+ expect(error).toEqual(new SkillRequestError({ reason: "projectNotRegistered" }));
+ expect(yield* fs.exists(path.join(project, ".agents/skills/verify/SKILL.md"))).toBe(
+ true,
+ );
+ }),
+ );
+ }),
+ );
+ });
+
+ describe("create", () => {
+ it.effect.skipIf(!symlinksSupported)(
+ "writes a Global skill in the shared folder and links it for the agents that read another",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home } = yield* makeMachine;
+ yield* withManager(home, [], ({ manager, catalog, refreshes }) =>
+ Effect.gen(function* () {
+ const description = 'Check a diff: "style" first # then tests';
+ const result = yield* manager.create({
+ scope: "global",
+ name: "review-code",
+ description,
+ });
+
+ expect(result).toEqual({
+ skill: {
+ scope: "global",
+ name: "review-code",
+ home: "~/.agents/skills/review-code",
+ },
+ blocked: [],
+ });
+ const folder = path.join(home, ".agents/skills");
+ const text = yield* fs.readFileString(path.join(folder, "review-code/SKILL.md"));
+ expect(parseSkillFrontmatter(text)).toEqual({
+ kind: "parsed",
+ name: "review-code",
+ description,
+ });
+ expect(yield* fs.readLink(path.join(home, ".claude/skills/review-code"))).toBe(
+ path.join(folder, "review-code"),
+ );
+ const { skills } = yield* catalog.list({});
+ expect(stateOf(skills, "global", "review-code")).toEqual({
+ claudeAgent: "link",
+ codex: "direct",
+ cursor: "direct",
+ grok: "direct",
+ opencode: "direct",
+ antigravity: "link",
+ pi: "direct",
+ });
+ // Nothing half-made is left beside it.
+ expect((yield* fs.readDirectory(folder)).toSorted()).toEqual([
+ "alpha",
+ "review-code",
+ ]);
+ // Every agent that has the skill now has its picker refreshed.
+ const asked = yield* Effect.forEach(ALL_AGENTS, () => Queue.take(refreshes));
+ expect(asked.map((item) => item.instanceId).toSorted()).toEqual(
+ ALL_AGENTS.toSorted(),
+ );
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "writes a project skill in the project's shared folder, with relative links",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project } = yield* makeMachine;
+ yield* withManager(home, [project], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ // A Global skill with the name doesn't stand in the way of a project one.
+ const result = yield* manager.create({
+ cwd: project,
+ scope: "project",
+ name: "alpha",
+ description: "The project's own alpha.",
+ });
+
+ expect(result.skill).toEqual({
+ scope: "project",
+ name: "alpha",
+ home: ".agents/skills/alpha",
+ });
+ expect(yield* fs.exists(path.join(project, ".agents/skills/alpha/SKILL.md"))).toBe(
+ true,
+ );
+ expect(yield* fs.readLink(path.join(project, ".claude/skills/alpha"))).toBe(
+ "../../.agents/skills/alpha",
+ );
+ const { skills } = yield* catalog.list({ cwd: project });
+ expect(stateOf(skills, "project", "alpha")).toMatchObject({
+ claudeAgent: "link",
+ codex: "direct",
+ grok: "link",
+ });
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "refuses a name anything in that scope's folders has, and writes nothing",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project, write } = yield* makeMachine;
+ // A plain file in one agent's folder, and a link that leads nowhere in another's.
+ yield* write("repos/app/.claude/skills/notes", "not a skill");
+ yield* fs.makeDirectory(path.join(project, ".cursor/skills"), { recursive: true });
+ yield* fs.symlink(path.join(home, "missing"), path.join(project, ".cursor/skills/gone"));
+ yield* withManager(home, [project], ({ manager }) =>
+ Effect.gen(function* () {
+ const refused = (scope: "global" | "project", name: string) =>
+ manager
+ .create({ cwd: project, scope, name, description: "Anything." })
+ .pipe(Effect.flip);
+
+ // `solo` is a real folder in Claude's, `alpha` a link in the shared one.
+ for (const name of ["solo", "alpha"]) {
+ expect(yield* refused("global", name)).toEqual(
+ new SkillCreateError({ reason: "nameTaken" }),
+ );
+ }
+ for (const name of ["verify", "notes", "gone"]) {
+ expect(yield* refused("project", name)).toEqual(
+ new SkillCreateError({ reason: "nameTaken" }),
+ );
+ }
+ expect(yield* fs.exists(path.join(home, ".agents/skills/solo"))).toBe(false);
+ expect(
+ (yield* fs.readDirectory(path.join(project, ".agents/skills"))).toSorted(),
+ ).toEqual(["verify"]);
+ expect(yield* fs.readFileString(path.join(project, ".claude/skills/notes"))).toBe(
+ "not a skill",
+ );
+ }),
+ );
+ }),
+ );
+
+ it.effect("makes a project skill only in a registered project", () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project } = yield* makeMachine;
+ yield* withManager(home, [], ({ manager }) =>
+ Effect.gen(function* () {
+ const input = { scope: "project" as const, name: "ship-it", description: "Ship." };
+ for (const cwd of [undefined, project]) {
+ expect(yield* manager.create({ cwd, ...input }).pipe(Effect.flip)).toEqual(
+ new SkillRequestError({ reason: "projectNotRegistered" }),
+ );
+ }
+ expect(yield* fs.exists(path.join(project, ".agents/skills/ship-it"))).toBe(false);
+ }),
+ );
+ }),
+ );
+ });
+
+ describe("the picker refresh", () => {
+ it.effect.skipIf(!symlinksSupported)(
+ "refreshes the agents whose skills changed, once, and nothing after a no-op or a refusal",
+ () =>
+ Effect.gen(function* () {
+ const { home } = yield* makeMachine;
+ yield* withManager(home, [], ({ manager, catalog, refreshes }) =>
+ Effect.gen(function* () {
+ const { skills } = yield* catalog.list({});
+ const alpha = refOf(skills, "global", "alpha");
+ const ghost: SkillRef = { scope: "global", name: "ghost", home: "~/ghost" };
+ // Codex reads the skill already, and the other one isn't there: nothing is written.
+ yield* manager.enable({ skills: [alpha, ghost], agents: [agent("codex")] });
+ yield* manager.disable({ skills: [alpha], agents: [agent("claudeAgent")] });
+
+ yield* manager.enable({ skills: [alpha], agents: [agent("claudeAgent")] });
+
+ // Anything the two no-ops had asked for would come first.
+ expect(yield* Queue.take(refreshes)).toEqual({
+ instanceId: agent("claudeAgent"),
+ cwd: undefined,
+ fresh: undefined,
+ });
+ expect(yield* Queue.size(refreshes)).toBe(0);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "refreshes the open project's list for each agent that gained or lost the skill",
+ () =>
+ Effect.gen(function* () {
+ const { home, project } = yield* makeMachine;
+ yield* withManager(home, [project], ({ manager, catalog, refreshes }) =>
+ Effect.gen(function* () {
+ const { skills } = yield* catalog.list({ cwd: project });
+ const verify = refOf(skills, "project", "verify");
+
+ // A skill that is already on for the agent asked for changes nothing.
+ yield* manager.enable({ cwd: project, skills: [verify], agents: [agent("codex")] });
+ yield* manager.enable({
+ cwd: project,
+ skills: [verify],
+ agents: [agent("claudeAgent")],
+ });
+ yield* manager.disable({
+ cwd: project,
+ skills: [verify],
+ agents: [agent("claudeAgent")],
+ });
+
+ const asked = yield* Effect.forEach([1, 2], () => Queue.take(refreshes));
+ expect(asked).toEqual([
+ { instanceId: agent("claudeAgent"), cwd: project, fresh: true },
+ { instanceId: agent("claudeAgent"), cwd: project, fresh: true },
+ ]);
+ expect(yield* Queue.size(refreshes)).toBe(0);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "refreshes every agent a move or a delete touches, and none when the move is refused",
+ () =>
+ Effect.gen(function* () {
+ const { home, project } = yield* makeMachine;
+ yield* withManager(home, [project], ({ manager, catalog, refreshes }) =>
+ Effect.gen(function* () {
+ const { skills } = yield* catalog.list({ cwd: project });
+ const alpha = refOf(skills, "global", "alpha");
+ const verify = refOf(skills, "project", "verify");
+ const solo = refOf(skills, "global", "solo");
+ const touched = (count: number) =>
+ Effect.forEach(Array.from({ length: count }), () => Queue.take(refreshes)).pipe(
+ Effect.map((asked) => asked.map((item) => item.instanceId).toSorted()),
+ );
+
+ // A skill reached through a link can't move: nothing was written, so nothing refreshes.
+ yield* manager.place({
+ cwd: project,
+ skills: [alpha],
+ to: { kind: "project", cwd: project },
+ });
+ yield* manager.place({ cwd: project, skills: [verify], to: { kind: "global" } });
+ // Claude never used it. The other six did, or do now, or both.
+ expect(yield* touched(6)).toEqual(
+ ALL_AGENTS.filter((id) => id !== agent("claudeAgent")).toSorted(),
+ );
+ expect(yield* Queue.size(refreshes)).toBe(0);
+
+ yield* manager.delete({ cwd: project, skills: [solo] });
+ // Claude, Cursor and OpenCode read the global `.claude/skills` folder.
+ expect(yield* touched(3)).toEqual(
+ [agent("claudeAgent"), agent("cursor"), agent("opencode")].toSorted(),
+ );
+ }),
+ );
+ }),
+ );
+ });
+
+ describe("requests", () => {
+ it.effect.skipIf(!symlinksSupported)(
+ "refuses to write when the skill is no longer where the list said, or gone",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home } = yield* makeMachine;
+ yield* withManager(home, [], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const { skills } = yield* catalog.list({});
+ const alpha = refOf(skills, "global", "alpha");
+ // The shared folder's `alpha` now leads to another folder.
+ yield* fs.remove(path.join(home, ".agents/skills/alpha"));
+ yield* fs.symlink(
+ path.join(home, "library/skills/beta"),
+ path.join(home, ".agents/skills/alpha"),
+ );
+
+ const result = yield* manager.enable({
+ skills: [alpha, { scope: "global", name: "ghost", home: "~/ghost" }],
+ agents: [agent("claudeAgent")],
+ });
+
+ expect(result.outcomes.map(({ status, reason }) => ({ status, reason }))).toEqual([
+ { status: "skipped", reason: "changed" },
+ { status: "skipped", reason: "notFound" },
+ ]);
+ yield* encodeResult(result);
+ expect(yield* fs.exists(path.join(home, ".claude/skills/alpha"))).toBe(false);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "refuses a project folder the environment doesn't know, and an agent it doesn't have",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project } = yield* makeMachine;
+ yield* withManager(home, [], ({ manager }) =>
+ Effect.gen(function* () {
+ // The list itself refuses a folder that isn't a project, so name the skill as a
+ // client holding an older list would.
+ const verify: SkillRef = {
+ scope: "project",
+ name: "verify",
+ home: ".agents/skills/verify",
+ };
+
+ const unregistered = yield* manager
+ .enable({ cwd: project, skills: [verify], agents: [agent("claudeAgent")] })
+ .pipe(Effect.flip);
+ expect(unregistered).toEqual(
+ new SkillRequestError({ reason: "projectNotRegistered" }),
+ );
+ expect(yield* fs.exists(path.join(project, ".claude"))).toBe(false);
+ }),
+ );
+ yield* withManager(home, [project], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const { skills } = yield* catalog.list({ cwd: project });
+
+ const unknown = yield* manager
+ .enable({
+ cwd: project,
+ skills: [refOf(skills, "project", "verify")],
+ agents: [agent("not-an-agent")],
+ })
+ .pipe(Effect.flip);
+ expect(unknown).toEqual(new SkillRequestError({ reason: "unknownAgent" }));
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "tells what happened to each skill in a bulk request, and one bad skill doesn't stop the rest",
+ () =>
+ Effect.gen(function* () {
+ const { home, project } = yield* makeMachine;
+ yield* withManager(home, [project], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const { skills } = yield* catalog.list({ cwd: project });
+ const alpha = refOf(skills, "global", "alpha");
+ const ghost: SkillRef = { scope: "global", name: "ghost", home: "~/ghost" };
+ const verify = refOf(skills, "project", "verify");
+ const solo = refOf(skills, "global", "solo");
+
+ const result = yield* manager.enable({
+ cwd: project,
+ skills: [alpha, ghost, verify, solo],
+ agents: [agent("claudeAgent")],
+ });
+
+ expect(
+ result.outcomes.map(({ skill, status }) => [skill.name, status] as const),
+ ).toEqual([
+ ["alpha", "changed"],
+ ["ghost", "skipped"],
+ ["verify", "changed"],
+ // Claude reads solo's folder itself.
+ ["solo", "unchanged"],
+ ]);
+ yield* encodeResult(result);
+ }),
+ );
+ }),
+ );
+ });
+
+ describe("skills that come with an agent", () => {
+ /** Codex's system skill, one Claude plugin, and a project skill of the system skill's name. */
+ const withProvided = Effect.gen(function* () {
+ const machine = yield* makeMachine;
+ const { path, home, write } = machine;
+ yield* write(".codex/skills/.system/imagegen/SKILL.md", skillFile("imagegen"));
+ yield* write("repos/app/.agents/skills/imagegen/SKILL.md", skillFile("imagegen"));
+ const installPath = path.join(home, ".claude/plugins/cache/acme-market/review-kit/1.0.0");
+ yield* write(
+ ".claude/plugins/installed_plugins.json",
+ JSON.stringify({
+ version: 2,
+ plugins: { "review-kit@acme-market": [{ scope: "user", installPath }] },
+ }),
+ );
+ yield* write(
+ ".claude/plugins/cache/acme-market/review-kit/1.0.0/skills/review/SKILL.md",
+ skillFile("review"),
+ );
+ return machine;
+ });
+
+ const providedRef = (skills: readonly SkillSummary[], name: string): SkillRef => {
+ const skill = skills.find((item) => item.name === name && item.provided !== undefined);
+ if (!skill) throw new Error(`No provided skill ${name} in the list`);
+ return { scope: skill.scope, name, home: skill.home };
+ };
+
+ it.effect("never deletes, moves or links one, and only its own agent is switched", () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project } = yield* withProvided;
+ yield* withManager(home, [project], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const { skills } = yield* catalog.list({ cwd: project });
+ const system = providedRef(skills, "imagegen");
+ const plugin = providedRef(skills, "review-kit:review");
+
+ const deleted = yield* manager.delete({ cwd: project, skills: [system, plugin] });
+ expect(deleted.outcomes.map(({ status, reason }) => [status, reason])).toEqual([
+ ["skipped", "provided"],
+ ["skipped", "provided"],
+ ]);
+ const placed = yield* manager.place({
+ cwd: project,
+ skills: [plugin],
+ to: { kind: "project", cwd: project },
+ });
+ expect(placed.outcomes.map(({ status, reason }) => [status, reason])).toEqual([
+ ["skipped", "provided"],
+ ]);
+ yield* encodeResult(placed);
+
+ // Another agent can't be given it; the plugin's own agent has nothing T3 Code can write.
+ const enabled = yield* manager.enable({
+ cwd: project,
+ skills: [system],
+ agents: [agent("cursor")],
+ });
+ expect(enabled.outcomes[0]).toMatchObject({
+ status: "skipped",
+ blocked: [{ instanceId: "cursor", reason: "provided" }],
+ });
+ const disabled = yield* manager.disable({
+ cwd: project,
+ skills: [plugin],
+ agents: [agent("claudeAgent"), agent("codex")],
+ });
+ expect(disabled.outcomes[0]).toMatchObject({
+ status: "skipped",
+ blocked: [{ instanceId: "claudeAgent", reason: "alwaysOn" }],
+ });
+
+ expect(
+ yield* fs.exists(path.join(home, ".codex/skills/.system/imagegen/SKILL.md")),
+ ).toBe(true);
+ expect(
+ yield* fs.exists(
+ path.join(
+ home,
+ ".claude/plugins/cache/acme-market/review-kit/1.0.0/skills/review/SKILL.md",
+ ),
+ ),
+ ).toBe(true);
+ expect(yield* fs.exists(path.join(home, ".agents/skills/imagegen"))).toBe(false);
+ expect(yield* fs.exists(path.join(project, ".agents/skills/review"))).toBe(false);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "doesn't keep the user's own skill of the same name out of Global",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, project } = yield* withProvided;
+ yield* withManager(home, [project], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const { skills } = yield* catalog.list({ cwd: project });
+ const result = yield* manager.place({
+ cwd: project,
+ skills: [refOf(skills, "project", "imagegen")],
+ to: { kind: "global" },
+ });
+ expect(result.outcomes[0]?.status).toBe("changed");
+ expect(yield* fs.exists(path.join(home, ".agents/skills/imagegen/SKILL.md"))).toBe(
+ true,
+ );
+ }),
+ );
+ }),
+ );
+ });
+});
+
+describe("the request and result schemas", () => {
+ const ref = { scope: "global", name: "alpha", home: "~/alpha" } as const;
+ const decodes = (schema: Schema.Decoder, input: unknown) =>
+ Schema.decodeUnknownOption(schema)(input).pipe(Option.isSome);
+
+ it("accepts a request for some skills and agents", () => {
+ expect(decodes(SkillEnableInput, { skills: [ref], agents: ["claudeAgent"] })).toBe(true);
+ expect(decodes(SkillDisableInput, { cwd: "/repo", skills: [ref], agents: ["codex"] })).toBe(
+ true,
+ );
+ });
+
+ it("rejects a request for no skills, no agents or too many skills", () => {
+ expect(decodes(SkillEnableInput, { skills: [], agents: ["codex"] })).toBe(false);
+ expect(decodes(SkillEnableInput, { skills: [ref], agents: [] })).toBe(false);
+ expect(decodes(SkillDeleteInput, { skills: Array.from({ length: 201 }, () => ref) })).toBe(
+ false,
+ );
+ expect(decodes(SkillDeleteInput, { skills: Array.from({ length: 200 }, () => ref) })).toBe(
+ true,
+ );
+ });
+
+ it("accepts a request to place skills in a project, in Global or in some projects", () => {
+ const to = [
+ { kind: "project", cwd: "/repo" },
+ { kind: "global" },
+ { kind: "projects", cwds: ["/repo", "/other"] },
+ ];
+ for (const placement of to) {
+ expect(decodes(SkillPlaceInput, { skills: [ref], to: placement })).toBe(true);
+ expect(decodes(SkillPlaceInput, { cwd: "/repo", skills: [ref], to: placement })).toBe(true);
+ }
+ expect(decodes(SkillDeleteInput, { skills: [ref] })).toBe(true);
+ });
+
+ it("rejects a placement with no skills, no destination, or a destination it can't use", () => {
+ expect(decodes(SkillPlaceInput, { skills: [], to: { kind: "global" } })).toBe(false);
+ expect(decodes(SkillPlaceInput, { skills: [ref] })).toBe(false);
+ expect(decodes(SkillPlaceInput, { skills: [ref], to: { kind: "project" } })).toBe(false);
+ expect(decodes(SkillPlaceInput, { skills: [ref], to: { kind: "elsewhere" } })).toBe(false);
+ expect(decodes(SkillPlaceInput, { skills: [ref], to: "global" })).toBe(false);
+ expect(decodes(SkillPlaceInput, { skills: [ref], to: { kind: "projects", cwds: [] } })).toBe(
+ false,
+ );
+ const cwds = (count: number) => Array.from({ length: count }, (_, index) => `/repo-${index}`);
+ expect(
+ decodes(SkillPlaceInput, { skills: [ref], to: { kind: "projects", cwds: cwds(65) } }),
+ ).toBe(false);
+ expect(
+ decodes(SkillPlaceInput, { skills: [ref], to: { kind: "projects", cwds: cwds(64) } }),
+ ).toBe(true);
+ expect(decodes(SkillDeleteInput, { skills: [] })).toBe(false);
+ });
+
+ it("describes a skill by how an agent reaches it, who it is used in and where it came from", () => {
+ const summary = {
+ name: "alpha",
+ scope: "global",
+ home: "~/.agents/skill-library/alpha",
+ description: "The alpha skill.",
+ copies: [],
+ access: [
+ {
+ instanceId: "codex",
+ driver: "codex",
+ state: "off",
+ folder: "~/.agents/skills",
+ fixed: false,
+ },
+ {
+ instanceId: "cursor",
+ driver: "cursor",
+ state: "direct",
+ folder: "~/.cursor",
+ fixed: true,
+ },
+ ],
+ source: "acme/skills",
+ projects: ["/home/user/acme-web"],
+ };
+ expect(decodes(SkillListResult, { skills: [summary], unreadable: [] })).toBe(true);
+ expect(
+ decodes(SkillListResult, {
+ skills: [{ ...summary, access: [{ ...summary.access[0], state: "maybe" }] }],
+ unreadable: [],
+ }),
+ ).toBe(false);
+ expect(
+ decodes(SkillListResult, { skills: [{ ...summary, projects: [""] }], unreadable: [] }),
+ ).toBe(false);
+ });
+
+ it.effect("describes why a skill or an agent was left as it was, for each reason", () =>
+ Effect.forEach(["linked", "destinationTaken", "inUse", "setElsewhere"] as const, (reason) =>
+ encodeResult({
+ outcomes: [
+ {
+ skill: ref,
+ status: "skipped",
+ reason,
+ blocked: [{ instanceId: "codex", reason }],
+ affected: [],
+ },
+ ],
+ }),
+ ),
+ );
+});
+
+describe("planEnable", () => {
+ const read = (directory: string, scope: SkillScope, rival = false, standard = false) => ({
+ scope,
+ directory,
+ label: directory,
+ standard,
+ rival,
+ });
+ const skill = (
+ agents: SkillCatalog.ResolvedSkill["agents"],
+ entries: SkillCatalog.ResolvedSkill["entries"] = [],
+ ): SkillCatalog.ResolvedSkill => ({
+ scope: "project",
+ name: "verify",
+ displayHome: ".agents/skills/verify",
+ home: "/repo/.agents/skills/verify",
+ own: true,
+ standardFolders: { project: "/repo/.agents/skills", global: "/home/.agents/skills" },
+ entries,
+ agents,
+ });
+ const member = (
+ instanceId: string,
+ collision: "first-wins" | "all",
+ reads: ReturnType[],
+ ) => ({
+ instanceId: agent(instanceId),
+ driver: ProviderInstanceId.make(instanceId) as never,
+ collision,
+ state: "none" as const,
+ via: [],
+ reads,
+ });
+ const everyone = new Set(["a", "b"].map((id) => agent(id)));
+
+ it("links in the shared folder when the agent reads it, even when its own folder is first", () => {
+ const plan = planEnable(
+ skill([
+ member("a", "first-wins", [
+ read("/repo/.pi/skills", "project"),
+ read("/repo/.agents/skills", "project", false, true),
+ ]),
+ ]),
+ everyone,
+ );
+ expect(plan.links).toEqual([{ directory: "/repo/.agents/skills", agents: [agent("a")] }]);
+ });
+
+ it("makes one link for agents that read the same folder, and none where it is there already", () => {
+ const shared = [read("/repo/.agents/skills", "project", false, true)];
+ expect(
+ planEnable(skill([member("a", "all", shared), member("b", "first-wins", shared)]), everyone)
+ .links,
+ ).toEqual([{ directory: "/repo/.agents/skills", agents: [agent("a"), agent("b")] }]);
+ expect(
+ planEnable(
+ skill(
+ [member("a", "all", shared)],
+ [{ path: "/repo/.agents/skills/verify", directory: "/repo/.agents/skills", target: "x" }],
+ ),
+ everyone,
+ ).links,
+ ).toEqual([]);
+ });
+
+ it("holds back an agent that loads another skill with the name first, unless it loads them all", () => {
+ const reads = [
+ read("/home/.claude/skills", "global", true),
+ read("/repo/.claude/skills", "project"),
+ ];
+ const plan = planEnable(
+ skill([member("a", "first-wins", reads), member("b", "all", reads)]),
+ everyone,
+ );
+ expect(plan.blocked).toEqual([{ instanceId: agent("a"), reason: "shadowed" }]);
+ expect(plan.links).toEqual([{ directory: "/repo/.claude/skills", agents: [agent("b")] }]);
+ });
+});
diff --git a/apps/server/src/skills/SkillManager.ts b/apps/server/src/skills/SkillManager.ts
new file mode 100644
index 000000000000..11124b2a1b31
--- /dev/null
+++ b/apps/server/src/skills/SkillManager.ts
@@ -0,0 +1,988 @@
+/**
+ * SkillManager - turns skills on or off for each agent by making and removing links, or by writing
+ * the agent's own settings.
+ *
+ * A skill has one home, a real folder. An agent reads it either because the agent reads that
+ * folder itself (`direct`) or because a link in a folder the agent reads points at it (`link`).
+ * Turning a skill on makes such a link in the agent's own folder; turning it off removes it. An
+ * agent that reads the folder itself has no link to remove, so where it has a per-skill setting
+ * T3 Code knows (see `AgentSkillSettings`) that setting is written instead, and the agent is
+ * `off`; otherwise it is `fixed` and stays on. Those writes only touch links this service can show
+ * lead to the skill's home: a real folder is never replaced by them. Placing and deleting are the
+ * only writes that take a real folder, and only one that sits in an agent's skill folder itself
+ * (`own`), never a synced library behind a link (placing a synced skill into some projects links
+ * to it instead; see `SkillPlacement`). A skill that comes with an agent (`provided`) is never
+ * linked, placed or deleted: only that agent's own setting for it is written, where it has one.
+ * Creating a skill makes a new folder in the shared one, and only under a name no folder of that
+ * scope has yet (see `SkillCreate`).
+ *
+ * Every write starts from what the folders hold now, not from what a client last saw: a skill
+ * whose home is not where the client said is refused, and each link is checked again right
+ * before it is made or removed (see `SkillLinks`). Writes run one request at a time, and an agent
+ * whose skills changed has its skill list for the composer refreshed afterwards.
+ *
+ * @module SkillManager
+ */
+import {
+ SkillCreateError,
+ SkillRequestError,
+ type ProviderInstanceId,
+ type SkillBatchResult,
+ type SkillCreateInput,
+ type SkillCreateResult,
+ type SkillDeleteInput,
+ type SkillDisableInput,
+ type SkillEnableInput,
+ type SkillAgentState,
+ type SkillOutcome,
+ type SkillOutcomeReason,
+ type SkillPlaceInput,
+ type SkillRef,
+ type SkillScope,
+ type SkillShareInput,
+} from "@t3tools/contracts";
+import * as HostProcess from "@t3tools/shared/HostProcess";
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+import * as Semaphore from "effect/Semaphore";
+import * as Scope from "effect/Scope";
+import type { SkillSettingsWriter } from "@t3tools/provider-core/server/driver";
+import {
+ STANDARD_SKILL_FOLDER,
+ ownProjectFolderFor,
+} from "@t3tools/provider-core/server/AgentSkillFolders";
+
+import * as ProjectService from "../project/ProjectService.ts";
+import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
+import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
+import * as VcsProcess from "../vcs/VcsProcess.ts";
+import { setSkillSwitch, type SkillSwitchWrite, type SwitchedSkill } from "./AgentSkillSettings.ts";
+import {
+ codexRulesSwitchOff,
+ codexSkillFile,
+ planCodexSwitch,
+ readCodexSkillRules,
+} from "./CodexSkillSettings.ts";
+import * as SkillCatalog from "./SkillCatalog.ts";
+import { writeNewSkill } from "./SkillCreate.ts";
+import { createLink, removeLink, type RemoveLinkResult } from "./SkillLinks.ts";
+import { deleteFolder } from "./SkillMove.ts";
+import { makeSkillPlacement, projectsOfLibrarySkill, type LibrarySkill } from "./SkillPlacement.ts";
+
+type Blocked = SkillOutcome["blocked"][number];
+
+/** What was done to one skill, before it is told to a client. */
+interface SkillChange {
+ /** A link was made or removed. */
+ readonly wrote: boolean;
+ /** Agents the change didn't reach. */
+ readonly blocked: readonly Blocked[];
+ /** Something about the skill as a whole kept the change from being complete. */
+ readonly reason?: SkillOutcomeReason | undefined;
+ /** Agents that gained or lost the skill without being asked, when the change works that out. */
+ readonly affected?: readonly ProviderInstanceId[] | undefined;
+ /** Agents whose skill list changed, when the change works that out; their `$` picker is refreshed. */
+ readonly touched?: readonly ProviderInstanceId[] | undefined;
+ /** The skill's source record couldn't go along with a placement, so it has none now. */
+ readonly sourceDropped?: boolean | undefined;
+}
+
+/**
+ * Links to make so each requested agent that doesn't use the skill yet gets it. An agent gets its
+ * link in the shared folder when it reads that, else in its own first folder for the skill's
+ * scope; agents that read the same folder share one link. An agent whose own settings switch the
+ * skill off (`clears`) gets that setting taken away, unless the skill can't reach it anyway.
+ */
+export const planEnable = (
+ skill: SkillCatalog.ResolvedSkill,
+ requested: ReadonlySet,
+) => {
+ const links = new Map();
+ const blocked: Blocked[] = [];
+ const clears: ProviderInstanceId[] = [];
+ for (const agent of skill.agents) {
+ if (!requested.has(agent.instanceId)) continue;
+ if (agent.state === "off") {
+ clears.push(agent.instanceId);
+ continue;
+ }
+ if (agent.state !== "none") continue;
+ const shared = agent.reads.findIndex((read) => read.scope === skill.scope && read.standard);
+ const index =
+ shared >= 0 ? shared : agent.reads.findIndex((read) => read.scope === skill.scope);
+ const root = agent.reads[index];
+ if (root === undefined) {
+ blocked.push({ instanceId: agent.instanceId, reason: "failed" });
+ continue;
+ }
+ // A link would never load if the agent finds another skill with this name first.
+ if (
+ agent.collision === "first-wins" &&
+ agent.reads.slice(0, index).some((read) => read.rival)
+ ) {
+ blocked.push({ instanceId: agent.instanceId, reason: "shadowed" });
+ continue;
+ }
+ if (agent.switchedOff) clears.push(agent.instanceId);
+ // Linked there already, though the agent doesn't load it (Claude can't read its header).
+ if (skill.entries.some((entry) => entry.directory === root.directory)) continue;
+ const link = links.get(root.directory);
+ if (link) link.agents.push(agent.instanceId);
+ else links.set(root.directory, { directory: root.directory, agents: [agent.instanceId] });
+ }
+ return { links: [...links.values()], blocked, clears };
+};
+
+/**
+ * Links to remove so each requested agent stops using the skill. An agent that reads the
+ * skill's own folder, or a link in the shared folder that serves other agents too, can't be
+ * switched by a link: if it has a per-skill setting that is written instead (`switchOffs`),
+ * otherwise it stays on. An agent that is off already is left as it is.
+ */
+const planDisable = (
+ skill: SkillCatalog.ResolvedSkill,
+ requested: ReadonlySet,
+) => {
+ const unlinks = new Map();
+ const blocked: Blocked[] = [];
+ const switchOffs: ProviderInstanceId[] = [];
+ for (const agent of skill.agents) {
+ if (!requested.has(agent.instanceId) || agent.state === "none" || agent.state === "off") {
+ continue;
+ }
+ const entries = skill.entries.filter((entry) => agent.via.includes(entry.path));
+ if (agent.state === "direct" || entries.some((entry) => entry.target === undefined)) {
+ if (agent.settings === undefined) {
+ blocked.push({ instanceId: agent.instanceId, reason: "alwaysOn" });
+ } else {
+ switchOffs.push(agent.instanceId);
+ }
+ continue;
+ }
+ for (const entry of entries) {
+ if (entry.target === undefined) continue;
+ const unlink = unlinks.get(entry.path);
+ if (unlink) unlink.agents.push(agent.instanceId);
+ else
+ unlinks.set(entry.path, {
+ path: entry.path,
+ target: entry.target,
+ agents: [agent.instanceId],
+ });
+ }
+ }
+ return { unlinks: [...unlinks.values()], blocked, switchOffs };
+};
+
+/**
+ * What turning agents on takes for a skill used in only some projects. Every project that uses it
+ * has a link in the shared folder, which the agents that read that folder already have. An agent
+ * that doesn't gets a link in its own folder in each of those projects (`links`), and one whose
+ * own settings switch the skill off gets that taken away (`clears`).
+ */
+const planEnableLibrary = (skill: LibrarySkill, requested: ReadonlySet) => {
+ const folders = new Map();
+ const blocked: Blocked[] = [];
+ const clears: ProviderInstanceId[] = [];
+ const projects = projectsOfLibrarySkill(skill);
+ for (const agent of skill.agents) {
+ if (!requested.has(agent.instanceId)) continue;
+ if (agent.state === "off") {
+ clears.push(agent.instanceId);
+ continue;
+ }
+ if (agent.state !== "none") continue;
+ const folder = ownProjectFolderFor(agent.driver);
+ // With no project using the skill there is nowhere to link it for the agent.
+ if (folder === undefined || projects.length === 0) {
+ blocked.push({ instanceId: agent.instanceId, reason: "failed" });
+ continue;
+ }
+ if (agent.switchedOff) clears.push(agent.instanceId);
+ folders.set(folder, [...(folders.get(folder) ?? []), agent.instanceId]);
+ }
+ return {
+ links: [...folders].map(([folder, agents]) => ({ folder, agents })),
+ blocked,
+ clears,
+ };
+};
+
+/**
+ * What turning agents off takes for a skill used in only some projects: the links in an agent's
+ * own folder go (`unlinks`), in every project. An agent that reads the shared folder, which every
+ * project that uses the skill links into, can't be switched by a link: it has its own setting
+ * written (`switchOffs`) or stays on.
+ */
+const planDisableLibrary = (skill: LibrarySkill, requested: ReadonlySet) => {
+ const folders = new Map();
+ const blocked: Blocked[] = [];
+ const switchOffs: ProviderInstanceId[] = [];
+ for (const agent of skill.agents) {
+ if (!requested.has(agent.instanceId) || agent.state === "none" || agent.state === "off") {
+ continue;
+ }
+ const folder = ownProjectFolderFor(agent.driver);
+ if (folder !== undefined) {
+ folders.set(folder, [...(folders.get(folder) ?? []), agent.instanceId]);
+ } else if (agent.settings === undefined) {
+ blocked.push({ instanceId: agent.instanceId, reason: "alwaysOn" });
+ } else {
+ switchOffs.push(agent.instanceId);
+ }
+ }
+ return {
+ unlinks: [...folders].map(([folder, agents]) => ({ folder, agents })),
+ blocked,
+ switchOffs,
+ };
+};
+
+const hasSkill = (state: SkillAgentState) => state === "direct" || state === "link";
+
+/**
+ * Opens an agent's settings writer the first time a request needs it and keeps it for the rest of
+ * the request, so switching a hundred skills in Codex starts Codex once. Undefined when the
+ * agent has none or it can't be started.
+ */
+type SettingsWriters = (
+ instanceId: ProviderInstanceId,
+) => Effect.Effect;
+
+const combine = (first: SkillChange, second: SkillChange): SkillChange => ({
+ wrote: first.wrote || second.wrote,
+ blocked: [...first.blocked, ...second.blocked],
+ reason: first.reason ?? second.reason,
+});
+
+export class SkillManager extends Context.Service<
+ SkillManager,
+ {
+ /**
+ * Make a link in each agent's own folder so it can use each skill. `"all"` means every
+ * enabled agent. An agent is named by its instance id, or by its driver kind to mean every
+ * instance of that driver when no instance has that id.
+ */
+ readonly enable: (
+ input: Omit & {
+ readonly agents: SkillEnableInput["agents"] | "all";
+ },
+ ) => Effect.Effect;
+ /** Remove each agent's link to each skill. Agents are named as for `enable`. */
+ readonly disable: (
+ input: SkillDisableInput,
+ ) => Effect.Effect;
+ /** Put each skill where `to` says, moving its folder; the agents that used it keep using it. */
+ readonly place: (input: SkillPlaceInput) => Effect.Effect;
+ /**
+ * Move each skill's real folder from an agent's own folder into its scope's shared folder;
+ * the agents that used it keep using it.
+ */
+ readonly share: (input: SkillShareInput) => Effect.Effect;
+ /** Delete each skill's own folder and every link to it. */
+ readonly delete: (
+ input: SkillDeleteInput,
+ ) => Effect.Effect;
+ /**
+ * Make a skill in the scope's shared folder and turn it on for every enabled agent, which
+ * links it for the agents that don't read that folder. Refused when anything in a folder the
+ * list reads for that scope has the name already.
+ */
+ readonly create: (
+ input: SkillCreateInput,
+ ) => Effect.Effect;
+ }
+>()("t3/skills/SkillManager") {}
+
+const make = Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const platform = yield* HostProcess.Platform;
+ const catalog = yield* SkillCatalog.SkillCatalog;
+ const projects = yield* ProjectService.ProjectService;
+ const providers = yield* ProviderRegistry.ProviderRegistry;
+ const providerInstances = yield* ProviderInstanceRegistry.ProviderInstanceRegistry;
+ const environment = yield* HostProcess.Environment;
+ const homeDirectory = yield* HostProcess.HomeDirectory;
+ const writeLock = yield* Semaphore.make(1);
+ // The link primitives take the filesystem from their environment.
+ const filesystemContext = yield* Effect.context<
+ FileSystem.FileSystem | Path.Path | VcsProcess.VcsProcess
+ >();
+
+ /**
+ * Links are only written under a folder the environment knows as a project. The catalog says
+ * which: it refuses any other folder, one that is gone included, before it reads anything.
+ */
+ const requireProject = (cwd: string) => catalog.resolve({ cwd, skills: [] }).pipe(Effect.asVoid);
+
+ const removeAll = Effect.fnUntraced(function* (
+ entries: ReadonlyArray<{ readonly path: string; readonly target: string }>,
+ ) {
+ const results = new Map();
+ for (const entry of entries) {
+ results.set(
+ entry.path,
+ yield* removeLink({ path: entry.path, expectedTarget: entry.target }).pipe(
+ Effect.provideContext(filesystemContext),
+ Effect.catchTags({ SkillLinkError: () => Effect.succeed("failed" as const) }),
+ ),
+ );
+ }
+ return results;
+ });
+
+ const enableOne = Effect.fnUntraced(function* (
+ skill: SkillCatalog.ResolvedSkill,
+ requested: ReadonlySet,
+ projectRoot: string | undefined,
+ ) {
+ const plan = planEnable(skill, requested);
+ const blocked: Blocked[] = [...plan.blocked];
+ let wrote = false;
+ for (const link of plan.links) {
+ const result = yield* createLink({
+ link: path.join(link.directory, skill.name),
+ home: skill.home,
+ scope: skill.scope,
+ platform,
+ projectRoot,
+ }).pipe(
+ Effect.provideContext(filesystemContext),
+ Effect.catchTags({ SkillLinkError: () => Effect.succeed("failed" as const) }),
+ );
+ if (result === "created") wrote = true;
+ else if (result !== "unchanged") {
+ const reason: SkillOutcomeReason =
+ result === "taken" ? "entryTaken" : result === "notAllowed" ? "linkNotAllowed" : "failed";
+ for (const instanceId of link.agents) blocked.push({ instanceId, reason });
+ }
+ }
+ return { wrote, blocked } satisfies SkillChange;
+ });
+
+ const disableOne = Effect.fnUntraced(function* (
+ skill: SkillCatalog.ResolvedSkill,
+ requested: ReadonlySet,
+ ) {
+ const plan = planDisable(skill, requested);
+ const results = yield* removeAll(plan.unlinks);
+ const blocked: Blocked[] = [...plan.blocked];
+ let wrote = false;
+ for (const unlink of plan.unlinks) {
+ const result = results.get(unlink.path);
+ if (result === "removed") wrote = true;
+ else if (result === "changed" || result === "failed") {
+ for (const instanceId of unlink.agents) {
+ blocked.push({ instanceId, reason: result });
+ }
+ }
+ }
+ return { wrote, blocked } satisfies SkillChange;
+ });
+
+ /** The writers of one request, each opened on first use inside the request's scope. */
+ const makeWriters = (scope: Scope.Scope): SettingsWriters => {
+ const opened = new Map();
+ return (instanceId) =>
+ Effect.gen(function* () {
+ if (opened.has(instanceId)) return opened.get(instanceId);
+ const instance = yield* providerInstances.getInstance(instanceId);
+ const writer =
+ instance?.enabled && instance.openSkillSettingsWriter
+ ? yield* instance.openSkillSettingsWriter.pipe(
+ Effect.provideService(Scope.Scope, scope),
+ Effect.tapError((error) => Effect.logWarning("skill settings writer", { error })),
+ Effect.option,
+ Effect.map(Option.getOrUndefined),
+ )
+ : undefined;
+ opened.set(instanceId, writer);
+ return writer;
+ });
+ };
+
+ const switchedSkillOf = (skill: SkillCatalog.ResolvedSkill): SwitchedSkill => ({
+ scope: skill.scope,
+ name: skill.name,
+ declaredName: skill.declaredName,
+ home: skill.home,
+ entryPaths: skill.entries.map((entry) => entry.path),
+ });
+
+ /** Codex's settings are written by Codex; the file is read before and after to check. */
+ const switchCodex = Effect.fnUntraced(function* (
+ agent: SkillCatalog.ResolvedSkill["agents"][number],
+ target: SwitchedSkill,
+ off: boolean,
+ writers: SettingsWriters,
+ ) {
+ if (agent.settings === undefined) return "failed" as const;
+ const context = agent.settings;
+ const file = codexSkillFile(path, target);
+ const rules = yield* readCodexSkillRules(context).pipe(
+ Effect.provideContext(filesystemContext),
+ );
+ const changes = planCodexSwitch(rules, file, target, off);
+ if (changes.length === 0) return "unchanged" as const;
+ const write = yield* writers(agent.instanceId);
+ if (write === undefined) return "failed" as const;
+
+ let decidedElsewhere = false;
+ for (const change of changes) {
+ const result = yield* write(change).pipe(Effect.option);
+ if (Option.isNone(result)) return "failed" as const;
+ // `effectiveEnabled` is what Codex decides after the write, with every layer it reads.
+ if (result.value.effectiveEnabled !== change.enabled) decidedElsewhere = true;
+ }
+ if (decidedElsewhere) return "setElsewhere" as const;
+ const after = yield* readCodexSkillRules(context).pipe(
+ Effect.provideContext(filesystemContext),
+ );
+ return codexRulesSwitchOff(after, file, target) === off
+ ? ("written" as const)
+ : ("failed" as const);
+ });
+
+ /** Writes the agent's own setting for the skill so the agent is `off` (or no longer off). */
+ const switchAgents = Effect.fnUntraced(function* (
+ skill: SkillCatalog.ResolvedSkill,
+ instanceIds: readonly ProviderInstanceId[],
+ off: boolean,
+ writers: SettingsWriters,
+ ) {
+ const target = switchedSkillOf(skill);
+ const blocked: Blocked[] = [];
+ let wrote = false;
+ for (const instanceId of instanceIds) {
+ const agent = skill.agents.find((candidate) => candidate.instanceId === instanceId);
+ if (agent === undefined) continue;
+ const result: SkillSwitchWrite =
+ agent.settings === undefined
+ ? "failed"
+ : agent.driver === "codex"
+ ? yield* switchCodex(agent, target, off, writers)
+ : yield* setSkillSwitch(agent.settings, target, off).pipe(
+ Effect.provideContext(filesystemContext),
+ );
+ if (result === "written") wrote = true;
+ else if (result === "setElsewhere" || result === "failed") {
+ blocked.push({ instanceId, reason: result });
+ }
+ }
+ return { wrote, blocked } satisfies SkillChange;
+ });
+
+ const placement = yield* makeSkillPlacement({
+ catalog,
+ platform,
+ environment,
+ home: homeDirectory,
+ registeredRoots: projects.listShells().pipe(
+ Effect.map((shells) => shells.map((shell) => shell.workspaceRoot)),
+ Effect.orElseSucceed((): string[] => []),
+ ),
+ enable: (skill, agents, projectRoot) => enableOne(skill, agents, projectRoot),
+ });
+
+ /** A skill used in only some projects: links in the projects' folders, and the agents' settings. */
+ const enableLibraryAgents = Effect.fnUntraced(function* (
+ skill: LibrarySkill,
+ requested: ReadonlySet,
+ writers: SettingsWriters,
+ ) {
+ const plan = planEnableLibrary(skill, requested);
+ const linked = yield* placement.addLibraryLinks(skill, plan.links);
+ const cleared = yield* switchAgents(skill, plan.clears, false, writers);
+ return {
+ wrote: linked.wrote || cleared.wrote,
+ blocked: [...plan.blocked, ...linked.blocked, ...cleared.blocked],
+ } satisfies SkillChange;
+ });
+
+ const disableLibraryAgents = Effect.fnUntraced(function* (
+ skill: LibrarySkill,
+ requested: ReadonlySet,
+ writers: SettingsWriters,
+ ) {
+ const plan = planDisableLibrary(skill, requested);
+ const unlinked = yield* placement.removeLibraryLinks(skill, plan.unlinks);
+ const switched = yield* switchAgents(skill, plan.switchOffs, true, writers);
+ return {
+ wrote: unlinked.wrote || switched.wrote,
+ blocked: [...plan.blocked, ...unlinked.blocked, ...switched.blocked],
+ } satisfies SkillChange;
+ });
+
+ /**
+ * A skill that comes with an agent is switched in that agent's own settings and nowhere else. An
+ * agent without such a setting stays as it is, and any other agent can't be given the skill.
+ */
+ const switchProvided = Effect.fnUntraced(function* (
+ skill: SkillCatalog.ResolvedSkill,
+ requested: ReadonlySet,
+ off: boolean,
+ writers: SettingsWriters,
+ ) {
+ const blocked: Blocked[] = [];
+ const switching: ProviderInstanceId[] = [];
+ for (const agent of skill.agents) {
+ if (!requested.has(agent.instanceId)) continue;
+ if (agent.state === "none") {
+ if (!off) blocked.push({ instanceId: agent.instanceId, reason: "provided" });
+ } else if ((agent.state === "off") === off) {
+ continue;
+ } else if (agent.settings === undefined) {
+ blocked.push({ instanceId: agent.instanceId, reason: off ? "alwaysOn" : "setElsewhere" });
+ } else {
+ switching.push(agent.instanceId);
+ }
+ }
+ const switched = yield* switchAgents(skill, switching, off, writers);
+ return {
+ wrote: switched.wrote,
+ blocked: [...blocked, ...switched.blocked],
+ } satisfies SkillChange;
+ });
+
+ const enableAgents = Effect.fnUntraced(function* (
+ skill: SkillCatalog.ResolvedSkill,
+ requested: ReadonlySet,
+ projectRoot: string | undefined,
+ writers: SettingsWriters,
+ ) {
+ if (skill.provided !== undefined) {
+ return yield* switchProvided(skill, requested, false, writers);
+ }
+ if (skill.library !== undefined) {
+ return yield* enableLibraryAgents({ ...skill, library: skill.library }, requested, writers);
+ }
+ const linked = yield* enableOne(skill, requested, projectRoot);
+ const cleared = yield* switchAgents(skill, planEnable(skill, requested).clears, false, writers);
+ return combine(linked, cleared);
+ });
+
+ const disableAgents = Effect.fnUntraced(function* (
+ skill: SkillCatalog.ResolvedSkill,
+ requested: ReadonlySet,
+ writers: SettingsWriters,
+ ) {
+ if (skill.provided !== undefined) return yield* switchProvided(skill, requested, true, writers);
+ if (skill.library !== undefined) {
+ return yield* disableLibraryAgents({ ...skill, library: skill.library }, requested, writers);
+ }
+ const unlinked = yield* disableOne(skill, requested);
+ const switched = yield* switchAgents(
+ skill,
+ planDisable(skill, requested).switchOffs,
+ true,
+ writers,
+ );
+ return combine(unlinked, switched);
+ });
+
+ /**
+ * Codex names a skill it switches off by the real path of its SKILL.md, so a moved folder leaves
+ * that entry behind and the skill on. The setting goes to the new path and the old entry is
+ * cleared, through Codex like any other write. A rule that names the skill by its name needs no
+ * change. The agents it couldn't carry over are returned.
+ */
+ const followCodexMove = Effect.fnUntraced(function* (
+ skill: SkillCatalog.ResolvedSkill,
+ home: string,
+ writers: SettingsWriters,
+ ) {
+ const blocked: Blocked[] = [];
+ const old = switchedSkillOf(skill);
+ const from = codexSkillFile(path, old);
+ const moved: SwitchedSkill = { ...old, home, entryPaths: [] };
+ for (const agent of skill.agents) {
+ if (agent.driver !== "codex" || agent.settings === undefined) continue;
+ const rules = yield* readCodexSkillRules(agent.settings).pipe(
+ Effect.provideContext(filesystemContext),
+ );
+ const keyed = rules.findLast(
+ (rule) => "path" in rule.selector && rule.selector.path === from,
+ );
+ if (keyed === undefined || keyed.enabled) continue;
+ const wrote = yield* switchCodex(agent, moved, true, writers);
+ if (wrote === "failed" || wrote === "setElsewhere") {
+ blocked.push({ instanceId: agent.instanceId, reason: wrote });
+ continue;
+ }
+ const write = yield* writers(agent.instanceId);
+ const cleared =
+ write === undefined
+ ? undefined
+ : yield* write({ path: from, enabled: true }).pipe(Effect.option);
+ if (cleared === undefined || Option.isNone(cleared)) {
+ blocked.push({ instanceId: agent.instanceId, reason: "failed" });
+ }
+ }
+ return blocked;
+ });
+
+ /** The links among the skills' entries, with what each points at as written. */
+ const linksTo = (skills: ReadonlyArray) =>
+ skills.flatMap((skill) =>
+ skill.entries.flatMap((entry) =>
+ entry.target === undefined ? [] : [{ path: entry.path, target: entry.target }],
+ ),
+ );
+
+ const skipped = (reason: SkillOutcomeReason): SkillChange => ({
+ wrote: false,
+ blocked: [],
+ reason,
+ });
+
+ /** Every group that reaches this skill's folder, in either scope: the folder's whole audience. */
+ const reaching = (
+ skill: SkillCatalog.ResolvedSkill,
+ all: ReadonlyArray,
+ ) => all.filter((other) => other.name === skill.name && other.home === skill.home);
+
+ const agentsWith = (skills: ReadonlyArray) =>
+ new Set(
+ skills.flatMap((skill) =>
+ skill.agents.filter((agent) => hasSkill(agent.state)).map((agent) => agent.instanceId),
+ ),
+ );
+
+ const deleteOne = Effect.fnUntraced(function* (
+ skill: SkillCatalog.ResolvedSkill,
+ all: ReadonlyArray,
+ ) {
+ if (skill.provided !== undefined) return skipped("provided");
+ if (!skill.own) return skipped("linked");
+ const audience = reaching(skill, all);
+ const had = [...agentsWith(audience)];
+ const failed = yield* deleteFolder(skill.home).pipe(
+ Effect.provideContext(filesystemContext),
+ Effect.as(false),
+ Effect.catchTags({ SkillMoveError: () => Effect.succeed(true) }),
+ );
+ // A delete that stopped before touching SKILL.md changed nothing an agent can see.
+ if (
+ failed &&
+ (yield* fileSystem
+ .exists(path.join(skill.home, "SKILL.md"))
+ .pipe(Effect.orElseSucceed(() => true)))
+ ) {
+ return skipped("failed");
+ }
+ // A library skill is linked into projects the list may not have been read for.
+ yield* placement.unlinkLibrarySkill(skill);
+ const results = new Set((yield* removeAll(linksTo(audience))).values());
+ const reason: SkillOutcomeReason | undefined =
+ failed || results.has("failed") ? "failed" : results.has("changed") ? "changed" : undefined;
+ return {
+ wrote: true,
+ blocked: [],
+ reason,
+ affected: had,
+ touched: had,
+ } satisfies SkillChange;
+ });
+
+ /**
+ * Refreshes the skills the composer's `$` picker lists for agents whose skills changed: the
+ * project's own list when a project is open, else the agent's machine-wide one. A scan can take
+ * seconds, since some agents answer through their CLI, and the change is already on disk, so it
+ * runs in the background and a scan that fails changes nothing.
+ */
+ const refreshPickers = (cwd: string | undefined, instances: Iterable) =>
+ Effect.forEach(
+ instances,
+ (instanceId) =>
+ cwd === undefined
+ ? providers.refreshInstance(instanceId)
+ : providers.refreshWorkspaceSnapshot({ instanceId, cwd, fresh: true }),
+ { discard: true },
+ ).pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid);
+
+ /**
+ * Looks every skill up as the folders hold it now, applies `change` to those that are still
+ * where the client said, and tells what happened to each. An agent that gained or lost a skill
+ * without being asked is found by reading the folders again afterwards.
+ */
+ const run = (input: {
+ readonly cwd: string | undefined;
+ readonly skills: ReadonlyArray;
+ readonly agents: "all" | ReadonlySet;
+ /** Skills to look up besides those asked for, such as the same names in the other scope. */
+ readonly alsoLookUp?: ReadonlyArray<{ readonly scope: SkillScope; readonly name: string }>;
+ readonly change: (
+ skill: SkillCatalog.ResolvedSkill,
+ agents: ReadonlySet,
+ projectRoot: string | undefined,
+ /** Everything looked up, which includes the skills asked for. */
+ all: ReadonlyArray,
+ ) => Effect.Effect;
+ }) =>
+ writeLock.withPermits(1)(
+ Effect.gen(function* () {
+ const before = yield* catalog.resolve({
+ cwd: input.cwd,
+ skills: [...input.skills, ...(input.alsoLookUp ?? [])],
+ });
+ const instances = before[0]?.agents ?? [];
+ const agents = new Set();
+ for (const name of input.agents === "all" ? [] : input.agents) {
+ const byId = instances.filter((agent) => agent.instanceId === name);
+ const matches =
+ byId.length > 0 ? byId : instances.filter((agent) => agent.driver === name);
+ if (matches.length === 0 && instances.length > 0) {
+ return yield* new SkillRequestError({ reason: "unknownAgent" });
+ }
+ for (const agent of matches) agents.add(agent.instanceId);
+ }
+ if (input.agents === "all") for (const agent of instances) agents.add(agent.instanceId);
+ const projectRoot =
+ input.cwd === undefined
+ ? undefined
+ : yield* fileSystem.realPath(input.cwd).pipe(Effect.orElseSucceed(() => input.cwd));
+
+ const changes = yield* Effect.forEach(input.skills, (ref) =>
+ Effect.gen(function* () {
+ const candidates = before.filter(
+ (skill) => skill.scope === ref.scope && skill.name === ref.name,
+ );
+ const found = candidates.find((skill) => skill.displayHome === ref.home);
+ if (found === undefined) {
+ const reason = candidates.length > 0 ? "changed" : "notFound";
+ return { ref, found, change: { wrote: false, blocked: [], reason } as SkillChange };
+ }
+ return { ref, found, change: yield* input.change(found, agents, projectRoot, before) };
+ }),
+ );
+
+ const after = changes.some((entry) => entry.change.wrote)
+ ? yield* catalog.resolve({ cwd: input.cwd, skills: input.skills })
+ : before;
+ const results = changes.map(({ ref, found, change }) => {
+ const now = after.find(
+ (skill) =>
+ skill.scope === ref.scope &&
+ skill.name === ref.name &&
+ skill.displayHome === ref.home,
+ );
+ // Agents whose use of the skill flipped, whether they were asked for or not.
+ const flipped =
+ found === undefined
+ ? []
+ : found.agents
+ .filter(
+ (agent) =>
+ hasSkill(agent.state) !==
+ hasSkill(
+ now?.agents.find((other) => other.instanceId === agent.instanceId)?.state ??
+ "none",
+ ),
+ )
+ .map((agent) => agent.instanceId);
+ return {
+ // Only what this request wrote counts; a change someone else made meanwhile doesn't.
+ touched: change.wrote ? (change.touched ?? flipped) : [],
+ outcome: {
+ skill: ref,
+ status: change.wrote
+ ? "changed"
+ : change.reason !== undefined || change.blocked.length > 0
+ ? "skipped"
+ : "unchanged",
+ ...(change.reason === undefined ? {} : { reason: change.reason }),
+ ...(change.sourceDropped === true ? { sourceDropped: true } : {}),
+ blocked: change.blocked.filter(
+ (item, index, all) =>
+ all.findIndex((other) => other.instanceId === item.instanceId) === index,
+ ),
+ affected: change.affected ?? flipped.filter((id) => !agents.has(id)),
+ } satisfies SkillOutcome,
+ };
+ });
+
+ const touched = new Set(results.flatMap((result) => result.touched));
+ if (touched.size > 0) yield* refreshPickers(input.cwd, touched);
+ return { outcomes: results.map((result) => result.outcome) } satisfies SkillBatchResult;
+ }),
+ );
+
+ /** Anything at all under that name, a dangling link included, is in the way of a new skill. */
+ const occupied = (entry: string) =>
+ fileSystem.exists(entry).pipe(
+ Effect.flatMap((exists) =>
+ exists
+ ? Effect.succeed(true)
+ : fileSystem.readLink(entry).pipe(
+ Effect.as(true),
+ Effect.catchTags({
+ PlatformError: (error) =>
+ error.reason._tag === "NotFound" ? Effect.succeed(false) : Effect.fail(error),
+ }),
+ ),
+ ),
+ );
+
+ const createOne = Effect.fnUntraced(function* (
+ input: SkillCreateInput,
+ writers: SettingsWriters,
+ ) {
+ const { cwd, scope, name } = input;
+ if (scope === "project" && cwd === undefined) {
+ return yield* new SkillRequestError({ reason: "projectNotRegistered" });
+ }
+ const failed = () => new SkillCreateError({ reason: "failed" });
+ for (const folder of yield* catalog.folders({ cwd, scope })) {
+ if (yield* occupied(path.join(folder, name)).pipe(Effect.mapError(failed))) {
+ return yield* new SkillCreateError({ reason: "nameTaken" });
+ }
+ }
+ const folder = path.join(
+ scope === "project" && cwd !== undefined ? cwd : homeDirectory,
+ STANDARD_SKILL_FOLDER,
+ );
+ const written = yield* writeNewSkill({ folder, name, description: input.description }).pipe(
+ Effect.provideContext(filesystemContext),
+ Effect.tapError((error) => Effect.logWarning("skill create", { error })),
+ Effect.mapError(failed),
+ );
+ if (written === "taken") return yield* new SkillCreateError({ reason: "nameTaken" });
+
+ const home = yield* fileSystem
+ .realPath(path.join(folder, name))
+ .pipe(Effect.orElseSucceed(() => path.join(folder, name)));
+ const find = (skills: ReadonlyArray) =>
+ skills.find((skill) => skill.scope === scope && skill.home === home);
+ const created = find(yield* catalog.resolve({ cwd, skills: [{ scope, name }] }));
+ // The skill is written; with nothing to read it, no agent has to be given it.
+ if (created === undefined) {
+ return {
+ skill: {
+ scope,
+ name,
+ home:
+ scope === "project"
+ ? `${STANDARD_SKILL_FOLDER}/${name}`
+ : `~/${STANDARD_SKILL_FOLDER}/${name}`,
+ },
+ blocked: [],
+ } satisfies SkillCreateResult;
+ }
+ const projectRoot =
+ cwd === undefined
+ ? undefined
+ : yield* fileSystem.realPath(cwd).pipe(Effect.orElseSucceed(() => cwd));
+ const change = yield* enableAgents(
+ created,
+ new Set(created.agents.map((agent) => agent.instanceId)),
+ projectRoot,
+ writers,
+ );
+ const now = change.wrote
+ ? (find(yield* catalog.resolve({ cwd, skills: [{ scope, name }] })) ?? created)
+ : created;
+ yield* refreshPickers(
+ cwd,
+ now.agents.filter((agent) => hasSkill(agent.state)).map((agent) => agent.instanceId),
+ );
+ return {
+ skill: { scope, name, home: created.displayHome },
+ blocked: change.blocked.filter(
+ (item, index, all) =>
+ all.findIndex((other) => other.instanceId === item.instanceId) === index,
+ ),
+ } satisfies SkillCreateResult;
+ });
+
+ return SkillManager.of({
+ enable: Effect.fn("SkillManager.enable")(function* (input) {
+ // Codex, if it has to be asked, stays open for the whole request.
+ const writers = makeWriters(yield* Scope.Scope);
+ return yield* run({
+ cwd: input.cwd,
+ skills: input.skills,
+ agents: input.agents === "all" ? "all" : new Set(input.agents),
+ change: (skill, agents, projectRoot) => enableAgents(skill, agents, projectRoot, writers),
+ });
+ }, Effect.scoped),
+ disable: Effect.fn("SkillManager.disable")(function* (input) {
+ const writers = makeWriters(yield* Scope.Scope);
+ return yield* run({
+ cwd: input.cwd,
+ skills: input.skills,
+ agents: new Set(input.agents),
+ change: (skill, agents) => disableAgents(skill, agents, writers),
+ });
+ }, Effect.scoped),
+ place: Effect.fn("SkillManager.place")(function* (input) {
+ // Codex, if its setting has to follow a moved folder, stays open for the whole request.
+ const writers = makeWriters(yield* Scope.Scope);
+ const { to } = input;
+ // The skills' own folder is checked when they are looked up.
+ if (to.kind === "project") yield* requireProject(to.cwd);
+ if (to.kind === "projects") for (const cwd of to.cwds) yield* requireProject(cwd);
+ return yield* run({
+ cwd: input.cwd,
+ skills: input.skills,
+ agents: new Set(),
+ // The same names in the other scope are in the way of a move, or are what links lead to.
+ alsoLookUp: input.skills.flatMap((ref) => [
+ { scope: "global" as const, name: ref.name },
+ { scope: "project" as const, name: ref.name },
+ ]),
+ change: (skill, _agents, _projectRoot, all) =>
+ skill.provided !== undefined
+ ? Effect.succeed(skipped("provided"))
+ : placement.place(skill, to, {
+ cwd: input.cwd,
+ all,
+ followMove: (home) => followCodexMove(skill, home, writers),
+ }),
+ });
+ }, Effect.scoped),
+ share: Effect.fn("SkillManager.share")(function* (input) {
+ const writers = makeWriters(yield* Scope.Scope);
+ return yield* run({
+ cwd: input.cwd,
+ skills: input.skills,
+ agents: new Set(),
+ // Links from the other scope lead to the folder too, and would be left dangling.
+ alsoLookUp: input.skills.map((ref) => ({
+ scope: ref.scope === "project" ? ("global" as const) : ("project" as const),
+ name: ref.name,
+ })),
+ change: (skill, _agents, _projectRoot, all) =>
+ placement.share(skill, {
+ cwd: input.cwd,
+ all,
+ followMove: (home) => followCodexMove(skill, home, writers),
+ }),
+ });
+ }, Effect.scoped),
+ delete: Effect.fn("SkillManager.delete")(function* (input) {
+ return yield* run({
+ cwd: input.cwd,
+ skills: input.skills,
+ agents: new Set(),
+ // Links from the other scope lead to the folder too, and would be left dangling.
+ alsoLookUp: input.skills.map((ref) => ({
+ scope: ref.scope === "project" ? ("global" as const) : ("project" as const),
+ name: ref.name,
+ })),
+ change: (skill, _agents, _projectRoot, all) => deleteOne(skill, all),
+ });
+ }),
+ create: Effect.fn("SkillManager.create")(function* (input) {
+ // Codex, if a setting has to be cleared for the new skill, stays open for the request.
+ const writers = makeWriters(yield* Scope.Scope);
+ return yield* writeLock.withPermits(1)(createOne(input, writers));
+ }, Effect.scoped),
+ });
+});
+
+export const layer = Layer.effect(SkillManager, make);
diff --git a/apps/server/src/skills/SkillMove.test.ts b/apps/server/src/skills/SkillMove.test.ts
new file mode 100644
index 000000000000..52a612c3370f
--- /dev/null
+++ b/apps/server/src/skills/SkillMove.test.ts
@@ -0,0 +1,275 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { describe, expect, it } from "@effect/vitest";
+import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import * as PlatformError from "effect/PlatformError";
+
+import { deleteFolder, moveFolder, SkillMoveError } from "./SkillMove.ts";
+
+/** A skill folder with nested files, an executable and a link that stays inside the skill. */
+const makeSkill = Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const root = yield* fs.realPath(yield* fs.makeTempDirectoryScoped({ prefix: "t3code-move-" }));
+ const from = path.join(root, "from/.agents/skills/verify");
+ const to = path.join(root, "to/.agents/skills/verify");
+ yield* fs.makeDirectory(path.join(from, "bin"), { recursive: true });
+ yield* fs.makeDirectory(path.join(from, "refs/deep"), { recursive: true });
+ yield* fs.writeFileString(path.join(from, "SKILL.md"), "---\nname: verify\n---\n");
+ yield* fs.writeFileString(path.join(from, "bin/run"), "#!/bin/sh\necho ok\n");
+ yield* fs.chmod(path.join(from, "bin/run"), 0o755);
+ yield* fs.writeFileString(path.join(from, "refs/deep/notes.md"), "notes");
+ if (symlinksSupported) yield* fs.symlink("refs/deep/notes.md", path.join(from, "latest.md"));
+ return { fs, path, root, from, to };
+});
+
+const entriesOf = (fs: FileSystem.FileSystem, folder: string) =>
+ fs.readDirectory(folder, { recursive: true }).pipe(Effect.map((names) => names.toSorted()));
+
+/** A failure the way the Node file system reports it, so the code under test reads it as real. */
+const platformError = (
+ tag: "Unknown" | "Busy",
+ method: string,
+ pathOrDescriptor: string,
+ code: string,
+) =>
+ PlatformError.systemError({
+ _tag: tag,
+ module: "FileSystem",
+ method,
+ pathOrDescriptor,
+ cause: Object.assign(new Error(code), { code }),
+ });
+
+/** Runs `effect` against a file system where some calls are replaced. */
+const withFileSystem = (
+ effect: Effect.Effect,
+ replace: (real: FileSystem.FileSystem) => Partial,
+) =>
+ Effect.gen(function* () {
+ const real = yield* FileSystem.FileSystem;
+ return yield* effect.pipe(
+ Effect.provideService(
+ FileSystem.FileSystem,
+ FileSystem.FileSystem.of({ ...real, ...replace(real) }),
+ ),
+ );
+ });
+
+const move = (input: { from: string; to: string }) => moveFolder({ ...input, platform: "linux" });
+
+it.layer(NodeServices.layer, { excludeTestServices: true })("SkillMove", (it) => {
+ describe("on one filesystem", () => {
+ it.effect.skipIf(!symlinksSupported)("is a single rename that keeps everything", () =>
+ Effect.gen(function* () {
+ const { fs, path, from, to } = yield* makeSkill;
+ const before = yield* entriesOf(fs, from);
+
+ expect(yield* move({ from, to })).toBe("moved");
+
+ expect(yield* fs.exists(from)).toBe(false);
+ expect(yield* entriesOf(fs, to)).toEqual(before);
+ expect(yield* fs.readLink(path.join(to, "latest.md"))).toBe("refs/deep/notes.md");
+ expect((yield* fs.stat(path.join(to, "bin/run"))).mode & 0o111).not.toBe(0);
+ }),
+ );
+
+ it.effect("never merges into or replaces what is at the destination", () =>
+ Effect.gen(function* () {
+ const { fs, path, from, to } = yield* makeSkill;
+ yield* fs.makeDirectory(to, { recursive: true });
+ const before = yield* entriesOf(fs, from);
+
+ // An empty folder is taken, and so is one with a skill in it.
+ expect(yield* move({ from, to })).toBe("taken");
+ yield* fs.writeFileString(path.join(to, "SKILL.md"), "theirs");
+ expect(yield* move({ from, to })).toBe("taken");
+
+ expect(yield* entriesOf(fs, from)).toEqual(before);
+ expect(yield* fs.readFileString(path.join(to, "SKILL.md"))).toBe("theirs");
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)("treats a link that leads nowhere as taken", () =>
+ Effect.gen(function* () {
+ const { fs, path, from, to } = yield* makeSkill;
+ yield* fs.makeDirectory(path.dirname(to), { recursive: true });
+ yield* fs.symlink(path.join(path.dirname(to), "missing"), to);
+
+ expect(yield* move({ from, to })).toBe("taken");
+
+ expect(yield* fs.exists(path.join(from, "SKILL.md"))).toBe(true);
+ expect(yield* fs.readLink(to)).toBe(path.join(path.dirname(to), "missing"));
+ }),
+ );
+
+ it.effect("says so when the folder is in use, and changes nothing", () =>
+ Effect.gen(function* () {
+ const { fs, from, to } = yield* makeSkill;
+ const before = yield* entriesOf(fs, from);
+
+ const result = yield* withFileSystem(move({ from, to }), () => ({
+ rename: (oldPath) => Effect.fail(platformError("Busy", "rename", oldPath, "EBUSY")),
+ }));
+
+ expect(result).toBe("inUse");
+ expect(yield* entriesOf(fs, from)).toEqual(before);
+ expect(yield* fs.exists(to)).toBe(false);
+ }),
+ );
+
+ it.effect("reads a destination that filled up after the check as taken", () =>
+ Effect.gen(function* () {
+ const { fs, path, from, to } = yield* makeSkill;
+
+ const result = yield* withFileSystem(move({ from, to }), () => ({
+ rename: (oldPath) =>
+ Effect.fail(platformError("Unknown", "rename", oldPath, "ENOTEMPTY")),
+ }));
+
+ expect(result).toBe("taken");
+ expect(yield* fs.exists(path.join(from, "SKILL.md"))).toBe(true);
+ }),
+ );
+ });
+
+ describe("across filesystems", () => {
+ /** The first rename, of the skill's own folder, fails the way a different device does. */
+ const crossDevice = (from: string) => (real: FileSystem.FileSystem) => ({
+ rename: (oldPath: string, newPath: string) =>
+ oldPath === from
+ ? Effect.fail(platformError("Unknown", "rename", oldPath, "EXDEV"))
+ : real.rename(oldPath, newPath),
+ });
+
+ it.effect.skipIf(!symlinksSupported)(
+ "copies, checks and renames the copy into place, then removes the original",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, from, to } = yield* makeSkill;
+ const before = yield* entriesOf(fs, from);
+
+ const result = yield* withFileSystem(move({ from, to }), crossDevice(from));
+
+ expect(result).toBe("moved");
+ expect(yield* fs.exists(from)).toBe(false);
+ expect(yield* entriesOf(fs, to)).toEqual(before);
+ expect(yield* fs.readFileString(path.join(to, "refs/deep/notes.md"))).toBe("notes");
+ // A link inside the skill keeps its target as written, so it still leads inside the copy.
+ expect(yield* fs.readLink(path.join(to, "latest.md"))).toBe("refs/deep/notes.md");
+ expect((yield* fs.stat(path.join(to, "bin/run"))).mode & 0o111).not.toBe(0);
+ // No hidden folder is left beside the destination.
+ expect(yield* fs.readDirectory(path.dirname(to))).toEqual(["verify"]);
+ }),
+ );
+
+ it.effect("leaves no half-copied skill when a file can't be copied", () =>
+ Effect.gen(function* () {
+ const { fs, path, from, to } = yield* makeSkill;
+ const before = yield* entriesOf(fs, from);
+
+ const exit = yield* withFileSystem(move({ from, to }), (real) => ({
+ ...crossDevice(from)(real),
+ copyFile: (source, target) =>
+ source.endsWith("notes.md")
+ ? Effect.fail(platformError("Unknown", "copyFile", source, "EIO"))
+ : real.copyFile(source, target),
+ })).pipe(Effect.flip);
+
+ expect(exit).toBeInstanceOf(SkillMoveError);
+ expect(exit.operation).toBe("copy");
+ expect(yield* entriesOf(fs, from)).toEqual(before);
+ expect(yield* fs.exists(to)).toBe(false);
+ expect(yield* fs.readDirectory(path.dirname(to))).toEqual([]);
+ }),
+ );
+
+ it.effect("doesn't trust a copy that differs from the original", () =>
+ Effect.gen(function* () {
+ const { fs, path, from, to } = yield* makeSkill;
+ const before = yield* entriesOf(fs, from);
+
+ const error = yield* withFileSystem(move({ from, to }), (real) => ({
+ ...crossDevice(from)(real),
+ // Writes an empty file where the original has text.
+ copyFile: (source, target) =>
+ source.endsWith("notes.md")
+ ? fs.writeFileString(target, "")
+ : real.copyFile(source, target),
+ })).pipe(Effect.flip);
+
+ expect(error.operation).toBe("verify");
+ expect(yield* entriesOf(fs, from)).toEqual(before);
+ expect(yield* fs.exists(to)).toBe(false);
+ expect(yield* fs.readDirectory(path.dirname(to))).toEqual([]);
+ }),
+ );
+
+ it.effect("stops and removes its copy when something took the destination meanwhile", () =>
+ Effect.gen(function* () {
+ const { fs, path, from, to } = yield* makeSkill;
+
+ const result = yield* withFileSystem(move({ from, to }), () => ({
+ rename: (oldPath) =>
+ oldPath === from
+ ? Effect.fail(platformError("Unknown", "rename", oldPath, "EXDEV"))
+ : Effect.fail(platformError("Unknown", "rename", oldPath, "ENOTEMPTY")),
+ }));
+
+ expect(result).toBe("taken");
+ expect(yield* fs.exists(path.join(from, "SKILL.md"))).toBe(true);
+ expect(yield* fs.readDirectory(path.dirname(to))).toEqual([]);
+ }),
+ );
+
+ it.effect("keeps the new copy and says so when the original can't be removed", () =>
+ Effect.gen(function* () {
+ const { fs, from, to } = yield* makeSkill;
+ const before = yield* entriesOf(fs, from);
+
+ const result = yield* withFileSystem(move({ from, to }), (real) => ({
+ ...crossDevice(from)(real),
+ remove: (target, options) =>
+ target === from
+ ? Effect.fail(platformError("Unknown", "remove", target, "EACCES"))
+ : real.remove(target, options),
+ }));
+
+ expect(result).toBe("movedWithLeftover");
+ expect(yield* entriesOf(fs, to)).toEqual(before);
+ expect(yield* entriesOf(fs, from)).toEqual(before);
+ }),
+ );
+ });
+
+ describe("deleting", () => {
+ it.effect("removes the folder and what is in it, and nothing beside it", () =>
+ Effect.gen(function* () {
+ const { fs, path, from } = yield* makeSkill;
+ const sibling = path.join(path.dirname(from), "other");
+ yield* fs.makeDirectory(sibling);
+ yield* fs.writeFileString(path.join(sibling, "SKILL.md"), "other");
+
+ yield* deleteFolder(from);
+
+ expect(yield* fs.exists(from)).toBe(false);
+ expect(yield* fs.readFileString(path.join(sibling, "SKILL.md"))).toBe("other");
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)("removes a link without following it", () =>
+ Effect.gen(function* () {
+ const { fs, path, from, root } = yield* makeSkill;
+ const link = path.join(root, "link");
+ yield* fs.symlink(from, link);
+
+ yield* deleteFolder(link);
+
+ expect(yield* fs.exists(link)).toBe(false);
+ expect(yield* fs.exists(path.join(from, "SKILL.md"))).toBe(true);
+ }),
+ );
+ });
+});
diff --git a/apps/server/src/skills/SkillMove.ts b/apps/server/src/skills/SkillMove.ts
new file mode 100644
index 000000000000..f6766d71a707
--- /dev/null
+++ b/apps/server/src/skills/SkillMove.ts
@@ -0,0 +1,234 @@
+/**
+ * SkillMove - the two filesystem writes that take a real skill folder somewhere else: moving it,
+ * and deleting it.
+ *
+ * A move is never allowed to leave a half-made skill or to replace one:
+ * - On one filesystem it is a single rename, which is all or nothing. Something already at the
+ * destination makes it stop; a rename can only replace an empty folder, which loses nothing.
+ * - Across filesystems a rename isn't possible, so the folder is copied next to the destination
+ * under a hidden name no agent reads, checked against the original, and only then renamed into
+ * place. Anything that goes wrong before that removes the copy and leaves the original alone.
+ * The original is removed last, so a crash leaves the skill in both places, never in neither.
+ *
+ * @module SkillMove
+ */
+import * as Effect from "effect/Effect";
+import * as Exit from "effect/Exit";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import type * as PlatformError from "effect/PlatformError";
+import * as Schema from "effect/Schema";
+
+export class SkillMoveError extends Schema.TaggedError()("SkillMoveError", {
+ operation: Schema.Literals(["makeDirectory", "inspect", "copy", "verify", "rename", "remove"]),
+ path: Schema.String,
+ cause: Schema.optional(Schema.Defect()),
+}) {
+ override get message(): string {
+ return `Skill folder operation '${this.operation}' failed.`;
+ }
+}
+
+type MoveFolderResult =
+ /** The folder is at the destination and the original is gone. */
+ | "moved"
+ /** The folder is at the destination, but the original couldn't be removed after a copy. */
+ | "movedWithLeftover"
+ /** Something is at the destination. Nothing was changed. */
+ | "taken"
+ /** Another program is using the folder. Nothing was changed. */
+ | "inUse";
+
+const errorCode = (error: PlatformError.PlatformError) => {
+ const cause: unknown = error.reason.cause;
+ return typeof cause === "object" && cause !== null && "code" in cause ? cause.code : undefined;
+};
+
+/** What a failed rename tells: another device, a busy folder, or something in the way. */
+type RenameFailure = "otherDevice" | "inUse" | "taken";
+
+const renameFailure = (
+ error: PlatformError.PlatformError,
+ platform: NodeJS.Platform,
+): RenameFailure | undefined => {
+ const code = errorCode(error);
+ if (code === "EXDEV") return "otherDevice";
+ if (error.reason._tag === "Busy" || (platform === "win32" && code === "EPERM")) return "inUse";
+ if (error.reason._tag === "AlreadyExists" || code === "ENOTEMPTY" || code === "ENOTDIR") {
+ return "taken";
+ }
+ return undefined;
+};
+
+/** One thing found in a folder, with what a copy has to keep the same. */
+interface Surveyed {
+ readonly relative: string;
+ readonly kind: "directory" | "file" | "link" | "other";
+ readonly size: number;
+ readonly mode: number;
+ /** What a link points at, as written. */
+ readonly target: string | undefined;
+}
+
+const signature = (entry: Surveyed) =>
+ `${entry.kind}\0${entry.relative}\0${entry.kind === "file" ? entry.size : (entry.target ?? "")}`;
+
+/**
+ * Moves the folder `from` to `to`, which must not exist. Its parent is made if it is missing.
+ * `from` is removed recursively only after a copy of it has been checked and put in place.
+ */
+export const moveFolder = Effect.fn("SkillMove.moveFolder")(function* (input: {
+ readonly from: string;
+ readonly to: string;
+ readonly platform: NodeJS.Platform;
+}) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const parent = path.dirname(input.to);
+ const fail = (operation: SkillMoveError["operation"], target: string) => (cause: unknown) =>
+ new SkillMoveError({ operation, path: target, cause });
+
+ /** Anything at the path, even a link that leads nowhere. Doubt counts as taken. */
+ const occupied = fileSystem.readLink(input.to).pipe(
+ Effect.as(true),
+ Effect.catchTags({
+ PlatformError: (error) => Effect.succeed(error.reason._tag !== "NotFound"),
+ }),
+ );
+ if (yield* occupied) return "taken" as const satisfies MoveFolderResult;
+ yield* fileSystem
+ .makeDirectory(parent, { recursive: true })
+ .pipe(Effect.mapError(fail("makeDirectory", parent)));
+
+ /** Renames `from` to `to`, or says why it didn't. */
+ const rename = (from: string) =>
+ fileSystem.rename(from, input.to).pipe(
+ Effect.as(undefined),
+ Effect.catchTags({
+ PlatformError: (error) => {
+ const failure = renameFailure(error, input.platform);
+ return failure === undefined
+ ? Effect.fail(new SkillMoveError({ operation: "rename", path: from, cause: error }))
+ : Effect.succeed(failure);
+ },
+ }),
+ );
+
+ const renamed = yield* rename(input.from);
+ if (renamed === undefined) return "moved" as const satisfies MoveFolderResult;
+ if (renamed !== "otherDevice") return renamed satisfies MoveFolderResult;
+
+ /** Everything under `root`, parents before the folders and files in them. */
+ const survey = Effect.fnUntraced(function* (root: string) {
+ const found: Surveyed[] = [];
+ const pending = [""];
+ for (let folder = pending.shift(); folder !== undefined; folder = pending.shift()) {
+ const names = yield* fileSystem
+ .readDirectory(path.join(root, folder))
+ .pipe(Effect.mapError(fail("copy", path.join(root, folder))));
+ for (const name of names.toSorted()) {
+ const relative = path.join(folder, name);
+ const absolute = path.join(root, relative);
+ const target = yield* fileSystem.readLink(absolute).pipe(
+ Effect.map((value): string | undefined => value),
+ Effect.orElseSucceed(() => undefined),
+ );
+ if (target !== undefined) {
+ found.push({ relative, kind: "link", size: 0, mode: 0, target });
+ continue;
+ }
+ const info = yield* fileSystem.stat(absolute).pipe(Effect.mapError(fail("copy", absolute)));
+ const kind =
+ info.type === "Directory" ? "directory" : info.type === "File" ? "file" : "other";
+ found.push({
+ relative,
+ kind,
+ size: Number(info.size),
+ mode: info.mode & 0o777,
+ target: undefined,
+ });
+ if (kind === "directory") pending.push(relative);
+ }
+ }
+ return found;
+ });
+
+ const original = yield* survey(input.from);
+ const stage = yield* fileSystem
+ .makeTempDirectory({ directory: parent, prefix: ".t3-moving-" })
+ .pipe(Effect.mapError(fail("makeDirectory", parent)));
+ const discardStage = fileSystem
+ .remove(stage, { recursive: true, force: true })
+ .pipe(Effect.ignore);
+
+ const staged = Effect.gen(function* () {
+ for (const entry of original) {
+ const source = path.join(input.from, entry.relative);
+ const copy = path.join(stage, entry.relative);
+ const done =
+ entry.kind === "directory"
+ ? fileSystem.makeDirectory(copy)
+ : entry.kind === "file"
+ ? fileSystem.copyFile(source, copy)
+ : entry.kind === "link" && entry.target !== undefined
+ ? fileSystem.symlink(entry.target, copy)
+ : // A socket or device can't be copied, and silently skipping it would lose it.
+ Effect.fail(new SkillMoveError({ operation: "copy", path: source }));
+ yield* done.pipe(Effect.mapError(fail("copy", source)));
+ }
+ // Folders last, so one without write permission doesn't stop its own contents.
+ for (const entry of original.toReversed()) {
+ if (entry.kind !== "directory") continue;
+ yield* fileSystem
+ .chmod(path.join(stage, entry.relative), entry.mode)
+ .pipe(Effect.mapError(fail("copy", entry.relative)));
+ }
+ const rootMode = yield* fileSystem
+ .stat(input.from)
+ .pipe(Effect.mapError(fail("copy", input.from)));
+ yield* fileSystem
+ .chmod(stage, rootMode.mode & 0o777)
+ .pipe(Effect.mapError(fail("copy", stage)));
+
+ // The copy has to be the original, which must not have changed meanwhile: same entries,
+ // sizes and link targets.
+ const same = (left: readonly Surveyed[], right: readonly Surveyed[]) =>
+ left.length === right.length &&
+ left.every((entry, index) => signature(entry) === signature(right[index]!));
+ const copied = yield* survey(stage);
+ const now = yield* survey(input.from);
+ if (!same(copied, original) || !same(now, original)) {
+ return yield* new SkillMoveError({ operation: "verify", path: stage });
+ }
+ const placed = yield* rename(stage);
+ if (placed === "otherDevice") {
+ return yield* new SkillMoveError({ operation: "rename", path: stage });
+ }
+ return placed;
+ }).pipe(Effect.onExit((exit) => (Exit.isFailure(exit) ? discardStage : Effect.void)));
+
+ const placed = yield* staged;
+ if (placed !== undefined) {
+ // Put in place by someone else, or in use: the copy goes and the original stays.
+ yield* discardStage;
+ return placed satisfies MoveFolderResult;
+ }
+ return yield* fileSystem.remove(input.from, { recursive: true }).pipe(
+ Effect.as("moved" as MoveFolderResult),
+ // The skill is whole at its new place; what is left behind is reported, not undone.
+ Effect.catchTags({
+ PlatformError: () => Effect.succeed("movedWithLeftover" as MoveFolderResult),
+ }),
+ );
+});
+
+/**
+ * Deletes a skill's real folder and everything in it. The caller has to know the path is the
+ * skill's own folder and not a library's: a link at the path is removed, not followed.
+ */
+export const deleteFolder = Effect.fn("SkillMove.deleteFolder")(function* (path: string) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ yield* fileSystem
+ .remove(path, { recursive: true })
+ .pipe(Effect.mapError((cause) => new SkillMoveError({ operation: "remove", path, cause })));
+});
diff --git a/apps/server/src/skills/SkillPlacement.test.ts b/apps/server/src/skills/SkillPlacement.test.ts
new file mode 100644
index 000000000000..c99aefaa9012
--- /dev/null
+++ b/apps/server/src/skills/SkillPlacement.test.ts
@@ -0,0 +1,2084 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { describe, expect, it } from "@effect/vitest";
+import {
+ ProjectId,
+ ProviderDriverKind,
+ ProviderInstanceId,
+ SkillBatchResult,
+ type Project,
+ type SkillRef,
+ type SkillScope,
+ type SkillSummary,
+} from "@t3tools/contracts";
+import * as HostProcess from "@t3tools/shared/HostProcess";
+import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+import * as PlatformError from "effect/PlatformError";
+import * as Schema from "effect/Schema";
+import { parse as parseToml } from "smol-toml";
+
+import * as ProcessRunner from "../processRunner.ts";
+import * as ProjectService from "../project/ProjectService.ts";
+import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
+import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
+import * as Settings from "../serverSettings.ts";
+import * as VcsProcess from "../vcs/VcsProcess.ts";
+import { EXCLUDE_BLOCK_START } from "./SkillGitExclude.ts";
+import * as SkillCatalog from "./SkillCatalog.ts";
+import { RegisteredProjects, restoreLibraryLinks } from "./SkillLibrary.ts";
+import * as SkillManager from "./SkillManager.ts";
+import { makeCodexDouble, type CodexDouble } from "./testing/CodexDouble.ts";
+
+const encodeResult = Schema.encodeUnknownEffect(SkillBatchResult);
+const agent = ProviderInstanceId.make;
+const ALL_AGENTS = ["claudeAgent", "codex", "cursor", "grok", "opencode", "antigravity", "pi"].map(
+ (id) => agent(id),
+);
+
+const skillFile = (name: string) => `---\nname: ${name}\ndescription: The ${name} skill.\n---\n`;
+
+/** The skill folder whose hashes the real skills CLI and git agree on (see SkillLockFiles.test). */
+const GOLDEN = {
+ computedHash: "a7f77818fb1962dfbb40da69550e2c9c0c035e97a11012946465db99bad816c0",
+ treeSha: "18071366eef226103eef569e462d6a3e8e11fac7",
+};
+const GOLDEN_SKILL_FILE =
+ "---\nname: db-migrations\ndescription: Plan and run database migrations.\n---\n\n# Migrations\n";
+
+const git = (cwd: string, args: ReadonlyArray) =>
+ Effect.gen(function* () {
+ const runner = yield* ProcessRunner.ProcessRunner;
+ return yield* runner.run({
+ command: "git",
+ args: [
+ "-C",
+ cwd,
+ "-c",
+ "user.name=Test",
+ "-c",
+ "user.email=test@example.com",
+ "-c",
+ "core.fileMode=true",
+ ...args,
+ ],
+ });
+ }).pipe(Effect.provide(ProcessRunner.layer));
+
+/**
+ * A made-up machine: three projects that are real git repositories, one project that isn't, an
+ * untracked project skill, a Global skill in Claude's own folder, and a synced library whose skill
+ * is linked into the shared Global folder.
+ */
+const makeMachine = Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const home = yield* fs.realPath(
+ yield* fs.makeTempDirectoryScoped({ prefix: "t3code-placement-" }),
+ );
+ const web = path.join(home, "repos/acme-web");
+ const api = path.join(home, "repos/acme-api");
+ const marketing = path.join(home, "repos/marketing-site");
+ const loose = path.join(home, "repos/scratch");
+ const write = (relative: string, contents: string, mode?: number) =>
+ Effect.gen(function* () {
+ const target = path.join(home, relative);
+ yield* fs.makeDirectory(path.dirname(target), { recursive: true });
+ yield* fs.writeFileString(target, contents);
+ if (mode !== undefined) yield* fs.chmod(target, mode);
+ });
+ for (const repo of [web, api, marketing]) {
+ yield* fs.makeDirectory(repo, { recursive: true });
+ yield* git(repo, ["init", "-q", "-b", "main"]);
+ // Not the machine's own global ignore file, which may already name what a test creates.
+ yield* git(repo, ["config", "core.excludesFile", path.join(home, "global-ignore")]);
+ yield* fs.writeFileString(path.join(repo, "README.md"), `# ${path.basename(repo)}\n`);
+ yield* git(repo, ["add", "-A"]);
+ yield* git(repo, ["commit", "-q", "-m", "init"]);
+ }
+ yield* fs.makeDirectory(loose, { recursive: true });
+
+ yield* write("repos/acme-web/.agents/skills/db-migrations/SKILL.md", skillFile("db-migrations"));
+ yield* write("repos/acme-web/.agents/skills/db-migrations/run.sh", "echo ok");
+ yield* write(".claude/skills/solo/SKILL.md", skillFile("solo"));
+ yield* write("library/skills/alpha/SKILL.md", skillFile("alpha"));
+ yield* fs.makeDirectory(path.join(home, ".agents/skills"), { recursive: true });
+ yield* fs.symlink(
+ path.join(home, "library/skills/alpha"),
+ path.join(home, ".agents/skills/alpha"),
+ );
+ return {
+ fs,
+ path,
+ home,
+ web,
+ api,
+ marketing,
+ loose,
+ write,
+ library: path.join(home, ".agents/skill-library"),
+ };
+});
+
+const makeProject = (workspaceRoot: string): Project => ({
+ id: ProjectId.make(`project-${workspaceRoot.replaceAll("/", "-")}`),
+ title: "App",
+ workspaceRoot,
+ repositoryIdentity: null,
+ faviconPath: null,
+ projectIcon: null,
+ defaultModelSelection: null,
+ defaultThreadEnvMode: null,
+ autoPull: false,
+ scripts: [],
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ deletedAt: null,
+});
+
+/** The manager and catalog on a machine whose home is `home`; only `registered` folders are projects. */
+const withManager = (
+ home: string,
+ registered: readonly string[],
+ use: (services: {
+ readonly manager: SkillManager.SkillManager["Service"];
+ readonly catalog: SkillCatalog.SkillCatalog["Service"];
+ }) => Effect.Effect,
+ environment: NodeJS.ProcessEnv = {},
+ codex?: CodexDouble,
+) =>
+ Effect.gen(function* () {
+ const registry = Layer.mock(ProviderRegistry.ProviderRegistry)({
+ refreshInstance: () => Effect.succeed([]),
+ refreshWorkspaceSnapshot: () => Effect.succeed([]),
+ });
+ const projects = Layer.mock(ProjectService.ProjectService)({
+ getByWorkspaceRoot: (root) =>
+ Effect.succeed(registered.includes(root) ? Option.some(makeProject(root)) : Option.none()),
+ listShells: () =>
+ Effect.succeed(registered.map((workspaceRoot) => ({ workspaceRoot }) as never)),
+ });
+ // Only Codex has a settings writer, and only when a test gives it a double.
+ const instances = Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({
+ getInstance: (instanceId) =>
+ Effect.succeed(
+ instanceId === "codex" && codex !== undefined
+ ? ({
+ enabled: true,
+ openSkillSettingsWriter: Effect.sync(() => {
+ codex.state.opened += 1;
+ return codex.write;
+ }),
+ } as never)
+ : undefined,
+ ),
+ });
+ const catalog = SkillCatalog.layer.pipe(
+ Layer.provide(
+ Settings.layerTest({
+ providerInstances: Object.fromEntries(
+ ["cursor", "grok", "opencode", "antigravity", "pi"].map((driver) => [
+ ProviderInstanceId.make(driver),
+ { driver: ProviderDriverKind.make(driver), enabled: true },
+ ]),
+ ),
+ }),
+ ),
+ );
+ return yield* Effect.gen(function* () {
+ return yield* use({
+ manager: yield* SkillManager.SkillManager,
+ catalog: yield* SkillCatalog.SkillCatalog,
+ });
+ }).pipe(
+ Effect.provide(
+ SkillManager.layer.pipe(
+ Layer.provideMerge(catalog),
+ Layer.provide(projects),
+ Layer.provide(registry),
+ Layer.provide(instances),
+ Layer.provide(VcsProcess.layer),
+ ),
+ ),
+ );
+ }).pipe(
+ Effect.provideService(HostProcess.Environment, {
+ HOME: home,
+ // Keep the managed folders of the agents that read one off the real machine.
+ OPENCODE_TEST_MANAGED_CONFIG_DIR: `${home}/no-managed-opencode`,
+ ...environment,
+ }),
+ Effect.provideService(HostProcess.HomeDirectory, home),
+ Effect.provideService(RegisteredProjects, Effect.succeed(registered)),
+ );
+
+const refOf = (skills: readonly SkillSummary[], scope: SkillScope, name: string): SkillRef => {
+ const skill = skills.find((item) => item.scope === scope && item.name === name);
+ if (!skill) throw new Error(`No ${scope} skill ${name} in the list`);
+ return { scope, name, home: skill.home };
+};
+
+const summaryOf = (skills: readonly SkillSummary[], scope: SkillScope, name: string) =>
+ skills.find((item) => item.scope === scope && item.name === name);
+
+const stateOf = (skills: readonly SkillSummary[], scope: SkillScope, name: string) =>
+ Object.fromEntries(
+ (summaryOf(skills, scope, name)?.access ?? []).map((entry) => [entry.instanceId, entry.state]),
+ );
+
+/** What git shows as changed, file by file. */
+const status = (repo: string) =>
+ git(repo, ["status", "--porcelain", "-uall"]).pipe(Effect.map((result) => result.stdout));
+
+const exclude = (repo: string) =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ return yield* fs
+ .readFileString(`${repo}/.git/info/exclude`)
+ .pipe(Effect.orElseSucceed(() => ""));
+ });
+
+/** Only the lines T3 Code put in an exclude file. */
+const blockLines = (text: string) => {
+ const lines = text.split("\n");
+ const start = lines.indexOf(EXCLUDE_BLOCK_START);
+ return start < 0
+ ? []
+ : lines.slice(start + 1, lines.indexOf("# End T3 Code: skills used from Global"));
+};
+
+it.layer(NodeServices.layer, { excludeTestServices: true })("SkillPlacement", (it) => {
+ describe("into only some projects", () => {
+ it.effect.skipIf(!symlinksSupported)(
+ "keeps one copy in the library and links it into each project, out of git's sight",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, api, marketing, library } = yield* makeMachine;
+ yield* withManager(home, [web, api, marketing], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const verify = refOf(
+ (yield* catalog.list({ cwd: web })).skills,
+ "project",
+ "db-migrations",
+ );
+ yield* manager.enable({
+ cwd: web,
+ skills: [verify],
+ agents: [agent("claudeAgent")],
+ });
+ expect(yield* status(web)).toContain("?? .agents/skills/db-migrations/SKILL.md");
+
+ const result = yield* manager.place({
+ cwd: web,
+ skills: [verify],
+ to: { kind: "projects", cwds: [web, api] },
+ });
+
+ expect(result.outcomes).toEqual([
+ { skill: verify, status: "changed", blocked: [], affected: [] },
+ ]);
+ yield* encodeResult(result);
+ // One copy, in the library.
+ const entry = path.join(library, "db-migrations");
+ expect(yield* fs.readFileString(path.join(entry, "run.sh"))).toBe("echo ok");
+ // An absolute link in each project, and Claude's own where Claude had it on.
+ for (const project of [web, api]) {
+ expect(yield* fs.readLink(path.join(project, ".agents/skills/db-migrations"))).toBe(
+ entry,
+ );
+ expect(yield* fs.readLink(path.join(project, ".claude/skills/db-migrations"))).toBe(
+ entry,
+ );
+ expect(blockLines(yield* exclude(project))).toEqual([
+ "/.agents/skills/db-migrations",
+ "/.claude/skills/db-migrations",
+ ]);
+ expect(yield* status(project)).toBe("");
+ }
+ expect(yield* fs.exists(path.join(marketing, ".agents"))).toBe(false);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "lists the skill once as Global, with the projects it is used in, from every view",
+ () =>
+ Effect.gen(function* () {
+ const { home, web, api, marketing } = yield* makeMachine;
+ yield* withManager(home, [web, api, marketing], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const verify = refOf(
+ (yield* catalog.list({ cwd: web })).skills,
+ "project",
+ "db-migrations",
+ );
+ yield* manager.enable({ cwd: web, skills: [verify], agents: [agent("claudeAgent")] });
+ yield* manager.place({
+ cwd: web,
+ skills: [verify],
+ to: { kind: "projects", cwds: [web, api] },
+ });
+
+ for (const cwd of [undefined, web, api, marketing]) {
+ const { skills } = yield* catalog.list(cwd === undefined ? {} : { cwd });
+ const rows = skills.filter((skill) => skill.name === "db-migrations");
+ // No project row for the link: it is the Global skill.
+ expect(rows.map((row) => [row.scope, row.home, row.projects])).toEqual([
+ ["global", "~/.agents/skill-library/db-migrations", [web, api]],
+ ]);
+ expect(rows[0]?.realFolder).toBe(true);
+ }
+
+ // The agents are the skill's, whichever project is open: the ones that read the
+ // folders it is linked into have it.
+ for (const cwd of [undefined, web, marketing]) {
+ const states = stateOf(
+ (yield* catalog.list(cwd === undefined ? {} : { cwd })).skills,
+ "global",
+ "db-migrations",
+ );
+ expect(states).toMatchObject({
+ claudeAgent: "link",
+ codex: "direct",
+ pi: "direct",
+ });
+ }
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "makes a project's tracked skill show in git only as the files that left",
+ () =>
+ Effect.gen(function* () {
+ const { home, web, api } = yield* makeMachine;
+ yield* git(web, ["add", "-A"]);
+ yield* git(web, ["commit", "-q", "-m", "add the skill"]);
+ yield* withManager(home, [web, api], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const verify = refOf(
+ (yield* catalog.list({ cwd: web })).skills,
+ "project",
+ "db-migrations",
+ );
+
+ yield* manager.place({
+ cwd: web,
+ skills: [verify],
+ to: { kind: "projects", cwds: [web, api] },
+ });
+
+ // The tracked files are gone from where git expects them; the link adds no noise.
+ expect(yield* status(web)).toBe(
+ " D .agents/skills/db-migrations/SKILL.md\n D .agents/skills/db-migrations/run.sh\n",
+ );
+ expect(yield* status(api)).toBe("");
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "takes a Global skill out of every project but the chosen ones, and its agents keep it there",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, api, library } = yield* makeMachine;
+ yield* withManager(home, [web, api], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const solo = refOf((yield* catalog.list({})).skills, "global", "solo");
+
+ const result = yield* manager.place({
+ skills: [solo],
+ to: { kind: "projects", cwds: [api] },
+ });
+
+ expect(result.outcomes[0]).toMatchObject({ status: "changed", blocked: [] });
+ // Claude, Cursor and OpenCode read Claude's folder, and keep it in api. The agents
+ // that read the shared folder only get it there too.
+ expect(result.outcomes[0]?.affected.toSorted()).toEqual(
+ [agent("antigravity"), agent("codex"), agent("pi")].toSorted(),
+ );
+ expect(yield* fs.exists(path.join(home, ".claude/skills/solo"))).toBe(false);
+ expect(yield* fs.exists(path.join(library, "solo/SKILL.md"))).toBe(true);
+ expect(yield* fs.readLink(path.join(api, ".agents/skills/solo"))).toBe(
+ path.join(library, "solo"),
+ );
+ expect(yield* fs.readLink(path.join(api, ".claude/skills/solo"))).toBe(
+ path.join(library, "solo"),
+ );
+ expect(yield* status(api)).toBe("");
+ expect(yield* fs.exists(path.join(web, ".agents/skills/solo"))).toBe(false);
+
+ const inApi = stateOf((yield* catalog.list({ cwd: api })).skills, "global", "solo");
+ expect(inApi).toMatchObject({ claudeAgent: "link", codex: "direct" });
+ // The skill's agents are the same whichever project is open.
+ const inWeb = stateOf((yield* catalog.list({ cwd: web })).skills, "global", "solo");
+ expect(inWeb).toEqual(inApi);
+ expect(
+ summaryOf((yield* catalog.list({})).skills, "global", "solo")?.projects,
+ ).toEqual([api]);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "leaves a synced library's folder where it is and links the library entry to it",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, library } = yield* makeMachine;
+ yield* withManager(home, [web], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const alpha = refOf((yield* catalog.list({})).skills, "global", "alpha");
+
+ const result = yield* manager.place({
+ skills: [alpha],
+ to: { kind: "projects", cwds: [web] },
+ });
+
+ expect(result.outcomes[0]).toMatchObject({ status: "changed" });
+ const synced = path.join(home, "library/skills/alpha");
+ expect(yield* fs.exists(path.join(synced, "SKILL.md"))).toBe(true);
+ // The library holds a link, not a copy, and the Global link is gone.
+ expect(yield* fs.readLink(path.join(library, "alpha"))).toBe(synced);
+ expect(yield* fs.exists(path.join(home, ".agents/skills/alpha"))).toBe(false);
+ expect(yield* fs.readLink(path.join(web, ".agents/skills/alpha"))).toBe(
+ path.join(library, "alpha"),
+ );
+ expect(yield* status(web)).not.toContain("alpha");
+ const row = summaryOf((yield* catalog.list({})).skills, "global", "alpha");
+ expect(row).toMatchObject({ home: "~/library/skills/alpha", projects: [web] });
+ expect(row?.realFolder).toBeUndefined();
+
+ // And back to Global: the same real folder, linked from the shared folder again.
+ const back = yield* manager.place({
+ skills: [refOf((yield* catalog.list({})).skills, "global", "alpha")],
+ to: { kind: "global" },
+ });
+ expect(back.outcomes[0]).toMatchObject({ status: "changed" });
+ expect(yield* fs.readLink(path.join(home, ".agents/skills/alpha"))).toBe(synced);
+ expect(yield* fs.exists(path.join(library, "alpha"))).toBe(false);
+ expect(yield* fs.exists(path.join(web, ".agents/skills/alpha"))).toBe(false);
+ expect(blockLines(yield* exclude(web))).toEqual([]);
+ expect(yield* fs.exists(path.join(synced, "SKILL.md"))).toBe(true);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "adds and removes project links when the projects change",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, api, marketing, library } = yield* makeMachine;
+ yield* withManager(home, [web, api, marketing], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const verify = refOf(
+ (yield* catalog.list({ cwd: web })).skills,
+ "project",
+ "db-migrations",
+ );
+ yield* manager.enable({ cwd: web, skills: [verify], agents: [agent("claudeAgent")] });
+ yield* manager.place({
+ cwd: web,
+ skills: [verify],
+ to: { kind: "projects", cwds: [web, api] },
+ });
+ const inLibrary = () =>
+ Effect.map(catalog.list({}), ({ skills }) =>
+ refOf(skills, "global", "db-migrations"),
+ );
+
+ const result = yield* manager.place({
+ skills: [yield* inLibrary()],
+ to: { kind: "projects", cwds: [api, marketing] },
+ });
+
+ expect(result.outcomes[0]).toMatchObject({ status: "changed", blocked: [] });
+ const entry = path.join(library, "db-migrations");
+ expect(yield* fs.exists(path.join(web, ".agents/skills/db-migrations"))).toBe(false);
+ expect(yield* fs.exists(path.join(web, ".claude/skills/db-migrations"))).toBe(false);
+ expect(blockLines(yield* exclude(web))).toEqual([]);
+ // Marketing gets the same links the other project has, Claude's included.
+ for (const project of [api, marketing]) {
+ expect(yield* fs.readLink(path.join(project, ".agents/skills/db-migrations"))).toBe(
+ entry,
+ );
+ expect(yield* fs.readLink(path.join(project, ".claude/skills/db-migrations"))).toBe(
+ entry,
+ );
+ expect(blockLines(yield* exclude(project))).toEqual([
+ "/.agents/skills/db-migrations",
+ "/.claude/skills/db-migrations",
+ ]);
+ }
+ for (const project of [web, api, marketing]) expect(yield* status(project)).toBe("");
+ expect(
+ summaryOf((yield* catalog.list({})).skills, "global", "db-migrations")?.projects,
+ ).toEqual([api, marketing]);
+
+ // The same set again changes nothing.
+ const again = yield* manager.place({
+ skills: [yield* inLibrary()],
+ to: { kind: "projects", cwds: [marketing, api] },
+ });
+ expect(again.outcomes[0]).toMatchObject({ status: "unchanged", blocked: [] });
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "tells which agent's own link couldn't be made, and links the rest",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, api, write, library } = yield* makeMachine;
+ // Something of api's is in Claude's folder under the name; it is left as it is.
+ yield* write("repos/acme-api/.claude/skills/db-migrations/SKILL.md", skillFile("theirs"));
+ yield* withManager(home, [web, api], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const verify = refOf(
+ (yield* catalog.list({ cwd: web })).skills,
+ "project",
+ "db-migrations",
+ );
+ yield* manager.enable({ cwd: web, skills: [verify], agents: [agent("claudeAgent")] });
+
+ const result = yield* manager.place({
+ cwd: web,
+ skills: [verify],
+ to: { kind: "projects", cwds: [web, api] },
+ });
+
+ expect(result.outcomes[0]).toMatchObject({
+ status: "changed",
+ blocked: [{ instanceId: agent("claudeAgent"), reason: "entryTaken" }],
+ });
+ yield* encodeResult(result);
+ expect(yield* fs.readLink(path.join(api, ".agents/skills/db-migrations"))).toBe(
+ path.join(library, "db-migrations"),
+ );
+ expect(
+ yield* fs.readFileString(path.join(api, ".claude/skills/db-migrations/SKILL.md")),
+ ).toBe(skillFile("theirs"));
+ expect(blockLines(yield* exclude(api))).toEqual(["/.agents/skills/db-migrations"]);
+ expect(blockLines(yield* exclude(web))).toEqual([
+ "/.agents/skills/db-migrations",
+ "/.claude/skills/db-migrations",
+ ]);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "makes a project that isn't a git repository just a link, with no exclude file",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, loose, library } = yield* makeMachine;
+ yield* withManager(home, [loose], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const solo = refOf((yield* catalog.list({})).skills, "global", "solo");
+
+ const result = yield* manager.place({
+ skills: [solo],
+ to: { kind: "projects", cwds: [loose] },
+ });
+
+ expect(result.outcomes[0]).toMatchObject({ status: "changed" });
+ expect(yield* fs.readLink(path.join(loose, ".agents/skills/solo"))).toBe(
+ path.join(library, "solo"),
+ );
+ expect(yield* fs.exists(path.join(loose, ".git"))).toBe(false);
+ }),
+ );
+ }),
+ );
+ });
+
+ describe("out of the library", () => {
+ /** The db-migrations skill used in web and api, with Claude on in web. */
+ const usedInTwo = (
+ manager: SkillManager.SkillManager["Service"],
+ catalog: SkillCatalog.SkillCatalog["Service"],
+ web: string,
+ api: string,
+ ) =>
+ Effect.gen(function* () {
+ const verify = refOf(
+ (yield* catalog.list({ cwd: web })).skills,
+ "project",
+ "db-migrations",
+ );
+ yield* manager.enable({ cwd: web, skills: [verify], agents: [agent("claudeAgent")] });
+ yield* manager.place({
+ cwd: web,
+ skills: [verify],
+ to: { kind: "projects", cwds: [web, api] },
+ });
+ return refOf((yield* catalog.list({})).skills, "global", "db-migrations");
+ });
+
+ it.effect.skipIf(!symlinksSupported)(
+ "makes it Global again: the folder moves back, the links and their exclude lines go",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, api, library } = yield* makeMachine;
+ yield* withManager(home, [web, api], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const used = yield* usedInTwo(manager, catalog, web, api);
+
+ const result = yield* manager.place({ skills: [used], to: { kind: "global" } });
+
+ expect(result.outcomes[0]).toMatchObject({ status: "changed", blocked: [] });
+ yield* encodeResult(result);
+ const moved = path.join(home, ".agents/skills/db-migrations");
+ expect(yield* fs.readFileString(path.join(moved, "run.sh"))).toBe("echo ok");
+ expect(yield* fs.exists(path.join(library, "db-migrations"))).toBe(false);
+ for (const project of [web, api]) {
+ expect(yield* fs.exists(path.join(project, ".agents/skills/db-migrations"))).toBe(
+ false,
+ );
+ expect(yield* fs.exists(path.join(project, ".claude/skills/db-migrations"))).toBe(
+ false,
+ );
+ expect(yield* exclude(project)).not.toContain("T3 Code");
+ expect(yield* status(project)).toBe("");
+ }
+ // Claude had it in a project, so it has it Global now; Codex reads the folder.
+ expect(yield* fs.readLink(path.join(home, ".claude/skills/db-migrations"))).toBe(
+ moved,
+ );
+ const global = (yield* catalog.list({})).skills;
+ expect(summaryOf(global, "global", "db-migrations")?.projects).toBeUndefined();
+ expect(stateOf(global, "global", "db-migrations")).toMatchObject({
+ claudeAgent: "link",
+ codex: "direct",
+ });
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "makes it one project's own skill, and every other project's link goes",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, api, marketing, library } = yield* makeMachine;
+ yield* withManager(home, [web, api, marketing], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const used = yield* usedInTwo(manager, catalog, web, api);
+
+ const result = yield* manager.place({
+ skills: [used],
+ to: { kind: "project", cwd: marketing },
+ });
+
+ expect(result.outcomes[0]).toMatchObject({ status: "changed", blocked: [] });
+ const moved = path.join(marketing, ".agents/skills/db-migrations");
+ expect(yield* fs.readFileString(path.join(moved, "run.sh"))).toBe("echo ok");
+ // A project's link to its own skill is relative, so it survives a clone.
+ expect(yield* fs.readLink(path.join(marketing, ".claude/skills/db-migrations"))).toBe(
+ "../../.agents/skills/db-migrations",
+ );
+ expect(yield* fs.exists(path.join(library, "db-migrations"))).toBe(false);
+ for (const project of [web, api]) {
+ expect(yield* fs.exists(path.join(project, ".agents/skills/db-migrations"))).toBe(
+ false,
+ );
+ expect(yield* exclude(project)).not.toContain("T3 Code");
+ }
+ const inMarketing = (yield* catalog.list({ cwd: marketing })).skills;
+ expect(summaryOf(inMarketing, "project", "db-migrations")?.realFolder).toBe(true);
+ expect(summaryOf(inMarketing, "global", "db-migrations")).toBeUndefined();
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "moves a project skill into a project other than the one the list was read for",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, api } = yield* makeMachine;
+ yield* withManager(home, [web, api], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const verify = refOf(
+ (yield* catalog.list({ cwd: web })).skills,
+ "project",
+ "db-migrations",
+ );
+
+ const result = yield* manager.place({
+ cwd: web,
+ skills: [verify],
+ to: { kind: "project", cwd: api },
+ });
+
+ expect(result.outcomes[0]).toMatchObject({ status: "changed", blocked: [] });
+ expect(
+ yield* fs.readFileString(path.join(api, ".agents/skills/db-migrations/run.sh")),
+ ).toBe("echo ok");
+ expect(yield* fs.exists(path.join(web, ".agents/skills/db-migrations"))).toBe(false);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "deletes the library copy and every project's link to it",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, api, library } = yield* makeMachine;
+ yield* withManager(home, [web, api], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const used = yield* usedInTwo(manager, catalog, web, api);
+
+ const result = yield* manager.delete({ skills: [used] });
+
+ expect(result.outcomes[0]).toMatchObject({ status: "changed" });
+ expect(yield* fs.exists(path.join(library, "db-migrations"))).toBe(false);
+ for (const project of [web, api]) {
+ expect(yield* fs.readDirectory(path.join(project, ".agents/skills"))).toEqual([]);
+ expect(yield* exclude(project)).not.toContain("T3 Code");
+ }
+ }),
+ );
+ }),
+ );
+ });
+
+ describe("never replacing what is in the way", () => {
+ it.effect.skipIf(!symlinksSupported)(
+ "refuses when the library already has something under the name",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, api, library } = yield* makeMachine;
+ yield* fs.makeDirectory(path.join(library, "db-migrations"), { recursive: true });
+ yield* withManager(home, [web, api], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const verify = refOf(
+ (yield* catalog.list({ cwd: web })).skills,
+ "project",
+ "db-migrations",
+ );
+
+ const result = yield* manager.place({
+ cwd: web,
+ skills: [verify],
+ to: { kind: "projects", cwds: [web, api] },
+ });
+
+ expect(result.outcomes[0]).toMatchObject({
+ status: "skipped",
+ reason: "destinationTaken",
+ });
+ expect(
+ yield* fs.readFileString(path.join(web, ".agents/skills/db-migrations/run.sh")),
+ ).toBe("echo ok");
+ expect(yield* fs.readDirectory(path.join(library, "db-migrations"))).toEqual([]);
+ expect(yield* fs.exists(path.join(api, ".agents"))).toBe(false);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "refuses when a chosen project has a different skill under the name, and changes nothing",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, api, write, library } = yield* makeMachine;
+ yield* write("repos/acme-api/.agents/skills/db-migrations/SKILL.md", skillFile("theirs"));
+ yield* withManager(home, [web, api], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const verify = refOf(
+ (yield* catalog.list({ cwd: web })).skills,
+ "project",
+ "db-migrations",
+ );
+ yield* manager.enable({ cwd: web, skills: [verify], agents: [agent("claudeAgent")] });
+
+ const result = yield* manager.place({
+ cwd: web,
+ skills: [verify],
+ to: { kind: "projects", cwds: [web, api] },
+ });
+
+ expect(result.outcomes[0]).toMatchObject({
+ status: "skipped",
+ reason: "destinationTaken",
+ });
+ expect(
+ yield* fs.readFileString(path.join(api, ".agents/skills/db-migrations/SKILL.md")),
+ ).toBe(skillFile("theirs"));
+ expect(
+ yield* fs.readFileString(path.join(web, ".agents/skills/db-migrations/run.sh")),
+ ).toBe("echo ok");
+ expect(yield* fs.readLink(path.join(web, ".claude/skills/db-migrations"))).toBe(
+ "../../.agents/skills/db-migrations",
+ );
+ expect(yield* fs.exists(library)).toBe(false);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "refuses to make a library skill Global or a project's when that place has one already",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, api, marketing, write, library } = yield* makeMachine;
+ yield* withManager(home, [web, api, marketing], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const verify = refOf(
+ (yield* catalog.list({ cwd: web })).skills,
+ "project",
+ "db-migrations",
+ );
+ yield* manager.place({
+ cwd: web,
+ skills: [verify],
+ to: { kind: "projects", cwds: [web, api] },
+ });
+ const used = refOf((yield* catalog.list({})).skills, "global", "db-migrations");
+ yield* write(".agents/skills/db-migrations/SKILL.md", skillFile("theirs"));
+ yield* write(
+ "repos/marketing-site/.agents/skills/db-migrations/SKILL.md",
+ skillFile("theirs"),
+ );
+
+ const global = yield* manager.place({ skills: [used], to: { kind: "global" } });
+ const project = yield* manager.place({
+ skills: [used],
+ to: { kind: "project", cwd: marketing },
+ });
+
+ for (const result of [global, project]) {
+ expect(result.outcomes[0]).toMatchObject({
+ status: "skipped",
+ reason: "destinationTaken",
+ });
+ }
+ // Still used where it was.
+ const entry = path.join(library, "db-migrations");
+ expect(yield* fs.readFileString(path.join(entry, "run.sh"))).toBe("echo ok");
+ for (const repo of [web, api]) {
+ expect(yield* fs.readLink(path.join(repo, ".agents/skills/db-migrations"))).toBe(
+ entry,
+ );
+ expect(blockLines(yield* exclude(repo))).toEqual(["/.agents/skills/db-migrations"]);
+ }
+ }),
+ );
+ }),
+ );
+ });
+
+ describe("when a step fails", () => {
+ /** A write to this repository's exclude file fails, as with a read-only `.git`. */
+ const failingExclude = (fs: FileSystem.FileSystem, repo: string) =>
+ FileSystem.FileSystem.of({
+ ...fs,
+ writeFileString: (target, ...rest) =>
+ target.startsWith(`${repo}/.git/info/`)
+ ? Effect.fail(
+ PlatformError.systemError({
+ _tag: "PermissionDenied",
+ module: "FileSystem",
+ method: "writeFileString",
+ pathOrDescriptor: target,
+ }),
+ )
+ : fs.writeFileString(target, ...rest),
+ });
+
+ it.effect.skipIf(!symlinksSupported)(
+ "puts everything back: the folder, Claude's link, and the links and lines already made",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, api, library } = yield* makeMachine;
+ yield* withManager(home, [web, api], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const verify = refOf(
+ (yield* catalog.list({ cwd: web })).skills,
+ "project",
+ "db-migrations",
+ );
+ yield* manager.enable({ cwd: web, skills: [verify], agents: [agent("claudeAgent")] });
+ const before = yield* status(web);
+
+ // web's links and lines are made first, then api's exclude file can't be written.
+ const result = yield* manager.place({
+ cwd: web,
+ skills: [verify],
+ to: { kind: "projects", cwds: [web, api] },
+ });
+
+ expect(result.outcomes[0]).toMatchObject({ status: "skipped", reason: "failed" });
+ yield* encodeResult(result);
+ expect(
+ yield* fs.readFileString(path.join(web, ".agents/skills/db-migrations/run.sh")),
+ ).toBe("echo ok");
+ expect(yield* fs.readLink(path.join(web, ".claude/skills/db-migrations"))).toBe(
+ "../../.agents/skills/db-migrations",
+ );
+ expect(yield* fs.exists(path.join(library, "db-migrations"))).toBe(false);
+ expect(yield* fs.exists(path.join(api, ".agents/skills/db-migrations"))).toBe(false);
+ expect(yield* fs.exists(path.join(api, ".claude/skills/db-migrations"))).toBe(false);
+ for (const repo of [web, api]) expect(yield* exclude(repo)).not.toContain("T3 Code");
+ expect(yield* status(web)).toBe(before);
+ // Still the project's own skill, as the list says.
+ expect(
+ summaryOf((yield* catalog.list({ cwd: web })).skills, "project", "db-migrations")
+ ?.realFolder,
+ ).toBe(true);
+ }),
+ ).pipe(Effect.provideService(FileSystem.FileSystem, failingExclude(fs, api)));
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "puts a library skill's links back when taking them away fails halfway",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, api, library } = yield* makeMachine;
+ // Writes to api's exclude file fail once armed, as with a read-only `.git`.
+ let armed = false;
+ const flaky = FileSystem.FileSystem.of({
+ ...fs,
+ writeFileString: (target, ...rest) =>
+ armed && target.startsWith(`${api}/.git/info/`)
+ ? Effect.fail(
+ PlatformError.systemError({
+ _tag: "PermissionDenied",
+ module: "FileSystem",
+ method: "writeFileString",
+ pathOrDescriptor: target,
+ }),
+ )
+ : fs.writeFileString(target, ...rest),
+ });
+ yield* withManager(home, [web, api], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const verify = refOf(
+ (yield* catalog.list({ cwd: web })).skills,
+ "project",
+ "db-migrations",
+ );
+ yield* manager.place({
+ cwd: web,
+ skills: [verify],
+ to: { kind: "projects", cwds: [web, api] },
+ });
+ const used = refOf((yield* catalog.list({})).skills, "global", "db-migrations");
+ armed = true;
+
+ // Making it Global drops web's link and exclude line first; then api's can't change.
+ const result = yield* manager.place({ skills: [used], to: { kind: "global" } });
+
+ expect(result.outcomes[0]).toMatchObject({ status: "skipped", reason: "failed" });
+ const entry = path.join(library, "db-migrations");
+ expect(yield* fs.readFileString(path.join(entry, "run.sh"))).toBe("echo ok");
+ expect(yield* fs.exists(path.join(home, ".agents/skills/db-migrations"))).toBe(false);
+ for (const repo of [web, api]) {
+ expect(yield* fs.readLink(path.join(repo, ".agents/skills/db-migrations"))).toBe(
+ entry,
+ );
+ expect(blockLines(yield* exclude(repo))).toEqual(["/.agents/skills/db-migrations"]);
+ expect(yield* status(repo)).toBe("");
+ }
+ }),
+ ).pipe(Effect.provideService(FileSystem.FileSystem, flaky));
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "leaves the skill alone when a copy across disks fails",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, api, library } = yield* makeMachine;
+ const from = path.join(web, ".agents/skills/db-migrations");
+ const failing = FileSystem.FileSystem.of({
+ ...fs,
+ rename: (oldPath, newPath) =>
+ oldPath === from
+ ? Effect.fail(
+ PlatformError.systemError({
+ _tag: "Unknown",
+ module: "FileSystem",
+ method: "rename",
+ pathOrDescriptor: oldPath,
+ cause: Object.assign(new Error("EXDEV"), { code: "EXDEV" }),
+ }),
+ )
+ : fs.rename(oldPath, newPath),
+ copyFile: (source) =>
+ Effect.fail(
+ PlatformError.systemError({
+ _tag: "Unknown",
+ module: "FileSystem",
+ method: "copyFile",
+ pathOrDescriptor: source,
+ cause: Object.assign(new Error("EIO"), { code: "EIO" }),
+ }),
+ ),
+ });
+ yield* withManager(home, [web, api], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const verify = refOf(
+ (yield* catalog.list({ cwd: web })).skills,
+ "project",
+ "db-migrations",
+ );
+
+ const result = yield* manager.place({
+ cwd: web,
+ skills: [verify],
+ to: { kind: "projects", cwds: [api] },
+ });
+
+ expect(result.outcomes[0]).toMatchObject({ status: "skipped", reason: "failed" });
+ expect(yield* fs.readFileString(path.join(from, "run.sh"))).toBe("echo ok");
+ expect(yield* fs.exists(path.join(library, "db-migrations"))).toBe(false);
+ expect(yield* fs.exists(path.join(api, ".agents"))).toBe(false);
+ }),
+ ).pipe(Effect.provideService(FileSystem.FileSystem, failing));
+ }),
+ );
+ });
+
+ describe("the skill's source record", () => {
+ const lockFile = (skills: Record) =>
+ `${JSON.stringify({ version: 1, skills }, null, 2)}\n`;
+ const record = {
+ source: "acme/skills",
+ sourceType: "github",
+ skillPath: "skills/db-migrations/SKILL.md",
+ computedHash: "0".repeat(64),
+ };
+
+ it.effect.skipIf(!symlinksSupported)(
+ "goes with a project skill into Global, and shows as the skill's source in both places",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, write } = yield* makeMachine;
+ yield* write("repos/acme-web/skills-lock.json", lockFile({ "db-migrations": record }));
+ yield* withManager(
+ home,
+ [web],
+ ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const before = (yield* catalog.list({ cwd: web })).skills;
+ expect(summaryOf(before, "project", "db-migrations")?.source).toBe("acme/skills");
+
+ const result = yield* manager.place({
+ cwd: web,
+ skills: [refOf(before, "project", "db-migrations")],
+ to: { kind: "global" },
+ });
+
+ // Nothing was lost on the way, so there is nothing to say about it.
+ expect(result.outcomes[0]?.sourceDropped).toBeUndefined();
+ const lock = JSON.parse(
+ yield* fs.readFileString(path.join(home, "state/skills/.skill-lock.json")),
+ );
+ expect(lock.skills["db-migrations"]).toMatchObject({
+ source: "acme/skills",
+ sourceUrl: "https://github.com/acme/skills.git",
+ skillFolderHash: "",
+ });
+ expect(yield* fs.readFileString(path.join(web, "skills-lock.json"))).toBe(
+ lockFile({}),
+ );
+ const after = (yield* catalog.list({ cwd: web })).skills;
+ expect(summaryOf(after, "global", "db-migrations")?.source).toBe("acme/skills");
+ }),
+ { XDG_STATE_HOME: `${home}/state` },
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "goes with a project skill into the library, as a Global skill",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, api, write } = yield* makeMachine;
+ yield* write("repos/acme-web/skills-lock.json", lockFile({ "db-migrations": record }));
+ yield* withManager(home, [web, api], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const verify = refOf(
+ (yield* catalog.list({ cwd: web })).skills,
+ "project",
+ "db-migrations",
+ );
+
+ yield* manager.place({
+ cwd: web,
+ skills: [verify],
+ to: { kind: "projects", cwds: [api] },
+ });
+
+ expect(
+ JSON.parse(yield* fs.readFileString(path.join(home, ".agents/.skill-lock.json")))
+ .skills["db-migrations"].skillFolderHash,
+ ).toBe("");
+ expect(yield* fs.readFileString(path.join(web, "skills-lock.json"))).toBe(
+ lockFile({}),
+ );
+ expect(
+ summaryOf((yield* catalog.list({ cwd: api })).skills, "global", "db-migrations")
+ ?.source,
+ ).toBe("acme/skills");
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "goes with a Global skill into a project when the folder is exactly what was recorded",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, write } = yield* makeMachine;
+ // The skill whose git tree and CLI hash are known, as the CLI would have installed it.
+ yield* write(".agents/skills/exact/SKILL.md", GOLDEN_SKILL_FILE);
+ yield* write(".agents/skills/exact/references/x.md", "Read this first.\n");
+ yield* write(".agents/skills/exact/scripts/run.sh", "#!/bin/sh\necho migrate\n", 0o755);
+ yield* write(
+ ".agents/.skill-lock.json",
+ JSON.stringify({
+ version: 3,
+ skills: {
+ exact: {
+ source: "acme/skills",
+ sourceType: "github",
+ sourceUrl: "https://github.com/acme/skills.git",
+ skillPath: "skills/db-migrations/SKILL.md",
+ skillFolderHash: GOLDEN.treeSha,
+ installedAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ },
+ },
+ dismissed: {},
+ }),
+ );
+ yield* withManager(home, [web], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const global = refOf((yield* catalog.list({ cwd: web })).skills, "global", "exact");
+
+ yield* manager.place({
+ cwd: web,
+ skills: [global],
+ to: { kind: "project", cwd: web },
+ });
+
+ expect(
+ JSON.parse(yield* fs.readFileString(path.join(web, "skills-lock.json"))),
+ ).toEqual({
+ version: 1,
+ skills: {
+ exact: {
+ source: "acme/skills",
+ sourceType: "github",
+ skillPath: "skills/db-migrations/SKILL.md",
+ computedHash: GOLDEN.computedHash,
+ },
+ },
+ });
+ const globalLock = yield* fs.readFileString(
+ path.join(home, ".agents/.skill-lock.json"),
+ );
+ expect(JSON.parse(globalLock).skills).toEqual({});
+ expect(
+ summaryOf((yield* catalog.list({ cwd: web })).skills, "project", "exact")?.source,
+ ).toBe("acme/skills");
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "is dropped, and the skill becomes ungrouped, when the Global folder was edited",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, write } = yield* makeMachine;
+ yield* write(".agents/skills/exact/SKILL.md", GOLDEN_SKILL_FILE);
+ yield* write(".agents/skills/exact/references/x.md", "Edited by hand.\n");
+ yield* write(
+ ".agents/.skill-lock.json",
+ JSON.stringify({
+ version: 3,
+ skills: {
+ exact: {
+ source: "acme/skills",
+ sourceType: "github",
+ sourceUrl: "https://github.com/acme/skills.git",
+ skillFolderHash: GOLDEN.treeSha,
+ installedAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ },
+ },
+ }),
+ );
+ yield* withManager(home, [web], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const global = refOf((yield* catalog.list({ cwd: web })).skills, "global", "exact");
+ expect(
+ summaryOf((yield* catalog.list({ cwd: web })).skills, "global", "exact")?.source,
+ ).toBe("acme/skills");
+
+ const result = yield* manager.place({
+ cwd: web,
+ skills: [global],
+ to: { kind: "project", cwd: web },
+ });
+
+ expect(result.outcomes[0]).toMatchObject({ status: "changed", sourceDropped: true });
+ yield* encodeResult(result);
+ expect(yield* fs.exists(path.join(web, "skills-lock.json"))).toBe(false);
+ expect(
+ summaryOf((yield* catalog.list({ cwd: web })).skills, "project", "exact")?.source,
+ ).toBeUndefined();
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "is left exactly as it is when a lock doesn't parse, and the skill still moves",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, write } = yield* makeMachine;
+ const original = lockFile({ "db-migrations": record });
+ const broken = '<<<<<<< HEAD\n{"version":3,"skills":{}}\n=======\n';
+ yield* write("repos/acme-web/skills-lock.json", original);
+ yield* write(".agents/.skill-lock.json", broken);
+ yield* withManager(home, [web], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const verify = refOf(
+ (yield* catalog.list({ cwd: web })).skills,
+ "project",
+ "db-migrations",
+ );
+
+ const result = yield* manager.place({
+ cwd: web,
+ skills: [verify],
+ to: { kind: "global" },
+ });
+
+ // A lock that wasn't touched still holds the record: nothing was dropped.
+ expect(result.outcomes[0]).toMatchObject({ status: "changed" });
+ expect(result.outcomes[0]?.sourceDropped).toBeUndefined();
+ expect(
+ yield* fs.exists(path.join(home, ".agents/skills/db-migrations/SKILL.md")),
+ ).toBe(true);
+ expect(yield* fs.readFileString(path.join(web, "skills-lock.json"))).toBe(original);
+ expect(yield* fs.readFileString(path.join(home, ".agents/.skill-lock.json"))).toBe(
+ broken,
+ );
+ }),
+ );
+ }),
+ );
+ });
+
+ describe("the agents of a skill used in only some projects", () => {
+ /** web's project skill `db-migrations`, made Global and used in web and api; its Global row. */
+ const useInWebAndApi = (
+ manager: SkillManager.SkillManager["Service"],
+ catalog: SkillCatalog.SkillCatalog["Service"],
+ web: string,
+ api: string,
+ ) =>
+ Effect.gen(function* () {
+ const verify = refOf(
+ (yield* catalog.list({ cwd: web })).skills,
+ "project",
+ "db-migrations",
+ );
+ yield* manager.place({
+ cwd: web,
+ skills: [verify],
+ to: { kind: "projects", cwds: [web, api] },
+ });
+ return refOf((yield* catalog.list({})).skills, "global", "db-migrations");
+ });
+
+ it.effect.skipIf(!symlinksSupported)(
+ "turns Claude on in the projects that use the skill, never Global, and off again",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, api, marketing, library } = yield* makeMachine;
+ yield* withManager(home, [web, api, marketing], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const skill = yield* useInWebAndApi(manager, catalog, web, api);
+ expect(
+ stateOf((yield* catalog.list({})).skills, "global", "db-migrations").claudeAgent,
+ ).toBe("none");
+
+ // From a project that doesn't use the skill: the Global row acts on Global.
+ const on = yield* manager.enable({
+ cwd: marketing,
+ skills: [skill],
+ agents: [agent("claudeAgent")],
+ });
+
+ expect(on.outcomes).toEqual([
+ { skill, status: "changed", blocked: [], affected: [] },
+ ]);
+ yield* encodeResult(on);
+ const entry = path.join(library, "db-migrations");
+ for (const project of [web, api]) {
+ expect(yield* fs.readLink(path.join(project, ".claude/skills/db-migrations"))).toBe(
+ entry,
+ );
+ expect(blockLines(yield* exclude(project))).toEqual([
+ "/.agents/skills/db-migrations",
+ "/.claude/skills/db-migrations",
+ ]);
+ expect(yield* status(project)).toBe("");
+ }
+ expect(yield* fs.exists(path.join(home, ".claude/skills/db-migrations"))).toBe(false);
+ expect(yield* fs.exists(path.join(marketing, ".claude"))).toBe(false);
+ // Claude has the skill, whichever project is open, and it is one Global skill.
+ for (const cwd of [undefined, web, api, marketing]) {
+ const { skills } = yield* catalog.list(cwd === undefined ? {} : { cwd });
+ expect(
+ skills.filter((item) => item.name === "db-migrations").map((item) => item.scope),
+ ).toEqual(["global"]);
+ expect(stateOf(skills, "global", "db-migrations").claudeAgent).toBe("link");
+ }
+
+ const off = yield* manager.disable({
+ skills: [skill],
+ agents: [agent("claudeAgent")],
+ });
+
+ expect(off.outcomes).toEqual([
+ { skill, status: "changed", blocked: [], affected: [] },
+ ]);
+ for (const project of [web, api]) {
+ expect(yield* fs.exists(path.join(project, ".claude/skills/db-migrations"))).toBe(
+ false,
+ );
+ expect(blockLines(yield* exclude(project))).toEqual([
+ "/.agents/skills/db-migrations",
+ ]);
+ // The shared link stays: the agents that read it keep the skill.
+ expect(yield* fs.readLink(path.join(project, ".agents/skills/db-migrations"))).toBe(
+ entry,
+ );
+ }
+ expect(
+ stateOf((yield* catalog.list({})).skills, "global", "db-migrations"),
+ ).toMatchObject({ claudeAgent: "none", codex: "direct" });
+ yield* encodeResult(off);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "switches every agent from the row: a link where an agent needs one, its setting where it reads the shared folder",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, api, library } = yield* makeMachine;
+ const codex = yield* makeCodexDouble(path.join(home, ".codex"));
+ yield* withManager(
+ home,
+ [web, api],
+ ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const skill = yield* useInWebAndApi(manager, catalog, web, api);
+ const row = Effect.map(catalog.list({}), ({ skills }) =>
+ summaryOf(skills, "global", "db-migrations"),
+ );
+ const states = Effect.map(row, (item) =>
+ Object.fromEntries((item?.access ?? []).map((a) => [a.instanceId, a.state])),
+ );
+ const entry = path.join(library, "db-migrations");
+
+ expect(yield* states).toEqual({
+ claudeAgent: "none",
+ codex: "direct",
+ cursor: "direct",
+ grok: "none",
+ opencode: "direct",
+ antigravity: "direct",
+ pi: "direct",
+ });
+ // Cursor, Antigravity and Pi read the shared folder and have no setting to switch.
+ expect(
+ (yield* row)?.access
+ .filter((item) => item.fixed === true)
+ .map((item) => item.instanceId)
+ .toSorted(),
+ ).toEqual([agent("antigravity"), agent("cursor"), agent("pi")]);
+
+ // Turning on what is off: Claude and Grok read folders of their own.
+ const on = yield* manager.enable({
+ skills: [skill],
+ agents: [agent("claudeAgent"), agent("grok")],
+ });
+ expect(on.outcomes[0]).toMatchObject({ status: "changed", blocked: [] });
+ for (const project of [web, api]) {
+ for (const folder of [".claude/skills", ".grok/skills"]) {
+ expect(yield* fs.readLink(path.join(project, folder, "db-migrations"))).toBe(
+ entry,
+ );
+ }
+ expect(yield* status(project)).toBe("");
+ }
+ expect(yield* states).toMatchObject({ claudeAgent: "link", grok: "link" });
+
+ // Turning every agent off.
+ const off = yield* manager.disable({
+ skills: [skill],
+ agents: ALL_AGENTS,
+ });
+ yield* encodeResult(off);
+ expect(off.outcomes[0]?.status).toBe("changed");
+ expect(
+ off.outcomes[0]?.blocked.toSorted((a, b) =>
+ a.instanceId.localeCompare(b.instanceId),
+ ),
+ ).toEqual([
+ { instanceId: agent("antigravity"), reason: "alwaysOn" },
+ { instanceId: agent("cursor"), reason: "alwaysOn" },
+ { instanceId: agent("pi"), reason: "alwaysOn" },
+ ]);
+ for (const project of [web, api]) {
+ expect(yield* fs.exists(path.join(project, ".claude/skills/db-migrations"))).toBe(
+ false,
+ );
+ expect(yield* fs.exists(path.join(project, ".grok/skills/db-migrations"))).toBe(
+ false,
+ );
+ }
+ // Codex records the real SKILL.md, which is the library's; OpenCode names the skill.
+ expect(codex.calls).toEqual([
+ { path: path.join(entry, "SKILL.md"), enabled: false },
+ ]);
+ expect(
+ JSON.parse(
+ yield* fs.readFileString(path.join(home, ".config/opencode/opencode.json")),
+ ),
+ ).toEqual({ permission: { skill: { "db-migrations": "deny" } } });
+ expect(yield* states).toEqual({
+ claudeAgent: "none",
+ codex: "off",
+ cursor: "direct",
+ grok: "none",
+ opencode: "off",
+ antigravity: "direct",
+ pi: "direct",
+ });
+
+ // And on again.
+ const back = yield* manager.enable({
+ skills: [skill],
+ agents: [agent("claudeAgent"), agent("grok"), agent("codex"), agent("opencode")],
+ });
+ expect(back.outcomes[0]).toMatchObject({ status: "changed", blocked: [] });
+ expect(yield* states).toMatchObject({
+ claudeAgent: "link",
+ codex: "direct",
+ grok: "link",
+ opencode: "direct",
+ });
+ expect(yield* fs.readFileString(codex.file)).toBe("");
+ expect(
+ yield* fs.readFileString(path.join(home, ".config/opencode/opencode.json")),
+ ).not.toContain("deny");
+ }),
+ {},
+ codex,
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "links what it can and says which project had something else in the way",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, api, write } = yield* makeMachine;
+ yield* write("repos/acme-api/.claude/skills/db-migrations/SKILL.md", skillFile("mine"));
+ yield* withManager(home, [web, api], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const skill = yield* useInWebAndApi(manager, catalog, web, api);
+
+ const on = yield* manager.enable({ skills: [skill], agents: [agent("claudeAgent")] });
+
+ expect(on.outcomes[0]).toMatchObject({
+ status: "changed",
+ blocked: [{ instanceId: agent("claudeAgent"), reason: "entryTaken" }],
+ });
+ expect(yield* fs.readLink(path.join(web, ".claude/skills/db-migrations"))).toBe(
+ path.join(home, ".agents/skill-library/db-migrations"),
+ );
+ // The other project's own folder is never replaced.
+ expect(
+ yield* fs.readFileString(path.join(api, ".claude/skills/db-migrations/SKILL.md")),
+ ).toBe(skillFile("mine"));
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)("has nowhere to link a skill that no project uses", () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, write } = yield* makeMachine;
+ yield* write(".agents/skill-library/lonely/SKILL.md", skillFile("lonely"));
+ yield* withManager(home, [web], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const { skills } = yield* catalog.list({});
+ expect(new Set(Object.values(stateOf(skills, "global", "lonely")))).toEqual(
+ new Set(["none"]),
+ );
+
+ const on = yield* manager.enable({
+ skills: [refOf(skills, "global", "lonely")],
+ agents: [agent("claudeAgent")],
+ });
+
+ expect(on.outcomes[0]).toMatchObject({
+ status: "skipped",
+ blocked: [{ instanceId: agent("claudeAgent"), reason: "failed" }],
+ });
+ expect(yield* fs.exists(path.join(home, ".claude/skills/lonely"))).toBe(false);
+ expect(yield* fs.exists(path.join(web, ".claude"))).toBe(false);
+ }),
+ );
+ }),
+ );
+ });
+
+ describe("Codex's setting for a skill whose folder moves", () => {
+ const rulesOf = (text: string) =>
+ (parseToml(text) as { skills?: { config?: Array> } }).skills
+ ?.config ?? [];
+
+ it.effect.skipIf(!symlinksSupported)(
+ "follows the real SKILL.md from a project to Global, the library and another project",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, api, library } = yield* makeMachine;
+ const codex = yield* makeCodexDouble(path.join(home, ".codex"));
+ yield* withManager(
+ home,
+ [web, api],
+ ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const verify = refOf(
+ (yield* catalog.list({ cwd: web })).skills,
+ "project",
+ "db-migrations",
+ );
+ yield* manager.disable({ cwd: web, skills: [verify], agents: [agent("codex")] });
+ const rules = Effect.map(fs.readFileString(codex.file), rulesOf);
+ const codexState = (scope: SkillScope, cwd?: string) =>
+ Effect.map(
+ catalog.list(cwd === undefined ? {} : { cwd }),
+ ({ skills }) => stateOf(skills, scope, "db-migrations").codex,
+ );
+ expect(yield* rules).toEqual([
+ { path: path.join(web, ".agents/skills/db-migrations/SKILL.md"), enabled: false },
+ ]);
+ expect(yield* codexState("project", web)).toBe("off");
+
+ // Project -> Global.
+ const toGlobal = yield* manager.place({
+ cwd: web,
+ skills: [verify],
+ to: { kind: "global" },
+ });
+ expect(toGlobal.outcomes[0]).toMatchObject({ status: "changed", blocked: [] });
+ expect(yield* rules).toEqual([
+ {
+ path: path.join(home, ".agents/skills/db-migrations/SKILL.md"),
+ enabled: false,
+ },
+ ]);
+ expect(yield* codexState("global")).toBe("off");
+
+ // Global -> only some projects: the library's folder.
+ const global = refOf((yield* catalog.list({})).skills, "global", "db-migrations");
+ const toLibrary = yield* manager.place({
+ skills: [global],
+ to: { kind: "projects", cwds: [web, api] },
+ });
+ expect(toLibrary.outcomes[0]).toMatchObject({ status: "changed", blocked: [] });
+ expect(yield* rules).toEqual([
+ { path: path.join(library, "db-migrations/SKILL.md"), enabled: false },
+ ]);
+ expect(yield* codexState("global", web)).toBe("off");
+
+ // Some projects -> one project.
+ const inLibrary = refOf(
+ (yield* catalog.list({})).skills,
+ "global",
+ "db-migrations",
+ );
+ const toProject = yield* manager.place({
+ skills: [inLibrary],
+ to: { kind: "project", cwd: api },
+ });
+ expect(toProject.outcomes[0]).toMatchObject({ status: "changed", blocked: [] });
+ expect(yield* rules).toEqual([
+ { path: path.join(api, ".agents/skills/db-migrations/SKILL.md"), enabled: false },
+ ]);
+ expect(yield* codexState("project", api)).toBe("off");
+ // Written through Codex each time, one new path then the old one cleared.
+ expect(codex.calls.map((call) => call.enabled)).toEqual([
+ false,
+ false,
+ true,
+ false,
+ true,
+ false,
+ true,
+ ]);
+ }),
+ {},
+ codex,
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "leaves a setting that names the skill alone, and a skill Codex had on",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web } = yield* makeMachine;
+ const codex = yield* makeCodexDouble(path.join(home, ".codex"));
+ yield* fs.makeDirectory(path.join(home, ".codex"), { recursive: true });
+ yield* fs.writeFileString(
+ codex.file,
+ '[[skills.config]]\nname = "db-migrations"\nenabled = false\n',
+ );
+ yield* withManager(
+ home,
+ [web],
+ ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const verify = refOf(
+ (yield* catalog.list({ cwd: web })).skills,
+ "project",
+ "db-migrations",
+ );
+
+ const result = yield* manager.place({
+ cwd: web,
+ skills: [verify],
+ to: { kind: "global" },
+ });
+
+ expect(result.outcomes[0]).toMatchObject({ status: "changed", blocked: [] });
+ expect(codex.calls).toEqual([]);
+ expect(rulesOf(yield* fs.readFileString(codex.file))).toEqual([
+ { name: "db-migrations", enabled: false },
+ ]);
+ }),
+ {},
+ codex,
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "says Codex wasn't carried over when it can't be asked, and the skill still moves",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web } = yield* makeMachine;
+ yield* fs.makeDirectory(path.join(home, ".codex"), { recursive: true });
+ const old = path.join(web, ".agents/skills/db-migrations/SKILL.md");
+ yield* fs.writeFileString(
+ path.join(home, ".codex/config.toml"),
+ `[[skills.config]]\npath = "${old}"\nenabled = false\n`,
+ );
+ // No double: the registry has no Codex instance to open a writer on.
+ yield* withManager(home, [web], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const verify = refOf(
+ (yield* catalog.list({ cwd: web })).skills,
+ "project",
+ "db-migrations",
+ );
+
+ const result = yield* manager.place({
+ cwd: web,
+ skills: [verify],
+ to: { kind: "global" },
+ });
+
+ expect(result.outcomes[0]).toMatchObject({
+ status: "changed",
+ blocked: [{ instanceId: agent("codex"), reason: "failed" }],
+ });
+ expect(
+ yield* fs.exists(path.join(home, ".agents/skills/db-migrations/SKILL.md")),
+ ).toBe(true);
+ }),
+ );
+ }),
+ );
+ });
+
+ describe("the git worktrees of a project that uses a library skill", () => {
+ /**
+ * A worktree of the repository `project` is in, with the links the worktree hook makes in it.
+ * `prefix` is the project's folder in the repository, as git says it.
+ */
+ const addWorktree = (project: string, name: string, prefix = "") =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const up = prefix.split("/").filter((segment) => segment !== "");
+ const root = path.resolve(project, ...up.map(() => ".."));
+ const worktree = path.join(path.dirname(root), `${path.basename(root)}-${name}`);
+ yield* git(project, ["worktree", "add", "-q", "-b", name, worktree]);
+ yield* restoreLibraryLinks({ project, worktree, prefix }).pipe(
+ Effect.provideService(FileSystem.FileSystem, fs),
+ Effect.provideService(Path.Path, path),
+ );
+ return worktree;
+ });
+
+ it.effect.skipIf(!symlinksSupported)(
+ "lose the links when Claude is turned off and when the project stops using the skill",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, api, library } = yield* makeMachine;
+ yield* withManager(home, [web, api], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const verify = refOf(
+ (yield* catalog.list({ cwd: web })).skills,
+ "project",
+ "db-migrations",
+ );
+ yield* manager.place({
+ cwd: web,
+ skills: [verify],
+ to: { kind: "projects", cwds: [web, api] },
+ });
+ const skill = refOf((yield* catalog.list({})).skills, "global", "db-migrations");
+ yield* manager.enable({ skills: [skill], agents: [agent("claudeAgent")] });
+ const entry = path.join(library, "db-migrations");
+ const worktree = yield* addWorktree(web, "feature");
+ const other = yield* addWorktree(api, "feature");
+ // Something of the worktree's own is never touched.
+ yield* fs.makeDirectory(path.join(worktree, ".claude/skills/own"), {
+ recursive: true,
+ });
+ yield* fs.symlink(
+ path.join(home, "somewhere-else"),
+ path.join(worktree, ".agents/skills/different"),
+ );
+ expect(yield* fs.readLink(path.join(worktree, ".claude/skills/db-migrations"))).toBe(
+ entry,
+ );
+
+ // Turning Claude off takes its link out of the worktrees as well.
+ yield* manager.disable({ skills: [skill], agents: [agent("claudeAgent")] });
+ expect(yield* fs.exists(path.join(web, ".claude/skills/db-migrations"))).toBe(false);
+ expect(yield* fs.exists(path.join(worktree, ".claude/skills/db-migrations"))).toBe(
+ false,
+ );
+ expect(yield* fs.exists(path.join(other, ".claude/skills/db-migrations"))).toBe(
+ false,
+ );
+ expect(yield* fs.readLink(path.join(worktree, ".agents/skills/db-migrations"))).toBe(
+ entry,
+ );
+
+ // A project that stops using the skill: its worktrees' links go, api's stay.
+ const moved = yield* manager.place({
+ skills: [skill],
+ to: { kind: "projects", cwds: [api] },
+ });
+ expect(moved.outcomes[0]).toMatchObject({ status: "changed", blocked: [] });
+ expect(yield* fs.exists(path.join(web, ".agents/skills/db-migrations"))).toBe(false);
+ expect(yield* fs.exists(path.join(worktree, ".agents/skills/db-migrations"))).toBe(
+ false,
+ );
+ expect(yield* fs.readLink(path.join(other, ".agents/skills/db-migrations"))).toBe(
+ entry,
+ );
+ // What the worktree had of its own stays.
+ expect(yield* fs.exists(path.join(worktree, ".claude/skills/own"))).toBe(true);
+ expect(yield* fs.readLink(path.join(worktree, ".agents/skills/different"))).toBe(
+ path.join(home, "somewhere-else"),
+ );
+ // Only the worktree's own link shows in git; the exclude lines covered the rest.
+ expect(yield* status(worktree)).toBe("?? .agents/skills/different\n");
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "lose the links when the skill is deleted, and the project's own checkout isn't a worktree",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, api } = yield* makeMachine;
+ yield* withManager(home, [web, api], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const verify = refOf(
+ (yield* catalog.list({ cwd: web })).skills,
+ "project",
+ "db-migrations",
+ );
+ yield* manager.place({
+ cwd: web,
+ skills: [verify],
+ to: { kind: "projects", cwds: [web, api] },
+ });
+ const worktree = yield* addWorktree(web, "feature");
+ const skill = refOf((yield* catalog.list({})).skills, "global", "db-migrations");
+
+ const result = yield* manager.delete({ skills: [skill] });
+
+ expect(result.outcomes[0]).toMatchObject({ status: "changed" });
+ for (const root of [web, api, worktree]) {
+ expect(yield* fs.exists(path.join(root, ".agents/skills/db-migrations"))).toBe(
+ false,
+ );
+ }
+ expect(blockLines(yield* exclude(web))).toEqual([]);
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "keeps its links at the same folder of each worktree, and removes them from there",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, api, write, library } = yield* makeMachine;
+ // `repos/mono` is the repository; the project is its `apps/site` folder.
+ const mono = path.join(home, "repos/mono");
+ const site = path.join(mono, "apps/site");
+ yield* write("repos/mono/apps/site/README.md", "# site\n");
+ yield* git(mono, ["init", "-q", "-b", "main"]);
+ yield* git(mono, ["config", "core.excludesFile", path.join(home, "global-ignore")]);
+ yield* git(mono, ["add", "-A"]);
+ yield* git(mono, ["commit", "-q", "-m", "init"]);
+ // Untracked, like the skill of the other projects, so a worktree starts without it.
+ yield* write(
+ "repos/mono/apps/site/.agents/skills/db-migrations/SKILL.md",
+ skillFile("db-migrations"),
+ );
+ yield* withManager(home, [site, api], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const verify = refOf(
+ (yield* catalog.list({ cwd: site })).skills,
+ "project",
+ "db-migrations",
+ );
+ yield* manager.place({
+ cwd: site,
+ skills: [verify],
+ to: { kind: "projects", cwds: [site, api] },
+ });
+ const entry = path.join(library, "db-migrations");
+ const worktree = yield* addWorktree(site, "feature", "apps/site/");
+ // The link is in the project's folder of the worktree, not at its root.
+ expect(
+ yield* fs.readLink(path.join(worktree, "apps/site/.agents/skills/db-migrations")),
+ ).toBe(entry);
+ expect(yield* fs.exists(path.join(worktree, ".agents"))).toBe(false);
+
+ // The project stops using the skill: the worktree's link goes too.
+ const skill = refOf((yield* catalog.list({})).skills, "global", "db-migrations");
+ const moved = yield* manager.place({
+ skills: [skill],
+ to: { kind: "projects", cwds: [api] },
+ });
+ expect(moved.outcomes[0]).toMatchObject({ status: "changed", blocked: [] });
+ expect(yield* fs.exists(path.join(site, ".agents/skills/db-migrations"))).toBe(false);
+ expect(
+ yield* fs.exists(path.join(worktree, "apps/site/.agents/skills/db-migrations")),
+ ).toBe(false);
+ }),
+ );
+ }),
+ );
+ });
+
+ describe("Claude's local settings file", () => {
+ const LOCAL_BLOCK =
+ "# T3 Code: local settings\n/.claude/settings.local.json\n# End T3 Code: local settings";
+
+ it.effect.skipIf(!symlinksSupported)(
+ "stays out of git when T3 Code creates it, and is left alone when it was there",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, api, write } = yield* makeMachine;
+ yield* write("repos/acme-web/.claude/skills/own/SKILL.md", skillFile("own"));
+ yield* write("repos/acme-web/.claude/skills/other/SKILL.md", skillFile("other"));
+ yield* write("repos/acme-api/.claude/skills/own/SKILL.md", skillFile("own"));
+ yield* write("repos/acme-api/.claude/settings.local.json", '{"theme":"dark"}\n');
+ yield* withManager(home, [web, api], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const off = (cwd: string, name: string) =>
+ Effect.gen(function* () {
+ const skill = refOf((yield* catalog.list({ cwd })).skills, "project", name);
+ return yield* manager.disable({
+ cwd,
+ skills: [skill],
+ agents: [agent("claudeAgent")],
+ });
+ });
+
+ // A new file in a git repository: ignored by git from then on.
+ const created = yield* off(web, "own");
+ expect(created.outcomes[0]).toMatchObject({ status: "changed", blocked: [] });
+ expect(
+ JSON.parse(yield* fs.readFileString(path.join(web, ".claude/settings.local.json"))),
+ ).toEqual({ skillOverrides: { own: "off" } });
+ expect(yield* exclude(web)).toContain(LOCAL_BLOCK);
+ expect(yield* status(web)).not.toContain("settings.local.json");
+ const text = yield* exclude(web);
+
+ // Editing it again doesn't touch the exclude file.
+ yield* off(web, "other");
+ expect(yield* exclude(web)).toBe(text);
+
+ // A file that was already there is not T3 Code's to hide.
+ yield* off(api, "own");
+ expect(
+ JSON.parse(yield* fs.readFileString(path.join(api, ".claude/settings.local.json"))),
+ ).toEqual({ theme: "dark", skillOverrides: { own: "off" } });
+ expect(yield* exclude(api)).not.toContain("settings.local.json");
+ }),
+ );
+ }),
+ );
+ });
+
+ describe("into the shared folder", () => {
+ it.effect.skipIf(!symlinksSupported)(
+ "moves a folder out of Claude's own folder and leaves Claude a link, so every agent has it",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, write } = yield* makeMachine;
+ yield* write("repos/acme-web/.claude/skills/review/SKILL.md", skillFile("review"));
+ yield* write("repos/acme-web/.claude/skills/review/notes.md", "keep me");
+ yield* withManager(home, [web], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const before = (yield* catalog.list({ cwd: web })).skills;
+ const review = refOf(before, "project", "review");
+ expect(stateOf(before, "project", "review")).toMatchObject({
+ claudeAgent: "direct",
+ codex: "none",
+ });
+
+ const result = yield* manager.share({ cwd: web, skills: [review] });
+
+ yield* encodeResult(result);
+ expect(result.outcomes[0]).toMatchObject({ status: "changed", blocked: [] });
+ expect(
+ yield* fs.readFileString(path.join(web, ".agents/skills/review/notes.md")),
+ ).toBe("keep me");
+ expect(yield* fs.readLink(path.join(web, ".claude/skills/review"))).toBe(
+ "../../.agents/skills/review",
+ );
+ const after = (yield* catalog.list({ cwd: web })).skills;
+ expect(summaryOf(after, "project", "review")?.home).toBe(".agents/skills/review");
+ expect(stateOf(after, "project", "review")).toMatchObject({
+ claudeAgent: "link",
+ codex: "direct",
+ pi: "direct",
+ });
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "puts the folder where a link to it was in the shared folder",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, write } = yield* makeMachine;
+ yield* write("repos/acme-web/.claude/skills/review/SKILL.md", skillFile("review"));
+ yield* fs.symlink("../../.claude/skills/review", path.join(web, ".agents/skills/review"));
+ yield* withManager(home, [web], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const review = refOf((yield* catalog.list({ cwd: web })).skills, "project", "review");
+
+ const result = yield* manager.share({ cwd: web, skills: [review] });
+
+ expect(result.outcomes[0]).toMatchObject({ status: "changed", blocked: [] });
+ expect(
+ yield* fs.readFileString(path.join(web, ".agents/skills/review/SKILL.md")),
+ ).toBe(skillFile("review"));
+ expect(yield* fs.readLink(path.join(web, ".claude/skills/review"))).toBe(
+ "../../.agents/skills/review",
+ );
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "never replaces a different skill with the name in the shared folder",
+ () =>
+ Effect.gen(function* () {
+ const { fs, path, home, web, write } = yield* makeMachine;
+ yield* write("repos/acme-web/.claude/skills/review/SKILL.md", skillFile("review"));
+ yield* write("repos/acme-web/.agents/skills/review/SKILL.md", skillFile("theirs"));
+ yield* withManager(home, [web], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const skills = (yield* catalog.list({ cwd: web })).skills;
+ const ours = skills.find(
+ (item) => item.name === "review" && item.home === ".claude/skills/review",
+ );
+ expect(ours).toBeDefined();
+
+ const result = yield* manager.share({
+ cwd: web,
+ skills: [{ scope: "project", name: "review", home: ours!.home }],
+ });
+
+ expect(result.outcomes[0]).toMatchObject({
+ status: "skipped",
+ reason: "destinationTaken",
+ });
+ expect(
+ yield* fs.readFileString(path.join(web, ".claude/skills/review/SKILL.md")),
+ ).toBe(skillFile("review"));
+ expect(
+ yield* fs.readFileString(path.join(web, ".agents/skills/review/SKILL.md")),
+ ).toBe(skillFile("theirs"));
+ }),
+ );
+ }),
+ );
+
+ it.effect.skipIf(!symlinksSupported)(
+ "leaves a skill that is in the shared folder already as it is",
+ () =>
+ Effect.gen(function* () {
+ const { home, web } = yield* makeMachine;
+ yield* withManager(home, [web], ({ manager, catalog }) =>
+ Effect.gen(function* () {
+ const verify = refOf(
+ (yield* catalog.list({ cwd: web })).skills,
+ "project",
+ "db-migrations",
+ );
+
+ const result = yield* manager.share({ cwd: web, skills: [verify] });
+
+ expect(result.outcomes[0]).toMatchObject({ status: "unchanged" });
+ }),
+ );
+ }),
+ );
+ });
+});
diff --git a/apps/server/src/skills/SkillPlacement.ts b/apps/server/src/skills/SkillPlacement.ts
new file mode 100644
index 000000000000..8c94ac03470c
--- /dev/null
+++ b/apps/server/src/skills/SkillPlacement.ts
@@ -0,0 +1,1027 @@
+/**
+ * SkillPlacement - where a skill lives and where it is used: in a project, in Global, or in
+ * Global but used only in some projects.
+ *
+ * | from \ to | project | global | only these projects |
+ * | ---------------- | -------------------- | ------------------- | ------------------------ |
+ * | a project's | move the folder | move the folder | into the library, link |
+ * | Global | move the folder | - | into the library, link |
+ * | in the library | move it, drop links | move it, drop links | add and remove links |
+ *
+ * Within one scope, `share` moves a real folder out of an agent's own folder (Claude's
+ * `.claude/skills`) into the shared one, and leaves a link where it was for the agents that don't
+ * read the shared folder.
+ *
+ * A skill used in only some projects is one folder in the library (`SkillLibrary`) with a link to
+ * it in each of those projects, so there is one copy to edit. A skill whose real folder is outside
+ * every agent folder, such as a synced library's, is never moved: its library entry is a link to it.
+ *
+ * Every transition re-reads the folders it works on, replaces nothing that is in the way
+ * (`destinationTaken`), and undoes the steps it has taken when a later one fails, so a failure
+ * leaves the skill where it was. What an agent used it through (its own link, its settings) goes
+ * with the skill: Codex's switch-off is keyed by the real SKILL.md, so it follows a moved folder
+ * (`PlacementView.followMove`). The skill's source record in the `skills` CLI's lock
+ * (`SkillLockFiles`) moves with it between a project and Global, or is dropped when the lock
+ * can't take it, which the result says (`sourceDropped`).
+ *
+ * The agents of a skill used in only some projects are switched through its project links: a link
+ * in each project's folder for an agent that doesn't read the shared one (`addLibraryLinks`,
+ * `removeLibraryLinks`). A link that goes is taken out of the project's git worktrees too.
+ *
+ * @module SkillPlacement
+ */
+import {
+ SkillOutcomeReason,
+ type ProviderInstanceId,
+ type SkillAgentState,
+ type SkillOutcome,
+ type SkillPlacement,
+ type SkillScope,
+} from "@t3tools/contracts";
+import * as Cause from "effect/Cause";
+import * as Effect from "effect/Effect";
+import * as Exit from "effect/Exit";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+import {
+ STANDARD_SKILL_FOLDER,
+ ownProjectFolderFor,
+ skillFoldersFor,
+} from "@t3tools/provider-core/server/AgentSkillFolders";
+
+import * as VcsProcess from "../vcs/VcsProcess.ts";
+import type * as SkillCatalog from "./SkillCatalog.ts";
+import { projectPrefixOf, updateExclude, worktreesOf } from "./SkillGitExclude.ts";
+import { LIBRARY_FOLDER, libraryLinksOf, linkLeadsTo, type LibraryLink } from "./SkillLibrary.ts";
+import { createLink, removeLink, type RemoveLinkResult } from "./SkillLinks.ts";
+import { moveRecord, type LockScope, type MoveRecordResult } from "./SkillLockFiles.ts";
+import { moveFolder } from "./SkillMove.ts";
+
+type Blocked = SkillOutcome["blocked"][number];
+
+/** What a placement did to one skill, before it is told to a client. */
+export interface PlacementChange {
+ /** A folder or link was made, moved or removed. */
+ readonly wrote: boolean;
+ /** Agents the change didn't reach. */
+ readonly blocked: readonly Blocked[];
+ /** Something about the skill as a whole kept the change from being complete. */
+ readonly reason?: SkillOutcomeReason | undefined;
+ /** Agents that gained or lost the skill without being asked. */
+ readonly affected?: readonly ProviderInstanceId[] | undefined;
+ /** Agents whose skill list changed; their `$` picker is refreshed. */
+ readonly touched?: readonly ProviderInstanceId[] | undefined;
+ /** The skill's source record couldn't go along with it, so the skill no longer has one. */
+ readonly sourceDropped?: boolean | undefined;
+}
+
+/** A step found the placement can't be done; what was done before it is undone. */
+class SkillPlacementRefused extends Schema.TaggedError()(
+ "SkillPlacementRefused",
+ { reason: SkillOutcomeReason },
+) {}
+
+const isRefused = Schema.is(SkillPlacementRefused);
+
+const skipped = (reason: SkillOutcomeReason): PlacementChange => ({
+ wrote: false,
+ blocked: [],
+ reason,
+});
+
+/** An agent that sees the skill, whether or not its own settings have it switched off. */
+const sees = (state: SkillAgentState) => state !== "none";
+
+/** What the placement is about: where the list was read, and everything looked up for it. */
+export interface PlacementView {
+ /** The project the list was read for. */
+ readonly cwd: string | undefined;
+ readonly all: ReadonlyArray;
+ /**
+ * Called once the skill's real folder has moved to `home`, so the agents' own settings that name
+ * the old place can name the new one. Agents it couldn't carry over are returned.
+ */
+ readonly followMove?: (home: string) => Effect.Effect;
+}
+
+/** A skill kept in the library, whose registered projects' links the catalog found. */
+export type LibrarySkill = SkillCatalog.ResolvedSkill & {
+ readonly library: NonNullable;
+};
+
+/** The projects a library skill is used in: where the shared folder has its link. */
+export const projectsOfLibrarySkill = (skill: LibrarySkill) => [
+ ...new Set(
+ skill.library.links
+ .filter((link) => link.folder === STANDARD_SKILL_FOLDER)
+ .map((link) => link.project),
+ ),
+];
+
+export interface PlacementDeps {
+ readonly catalog: SkillCatalog.SkillCatalog["Service"];
+ readonly platform: NodeJS.Platform;
+ readonly environment: NodeJS.ProcessEnv;
+ readonly home: string;
+ /** The registered projects' workspace roots. */
+ readonly registeredRoots: Effect.Effect>;
+ /** Gives the agents a link, by the rules of turning a skill on. */
+ readonly enable: (
+ skill: SkillCatalog.ResolvedSkill,
+ agents: ReadonlySet