From cc39c0514d110644d1f045cea409e3693b58c219 Mon Sep 17 00:00:00 2001 From: boggedbrush <90526147+boggedbrush@users.noreply.github.com> Date: Sun, 19 Apr 2026 13:32:50 -0400 Subject: [PATCH 1/4] feat: add init slash command --- apps/server/src/server.test.ts | 53 +++++++ .../Layers/WorkspaceFileSystem.test.ts | 55 ++++++- .../workspace/Layers/WorkspaceFileSystem.ts | 45 +++++- .../workspace/Services/WorkspaceFileSystem.ts | 20 ++- apps/server/src/ws.ts | 17 ++ apps/web/src/components/ChatView.tsx | 146 +++++++++++++++++- .../components/chat/ComposerCommandMenu.tsx | 1 + apps/web/src/composer-logic.test.ts | 4 + apps/web/src/composer-logic.ts | 7 +- apps/web/src/initCommand.test.ts | 52 +++++++ apps/web/src/initCommand.ts | 44 ++++++ apps/web/src/wsNativeApi.test.ts | 1 + apps/web/src/wsNativeApi.ts | 1 + apps/web/src/wsRpcClient.ts | 3 + packages/contracts/src/ipc.ts | 3 + packages/contracts/src/project.ts | 24 +++ packages/contracts/src/rpc.ts | 11 ++ 17 files changed, 477 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/initCommand.test.ts create mode 100644 apps/web/src/initCommand.ts diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 410868b9..c5f1e968 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1927,6 +1927,59 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes websocket rpc projects.createSymlink", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-link-" }); + + yield* fs.writeFileString(path.join(workspaceDir, "AGENTS.md"), "# Repo\n"); + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsCreateSymlink]({ + cwd: workspaceDir, + targetRelativePath: "AGENTS.md", + linkRelativePath: "CLAUDE.md", + }), + ), + ); + + assert.equal(response.relativePath, "CLAUDE.md"); + const linked = yield* fs.readFileString(path.join(workspaceDir, "CLAUDE.md")); + assert.equal(linked, "# Repo\n"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("routes websocket rpc projects.createSymlink errors", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-link-" }); + + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsCreateSymlink]({ + cwd: workspaceDir, + targetRelativePath: "AGENTS.md", + linkRelativePath: "../CLAUDE.md", + }), + ).pipe(Effect.result), + ); + + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "ProjectCreateSymlinkError"); + assert.equal( + result.failure.message, + "Workspace symlink path must stay within the project root.", + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc shell.openInEditor", () => Effect.gen(function* () { let openedInput: { cwd: string; editor: EditorId } | null = null; diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts index fcfd13c9..2789559c 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts @@ -1,3 +1,5 @@ +import fsPromises from "node:fs/promises"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, describe, expect } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path } from "effect"; @@ -110,8 +112,8 @@ it.layer(TestLayer)("WorkspaceFileSystemLive", (it) => { Effect.gen(function* () { const workspaceFileSystem = yield* WorkspaceFileSystem; const cwd = yield* makeTempDir; - const path = yield* Path.Path; const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const error = yield* workspaceFileSystem .writeFile({ @@ -133,4 +135,55 @@ it.layer(TestLayer)("WorkspaceFileSystemLive", (it) => { }), ); }); + + describe("createSymlink", () => { + it.effect("creates symlinks relative to the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + yield* workspaceFileSystem.writeFile({ + cwd, + relativePath: "AGENTS.md", + contents: "# Repository Guidelines\n", + }); + const result = yield* workspaceFileSystem.createSymlink({ + cwd, + targetRelativePath: "AGENTS.md", + linkRelativePath: "CLAUDE.md", + }); + + const linkPath = path.join(cwd, "CLAUDE.md"); + const target = yield* Effect.promise(() => fsPromises.readlink(linkPath)).pipe( + Effect.orDie, + ); + const linkedContents = yield* fileSystem.readFileString(linkPath).pipe(Effect.orDie); + + expect(result).toEqual({ relativePath: "CLAUDE.md" }); + expect(target).toBe("AGENTS.md"); + expect(linkedContents).toBe("# Repository Guidelines\n"); + }), + ); + + it.effect("rejects symlinks outside the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + + const error = yield* workspaceFileSystem + .createSymlink({ + cwd, + targetRelativePath: "AGENTS.md", + linkRelativePath: "../CLAUDE.md", + }) + .pipe(Effect.flip); + + expect(error.message).toContain( + "Workspace file path must be relative to the project root: ../CLAUDE.md", + ); + }), + ); + }); }); diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts index 84e5d9c6..18210b3b 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts @@ -1,3 +1,5 @@ +import fsPromises from "node:fs/promises"; + import { Effect, FileSystem, Layer, Path } from "effect"; import { @@ -49,7 +51,48 @@ export const makeWorkspaceFileSystem = Effect.gen(function* () { yield* workspaceEntries.invalidate(input.cwd); return { relativePath: target.relativePath }; }); - return { writeFile } satisfies WorkspaceFileSystemShape; + const createSymlink: WorkspaceFileSystemShape["createSymlink"] = Effect.fn( + "WorkspaceFileSystem.createSymlink", + )(function* (input) { + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.targetRelativePath, + }); + const link = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.linkRelativePath, + }); + + yield* fileSystem.makeDirectory(path.dirname(link.absolutePath), { recursive: true }).pipe( + Effect.mapError( + (cause) => + new WorkspaceFileSystemError({ + cwd: input.cwd, + relativePath: input.linkRelativePath, + operation: "workspaceFileSystem.makeDirectory", + detail: cause.message, + cause, + }), + ), + ); + const relativeTargetPath = path.relative(path.dirname(link.absolutePath), target.absolutePath); + yield* Effect.tryPromise({ + try: async () => { + await fsPromises.symlink(relativeTargetPath, link.absolutePath); + }, + catch: (cause) => + new WorkspaceFileSystemError({ + cwd: input.cwd, + relativePath: input.linkRelativePath, + operation: "workspaceFileSystem.createSymlink", + detail: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }); + yield* workspaceEntries.invalidate(input.cwd); + return { relativePath: link.relativePath }; + }); + return { writeFile, createSymlink } satisfies WorkspaceFileSystemShape; }); export const WorkspaceFileSystemLive = Layer.effect(WorkspaceFileSystem, makeWorkspaceFileSystem); diff --git a/apps/server/src/workspace/Services/WorkspaceFileSystem.ts b/apps/server/src/workspace/Services/WorkspaceFileSystem.ts index 85db1514..80b5a614 100644 --- a/apps/server/src/workspace/Services/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/Services/WorkspaceFileSystem.ts @@ -9,7 +9,12 @@ import { Schema, ServiceMap } from "effect"; import type { Effect } from "effect"; -import type { ProjectWriteFileInput, ProjectWriteFileResult } from "@t3tools/contracts"; +import type { + ProjectCreateSymlinkInput, + ProjectCreateSymlinkResult, + ProjectWriteFileInput, + ProjectWriteFileResult, +} from "@t3tools/contracts"; import { WorkspacePathOutsideRootError } from "./WorkspacePaths.ts"; export class WorkspaceFileSystemError extends Schema.TaggedErrorClass()( @@ -39,6 +44,19 @@ export interface WorkspaceFileSystemShape { ProjectWriteFileResult, WorkspaceFileSystemError | WorkspacePathOutsideRootError >; + + /** + * Create a symlink relative to the workspace root. + * + * Creates parent directories as needed and rejects paths that escape the + * workspace root. + */ + readonly createSymlink: ( + input: ProjectCreateSymlinkInput, + ) => Effect.Effect< + ProjectCreateSymlinkResult, + WorkspaceFileSystemError | WorkspacePathOutsideRootError + >; } /** diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 25a65f72..a283aab6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -12,6 +12,7 @@ import { OrchestrationGetSnapshotError, OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, + ProjectCreateSymlinkError, ProjectSearchEntriesError, ProjectWriteFileError, OrchestrationReplayEventsError, @@ -680,6 +681,22 @@ const makeWsRpcLayer = () => ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.projectsCreateSymlink]: (input) => + observeRpcEffect( + WS_METHODS.projectsCreateSymlink, + workspaceFileSystem.createSymlink(input).pipe( + Effect.mapError((cause) => { + const message = Schema.is(WorkspacePathOutsideRootError)(cause) + ? "Workspace symlink path must stay within the project root." + : "Failed to create workspace symlink"; + return new ProjectCreateSymlinkError({ + message, + cause, + }); + }), + ), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.shellOpenInEditor]: (input) => observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), { "rpc.aggregate": "workspace", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2c03cdd6..959128bf 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -46,6 +46,12 @@ import { useGitStatus } from "~/lib/gitStatusState"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { isElectron } from "../env"; import { usesCustomDesktopTitlebar } from "../lib/utils"; +import { + countAvailableHarnesses, + getCompanionGuideFile, + getGuideFileForProvider, + INIT_COMMAND_TEMPLATE, +} from "../initCommand"; import { DesktopWindowControls } from "./DesktopTitleBar"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { @@ -1812,6 +1818,13 @@ export default function ChatView({ threadId }: ChatViewProps) { label: "/usage", description: "Toggle active provider usage panel", }, + { + id: "slash:init", + type: "slash-command", + command: "init", + label: "/init", + description: `Create ${getGuideFileForProvider(selectedProvider)} for this harness`, + }, ] satisfies ReadonlyArray>; const query = composerTrigger.query.trim().toLowerCase(); if (!query) { @@ -1848,6 +1861,7 @@ export default function ChatView({ threadId }: ChatViewProps) { composerTrigger, filterPresetCommandItems, searchableModelOptions, + selectedProvider, workspaceEntries, ]); const composerMenuOpen = Boolean(composerTrigger); @@ -2433,15 +2447,137 @@ export default function ChatView({ threadId }: ChatViewProps) { threadId, ], ); + const doesWorkspaceEntryExist = useCallback(async (cwd: string, relativePath: string) => { + const api = readNativeApi(); + if (!api) { + return false; + } + const result = await api.projects.searchEntries({ + cwd, + query: relativePath, + limit: 20, + }); + return result.entries.some((entry) => entry.path === relativePath); + }, []); + const handleInitSlashCommand = useCallback(async () => { + const api = readNativeApi(); + if (!api) { + return; + } + if (!activeWorkspaceRoot) { + toastManager.add({ + type: "error", + title: "Could not initialize harness guide", + description: "Open a project or worktree first so Kodo knows where to create the file.", + }); + return; + } + + const primaryFile = getGuideFileForProvider(selectedProvider); + const companionFile = getCompanionGuideFile(primaryFile); + const multiHarnessAvailable = countAvailableHarnesses(providerStatuses) > 1; + + try { + let [primaryExists, companionExists] = await Promise.all([ + doesWorkspaceEntryExist(activeWorkspaceRoot, primaryFile), + doesWorkspaceEntryExist(activeWorkspaceRoot, companionFile), + ]); + + if (!primaryExists && multiHarnessAvailable && companionExists) { + const shouldSymlinkPrimary = await api.dialogs.confirm( + [ + `${companionFile} already exists in this workspace.`, + `Create ${primaryFile} as a symlink to ${companionFile} instead of creating a second guide file?`, + ].join("\n"), + ); + if (shouldSymlinkPrimary) { + await api.projects.createSymlink({ + cwd: activeWorkspaceRoot, + targetRelativePath: companionFile, + linkRelativePath: primaryFile, + }); + toastManager.add({ + type: "success", + title: `Created ${primaryFile} symlink`, + description: `Linked ${primaryFile} to the existing ${companionFile}.`, + }); + return; + } + } + + let createdPrimary = false; + if (!primaryExists) { + await api.projects.writeFile({ + cwd: activeWorkspaceRoot, + relativePath: primaryFile, + contents: INIT_COMMAND_TEMPLATE, + }); + primaryExists = true; + createdPrimary = true; + } + + if (multiHarnessAvailable && primaryExists && !companionExists) { + const shouldCreateCompanionSymlink = await api.dialogs.confirm( + [ + `Create ${companionFile} as a symlink to ${primaryFile}?`, + "This keeps both harness guide filenames in sync with a single shared file.", + ].join("\n"), + ); + if (shouldCreateCompanionSymlink) { + await api.projects.createSymlink({ + cwd: activeWorkspaceRoot, + targetRelativePath: primaryFile, + linkRelativePath: companionFile, + }); + toastManager.add({ + type: "success", + title: createdPrimary ? `Created ${primaryFile}` : `Created ${companionFile} symlink`, + description: createdPrimary + ? `Also linked ${companionFile} to the same guide.` + : `Linked ${companionFile} to the existing ${primaryFile}.`, + }); + return; + } + } + + if (createdPrimary) { + toastManager.add({ + type: "success", + title: `Created ${primaryFile}`, + description: "Update the placeholder sections with repository-specific guidance.", + }); + return; + } + + toastManager.add({ + type: "info", + title: `${primaryFile} already exists`, + description: + multiHarnessAvailable && companionExists + ? `${companionFile} is already present too.` + : undefined, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Could not initialize harness guide", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + }, [activeWorkspaceRoot, doesWorkspaceEntryExist, providerStatuses, selectedProvider]); const handleStandaloneSlashCommand = useCallback( - (command: ComposerStandaloneSlashCommand) => { + async (command: ComposerStandaloneSlashCommand) => { if (command === "usage") { setProviderUsagePanelOpen((open) => !open); return; } - void handleInteractionModeChange(command); + if (command === "init") { + await handleInitSlashCommand(); + return; + } + handleInteractionModeChange(command); }, - [handleInteractionModeChange], + [handleInitSlashCommand, handleInteractionModeChange], ); const applyPresetSelectionToComposer = useCallback( (provider: ProviderKind, presetId: string | null) => { @@ -3552,7 +3688,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ? parseStandaloneComposerSlashCommand(promptRef.current.trim()) : null; if (standaloneSlashCommand) { - handleStandaloneSlashCommand(standaloneSlashCommand); + await handleStandaloneSlashCommand(standaloneSlashCommand); promptRef.current = ""; clearComposerDraftContent(activeThread.id); setComposerHighlightedItemId(null); @@ -4775,7 +4911,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } return; } - handleStandaloneSlashCommand(item.command); + void handleStandaloneSlashCommand(item.command); const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), }); diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 3fa97a19..35da1235 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -118,6 +118,7 @@ const SLASH_COMMAND_ICON_BY_COMMAND: Record = code: TerminalSquareIcon, review: WrenchIcon, usage: EyeIcon, + init: CirclePlusIcon, }; const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index b2a3d548..c0f0af30 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -265,6 +265,10 @@ describe("parseStandaloneComposerSlashCommand", () => { expect(parseStandaloneComposerSlashCommand("/usage")).toBe("usage"); }); + it("parses standalone /init command", () => { + expect(parseStandaloneComposerSlashCommand("/init")).toBe("init"); + }); + it("ignores slash commands with extra message text", () => { expect(parseStandaloneComposerSlashCommand("/plan explain this")).toBeNull(); }); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 71d7077c..173950d4 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -9,7 +9,8 @@ export type ComposerSlashCommand = | "plan" | "code" | "review" - | "usage"; + | "usage" + | "init"; export type ComposerStandaloneSlashCommand = Exclude; export interface ComposerTrigger { @@ -27,6 +28,7 @@ const SLASH_COMMANDS: readonly ComposerSlashCommand[] = [ "code", "review", "usage", + "init", ]; const isInlineTokenSegment = ( segment: { type: "text"; text: string } | { type: "mention" } | { type: "terminal-context" }, @@ -274,7 +276,7 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos export function parseStandaloneComposerSlashCommand( text: string, ): ComposerStandaloneSlashCommand | null { - const match = /^\/(ask|plan|code|review|usage)\s*$/i.exec(text.trim()); + const match = /^\/(ask|plan|code|review|usage|init)\s*$/i.exec(text.trim()); if (!match) { return null; } @@ -283,6 +285,7 @@ export function parseStandaloneComposerSlashCommand( if (command === "plan") return "plan"; if (command === "code") return "code"; if (command === "review") return "review"; + if (command === "init") return "init"; return "usage"; } diff --git a/apps/web/src/initCommand.test.ts b/apps/web/src/initCommand.test.ts new file mode 100644 index 00000000..7a3526b9 --- /dev/null +++ b/apps/web/src/initCommand.test.ts @@ -0,0 +1,52 @@ +import type { ServerProvider } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { + countAvailableHarnesses, + getCompanionGuideFile, + getGuideFileForProvider, + INIT_COMMAND_TEMPLATE, +} from "./initCommand"; + +function makeProvider( + overrides: Partial & Pick, +): ServerProvider { + return { + provider: overrides.provider, + enabled: overrides.enabled ?? true, + installed: overrides.installed ?? true, + version: overrides.version ?? null, + status: overrides.status ?? "ready", + auth: overrides.auth ?? { status: "authenticated" }, + checkedAt: overrides.checkedAt ?? "2026-04-19T00:00:00.000Z", + message: overrides.message, + models: overrides.models ?? [], + }; +} + +describe("initCommand", () => { + it("maps providers to the expected guide file", () => { + expect(getGuideFileForProvider("codex")).toBe("AGENTS.md"); + expect(getGuideFileForProvider("claudeAgent")).toBe("CLAUDE.md"); + }); + + it("returns the opposite guide file as the companion", () => { + expect(getCompanionGuideFile("AGENTS.md")).toBe("CLAUDE.md"); + expect(getCompanionGuideFile("CLAUDE.md")).toBe("AGENTS.md"); + }); + + it("counts only enabled and installed harnesses", () => { + expect( + countAvailableHarnesses([ + makeProvider({ provider: "codex", enabled: true, installed: true }), + makeProvider({ provider: "claudeAgent", enabled: true, installed: false }), + ]), + ).toBe(1); + }); + + it("ships a starter template with the expected sections", () => { + expect(INIT_COMMAND_TEMPLATE).toContain("# Repository Guidelines"); + expect(INIT_COMMAND_TEMPLATE).toContain("## Build, Test, and Development Commands"); + expect(INIT_COMMAND_TEMPLATE).toContain("## Operational Notes"); + }); +}); diff --git a/apps/web/src/initCommand.ts b/apps/web/src/initCommand.ts new file mode 100644 index 00000000..d50e5849 --- /dev/null +++ b/apps/web/src/initCommand.ts @@ -0,0 +1,44 @@ +import type { ProviderKind, ServerProvider } from "@t3tools/contracts"; + +export type HarnessGuideFile = "AGENTS.md" | "CLAUDE.md"; + +export const INIT_COMMAND_TEMPLATE = `# Repository Guidelines + +Replace the placeholder notes below with instructions that are specific to this repository. + +## Project Structure & Module Organization +- Describe where the app, packages, tests, and assets live. +- Call out important entrypoints, generated files, and directories to avoid editing directly. + +## Build, Test, and Development Commands +- List the main install, dev, build, lint, format, and typecheck commands. +- Note any commands that must not be run automatically or that require extra setup. + +## Coding Style & Naming Conventions +- Document formatter and linter expectations, naming patterns, and architecture boundaries. +- Mention language-specific preferences that should stay consistent across the repo. + +## Testing Guidelines +- Explain the primary test runners, smoke checks, and required verification before merge. +- Include test naming conventions and when to run each suite. + +## Commit & Pull Request Guidelines +- Capture commit message format, review expectations, and any PR checklist items. +- Note when screenshots, logs, or deployment notes should be included. + +## Operational Notes +- Record environment setup, secrets handling, sandbox caveats, and agent-specific guardrails. +- Add any repository-specific constraints that should be followed during implementation. +`; + +export function getGuideFileForProvider(provider: ProviderKind): HarnessGuideFile { + return provider === "claudeAgent" ? "CLAUDE.md" : "AGENTS.md"; +} + +export function getCompanionGuideFile(file: HarnessGuideFile): HarnessGuideFile { + return file === "AGENTS.md" ? "CLAUDE.md" : "AGENTS.md"; +} + +export function countAvailableHarnesses(providers: ReadonlyArray): number { + return providers.filter((provider) => provider.enabled && provider.installed).length; +} diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 7c082f1f..c2479623 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -58,6 +58,7 @@ const { projects: { searchEntries: vi.fn(), writeFile: vi.fn(), + createSymlink: vi.fn(), }, shell: { openInEditor: vi.fn(), diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 0737f71e..ede6ac1a 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -54,6 +54,7 @@ export function createWsNativeApi(): NativeApi { projects: { searchEntries: rpcClient.projects.searchEntries, writeFile: rpcClient.projects.writeFile, + createSymlink: rpcClient.projects.createSymlink, }, shell: { openInEditor: (cwd, editor) => rpcClient.shell.openInEditor({ cwd, editor }), diff --git a/apps/web/src/wsRpcClient.ts b/apps/web/src/wsRpcClient.ts index d1933ef4..aa6ffd44 100644 --- a/apps/web/src/wsRpcClient.ts +++ b/apps/web/src/wsRpcClient.ts @@ -59,6 +59,7 @@ export interface WsRpcClient { readonly projects: { readonly searchEntries: RpcUnaryMethod; readonly writeFile: RpcUnaryMethod; + readonly createSymlink: RpcUnaryMethod; }; readonly shell: { readonly openInEditor: (input: { @@ -155,6 +156,8 @@ export function createWsRpcClient(transport = new WsTransport()): WsRpcClient { transport.request((client) => client[WS_METHODS.projectsSearchEntries](input)), writeFile: (input) => transport.request((client) => client[WS_METHODS.projectsWriteFile](input)), + createSymlink: (input) => + transport.request((client) => client[WS_METHODS.projectsCreateSymlink](input)), }, shell: { openInEditor: (input) => diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 914817a0..99b02f50 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -19,6 +19,8 @@ import type { GitCreateBranchResult, } from "./git"; import type { + ProjectCreateSymlinkInput, + ProjectCreateSymlinkResult, ProjectSearchEntriesInput, ProjectSearchEntriesResult, ProjectWriteFileInput, @@ -158,6 +160,7 @@ export interface NativeApi { projects: { searchEntries: (input: ProjectSearchEntriesInput) => Promise; writeFile: (input: ProjectWriteFileInput) => Promise; + createSymlink: (input: ProjectCreateSymlinkInput) => Promise; }; shell: { openInEditor: (cwd: string, editor: EditorId) => Promise; diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 2851120d..eb1a9e44 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -53,3 +53,27 @@ export class ProjectWriteFileError extends Schema.TaggedErrorClass()( + "ProjectCreateSymlinkError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 65bcc7ce..a33a0e8f 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -46,6 +46,9 @@ import { OrchestrationRpcSchemas, } from "./orchestration"; import { + ProjectCreateSymlinkError, + ProjectCreateSymlinkInput, + ProjectCreateSymlinkResult, ProjectSearchEntriesError, ProjectSearchEntriesInput, ProjectSearchEntriesResult, @@ -86,6 +89,7 @@ export const WS_METHODS = { projectsRemove: "projects.remove", projectsSearchEntries: "projects.searchEntries", projectsWriteFile: "projects.writeFile", + projectsCreateSymlink: "projects.createSymlink", // Shell methods shellOpenInEditor: "shell.openInEditor", @@ -186,6 +190,12 @@ export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { error: ProjectWriteFileError, }); +export const WsProjectsCreateSymlinkRpc = Rpc.make(WS_METHODS.projectsCreateSymlink, { + payload: ProjectCreateSymlinkInput, + success: ProjectCreateSymlinkResult, + error: ProjectCreateSymlinkError, +}); + export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { payload: OpenInEditorInput, error: OpenError, @@ -376,6 +386,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerEnhancePromptRpc, WsProjectsSearchEntriesRpc, WsProjectsWriteFileRpc, + WsProjectsCreateSymlinkRpc, WsShellOpenInEditorRpc, WsSubscribeGitStatusRpc, WsGitPullRpc, From 3a599626c21a97e4de1998f2e6467d04cf62aa7d Mon Sep 17 00:00:00 2001 From: boggedbrush <90526147+boggedbrush@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:39:46 -0400 Subject: [PATCH 2/4] fix(git): index symlink workspace entries --- .../src/workspace/Layers/WorkspaceEntries.test.ts | 15 +++++++++++++++ .../src/workspace/Layers/WorkspaceEntries.ts | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts index 960cb69b..2a7c6a57 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts @@ -198,6 +198,21 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { }), ); + it.effect("indexes symlinks in non-git workspaces", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-non-git-symlink-" }); + const path = yield* Path.Path; + yield* writeTextFile(cwd, "AGENTS.md", "# Repository Guidelines\n"); + yield* Effect.promise(() => fsPromises.symlink("AGENTS.md", path.join(cwd, "CLAUDE.md"))); + + const result = yield* searchWorkspaceEntries({ cwd, query: "CLAUDE.md", limit: 10 }); + + expect(result.entries).toEqual( + expect.arrayContaining([expect.objectContaining({ kind: "file", path: "CLAUDE.md" })]), + ); + }), + ); + it.effect("deduplicates concurrent index builds for the same cwd", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-concurrent-build-" }); diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.ts index 12af8601..abd0f08b 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.ts @@ -362,7 +362,7 @@ export const makeWorkspaceEntries = Effect.gen(function* () { if (dirent.isDirectory() && IGNORED_DIRECTORY_NAMES.has(dirent.name)) { continue; } - if (!dirent.isDirectory() && !dirent.isFile()) { + if (!dirent.isDirectory() && !dirent.isFile() && !dirent.isSymbolicLink()) { continue; } From 68ea6e76a42e02bd2ee6763ffc81bdbc7d3b79c4 Mon Sep 17 00:00:00 2001 From: boggedbrush <90526147+boggedbrush@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:58:26 -0400 Subject: [PATCH 3/4] fix(ui): use app dialog for init symlink --- apps/web/src/components/ChatView.tsx | 106 +++++++++++++++++++++++---- 1 file changed, 93 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 959128bf..9ad796ca 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -133,6 +133,14 @@ import { XIcon, } from "lucide-react"; import { Button } from "./ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; import { Separator } from "./ui/separator"; import { cn, randomUUID } from "~/lib/utils"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; @@ -256,6 +264,14 @@ const LazyComposerPromptEditor = lazy(() => import("./ComposerPromptEditor").then((module) => ({ default: module.ComposerPromptEditor })), ); +interface InitGuideConfirmState { + title: string; + description: string; + confirmLabel: string; + cancelLabel: string; + resolve: (confirmed: boolean) => void; +} + const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; const IMAGE_ONLY_BOOTSTRAP_PROMPT = @@ -781,6 +797,10 @@ export default function ChatView({ threadId }: ChatViewProps) { const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); + const [initGuideConfirmState, setInitGuideConfirmState] = useState( + null, + ); + const initGuideConfirmStateRef = useRef(null); const [reviewSetupDraft, setReviewSetupDraft] = useState(() => ({ targetKind: "uncommitted", targetRef: "", @@ -2459,6 +2479,40 @@ export default function ChatView({ threadId }: ChatViewProps) { }); return result.entries.some((entry) => entry.path === relativePath); }, []); + const settleInitGuideConfirm = useCallback((confirmed: boolean) => { + const pending = initGuideConfirmStateRef.current; + if (!pending) { + return; + } + initGuideConfirmStateRef.current = null; + setInitGuideConfirmState(null); + pending.resolve(confirmed); + }, []); + const confirmInitGuideSymlink = useCallback( + (input: { title: string; description: string; confirmLabel?: string; cancelLabel?: string }) => + new Promise((resolve) => { + if (initGuideConfirmStateRef.current) { + initGuideConfirmStateRef.current.resolve(false); + } + const nextState: InitGuideConfirmState = { + title: input.title, + description: input.description, + confirmLabel: input.confirmLabel ?? "Create symlink", + cancelLabel: input.cancelLabel ?? "Create separate file", + resolve, + }; + initGuideConfirmStateRef.current = nextState; + setInitGuideConfirmState(nextState); + }), + [], + ); + useEffect( + () => () => { + initGuideConfirmStateRef.current?.resolve(false); + initGuideConfirmStateRef.current = null; + }, + [], + ); const handleInitSlashCommand = useCallback(async () => { const api = readNativeApi(); if (!api) { @@ -2484,12 +2538,10 @@ export default function ChatView({ threadId }: ChatViewProps) { ]); if (!primaryExists && multiHarnessAvailable && companionExists) { - const shouldSymlinkPrimary = await api.dialogs.confirm( - [ - `${companionFile} already exists in this workspace.`, - `Create ${primaryFile} as a symlink to ${companionFile} instead of creating a second guide file?`, - ].join("\n"), - ); + const shouldSymlinkPrimary = await confirmInitGuideSymlink({ + title: `${companionFile} already exists`, + description: `Create ${primaryFile} as a symlink to ${companionFile} instead of creating a second guide file?`, + }); if (shouldSymlinkPrimary) { await api.projects.createSymlink({ cwd: activeWorkspaceRoot, @@ -2517,12 +2569,11 @@ export default function ChatView({ threadId }: ChatViewProps) { } if (multiHarnessAvailable && primaryExists && !companionExists) { - const shouldCreateCompanionSymlink = await api.dialogs.confirm( - [ - `Create ${companionFile} as a symlink to ${primaryFile}?`, - "This keeps both harness guide filenames in sync with a single shared file.", - ].join("\n"), - ); + const shouldCreateCompanionSymlink = await confirmInitGuideSymlink({ + title: `Create ${companionFile} as a symlink?`, + description: `This keeps ${primaryFile} and ${companionFile} in sync with a single shared file.`, + cancelLabel: "Skip", + }); if (shouldCreateCompanionSymlink) { await api.projects.createSymlink({ cwd: activeWorkspaceRoot, @@ -2564,7 +2615,13 @@ export default function ChatView({ threadId }: ChatViewProps) { description: error instanceof Error ? error.message : "An error occurred.", }); } - }, [activeWorkspaceRoot, doesWorkspaceEntryExist, providerStatuses, selectedProvider]); + }, [ + activeWorkspaceRoot, + confirmInitGuideSymlink, + doesWorkspaceEntryExist, + providerStatuses, + selectedProvider, + ]); const handleStandaloneSlashCommand = useCallback( async (command: ComposerStandaloneSlashCommand) => { if (command === "usage") { @@ -5685,6 +5742,29 @@ export default function ChatView({ threadId }: ChatViewProps) { onPrepared={handlePreparedPullRequestThread} /> ) : null} + { + if (!open) { + settleInitGuideConfirm(false); + } + }} + > + + + {initGuideConfirmState?.title ?? "Create guide symlink?"} + {initGuideConfirmState?.description} + + + + + + + {isReviewSetupDialogOpen ? ( Date: Wed, 29 Apr 2026 15:45:12 -0400 Subject: [PATCH 4/4] fix(ui): refine init guide flow --- apps/web/src/components/ChatView.tsx | 206 +++++++++++++++++++++++++- apps/web/src/components/ui/dialog.tsx | 7 +- apps/web/src/initCommand.test.ts | 29 +++- apps/web/src/initCommand.ts | 63 ++++++-- 4 files changed, 287 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9ad796ca..44ed3365 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -47,10 +47,12 @@ import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { isElectron } from "../env"; import { usesCustomDesktopTitlebar } from "../lib/utils"; import { - countAvailableHarnesses, + buildInitCommandPrompt, getCompanionGuideFile, getGuideFileForProvider, + type HarnessGuideFile, INIT_COMMAND_TEMPLATE, + isClaudeHarnessEnabled, } from "../initCommand"; import { DesktopWindowControls } from "./DesktopTitleBar"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; @@ -2513,6 +2515,178 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [], ); + const submitInitGuidePrompt = useCallback( + async (file: HarnessGuideFile) => { + const api = readNativeApi(); + if ( + !api || + !activeThread || + !activeProject || + isSendBusy || + isConnecting || + sendInFlightRef.current + ) { + return false; + } + + const threadIdForSend = activeThread.id; + const initEnvMode: DraftThreadEnvMode = activeThread.worktreePath + ? "worktree" + : isLocalDraftThread + ? (draftThread?.envMode ?? "local") + : "local"; + const isFirstMessage = !isServerThread || activeThread.messages.length === 0; + const baseBranchForWorktree = + isFirstMessage && initEnvMode === "worktree" && !activeThread.worktreePath + ? activeThread.branch + : null; + const shouldCreateWorktree = + isFirstMessage && initEnvMode === "worktree" && !activeThread.worktreePath; + if (shouldCreateWorktree && !activeThread.branch) { + setStoreThreadError( + threadIdForSend, + "Select a base branch before sending in New worktree mode.", + ); + return false; + } + + sendInFlightRef.current = true; + beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); + + const promptText = buildInitCommandPrompt(file); + const outgoingMessageText = formatOutgoingPrompt({ + provider: selectedProvider, + model: selectedModel, + models: selectedProviderModels, + effort: selectedPromptEffort, + text: promptText, + }); + const messageIdForSend = newMessageId(); + const messageCreatedAt = new Date().toISOString(); + const title = truncate(`Initialize ${file}`); + const normalizedSelectedModelSelection = + normalizeModelSelectionForDispatch(selectedModelSelection); + const threadCreateModelSelection: ModelSelection = { + provider: selectedProvider, + model: normalizeModelForDispatch( + selectedModel || + activeProject.defaultModelSelection?.model || + DEFAULT_MODEL_BY_PROVIDER.codex, + ), + ...(selectedModelSelection.options ? { options: selectedModelSelection.options } : {}), + }; + + setOptimisticUserMessages((existing) => [ + ...existing, + { + id: messageIdForSend, + role: "user", + text: outgoingMessageText, + createdAt: messageCreatedAt, + streaming: false, + }, + ]); + shouldAutoScrollRef.current = true; + setThreadError(threadIdForSend, null); + + let turnStartSucceeded = false; + try { + if (isFirstMessage && isServerThread) { + await api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: threadIdForSend, + title, + }); + } + + const bootstrap = + isLocalDraftThread || baseBranchForWorktree + ? { + ...(isLocalDraftThread + ? { + createThread: { + projectId: activeProject.id, + title, + modelSelection: threadCreateModelSelection, + runtimeMode, + interactionMode: "code" as const, + branch: activeThread.branch, + worktreePath: activeThread.worktreePath, + createdAt: activeThread.createdAt, + }, + } + : {}), + ...(baseBranchForWorktree + ? { + prepareWorktree: { + projectCwd: activeProject.cwd, + baseBranch: baseBranchForWorktree, + branch: buildTemporaryWorktreeBranchName(), + }, + runSetupScript: true, + } + : {}), + } + : undefined; + beginLocalDispatch({ preparingWorktree: false }); + await api.orchestration.dispatchCommand({ + type: "thread.turn.start", + commandId: newCommandId(), + threadId: threadIdForSend, + message: { + messageId: messageIdForSend, + role: "user", + text: outgoingMessageText, + attachments: [], + }, + modelSelection: normalizedSelectedModelSelection, + titleSeed: title, + runtimeMode, + interactionMode: "code", + ...(bootstrap ? { bootstrap } : {}), + createdAt: messageCreatedAt, + }); + turnStartSucceeded = true; + return true; + } catch (err) { + if (!turnStartSucceeded) { + setOptimisticUserMessages((existing) => + existing.filter((message) => message.id !== messageIdForSend), + ); + } + setThreadError( + threadIdForSend, + err instanceof Error ? err.message : "Failed to send /init prompt.", + ); + return false; + } finally { + sendInFlightRef.current = false; + if (!turnStartSucceeded) { + resetLocalDispatch(); + } + } + }, + [ + activeProject, + activeThread, + beginLocalDispatch, + draftThread?.envMode, + isConnecting, + isLocalDraftThread, + isSendBusy, + isServerThread, + resetLocalDispatch, + runtimeMode, + selectedModel, + selectedModelSelection, + selectedPromptEffort, + selectedProvider, + selectedProviderModels, + setStoreThreadError, + setThreadError, + ], + ); const handleInitSlashCommand = useCallback(async () => { const api = readNativeApi(); if (!api) { @@ -2529,7 +2703,18 @@ export default function ChatView({ threadId }: ChatViewProps) { const primaryFile = getGuideFileForProvider(selectedProvider); const companionFile = getCompanionGuideFile(primaryFile); - const multiHarnessAvailable = countAvailableHarnesses(providerStatuses) > 1; + const shouldOfferGuideSymlink = isClaudeHarnessEnabled(providerStatuses); + const submitGuidePrompt = async () => { + const submitted = await submitInitGuidePrompt(primaryFile); + if (!submitted) { + toastManager.add({ + type: "warning", + title: `Created ${primaryFile}`, + description: + "Kodo could not start the guide editing turn. Try /init again after this turn.", + }); + } + }; try { let [primaryExists, companionExists] = await Promise.all([ @@ -2537,7 +2722,7 @@ export default function ChatView({ threadId }: ChatViewProps) { doesWorkspaceEntryExist(activeWorkspaceRoot, companionFile), ]); - if (!primaryExists && multiHarnessAvailable && companionExists) { + if (!primaryExists && shouldOfferGuideSymlink && companionExists) { const shouldSymlinkPrimary = await confirmInitGuideSymlink({ title: `${companionFile} already exists`, description: `Create ${primaryFile} as a symlink to ${companionFile} instead of creating a second guide file?`, @@ -2553,6 +2738,7 @@ export default function ChatView({ threadId }: ChatViewProps) { title: `Created ${primaryFile} symlink`, description: `Linked ${primaryFile} to the existing ${companionFile}.`, }); + await submitGuidePrompt(); return; } } @@ -2568,7 +2754,7 @@ export default function ChatView({ threadId }: ChatViewProps) { createdPrimary = true; } - if (multiHarnessAvailable && primaryExists && !companionExists) { + if (shouldOfferGuideSymlink && primaryExists && !companionExists) { const shouldCreateCompanionSymlink = await confirmInitGuideSymlink({ title: `Create ${companionFile} as a symlink?`, description: `This keeps ${primaryFile} and ${companionFile} in sync with a single shared file.`, @@ -2587,6 +2773,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ? `Also linked ${companionFile} to the same guide.` : `Linked ${companionFile} to the existing ${primaryFile}.`, }); + await submitGuidePrompt(); return; } } @@ -2597,6 +2784,7 @@ export default function ChatView({ threadId }: ChatViewProps) { title: `Created ${primaryFile}`, description: "Update the placeholder sections with repository-specific guidance.", }); + await submitGuidePrompt(); return; } @@ -2604,10 +2792,11 @@ export default function ChatView({ threadId }: ChatViewProps) { type: "info", title: `${primaryFile} already exists`, description: - multiHarnessAvailable && companionExists + shouldOfferGuideSymlink && companionExists ? `${companionFile} is already present too.` : undefined, }); + await submitGuidePrompt(); } catch (error) { toastManager.add({ type: "error", @@ -2621,6 +2810,7 @@ export default function ChatView({ threadId }: ChatViewProps) { doesWorkspaceEntryExist, providerStatuses, selectedProvider, + submitInitGuidePrompt, ]); const handleStandaloneSlashCommand = useCallback( async (command: ComposerStandaloneSlashCommand) => { @@ -5750,7 +5940,11 @@ export default function ChatView({ threadId }: ChatViewProps) { } }} > - + {initGuideConfirmState?.title ?? "Create guide symlink?"} {initGuideConfirmState?.description} diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index dc3890e7..cd7111aa 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -51,16 +51,21 @@ function DialogPopup({ children, showCloseButton = true, bottomStickOnMobile = true, + viewportClassName, ...props }: DialogPrimitive.Popup.Props & { showCloseButton?: boolean; bottomStickOnMobile?: boolean; + viewportClassName?: string; }) { return ( { ).toBe(1); }); + it("detects only enabled and installed Claude harnesses", () => { + expect( + isClaudeHarnessEnabled([ + makeProvider({ provider: "codex", enabled: true, installed: true }), + makeProvider({ provider: "claudeAgent", enabled: true, installed: true }), + ]), + ).toBe(true); + expect( + isClaudeHarnessEnabled([ + makeProvider({ provider: "codex", enabled: true, installed: true }), + makeProvider({ provider: "claudeAgent", enabled: false, installed: true }), + ]), + ).toBe(false); + }); + it("ships a starter template with the expected sections", () => { expect(INIT_COMMAND_TEMPLATE).toContain("# Repository Guidelines"); expect(INIT_COMMAND_TEMPLATE).toContain("## Build, Test, and Development Commands"); - expect(INIT_COMMAND_TEMPLATE).toContain("## Operational Notes"); + expect(INIT_COMMAND_TEMPLATE).toContain( + "## Optional: Security, Configuration, or Architecture Notes", + ); + }); + + it("builds the model prompt for the selected guide file", () => { + const prompt = buildInitCommandPrompt("CLAUDE.md"); + + expect(prompt).toContain("Generate a file named CLAUDE.md"); + expect(prompt).toContain("Edit CLAUDE.md in place"); + expect(prompt).toContain("Repository Guidelines"); }); }); diff --git a/apps/web/src/initCommand.ts b/apps/web/src/initCommand.ts index d50e5849..22b2f92a 100644 --- a/apps/web/src/initCommand.ts +++ b/apps/web/src/initCommand.ts @@ -4,19 +4,19 @@ export type HarnessGuideFile = "AGENTS.md" | "CLAUDE.md"; export const INIT_COMMAND_TEMPLATE = `# Repository Guidelines -Replace the placeholder notes below with instructions that are specific to this repository. +Replace these placeholder notes with concise, repository-specific instructions. Keep the final document direct and actionable, ideally 200-400 words. ## Project Structure & Module Organization -- Describe where the app, packages, tests, and assets live. -- Call out important entrypoints, generated files, and directories to avoid editing directly. +- Outline the project structure, including where source code, tests, and assets are located. +- Call out important entrypoints, generated files, and directories to avoid editing. ## Build, Test, and Development Commands -- List the main install, dev, build, lint, format, and typecheck commands. -- Note any commands that must not be run automatically or that require extra setup. +- List key commands for building, testing, and running locally. +- Briefly explain what each command does and note commands that require extra setup. ## Coding Style & Naming Conventions -- Document formatter and linter expectations, naming patterns, and architecture boundaries. -- Mention language-specific preferences that should stay consistent across the repo. +- Specify indentation rules, language-specific style preferences, and naming patterns. +- Include any formatting or linting tools used. ## Testing Guidelines - Explain the primary test runners, smoke checks, and required verification before merge. @@ -26,11 +26,48 @@ Replace the placeholder notes below with instructions that are specific to this - Capture commit message format, review expectations, and any PR checklist items. - Note when screenshots, logs, or deployment notes should be included. -## Operational Notes -- Record environment setup, secrets handling, sandbox caveats, and agent-specific guardrails. -- Add any repository-specific constraints that should be followed during implementation. +## Optional: Security, Configuration, or Architecture Notes +- Add sections only when relevant, such as security tips, configuration requirements, architecture overview, or agent-specific instructions. `; +export function buildInitCommandPrompt(file: HarnessGuideFile): string { + return `Generate a file named ${file} that serves as a contributor guide for this repository. +Your goal is to produce a clear, concise, and well-structured document with descriptive headings and actionable explanations for each section. +Follow the outline below, but adapt as needed: add sections if relevant, and omit those that do not apply to this project. + +Document Requirements + +- Title the document "Repository Guidelines". +- Use Markdown headings (#, ##, etc.) for structure. +- Keep the document concise. 200-400 words is optimal. +- Keep explanations short, direct, and specific to this repository. +- Provide examples where helpful (commands, directory paths, naming patterns). +- Maintain a professional, instructional tone. +- Edit ${file} in place. Do not create a different guide file. + +Recommended Sections + +Project Structure & Module Organization + +- Outline the project structure, including where the source code, tests, and assets are located. + +Build, Test, and Development Commands + +- List key commands for building, testing, and running locally (e.g., npm test, make build). +- Briefly explain what each command does. + +Coding Style & Naming Conventions + +- Specify indentation rules, language-specific style preferences, and naming patterns. +- Include any formatting or linting tools used. + +Testing Guidelines + +Commit & Pull Request Guidelines + +(Optional) Add other sections if relevant, such as Security & Configuration Tips, Architecture Overview, or Agent-Specific Instructions.`; +} + export function getGuideFileForProvider(provider: ProviderKind): HarnessGuideFile { return provider === "claudeAgent" ? "CLAUDE.md" : "AGENTS.md"; } @@ -42,3 +79,9 @@ export function getCompanionGuideFile(file: HarnessGuideFile): HarnessGuideFile export function countAvailableHarnesses(providers: ReadonlyArray): number { return providers.filter((provider) => provider.enabled && provider.installed).length; } + +export function isClaudeHarnessEnabled(providers: ReadonlyArray): boolean { + return providers.some( + (provider) => provider.provider === "claudeAgent" && provider.enabled && provider.installed, + ); +}