diff --git a/docs/en/reference/server-api.md b/docs/en/reference/server-api.md index 89d22226157..b0b6183582f 100644 --- a/docs/en/reference/server-api.md +++ b/docs/en/reference/server-api.md @@ -1564,6 +1564,7 @@ Workspaces are the registered project directories sessions live in. These endpoi | `GET /api/v1/workspaces/{workspace_id}/trust` | Read the trust state | | `POST /api/v1/workspaces/{workspace_id}/trust` | Grant trust | | `POST /api/v1/workspaces/{workspace_id}/untrust` | Revoke trust | +| `POST /api/v1/workspaces/{workspace_id}/add-dir` | Add an additional directory | #### The workspace object @@ -1660,6 +1661,22 @@ On success, `data` is `{ trusted: false }`. - `40410`: workspace not found +#### `POST /api/v1/workspaces/{workspace_id}/add-dir` + +Adds an additional directory to the workspace, with the same semantics as the CLI `--add-dir` flag and the TUI `/add-dir` command. The path accepts absolute paths, relative paths (resolved against the workspace root), and `~` expansion. + +| Parameter | In | Type | Description | +| --- | --- | --- | --- | +| `workspace_id` | path | string | **Required.** Workspace id | +| `path` | body | string | **Required.** Directory to add | +| `persist` | body | boolean | Defaults to `true`: appends to `workspace.additional_dir` in `/.kimi-code/local.toml`. With `false`, the directory only joins the in-memory ephemeral set shared by all sessions of the workspace | + +On success, `data` is `{ project_root, config_path, additional_dirs, persisted }`, where `additional_dirs` lists every additional directory (existing ones included) and `persisted` reports whether this call wrote to disk. + +- `40001`: validation failure (`details` lists each field), or an engine-side config validation error such as a corrupted project local config +- `40409`: `path` does not exist or is not a directory +- `40410`: workspace not found + ### File system In-session file operations go through `POST /api/v1/sessions/{session_id}/fs:{action}` with JSON bodies; actions are `list` / `read` / `list_many` / `stat` / `stat_many` / `mkdir` / `search` / `grep` / `git_status` / `diff` / `open` / `open-in` / `reveal`. Every action body also accepts an optional `runtime_id` (string, default `local`) selecting the runtime that executes the operation; `open`, `open-in`, and `reveal` only work on the `local` runtime. In addition: diff --git a/docs/zh/reference/server-api.md b/docs/zh/reference/server-api.md index a3b9b926d5e..776dadfff78 100644 --- a/docs/zh/reference/server-api.md +++ b/docs/zh/reference/server-api.md @@ -1564,6 +1564,7 @@ PTY 终端接口;仅在 loopback 绑定时挂载(非 loopback 绑定会跳 | `GET /api/v1/workspaces/{workspace_id}/trust` | 读取信任状态 | | `POST /api/v1/workspaces/{workspace_id}/trust` | 授予信任 | | `POST /api/v1/workspaces/{workspace_id}/untrust` | 撤销信任 | +| `POST /api/v1/workspaces/{workspace_id}/add-dir` | 添加附加目录 | #### workspace 对象 @@ -1660,6 +1661,22 @@ PTY 终端接口;仅在 loopback 绑定时挂载(非 loopback 绑定会跳 - `40410`:工作区不存在 +#### `POST /api/v1/workspaces/{workspace_id}/add-dir` + +为工作区添加附加目录,语义与 CLI `--add-dir` 及 TUI `/add-dir` 一致。路径支持绝对路径、相对路径(相对工作区根目录解析)与 `~` 展开。 + +| 参数 | 位置 | 类型 | 说明 | +| --- | --- | --- | --- | +| `workspace_id` | path | string | **必填。** 工作区 id | +| `path` | body | string | **必填。** 要添加的目录 | +| `persist` | body | boolean | 缺省 `true`:追加到 `<项目根>/.kimi-code/local.toml` 的 `workspace.additional_dir`;为 `false` 时仅加入内存中的临时集合(同一工作区所有会话共享),不写盘 | + +成功时 `data` 为 `{ project_root, config_path, additional_dirs, persisted }`,其中 `additional_dirs` 是全部附加目录(含既有目录),`persisted` 表示本次是否写盘。 + +- `40001`:校验失败(`details` 逐字段说明),或项目本地配置损坏等引擎校验错误 +- `40409`:`path` 不存在或不是目录 +- `40410`:工作区不存在 + ### 文件系统 会话内文件操作走 `POST /api/v1/sessions/{session_id}/fs:{action}`,请求体为 JSON;动作包括 `list` / `read` / `list_many` / `stat` / `stat_many` / `mkdir` / `search` / `grep` / `git_status` / `diff` / `open` / `open-in` / `reveal`。每个动作的请求体还接受可选的 `runtime_id`(string,默认 `local`),用于选择执行操作的运行时;`open`、`open-in` 与 `reveal` 仅在 `local` 运行时上可用。另有: diff --git a/packages/kap-server/src/protocol/rest-workspace.ts b/packages/kap-server/src/protocol/rest-workspace.ts index 9f494157b28..3f88ff71c85 100644 --- a/packages/kap-server/src/protocol/rest-workspace.ts +++ b/packages/kap-server/src/protocol/rest-workspace.ts @@ -38,3 +38,17 @@ export const workspaceTrustResponseSchema = z.object({ trusted: z.boolean(), }); export type WorkspaceTrustResponse = z.infer; + +export const addDirRequestSchema = z.object({ + path: z.string().min(1), + persist: z.boolean().optional(), +}); +export type AddDirRequest = z.infer; + +export const addDirResponseSchema = z.object({ + project_root: z.string(), + config_path: z.string(), + additional_dirs: z.array(z.string()), + persisted: z.boolean(), +}); +export type AddDirResponse = z.infer; diff --git a/packages/kap-server/src/routes/workspaces.ts b/packages/kap-server/src/routes/workspaces.ts index d9b110265d0..6d60ef90d5c 100644 --- a/packages/kap-server/src/routes/workspaces.ts +++ b/packages/kap-server/src/routes/workspaces.ts @@ -1,4 +1,5 @@ import { + IBootstrapService, IHostFileSystem, IWorkspaceInstanceManager, IWorkspaceService, @@ -7,7 +8,7 @@ import { type Scope, type Workspace, } from '@moonshot-ai/agent-core-v2'; -import { isAbsolute } from 'node:path'; +import { isAbsolute, join, normalize, resolve } from 'node:path'; import { z } from 'zod'; @@ -16,6 +17,8 @@ import { requestLog } from '../lib/requestLog'; import { defineRoute } from '../middleware/defineRoute'; import { ErrorCode } from '../protocol/error-codes'; import { + addDirRequestSchema, + addDirResponseSchema, createWorkspaceRequestSchema, createWorkspaceResponseSchema, deleteWorkspaceResponseSchema, @@ -269,6 +272,84 @@ export function registerWorkspacesRoutes(app: WorkspaceRouteHost, core: Scope): untrustRoute.options, untrustRoute.handler as Parameters[2], ); + + const addDirRoute = defineRoute( + { + method: 'POST', + path: '/workspaces/{workspace_id}/add-dir', + params: workspaceIdParamSchema, + body: addDirRequestSchema, + success: { data: addDirResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.FS_PATH_NOT_FOUND]: {}, + [ErrorCode.WORKSPACE_NOT_FOUND]: {}, + }, + description: 'Add an additional directory to the workspace', + tags: ['workspaces'], + }, + async (req, reply) => { + const { workspace_id } = req.params; + const ws = await core.accessor.get(IWorkspaceService).get(workspace_id); + if (ws === undefined) { + reply.send( + errEnvelope(ErrorCode.WORKSPACE_NOT_FOUND, `workspace ${workspace_id} does not exist`, req.id), + ); + return; + } + const resolved = resolveAdditionalDirPath(core, ws.root, req.body.path); + const hostFs = core.accessor.get(IHostFileSystem); + try { + const stat = await hostFs.stat(resolved); + if (!stat.isDirectory) { + reply.send( + errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, `path ${req.body.path} is not a directory`, req.id), + ); + return; + } + } catch { + reply.send( + errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, `path ${req.body.path} does not exist`, req.id), + ); + return; + } + const workspace = await core + .accessor.get(IWorkspaceInstanceManager) + .getOrCreate({ workspaceId: workspace_id, root: ws.root }); + const result = await workspace.program.dirs.addDir({ + path: req.body.path, + persist: req.body.persist, + }); + reply.send( + okEnvelope( + { + project_root: result.projectRoot, + config_path: result.configPath, + additional_dirs: [...result.additionalDirs], + persisted: result.persisted, + }, + req.id, + ), + ); + }, + ); + app.post( + addDirRoute.path, + addDirRoute.options, + addDirRoute.handler as Parameters[2], + ); +} + +function resolveAdditionalDirPath(core: Scope, root: string, input: string): string { + const trimmed = input.trim(); + const osHomeDir = core.accessor.get(IBootstrapService).osHomeDir; + const expanded = + trimmed === '~' + ? osHomeDir + : trimmed.startsWith('~/') + ? join(osHomeDir, trimmed.slice(2)) + : trimmed; + return isAbsolute(expanded) ? normalize(expanded) : resolve(root, expanded); } type TrustReply = { send(payload: unknown): unknown }; diff --git a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap index a2a5f4b78c6..1ac61bde675 100644 --- a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap +++ b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap @@ -484,6 +484,10 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "POST", "/api/v1/workspaces", ], + [ + "POST", + "/api/v1/workspaces/{workspace_id}/add-dir", + ], [ "POST", "/api/v1/workspaces/{workspace_id}/trust", diff --git a/packages/kap-server/test/workspaces.test.ts b/packages/kap-server/test/workspaces.test.ts index 685cd8b0482..486d591dccf 100644 --- a/packages/kap-server/test/workspaces.test.ts +++ b/packages/kap-server/test/workspaces.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -31,6 +31,13 @@ interface ListWire { items: WorkspaceWire[]; } +interface AddDirWire { + project_root: string; + config_path: string; + additional_dirs: string[]; + persisted: boolean; +} + describe('server-v2 /api/v1/workspaces', () => { let server: RunningServer | undefined; let home: string | undefined; @@ -244,4 +251,80 @@ describe('server-v2 /api/v1/workspaces', () => { expect([typedId, lowerId]).toContain(body.data.items[0]?.id); expect(body.data.items[0]?.session_count).toBe(2); }); + + it('adds an additional directory and persists it by default', async () => { + const root = home as string; + const extra = join(root, 'extra'); + await mkdir(extra); + const created = await postJson('/api/v1/workspaces', { root }); + const id = created.body.data.id; + + const { status, body } = await postJson(`/api/v1/workspaces/${id}/add-dir`, { + path: extra, + }); + expect(status).toBe(200); + expect(body.code).toBe(0); + expect(body.data.persisted).toBe(true); + expect(body.data.additional_dirs).toContain(extra); + expect(body.data.project_root).toBe(root); + expect(body.data.config_path).toBe(join(root, '.kimi-code', 'local.toml')); + const toml = await readFile(body.data.config_path, 'utf8'); + expect(toml).toContain('additional_dir'); + expect(toml).toContain(extra); + }); + + it('adds a relative directory without persisting when persist is false', async () => { + const root = home as string; + const extra = join(root, 'extra-rel'); + await mkdir(extra); + const created = await postJson('/api/v1/workspaces', { root }); + const id = created.body.data.id; + + const { body } = await postJson(`/api/v1/workspaces/${id}/add-dir`, { + path: 'extra-rel', + persist: false, + }); + expect(body.code).toBe(0); + expect(body.data.persisted).toBe(false); + expect(body.data.additional_dirs).toContain(extra); + await expect(readFile(body.data.config_path, 'utf8')).rejects.toThrow(); + }); + + it('returns 40410 when adding a directory to an unknown workspace', async () => { + const { body } = await postJson('/api/v1/workspaces/wd_missing_000000000000/add-dir', { + path: '/tmp', + }); + expect(body.code).toBe(40410); + }); + + it('returns 40409 when the added path does not exist', async () => { + const root = home as string; + const created = await postJson('/api/v1/workspaces', { root }); + const id = created.body.data.id; + + const { body } = await postJson(`/api/v1/workspaces/${id}/add-dir`, { + path: join(root, 'does-not-exist'), + }); + expect(body.code).toBe(40409); + }); + + it('returns 40409 when the added path is a file', async () => { + const root = home as string; + const file = join(root, 'a-file.txt'); + await writeFile(file, 'x', 'utf8'); + const created = await postJson('/api/v1/workspaces', { root }); + const id = created.body.data.id; + + const { body } = await postJson(`/api/v1/workspaces/${id}/add-dir`, { path: file }); + expect(body.code).toBe(40409); + }); + + it('returns 40001 when path is missing', async () => { + const root = home as string; + const created = await postJson('/api/v1/workspaces', { root }); + const id = created.body.data.id; + + const { body } = await postJson(`/api/v1/workspaces/${id}/add-dir`, {}); + expect(body.code).toBe(40001); + }); });