From f08b0a2335baf4527220033f1fc744ddf29ca311 Mon Sep 17 00:00:00 2001 From: Kalvin Chau Date: Mon, 31 Aug 2026 21:15:53 -0700 Subject: [PATCH] feat(berdctl): add project attach-folder and detach-folder Give existing projects CLI parity with create --working-dir so agents can add or remove folders without hand-editing project state files. --- .../crates/berdctl/api-surface-feedback.json | 76 ++- src-tauri/crates/berdctl/api-surface.json | 76 ++- .../crates/berdctl/cli-surface-feedback.json | 12 +- src-tauri/crates/berdctl/cli-surface.json | 12 +- src-tauri/crates/berdctl/src/main.rs | 3 + src-tauri/crates/berdctl/src/tree.rs | 3 +- .../__tests__/commands/commands.test.ts | 528 ++++++++++++++++++ .../commands/impl/attachProjectFolder.ts | 194 +++++++ .../commands/impl/detachProjectFolder.ts | 133 +++++ src/features/berdctl/commands/registry.ts | 11 +- .../berdctl/commands/runtime/paths.ts | 61 ++ 11 files changed, 1102 insertions(+), 7 deletions(-) create mode 100644 src/features/berdctl/commands/impl/attachProjectFolder.ts create mode 100644 src/features/berdctl/commands/impl/detachProjectFolder.ts diff --git a/src-tauri/crates/berdctl/api-surface-feedback.json b/src-tauri/crates/berdctl/api-surface-feedback.json index e5807acb7..7ed5474dd 100644 --- a/src-tauri/crates/berdctl/api-surface-feedback.json +++ b/src-tauri/crates/berdctl/api-surface-feedback.json @@ -688,7 +688,7 @@ } }, "projects": { - "description": "Manage the user's projects: create, list, get, set startup mode, archive.", + "description": "Manage the user's projects: create, list, get, attach folder, detach folder, set startup mode, archive.", "actions": { "create": { "description": "Create a new project; it appears immediately in the app's project list.", @@ -772,6 +772,80 @@ "additionalProperties": false } }, + "attach_folder": { + "description": "Add an existing folder to a project's configured folders, matching the project dialog's add-folder action. Git checkouts are detected so identity behaves as if the folder was added in the UI. Repeating the command is safe.", + "fields": [ + { + "name": "project_id", + "required": true, + "kind": "string", + "description": "Id of the project to update.", + "min": 1 + }, + { + "name": "path", + "required": true, + "kind": "string", + "description": "Existing absolute folder or Git checkout path; ~ is expanded.", + "min": 1 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "project_id": { + "type": "string", + "minLength": 1, + "description": "Id of the project to update." + }, + "path": { + "type": "string", + "minLength": 1, + "description": "Existing absolute folder or Git checkout path; ~ is expanded." + } + }, + "required": ["project_id", "path"], + "additionalProperties": false + } + }, + "detach_folder": { + "description": "Remove a folder from a project's configured folders without deleting anything from disk or Git, matching the project dialog's remove-folder action. Detaching the last folder leaves the project without folders, which is allowed.", + "fields": [ + { + "name": "project_id", + "required": true, + "kind": "string", + "description": "Id of the project to update.", + "min": 1 + }, + { + "name": "path", + "required": true, + "kind": "string", + "description": "Attached folder path to remove; ~ is expanded.", + "min": 1 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "project_id": { + "type": "string", + "minLength": 1, + "description": "Id of the project to update." + }, + "path": { + "type": "string", + "minLength": 1, + "description": "Attached folder path to remove; ~ is expanded." + } + }, + "required": ["project_id", "path"], + "additionalProperties": false + } + }, "set_startup_mode": { "description": "Set the startup behavior for new chats in a project. `auto-worktree` prompts before creating isolated worktrees, `ask-worktree` leaves worktree creation to the user, and `none` uses configured folders as-is. Legacy `worktree` and `branch` values migrate to those current modes.", "fields": [ diff --git a/src-tauri/crates/berdctl/api-surface.json b/src-tauri/crates/berdctl/api-surface.json index 9aaf40f05..b7daa844d 100644 --- a/src-tauri/crates/berdctl/api-surface.json +++ b/src-tauri/crates/berdctl/api-surface.json @@ -688,7 +688,7 @@ } }, "projects": { - "description": "Manage the user's projects: create, list, get, set startup mode, archive.", + "description": "Manage the user's projects: create, list, get, attach folder, detach folder, set startup mode, archive.", "actions": { "create": { "description": "Create a new project; it appears immediately in the app's project list.", @@ -772,6 +772,80 @@ "additionalProperties": false } }, + "attach_folder": { + "description": "Add an existing folder to a project's configured folders, matching the project dialog's add-folder action. Git checkouts are detected so identity behaves as if the folder was added in the UI. Repeating the command is safe.", + "fields": [ + { + "name": "project_id", + "required": true, + "kind": "string", + "description": "Id of the project to update.", + "min": 1 + }, + { + "name": "path", + "required": true, + "kind": "string", + "description": "Existing absolute folder or Git checkout path; ~ is expanded.", + "min": 1 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "project_id": { + "type": "string", + "minLength": 1, + "description": "Id of the project to update." + }, + "path": { + "type": "string", + "minLength": 1, + "description": "Existing absolute folder or Git checkout path; ~ is expanded." + } + }, + "required": ["project_id", "path"], + "additionalProperties": false + } + }, + "detach_folder": { + "description": "Remove a folder from a project's configured folders without deleting anything from disk or Git, matching the project dialog's remove-folder action. Detaching the last folder leaves the project without folders, which is allowed.", + "fields": [ + { + "name": "project_id", + "required": true, + "kind": "string", + "description": "Id of the project to update.", + "min": 1 + }, + { + "name": "path", + "required": true, + "kind": "string", + "description": "Attached folder path to remove; ~ is expanded.", + "min": 1 + } + ], + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "project_id": { + "type": "string", + "minLength": 1, + "description": "Id of the project to update." + }, + "path": { + "type": "string", + "minLength": 1, + "description": "Attached folder path to remove; ~ is expanded." + } + }, + "required": ["project_id", "path"], + "additionalProperties": false + } + }, "set_startup_mode": { "description": "Set the startup behavior for new chats in a project. `auto-worktree` prompts before creating isolated worktrees, `ask-worktree` leaves worktree creation to the user, and `none` uses configured folders as-is. Legacy `worktree` and `branch` values migrate to those current modes.", "fields": [ diff --git a/src-tauri/crates/berdctl/cli-surface-feedback.json b/src-tauri/crates/berdctl/cli-surface-feedback.json index 1f199f737..59438fb52 100644 --- a/src-tauri/crates/berdctl/cli-surface-feedback.json +++ b/src-tauri/crates/berdctl/cli-surface-feedback.json @@ -95,7 +95,7 @@ }, "project": { "group": "projects", - "about": "Manage projects: create, list, get, set startup mode, archive", + "about": "Manage projects: create, list, get, attach folder, detach folder, set startup mode, archive", "verbs": { "create": { "action": "create", @@ -112,6 +112,16 @@ "about": "Read one project's details", "afterHelp": "Example:\n berdctl project get --project-id --json\n\nResult:\n {\"project_id\": \"...\", \"name\": \"...\", \"description\": \"...\",\n \"instructions\": \"...\", \"working_dirs\": [\"...\"], \"workspaces\": [\n {\"path\": \"...\", \"startup_mode\": \"worktree\"}\n ], \"archived\": false, \"session_count\": 4, \"chat_groups\": [\n {\"group_id\": \"...\", \"name\": \"Launch\", \"session_ids\": [\"...\"]}\n ]}" }, + "attach-folder": { + "action": "attach_folder", + "about": "Attach a folder to an existing project", + "afterHelp": "The new folder starts with startup mode \"none\"; set worktree behavior with\n`berdctl project set-startup-mode` afterwards. Detach with\n`berdctl project detach-folder`.\n\nExample:\n berdctl project attach-folder --project-id --path ~/src/api\n\nResult:\n {\"ok\": true, \"path\": \"...\", \"kind\": \"...\", \"branch\": \"...\"|null,\n \"attached\": true, \"working_dirs\": [\"...\", ...]}" + }, + "detach-folder": { + "action": "detach_folder", + "about": "Detach a folder from an existing project", + "afterHelp": "Sessions already in the project keep their own folders; this only changes which\nfolders new project chats start from. Re-add with\n`berdctl project attach-folder`.\n\nExample:\n berdctl project detach-folder --project-id --path ~/src/api\n\nResult:\n {\"ok\": true, \"path\": \"...\", \"detached\": true|false, \"working_dirs\": [...]}" + }, "set-startup-mode": { "action": "set_startup_mode", "about": "Set how a project's new chats start from its Git workspaces", diff --git a/src-tauri/crates/berdctl/cli-surface.json b/src-tauri/crates/berdctl/cli-surface.json index a096970e9..1d297f14b 100644 --- a/src-tauri/crates/berdctl/cli-surface.json +++ b/src-tauri/crates/berdctl/cli-surface.json @@ -95,7 +95,7 @@ }, "project": { "group": "projects", - "about": "Manage projects: create, list, get, set startup mode, archive", + "about": "Manage projects: create, list, get, attach folder, detach folder, set startup mode, archive", "verbs": { "create": { "action": "create", @@ -112,6 +112,16 @@ "about": "Read one project's details", "afterHelp": "Example:\n berdctl project get --project-id --json\n\nResult:\n {\"project_id\": \"...\", \"name\": \"...\", \"description\": \"...\",\n \"instructions\": \"...\", \"working_dirs\": [\"...\"], \"workspaces\": [\n {\"path\": \"...\", \"startup_mode\": \"worktree\"}\n ], \"archived\": false, \"session_count\": 4, \"chat_groups\": [\n {\"group_id\": \"...\", \"name\": \"Launch\", \"session_ids\": [\"...\"]}\n ]}" }, + "attach-folder": { + "action": "attach_folder", + "about": "Attach a folder to an existing project", + "afterHelp": "The new folder starts with startup mode \"none\"; set worktree behavior with\n`berdctl project set-startup-mode` afterwards. Detach with\n`berdctl project detach-folder`.\n\nExample:\n berdctl project attach-folder --project-id --path ~/src/api\n\nResult:\n {\"ok\": true, \"path\": \"...\", \"kind\": \"...\", \"branch\": \"...\"|null,\n \"attached\": true, \"working_dirs\": [\"...\", ...]}" + }, + "detach-folder": { + "action": "detach_folder", + "about": "Detach a folder from an existing project", + "afterHelp": "Sessions already in the project keep their own folders; this only changes which\nfolders new project chats start from. Re-add with\n`berdctl project attach-folder`.\n\nExample:\n berdctl project detach-folder --project-id --path ~/src/api\n\nResult:\n {\"ok\": true, \"path\": \"...\", \"detached\": true|false, \"working_dirs\": [...]}" + }, "set-startup-mode": { "action": "set_startup_mode", "about": "Set how a project's new chats start from its Git workspaces", diff --git a/src-tauri/crates/berdctl/src/main.rs b/src-tauri/crates/berdctl/src/main.rs index da8e9a981..e762ffeec 100644 --- a/src-tauri/crates/berdctl/src/main.rs +++ b/src-tauri/crates/berdctl/src/main.rs @@ -293,6 +293,9 @@ mod tests { ("project", "create") => vec!["--name", "n"], ("project", "list") => vec![], ("project", "get") => vec!["--project-id", "p"], + ("project", "attach-folder") | ("project", "detach-folder") => { + vec!["--project-id", "p", "--path", "/w"] + } ("project", "set-startup-mode") => { vec!["--project-id", "p", "--mode", "worktree"] } diff --git a/src-tauri/crates/berdctl/src/tree.rs b/src-tauri/crates/berdctl/src/tree.rs index 0c1ca3f2b..6b960d7f0 100644 --- a/src-tauri/crates/berdctl/src/tree.rs +++ b/src-tauri/crates/berdctl/src/tree.rs @@ -25,7 +25,8 @@ sees there: session chat sessions create, open, list, get, rename, move, move-to-group, send, clear-project, fork, archive - project projects create, list, get, set-startup-mode, archive + project projects create, list, get, attach-folder, + detach-folder, set-startup-mode, archive agent agents (personas) create, list skill skills (SKILL.md) create, list, get info read-only lookups harnesses, models, context diff --git a/src/features/berdctl/__tests__/commands/commands.test.ts b/src/features/berdctl/__tests__/commands/commands.test.ts index aea64d53c..b19ded5ff 100644 --- a/src/features/berdctl/__tests__/commands/commands.test.ts +++ b/src/features/berdctl/__tests__/commands/commands.test.ts @@ -175,6 +175,7 @@ vi.mock("@/features/projects/api/projects", async (importOriginal) => { const actual = await importOriginal(); return { + isWorktreeStartupMode: actual.isWorktreeStartupMode, normalizeProjectWorkspaces: actual.normalizeProjectWorkspaces, projectWorkspaceFromDirectory: actual.projectWorkspaceFromDirectory, listProjects: (...args: unknown[]) => mocks.listProjects(...args), @@ -610,6 +611,8 @@ describe("action schemas", () => { "projects.create": { name: "Project" }, "projects.list": {}, "projects.get": { project_id: "p1" }, + "projects.attach_folder": { project_id: "p1", path: "/tmp/dir" }, + "projects.detach_folder": { project_id: "p1", path: "/tmp/dir" }, "projects.set_startup_mode": { project_id: "p1", mode: "worktree" }, "projects.archive": { project_id: "p1" }, "agents.create": { name: "Agent", system_prompt: "Be helpful" }, @@ -4060,6 +4063,531 @@ describe("projects", () => { expect(mocks.updateProject).not.toHaveBeenCalled(); }); }); + + describe("attach_folder", () => { + const existingWorkspace = { + id: "path:/projects/one", + path: "/projects/one", + kind: "directory" as const, + source: "inferred" as const, + branch: null, + usedByAgent: false, + startupMode: "none" as const, + }; + + function seedProject(overrides: Partial = {}) { + const project = makeProject({ + id: "p-1", + workingDirs: [existingWorkspace.path], + projectWorkspaces: [existingWorkspace], + ...overrides, + }); + useProjectStore.setState({ + projects: [project], + hasFetchedProjects: true, + }); + mocks.listProjects.mockResolvedValue([project]); + return project; + } + + it("attaches a new folder with its Git identity and startup mode none", async () => { + seedProject(); + mocks.getGitState.mockResolvedValue({ + isGitRepo: true, + currentBranch: "main", + dirtyFileCount: 0, + incomingCommitCount: 0, + worktrees: [{ path: "/src/api", branch: "main", isMain: true }], + isWorktree: false, + mainWorktreePath: "/src/api", + localBranches: ["main"], + }); + + const result = await dispatchCommand( + "projects", + { action: "attach_folder", project_id: "p-1", path: "/src/api" }, + ctx, + ); + + expect(mocks.updateProject).toHaveBeenCalledWith( + expect.objectContaining({ id: "p-1" }), + expect.objectContaining({ + workingDirs: ["/projects/one", "/src/api"], + useWorktrees: false, + projectWorkspaces: [ + existingWorkspace, + expect.objectContaining({ + path: "/src/api", + kind: "git-main-worktree", + branch: "main", + startupMode: "none", + repositoryPath: "/src/api", + }), + ], + }), + ); + expect(result).toEqual({ + ok: true, + path: "/src/api", + kind: "git-main-worktree", + branch: "main", + attached: true, + working_dirs: ["/projects/one", "/src/api"], + }); + }); + + it("re-attaching an existing folder is a no-op", async () => { + seedProject(); + + const result = await dispatchCommand( + "projects", + { action: "attach_folder", project_id: "p-1", path: "/projects/one" }, + ctx, + ); + + expect(mocks.updateProject).not.toHaveBeenCalled(); + expect(mocks.getGitState).not.toHaveBeenCalled(); + expect(mocks.checkDirectoriesExist).not.toHaveBeenCalled(); + expect(result).toEqual({ + ok: true, + path: "/projects/one", + kind: "directory", + branch: null, + attached: false, + working_dirs: ["/projects/one"], + }); + }); + + it("treats a stored ~ path as already attached", async () => { + mocks.resolvePath.mockImplementation( + async ({ parts }: { parts: string[] }) => ({ + path: parts[0].replace(/^~/, "/Users/me"), + }), + ); + seedProject({ + workingDirs: ["~/src/api"], + projectWorkspaces: [ + { + ...existingWorkspace, + id: "path:~/src/api", + path: "~/src/api", + }, + ], + }); + + const result = await dispatchCommand( + "projects", + { action: "attach_folder", project_id: "p-1", path: "~/src/api" }, + ctx, + ); + + expect(mocks.getGitState).not.toHaveBeenCalled(); + expect(mocks.updateProject).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + attached: false, + path: "~/src/api", + }); + }); + + it("rejects a relative path", async () => { + seedProject(); + await expectCommandError( + dispatchCommand( + "projects", + { action: "attach_folder", project_id: "p-1", path: "src/api" }, + ctx, + ), + "invalid_args", + ); + expect(mocks.getGitState).not.toHaveBeenCalled(); + expect(mocks.updateProject).not.toHaveBeenCalled(); + }); + + it("rejects a path that is not an existing directory", async () => { + seedProject(); + mocks.checkDirectoriesExist.mockResolvedValue(["/missing"]); + + await expectCommandError( + dispatchCommand( + "projects", + { action: "attach_folder", project_id: "p-1", path: "/missing" }, + ctx, + ), + "invalid_args", + ); + expect(mocks.getGitState).not.toHaveBeenCalled(); + expect(mocks.updateProject).not.toHaveBeenCalled(); + }); + + it("changes nothing when the Git probe fails", async () => { + seedProject(); + mocks.getGitState.mockRejectedValue(new Error("git exploded")); + + await expectCommandError( + dispatchCommand( + "projects", + { action: "attach_folder", project_id: "p-1", path: "/src/api" }, + ctx, + ), + "internal_error", + ); + expect(mocks.updateProject).not.toHaveBeenCalled(); + }); + + it("uses the live folder list after Git inspection", async () => { + seedProject(); + mocks.getGitState.mockImplementationOnce(async () => { + useProjectStore.setState({ + projects: [ + makeProject({ + id: "p-1", + workingDirs: [existingWorkspace.path, "/projects/two"], + projectWorkspaces: [ + existingWorkspace, + { ...existingWorkspace, path: "/projects/two" }, + ], + }), + ], + }); + return { + isGitRepo: false, + currentBranch: null, + dirtyFileCount: 0, + incomingCommitCount: 0, + worktrees: [], + isWorktree: false, + mainWorktreePath: null, + localBranches: [], + }; + }); + + const result = await dispatchCommand( + "projects", + { action: "attach_folder", project_id: "p-1", path: "/src/api" }, + ctx, + ); + + expect(mocks.updateProject).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + workingDirs: ["/projects/one", "/projects/two", "/src/api"], + }), + ); + expect(result).toMatchObject({ attached: true, path: "/src/api" }); + }); + + it("rejects an unknown project", async () => { + mocks.listProjects.mockResolvedValue([]); + await expectCommandError( + dispatchCommand( + "projects", + { action: "attach_folder", project_id: "missing", path: "/src/api" }, + ctx, + ), + "project_not_found", + ); + expect(mocks.getGitState).not.toHaveBeenCalled(); + expect(mocks.updateProject).not.toHaveBeenCalled(); + }); + + it("times out before mutating if validation overruns the deadline", async () => { + seedProject(); + const now = Date.now(); + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(now); + mocks.getGitState.mockImplementation(async () => { + nowSpy.mockReturnValue(now + 10_000); + return { + isGitRepo: false, + currentBranch: null, + dirtyFileCount: 0, + incomingCommitCount: 0, + worktrees: [], + isWorktree: false, + mainWorktreePath: null, + localBranches: [], + }; + }); + + await expectCommandError( + dispatchCommand( + "projects", + { action: "attach_folder", project_id: "p-1", path: "/src/api" }, + { deadlineMs: now + 4_000 }, + ), + "timed_out", + ); + expect(mocks.updateProject).not.toHaveBeenCalled(); + nowSpy.mockRestore(); + }); + }); + + describe("detach_folder", () => { + const mainWorkspace = { + id: "ws-main", + path: "/projects/repo", + kind: "git-main-worktree" as const, + source: "selected" as const, + branch: "main", + usedByAgent: false, + startupMode: "auto-worktree" as const, + repositoryPath: "/projects/repo", + worktreePath: "/projects/repo", + }; + const docsWorkspace = { + id: "ws-docs", + path: "/projects/docs", + kind: "non-git-directory" as const, + source: "selected" as const, + branch: null, + usedByAgent: false, + startupMode: "none" as const, + }; + + it("removes the folder and recomputes the worktree flag from what remains", async () => { + const project = makeProject({ + id: "p-1", + workingDirs: [mainWorkspace.path, docsWorkspace.path], + projectWorkspaces: [mainWorkspace, docsWorkspace], + useWorktrees: true, + }); + useProjectStore.setState({ + projects: [project], + hasFetchedProjects: true, + }); + mocks.listProjects.mockResolvedValue([project]); + + const result = await dispatchCommand( + "projects", + { action: "detach_folder", project_id: "p-1", path: "/projects/repo" }, + ctx, + ); + + expect(mocks.getGitState).not.toHaveBeenCalled(); + expect(mocks.updateProject).toHaveBeenCalledWith( + expect.objectContaining({ id: "p-1" }), + expect.objectContaining({ + workingDirs: ["/projects/docs"], + useWorktrees: false, + projectWorkspaces: [docsWorkspace], + }), + ); + expect(result).toEqual({ + ok: true, + path: "/projects/repo", + detached: true, + working_dirs: ["/projects/docs"], + }); + }); + + it("keeps the worktree flag when other worktree-mode folders remain", async () => { + const project = makeProject({ + id: "p-1", + workingDirs: [mainWorkspace.path, docsWorkspace.path], + projectWorkspaces: [mainWorkspace, docsWorkspace], + useWorktrees: true, + }); + useProjectStore.setState({ + projects: [project], + hasFetchedProjects: true, + }); + mocks.listProjects.mockResolvedValue([project]); + + await dispatchCommand( + "projects", + { action: "detach_folder", project_id: "p-1", path: "/projects/docs" }, + ctx, + ); + + expect(mocks.updateProject).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + workingDirs: ["/projects/repo"], + useWorktrees: true, + }), + ); + }); + + it("reports detached:false and changes nothing when the path is not attached", async () => { + const project = makeProject({ + id: "p-1", + workingDirs: [docsWorkspace.path], + projectWorkspaces: [docsWorkspace], + }); + useProjectStore.setState({ + projects: [project], + hasFetchedProjects: true, + }); + mocks.listProjects.mockResolvedValue([project]); + + const result = await dispatchCommand( + "projects", + { action: "detach_folder", project_id: "p-1", path: "/elsewhere" }, + ctx, + ); + + expect(result).toEqual({ + ok: true, + path: "/elsewhere", + detached: false, + working_dirs: ["/projects/docs"], + }); + expect(mocks.updateProject).not.toHaveBeenCalled(); + }); + + it("detaches a stored ~ path from an expanded request", async () => { + mocks.resolvePath.mockImplementation( + async ({ parts }: { parts: string[] }) => ({ + path: parts[0].replace(/^~/, "/Users/me"), + }), + ); + const project = makeProject({ + id: "p-1", + workingDirs: ["~/src/api"], + projectWorkspaces: [ + { + ...docsWorkspace, + id: "path:~/src/api", + path: "~/src/api", + }, + ], + }); + useProjectStore.setState({ + projects: [project], + hasFetchedProjects: true, + }); + mocks.listProjects.mockResolvedValue([project]); + + const result = await dispatchCommand( + "projects", + { action: "detach_folder", project_id: "p-1", path: "~/src/api" }, + ctx, + ); + + expect(result).toMatchObject({ detached: true, working_dirs: [] }); + }); + + it("rejects a relative path", async () => { + const project = makeProject({ + id: "p-1", + workingDirs: [docsWorkspace.path], + projectWorkspaces: [docsWorkspace], + }); + useProjectStore.setState({ + projects: [project], + hasFetchedProjects: true, + }); + mocks.listProjects.mockResolvedValue([project]); + + await expectCommandError( + dispatchCommand( + "projects", + { action: "detach_folder", project_id: "p-1", path: "docs" }, + ctx, + ), + "invalid_args", + ); + expect(mocks.updateProject).not.toHaveBeenCalled(); + }); + + it("allows detaching the last folder", async () => { + const project = makeProject({ + id: "p-1", + workingDirs: [docsWorkspace.path], + projectWorkspaces: [docsWorkspace], + }); + useProjectStore.setState({ + projects: [project], + hasFetchedProjects: true, + }); + mocks.listProjects.mockResolvedValue([project]); + + const result = await dispatchCommand( + "projects", + { action: "detach_folder", project_id: "p-1", path: "/projects/docs" }, + ctx, + ); + + expect(result).toMatchObject({ detached: true, working_dirs: [] }); + expect(mocks.updateProject).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + workingDirs: [], + useWorktrees: false, + projectWorkspaces: [], + }), + ); + }); + + it("allows detaching a folder that no longer exists on disk", async () => { + mocks.checkDirectoriesExist.mockResolvedValue(["/projects/docs"]); + const project = makeProject({ + id: "p-1", + workingDirs: [docsWorkspace.path], + projectWorkspaces: [docsWorkspace], + }); + useProjectStore.setState({ + projects: [project], + hasFetchedProjects: true, + }); + mocks.listProjects.mockResolvedValue([project]); + + const result = await dispatchCommand( + "projects", + { action: "detach_folder", project_id: "p-1", path: "/projects/docs" }, + ctx, + ); + + expect(mocks.checkDirectoriesExist).not.toHaveBeenCalled(); + expect(result).toMatchObject({ detached: true, working_dirs: [] }); + }); + + it("rejects an unknown project", async () => { + mocks.listProjects.mockResolvedValue([]); + await expectCommandError( + dispatchCommand( + "projects", + { action: "detach_folder", project_id: "missing", path: "/x" }, + ctx, + ), + "project_not_found", + ); + expect(mocks.updateProject).not.toHaveBeenCalled(); + }); + + it("times out before mutating if validation overruns the deadline", async () => { + const project = makeProject({ + id: "p-1", + workingDirs: [docsWorkspace.path], + projectWorkspaces: [docsWorkspace], + }); + useProjectStore.setState({ + projects: [project], + hasFetchedProjects: true, + }); + mocks.listProjects.mockResolvedValue([project]); + const now = Date.now(); + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(now); + mocks.resolvePath.mockImplementation(async ({ parts }) => { + nowSpy.mockReturnValue(now + 10_000); + return { path: parts[0] }; + }); + + await expectCommandError( + dispatchCommand( + "projects", + { + action: "detach_folder", + project_id: "p-1", + path: "/projects/docs", + }, + { deadlineMs: now + 4_000 }, + ), + "timed_out", + ); + expect(mocks.updateProject).not.toHaveBeenCalled(); + nowSpy.mockRestore(); + }); + }); }); describe("agents", () => { diff --git a/src/features/berdctl/commands/impl/attachProjectFolder.ts b/src/features/berdctl/commands/impl/attachProjectFolder.ts new file mode 100644 index 000000000..6dd66ab58 --- /dev/null +++ b/src/features/berdctl/commands/impl/attachProjectFolder.ts @@ -0,0 +1,194 @@ +import { z } from "zod/v4"; + +import { CommandError, defineCommand } from "../types"; + +const attachProjectFolderSchema = z + .object({ + project_id: z.string().min(1).describe("Id of the project to update."), + path: z + .string() + .min(1) + .describe( + "Existing absolute folder or Git checkout path; ~ is expanded.", + ), + }) + .strict(); + +export const attachProjectFolderCommand = defineCommand({ + effect: "update", + visibility: "immediate", + destructive: false, + summary: "Attach a folder to an existing project", + description: + "Add an existing folder to a project's configured folders, matching the project dialog's add-folder action. Git checkouts are detected so identity behaves as if the folder was added in the UI. Repeating the command is safe.", + helpFooter: `The new folder starts with startup mode "none"; set worktree behavior with +\`berdctl project set-startup-mode\` afterwards. Detach with +\`berdctl project detach-folder\`. + +Example: + berdctl project attach-folder --project-id --path ~/src/api + +Result: + {"ok": true, "path": "...", "kind": "...", "branch": "..."|null, + "attached": true, "working_dirs": ["...", ...]}`, + schema: attachProjectFolderSchema, + // getGitState's native budget is 90s; keep a persistence margin above it. + bridgeTimeoutMs: 120_000, + execute: async (args, ctx) => { + const [ + { refusePastDeadline }, + { assertExistingDirectory, findMatchingWorkspace, isAbsoluteOrHomePath }, + { findProjectOrThrow }, + { + isWorktreeStartupMode, + normalizeProjectWorkspaces, + projectWorkspaceFromDirectory, + }, + { classifyWorkspaceAttachment }, + { getGitState }, + { resolvePath }, + { useProjectStore }, + ] = await Promise.all([ + import("../runtime/deadline"), + import("../runtime/paths"), + import("../runtime/projects"), + import("@/features/projects/api/projects"), + import("@/features/chat/lib/workspaceAttachments"), + import("@/shared/api/git"), + import("@/shared/api/pathResolver"), + import("@/features/projects/stores/projectStore"), + ]); + + const project = await findProjectOrThrow(args.project_id); + const requestedPath = args.path.trim(); + if (requestedPath.length === 0) { + throw new CommandError( + "invalid_args", + "path must not be empty; pass an existing absolute folder or a ~ path.", + ); + } + if (!isAbsoluteOrHomePath(requestedPath)) { + throw new CommandError( + "invalid_args", + `Path "${args.path}" must be absolute or start with ~; relative paths follow the app's working directory, not the CLI's.`, + ); + } + let resolvedPath: string; + try { + resolvedPath = (await resolvePath({ parts: [requestedPath] })).path; + } catch (error) { + throw new CommandError( + "internal_error", + `Could not resolve "${args.path}" (${String(error)}); nothing was changed. Check the path and retry.`, + ); + } + + const alreadyAttached = await findMatchingWorkspace( + normalizeProjectWorkspaces( + project.projectWorkspaces, + project.workingDirs, + project.useWorktrees, + ), + resolvedPath, + ); + if (alreadyAttached) { + return { + ok: true as const, + path: alreadyAttached.path, + kind: alreadyAttached.kind, + branch: alreadyAttached.branch ?? null, + attached: false, + working_dirs: project.workingDirs, + }; + } + + await assertExistingDirectory(resolvedPath); + + let gitState: Awaited>; + try { + gitState = await getGitState(resolvedPath); + } catch (error) { + throw new CommandError( + "internal_error", + `Could not inspect "${resolvedPath}" (${String(error)}); nothing was changed. Retry once.`, + ); + } + + refusePastDeadline(ctx, "the folder was not attached"); + + const liveProject = useProjectStore + .getState() + .projects.find((candidate) => candidate.id === args.project_id); + if (!liveProject) { + throw new CommandError( + "project_not_found", + `No project "${args.project_id}"; list projects with \`berdctl project list\`.`, + ); + } + const liveWorkspaces = normalizeProjectWorkspaces( + liveProject.projectWorkspaces, + liveProject.workingDirs, + liveProject.useWorktrees, + ); + const alreadyAttachedLive = await findMatchingWorkspace( + liveWorkspaces, + resolvedPath, + ); + if (alreadyAttachedLive) { + return { + ok: true as const, + path: alreadyAttachedLive.path, + kind: alreadyAttachedLive.kind, + branch: alreadyAttachedLive.branch ?? null, + attached: false, + working_dirs: liveProject.workingDirs, + }; + } + + const classification = classifyWorkspaceAttachment(resolvedPath, gitState); + const workspace = projectWorkspaceFromDirectory(resolvedPath); + if (!workspace) { + throw new CommandError( + "internal_error", + `"${resolvedPath}" did not resolve to a usable folder path; nothing was changed. Check the path and retry.`, + ); + } + workspace.kind = classification.kind; + workspace.branch = classification.branch; + if (classification.repositoryPath) { + workspace.repositoryPath = classification.repositoryPath; + } + if (classification.worktreePath) { + workspace.worktreePath = classification.worktreePath; + } + + const projectWorkspaces = [...liveWorkspaces, workspace]; + + refusePastDeadline(ctx, "the folder was not attached"); + + // Deliberately no berd_project Edit Completed telemetry: see the + // rationale in setProjectStartupMode.ts. + const updated = await useProjectStore.getState().editProject( + liveProject.id, + liveProject.name, + liveProject.description, + liveProject.prompt, + liveProject.icon, + liveProject.color, + projectWorkspaces.map((current) => current.path), + projectWorkspaces.some((current) => + isWorktreeStartupMode(current.startupMode), + ), + projectWorkspaces, + ); + + return { + ok: true as const, + path: workspace.path, + kind: workspace.kind, + branch: workspace.branch ?? null, + attached: true, + working_dirs: updated.workingDirs, + }; + }, +}); diff --git a/src/features/berdctl/commands/impl/detachProjectFolder.ts b/src/features/berdctl/commands/impl/detachProjectFolder.ts new file mode 100644 index 000000000..ee27b2748 --- /dev/null +++ b/src/features/berdctl/commands/impl/detachProjectFolder.ts @@ -0,0 +1,133 @@ +import { z } from "zod/v4"; + +import { CommandError, defineCommand } from "../types"; + +const detachProjectFolderSchema = z + .object({ + project_id: z.string().min(1).describe("Id of the project to update."), + path: z + .string() + .min(1) + .describe("Attached folder path to remove; ~ is expanded."), + }) + .strict(); + +export const detachProjectFolderCommand = defineCommand({ + effect: "update", + visibility: "immediate", + destructive: false, + summary: "Detach a folder from an existing project", + description: + "Remove a folder from a project's configured folders without deleting anything from disk or Git, matching the project dialog's remove-folder action. Detaching the last folder leaves the project without folders, which is allowed.", + helpFooter: `Sessions already in the project keep their own folders; this only changes which +folders new project chats start from. Re-add with +\`berdctl project attach-folder\`. + +Example: + berdctl project detach-folder --project-id --path ~/src/api + +Result: + {"ok": true, "path": "...", "detached": true|false, "working_dirs": [...]}`, + schema: detachProjectFolderSchema, + execute: async (args, ctx) => { + const [ + { refusePastDeadline }, + { findProjectOrThrow }, + { isWorktreeStartupMode, normalizeProjectWorkspaces }, + { workspaceMatchesPath, isAbsoluteOrHomePath }, + { resolvePath }, + { useProjectStore }, + ] = await Promise.all([ + import("../runtime/deadline"), + import("../runtime/projects"), + import("@/features/projects/api/projects"), + import("../runtime/paths"), + import("@/shared/api/pathResolver"), + import("@/features/projects/stores/projectStore"), + ]); + + await findProjectOrThrow(args.project_id); + const requestedPath = args.path.trim(); + if (requestedPath.length === 0) { + throw new CommandError( + "invalid_args", + "path must not be empty; pass an attached folder path or a ~ path.", + ); + } + if (!isAbsoluteOrHomePath(requestedPath)) { + throw new CommandError( + "invalid_args", + `Path "${args.path}" must be absolute or start with ~; relative paths follow the app's working directory, not the CLI's.`, + ); + } + // Match against the same resolved form attach persists, so `~/src/api` + // detaches a folder attached as `/Users/me/src/api`. The folder does not + // need to exist anymore to be removed from the project. + let resolvedPath: string; + try { + resolvedPath = (await resolvePath({ parts: [requestedPath] })).path; + } catch (error) { + throw new CommandError( + "internal_error", + `Could not resolve "${args.path}" (${String(error)}); nothing was changed. Check the path and retry.`, + ); + } + + refusePastDeadline(ctx, "the folder was not detached"); + + const liveProject = useProjectStore + .getState() + .projects.find((candidate) => candidate.id === args.project_id); + if (!liveProject) { + throw new CommandError( + "project_not_found", + `No project "${args.project_id}"; list projects with \`berdctl project list\`.`, + ); + } + const liveWorkspaces = normalizeProjectWorkspaces( + liveProject.projectWorkspaces, + liveProject.workingDirs, + liveProject.useWorktrees, + ); + const projectWorkspaces = []; + for (const workspace of liveWorkspaces) { + if (!(await workspaceMatchesPath(workspace.path, resolvedPath))) { + projectWorkspaces.push(workspace); + } + } + const detached = projectWorkspaces.length !== liveWorkspaces.length; + if (!detached) { + return { + ok: true as const, + path: resolvedPath, + detached: false, + working_dirs: liveProject.workingDirs, + }; + } + + refusePastDeadline(ctx, "the folder was not detached"); + + // Deliberately no berd_project Edit Completed telemetry: see the + // rationale in setProjectStartupMode.ts. + const updated = await useProjectStore.getState().editProject( + liveProject.id, + liveProject.name, + liveProject.description, + liveProject.prompt, + liveProject.icon, + liveProject.color, + projectWorkspaces.map((workspace) => workspace.path), + projectWorkspaces.some((workspace) => + isWorktreeStartupMode(workspace.startupMode), + ), + projectWorkspaces, + ); + + return { + ok: true as const, + path: resolvedPath, + detached, + working_dirs: updated.workingDirs, + }; + }, +}); diff --git a/src/features/berdctl/commands/registry.ts b/src/features/berdctl/commands/registry.ts index 943f27f75..7e73fdc37 100644 --- a/src/features/berdctl/commands/registry.ts +++ b/src/features/berdctl/commands/registry.ts @@ -2,7 +2,9 @@ import type { ZodError } from "zod/v4"; import { archiveProjectCommand } from "./impl/archiveProject"; import { archiveSessionCommand } from "./impl/archiveSession"; +import { attachProjectFolderCommand } from "./impl/attachProjectFolder"; import { attachSessionFolderCommand } from "./impl/attachSessionFolder"; +import { detachProjectFolderCommand } from "./impl/detachProjectFolder"; import { detachSessionFolderCommand } from "./impl/detachSessionFolder"; import { listSessionFoldersCommand } from "./impl/listSessionFolders"; import { replaceSessionFolderCommand } from "./impl/replaceSessionFolder"; @@ -113,14 +115,17 @@ export const ALL_TOOL_GROUPS = { }, projects: { description: - "Manage the user's projects: create, list, get, set startup mode, archive.", + "Manage the user's projects: create, list, get, attach folder, detach folder, set startup mode, archive.", cli: { noun: "project", - about: "Manage projects: create, list, get, set startup mode, archive", + about: + "Manage projects: create, list, get, attach folder, detach folder, set startup mode, archive", verbs: { create: "create", list: "list", get: "get", + "attach-folder": "attach_folder", + "detach-folder": "detach_folder", "set-startup-mode": "set_startup_mode", archive: "archive", }, @@ -129,6 +134,8 @@ export const ALL_TOOL_GROUPS = { create: createProjectCommand, list: listProjectsCommand, get: getProjectCommand, + attach_folder: attachProjectFolderCommand, + detach_folder: detachProjectFolderCommand, set_startup_mode: setProjectStartupModeCommand, archive: archiveProjectCommand, }, diff --git a/src/features/berdctl/commands/runtime/paths.ts b/src/features/berdctl/commands/runtime/paths.ts index 6adda3cc8..7c73b57ef 100644 --- a/src/features/berdctl/commands/runtime/paths.ts +++ b/src/features/berdctl/commands/runtime/paths.ts @@ -1,3 +1,4 @@ +import { isSameWorkspacePath } from "@/features/chat/lib/workspaceAttachments"; import { checkDirectoriesExist, resolvePath } from "@/shared/api/pathResolver"; import { CommandError } from "../types"; @@ -33,3 +34,63 @@ export async function resolveExistingDirectoryOrThrow( } return resolvedPath; } + +export async function assertExistingDirectory( + resolvedPath: string, +): Promise { + let missing: string[]; + try { + missing = await checkDirectoriesExist([resolvedPath]); + } catch (error) { + throw new CommandError( + "internal_error", + `Could not verify "${resolvedPath}" is an existing directory (${String(error)}); nothing was changed. Check the path and retry.`, + ); + } + if (missing.length > 0) { + throw new CommandError( + "invalid_args", + `No directory at "${resolvedPath}"; pass an existing worktree or folder path.`, + ); + } +} + +export async function workspaceMatchesPath( + storedPath: string, + resolvedPath: string, +): Promise { + if (isSameWorkspacePath(storedPath, resolvedPath)) { + return true; + } + try { + const expanded = (await resolvePath({ parts: [storedPath] })).path; + return isSameWorkspacePath(expanded, resolvedPath); + } catch { + return false; + } +} + +export async function findMatchingWorkspace< + TWorkspace extends { path: string }, +>( + workspaces: TWorkspace[], + resolvedPath: string, +): Promise { + for (const workspace of workspaces) { + if (await workspaceMatchesPath(workspace.path, resolvedPath)) { + return workspace; + } + } + return undefined; +} + +export function isAbsoluteOrHomePath(path: string): boolean { + return ( + path.startsWith("/") || + path.startsWith("~/") || + path === "~" || + /^[A-Za-z]:[\\/]/.test(path) || + path.startsWith("\\\\") || + path.startsWith("~\\") + ); +}