opencode-workspace-scope 0.1.0 → 0.2.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -25
- package/dist/context.js +2 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +49 -44
- package/dist/init.d.ts +44 -19
- package/dist/init.js +60 -56
- package/dist/permissions.d.ts +12 -3
- package/dist/permissions.js +28 -32
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -1,34 +1,44 @@
|
|
|
1
1
|
# opencode-workspace-scope
|
|
2
2
|
|
|
3
|
-
全局安装、**sidecar 自激活**的 [
|
|
3
|
+
全局安装、**sidecar 自激活**的 [opencode2](https://opencode.ai)(V2)插件,为"一个项目涉及多个代码仓库"的并行开发场景提供工作区作用域隔离。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
在 `.opencode/workspace-scope.jsonc` 里声明本项目涉及的各个仓库路径与职责(如前端仓库、后端仓库、编排根),插件据此:
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
- 把"工作区根 +
|
|
7
|
+
- 在权限评估阶段**动态放行**已声明仓库的 `external_directory` 请求,其余外部路径保持默认 ask,配置中的显式 deny 仍然最终生效;
|
|
8
|
+
- 把"工作区根 + 各仓库路径及职责"注入 system prompt,让 AI 每轮都知道改哪里、哪些路径不能碰。
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
sidecar 只需在编排根(或任一仓库)放一份,其他并行会话通过向上查找自动继承作用域,配合 init 工具自动探测兄弟仓库,开箱即用、零重复配置。
|
|
11
|
+
|
|
12
|
+
无 sidecar 的项目中插件不注册任何 hook,完全 no-op,不影响任何正常使用。
|
|
13
|
+
|
|
14
|
+
> 仅适配 opencode2(V2 插件 API)。V1 版本见 `main` 分支,二者不可混用:V2 下加载 V1 插件会以 WARN 失败跳过。
|
|
11
15
|
|
|
12
16
|
## 安装
|
|
13
17
|
|
|
14
|
-
|
|
18
|
+
已发布为 npm 包,通过 V2 CLI 全局安装:
|
|
15
19
|
|
|
16
20
|
```bash
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
opencode2 plugin add opencode-workspace-scope@beta
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
或手动写入全局 `~/.config/opencode/opencode.json(c)` 的 `plugins` 数组(注意是复数 `plugins`,不是 V1 的 `plugin`):
|
|
25
|
+
|
|
26
|
+
```jsonc
|
|
27
|
+
{
|
|
28
|
+
"$schema": "https://opencode.ai/config.json",
|
|
29
|
+
"plugins": ["opencode-workspace-scope@beta"]
|
|
30
|
+
}
|
|
21
31
|
```
|
|
22
32
|
|
|
23
|
-
重启
|
|
33
|
+
重启 opencode2(`opencode2 service restart`)后生效。建议条目带版本/tag 固定,避免被 `plugin update` 升到不兼容版本。
|
|
24
34
|
|
|
25
35
|
## 快速开始
|
|
26
36
|
|
|
27
|
-
1. 在某个工作区根目录启动
|
|
37
|
+
1. 在某个工作区根目录启动 opencode2,对它说:
|
|
28
38
|
|
|
29
39
|
> 初始化这个工作区的 scope 配置
|
|
30
40
|
|
|
31
|
-
插件会调用 `workspace_scope_init` 工具:自动探测父目录下的兄弟 git 仓库作为候选 worktree,生成 `.opencode/workspace-scope.jsonc`(并自动写入 `.opencode/.gitignore`,不纳入版本管理),同时生成配套 JSON Schema
|
|
41
|
+
插件会调用 `workspace_scope_init` 工具:自动探测父目录下的兄弟 git 仓库作为候选 worktree,生成 `.opencode/workspace-scope.jsonc`(并自动写入 `.opencode/.gitignore`,不纳入版本管理),同时生成配套 JSON Schema。也可让 AI 传入显式 worktrees 列表,覆盖任意仓库/目录。
|
|
32
42
|
|
|
33
43
|
2. 编辑该文件,为每个 worktree 补充 `description`(职责说明,会注入 AI 上下文):
|
|
34
44
|
|
|
@@ -49,19 +59,23 @@ ln -s /path/to/opencode-workspace-scope/src/index.ts ~/.config/opencode/plugins/
|
|
|
49
59
|
|
|
50
60
|
`path` 支持绝对路径,也支持相对**工作区根目录**的相对路径(如 `../backend`)。
|
|
51
61
|
|
|
52
|
-
|
|
62
|
+
> `worktrees` 泛指本工作区涉及的各代码仓库/目录,不要求是 git worktree;可以是兄弟仓库、monorepo 子目录,或任意绝对/相对路径。
|
|
63
|
+
|
|
64
|
+
3. **重启 opencode2**,使 external_directory 放行生效(上下文注入无需重启,sidecar 修改后每轮调用会重新加载)。
|
|
53
65
|
|
|
54
66
|
## 权限行为
|
|
55
67
|
|
|
56
|
-
-
|
|
57
|
-
-
|
|
68
|
+
- V2 插件 API 无 config hook,插件**不再改写配置**,改为注册 `permission.evaluate` hook:当 `external_directory` 请求的资源边界全部落在 sidecar 声明的 worktree(词法路径或 realpath)内时,把 `ask` 升级为 `allow`;
|
|
69
|
+
- 只做 `ask → allow` 的单向升级:配置显式 `deny` 不会进入 hook(最终生效),已是 `allow` 的请求保持不变;部分命中的多资源请求**整体不放行**,避免越权放大;
|
|
70
|
+
- 对每个 worktree 同时比对**词法路径与 realpath**,规避符号链接祖先导致匹配失效;
|
|
71
|
+
- 工作区根目录之外、且不在声明范围内的路径:维持默认 `ask`;
|
|
58
72
|
- sidecar 中不存在的路径会被跳过,不会崩溃;
|
|
59
73
|
- **sidecar 向上查找**:从启动目录逐级向上找 `.opencode/workspace-scope.jsonc`(最多 10 层),嵌套子项目会继承父工作区的作用域(对齐 opencode 自身配置发现行为);
|
|
60
|
-
-
|
|
74
|
+
- 注意:`/tmp` 等托管临时目录被 opencode2 内置豁免,不经过 `external_directory` 决策;并行会话需要硬隔离时请在配置中追加显式 deny 规则。
|
|
61
75
|
|
|
62
76
|
## 注入的上下文
|
|
63
77
|
|
|
64
|
-
|
|
78
|
+
每轮模型调用(`session.context` hook)会把以下块 push 进 system parts(title 请求等无会话上下文的调用不注入):
|
|
65
79
|
|
|
66
80
|
```
|
|
67
81
|
## Workspace Scope(由 workspace-scope 插件自动注入)
|
|
@@ -73,11 +87,10 @@ ln -s /path/to/opencode-workspace-scope/src/index.ts ~/.config/opencode/plugins/
|
|
|
73
87
|
|
|
74
88
|
## 设计约束
|
|
75
89
|
|
|
76
|
-
- **自激活**:无 sidecar
|
|
77
|
-
- **权限只押 `external_directory`**:不依赖 read/edit 的路径 pattern
|
|
78
|
-
-
|
|
79
|
-
-
|
|
80
|
-
- **零新增运行时依赖**:仅依赖 `@opencode-ai/plugin`。
|
|
90
|
+
- **自激活**:无 sidecar 时 setup 不注册任何 hook,完全 no-op;
|
|
91
|
+
- **权限只押 `external_directory`**:不依赖 read/edit 的路径 pattern;
|
|
92
|
+
- **不改默认语义**:只升级 `ask → allow`,deny/allow 原样保留,多资源部分命中不放行;
|
|
93
|
+
- **零新增运行时依赖**:仅依赖 `@opencode-ai/plugin`(beta)。
|
|
81
94
|
|
|
82
95
|
## 开发
|
|
83
96
|
|
|
@@ -85,10 +98,18 @@ ln -s /path/to/opencode-workspace-scope/src/index.ts ~/.config/opencode/plugins/
|
|
|
85
98
|
npm install
|
|
86
99
|
npm run typecheck # tsc --noEmit
|
|
87
100
|
npm run build # 输出到 dist/
|
|
88
|
-
bun /tmp/opencode/smoke.ts # 冒烟测试(临时脚本,不入库)
|
|
89
101
|
```
|
|
90
102
|
|
|
91
|
-
|
|
103
|
+
本地联调:在测试工作区的 `.opencode/plugins/` 放一个转发文件(V2 自动发现该目录,直接 `.ts/.js` 文件形式最稳):
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
// <测试工作区>/.opencode/plugins/ws-scope.ts
|
|
107
|
+
export { default } from "/absolute/path/to/opencode-workspace-scope/dist/index.js"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
注意本地包目录形式的插件需要依赖可解析(`@opencode-ai/plugin`),建议用上面的转发文件 + 仓库内 `node_modules`。
|
|
111
|
+
|
|
112
|
+
发布:`npm version prerelease && npm publish --tag beta`(`prepublishOnly` 自动 typecheck + build);V2 插件 API 仍在 beta,随 opencode2 版本升级需回归验证。
|
|
92
113
|
|
|
93
114
|
## License
|
|
94
115
|
|
package/dist/context.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
export function buildScopeBlock(scope) {
|
|
2
2
|
const { config } = scope;
|
|
3
|
+
const wsDesc = config.workspace.description ? ` — ${config.workspace.description}` : "";
|
|
3
4
|
const lines = [
|
|
4
5
|
"## Workspace Scope(由 workspace-scope 插件自动注入)",
|
|
5
|
-
`当前工作区:${config.name}(工作区根:${config.workspace.path}
|
|
6
|
+
`当前工作区:${config.name}(工作区根:${config.workspace.path})${wsDesc}`,
|
|
6
7
|
"本次会话的修改范围仅限以下目录,其他路径一律视为外部目录、需用户确认后才可修改:",
|
|
7
8
|
...config.worktrees.map((w) => {
|
|
8
9
|
const desc = w.description ? ` — ${w.description}` : "";
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import
|
|
2
|
-
export declare const WorkspaceScopePlugin: Plugin;
|
|
1
|
+
import { Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
export declare const WorkspaceScopePlugin: Plugin.Plugin;
|
|
3
3
|
export default WorkspaceScopePlugin;
|
package/dist/index.js
CHANGED
|
@@ -1,49 +1,54 @@
|
|
|
1
|
+
import { Plugin } from "@opencode-ai/plugin";
|
|
1
2
|
import { loadScope } from "./scope.js";
|
|
2
|
-
import {
|
|
3
|
+
import { coveredByScope } from "./permissions.js";
|
|
3
4
|
import { buildScopeBlock } from "./context.js";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
5
|
+
import { INIT_ARGS_SCHEMA, INIT_TOOL_DESCRIPTION, runInit } from "./init.js";
|
|
6
|
+
const log = (message) => {
|
|
7
|
+
console.log(`[workspace-scope] ${message}`);
|
|
8
|
+
};
|
|
9
|
+
export const WorkspaceScopePlugin = Plugin.define({
|
|
10
|
+
id: "opencode-workspace-scope",
|
|
11
|
+
async setup(ctx) {
|
|
12
|
+
// 对齐 V1 语义:优先项目规范根(原 worktree),回退会话目录
|
|
13
|
+
const anchor = ctx.location.project.canonical || ctx.location.directory;
|
|
14
|
+
const scope = loadScope(anchor);
|
|
15
|
+
if (scope)
|
|
16
|
+
log(`已应用工作区作用域 ${scope.sidecarPath}`);
|
|
17
|
+
if (scope) {
|
|
18
|
+
// 权限:不再改写配置,改为在评估阶段动态放行。
|
|
19
|
+
// 仅 escalate ask→allow;配置显式 deny 不会进入本 hook(最终生效)。
|
|
20
|
+
await ctx.permission.hook("evaluate", (event) => {
|
|
21
|
+
if (event.action !== "external_directory")
|
|
22
|
+
return;
|
|
23
|
+
if (event.effect !== "ask")
|
|
24
|
+
return;
|
|
25
|
+
const current = loadScope(anchor);
|
|
26
|
+
if (!current)
|
|
27
|
+
return;
|
|
28
|
+
if (coveredByScope(event.resources, current)) {
|
|
29
|
+
event.effect = "allow";
|
|
30
|
+
event.message = "workspace-scope:目标路径位于本工作区声明的范围内";
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
// 上下文:每轮模型调用注入工作区块;V2 为可 push 数组,无 sessionID 时不会触发 title 请求
|
|
34
|
+
await ctx.session.hook("context", (event) => {
|
|
35
|
+
const current = loadScope(anchor);
|
|
36
|
+
if (!current)
|
|
37
|
+
return;
|
|
38
|
+
event.system.push({ type: "text", text: buildScopeBlock(current) });
|
|
15
39
|
});
|
|
16
|
-
if (result && typeof result.catch === "function") {
|
|
17
|
-
result.catch(() => { });
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
catch {
|
|
21
|
-
// ignore
|
|
22
40
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"experimental.chat.system.transform": async (input, output) => {
|
|
37
|
-
if (!input.sessionID)
|
|
38
|
-
return;
|
|
39
|
-
const scope = loadScope(anchor);
|
|
40
|
-
if (!scope)
|
|
41
|
-
return;
|
|
42
|
-
output.system[0] = `${output.system[0] ?? ""}\n\n${buildScopeBlock(scope)}`;
|
|
43
|
-
},
|
|
44
|
-
tool: {
|
|
45
|
-
workspace_scope_init: initTool,
|
|
46
|
-
},
|
|
47
|
-
};
|
|
48
|
-
};
|
|
41
|
+
// init 工具始终注册:sidecar 尚不存在时也要能引导创建
|
|
42
|
+
await ctx.tool.transform((editor) => {
|
|
43
|
+
editor.add({
|
|
44
|
+
name: "workspace_scope_init",
|
|
45
|
+
description: INIT_TOOL_DESCRIPTION,
|
|
46
|
+
input: INIT_ARGS_SCHEMA,
|
|
47
|
+
async execute(input) {
|
|
48
|
+
return { content: runInit(anchor, input) };
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
},
|
|
53
|
+
});
|
|
49
54
|
export default WorkspaceScopePlugin;
|
package/dist/init.d.ts
CHANGED
|
@@ -1,21 +1,46 @@
|
|
|
1
|
-
export
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
1
|
+
export interface InitArgs {
|
|
2
|
+
name?: string;
|
|
3
|
+
workspaceDescription?: string;
|
|
4
|
+
worktrees?: Array<{
|
|
5
|
+
name: string;
|
|
6
|
+
path: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
}>;
|
|
9
|
+
}
|
|
10
|
+
export declare const INIT_ARGS_SCHEMA: {
|
|
11
|
+
type: string;
|
|
12
|
+
properties: {
|
|
13
|
+
name: {
|
|
14
|
+
type: string;
|
|
15
|
+
description: string;
|
|
16
|
+
};
|
|
17
|
+
workspaceDescription: {
|
|
18
|
+
type: string;
|
|
19
|
+
description: string;
|
|
20
|
+
};
|
|
21
|
+
worktrees: {
|
|
22
|
+
type: string;
|
|
23
|
+
description: string;
|
|
24
|
+
items: {
|
|
25
|
+
type: string;
|
|
26
|
+
required: string[];
|
|
27
|
+
properties: {
|
|
28
|
+
name: {
|
|
29
|
+
type: string;
|
|
30
|
+
description: string;
|
|
31
|
+
};
|
|
32
|
+
path: {
|
|
33
|
+
type: string;
|
|
34
|
+
description: string;
|
|
35
|
+
};
|
|
36
|
+
description: {
|
|
37
|
+
type: string;
|
|
38
|
+
description: string;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
};
|
|
11
43
|
};
|
|
12
|
-
execute(args: {
|
|
13
|
-
name?: string | undefined;
|
|
14
|
-
workspaceDescription?: string | undefined;
|
|
15
|
-
worktrees?: {
|
|
16
|
-
name: string;
|
|
17
|
-
path: string;
|
|
18
|
-
description?: string | undefined;
|
|
19
|
-
}[] | undefined;
|
|
20
|
-
}, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
|
|
21
44
|
};
|
|
45
|
+
export declare const INIT_TOOL_DESCRIPTION = "\u521D\u59CB\u5316\u6216\u91CD\u5EFA\u5F53\u524D\u5DE5\u4F5C\u533A\u7684 workspace-scope \u914D\u7F6E\uFF08.opencode/workspace-scope.jsonc\uFF09\u3002\u4E0D\u4F20 worktrees \u65F6\u81EA\u52A8\u63A2\u6D4B\u7236\u76EE\u5F55\u4E0B\u7684\u5144\u5F1F git \u4ED3\u5E93\u4F5C\u4E3A\u5019\u9009 worktree\u3002\u751F\u6210\u7684 sidecar \u4E0D\u7EB3\u5165\u7248\u672C\u7BA1\u7406\uFF08\u81EA\u52A8\u5199\u5165 .gitignore\uFF09\uFF0C\u5E76\u9644\u5E26 JSON Schema\u3002";
|
|
46
|
+
export declare function runInit(anchor: string, args: InitArgs): string;
|
package/dist/init.js
CHANGED
|
@@ -1,8 +1,28 @@
|
|
|
1
|
-
import { tool } from "@opencode-ai/plugin";
|
|
2
1
|
import { basename, dirname, join } from "node:path";
|
|
3
2
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
|
|
4
3
|
import { invalidateScope } from "./scope.js";
|
|
5
4
|
import { WORKSPACE_SCOPE_SCHEMA } from "./schema.js";
|
|
5
|
+
export const INIT_ARGS_SCHEMA = {
|
|
6
|
+
type: "object",
|
|
7
|
+
properties: {
|
|
8
|
+
name: { type: "string", description: "工作区名称,缺省使用当前目录名" },
|
|
9
|
+
workspaceDescription: { type: "string", description: "工作区职责说明" },
|
|
10
|
+
worktrees: {
|
|
11
|
+
type: "array",
|
|
12
|
+
description: "可选:显式指定 worktree 列表;缺省自动探测父目录兄弟 git 仓库",
|
|
13
|
+
items: {
|
|
14
|
+
type: "object",
|
|
15
|
+
required: ["name", "path"],
|
|
16
|
+
properties: {
|
|
17
|
+
name: { type: "string", description: "仓库名,例如 frontend / backend" },
|
|
18
|
+
path: { type: "string", description: "绝对路径" },
|
|
19
|
+
description: { type: "string", description: "该路径职责说明,会注入 AI 上下文" },
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
export const INIT_TOOL_DESCRIPTION = "初始化或重建当前工作区的 workspace-scope 配置(.opencode/workspace-scope.jsonc)。不传 worktrees 时自动探测父目录下的兄弟 git 仓库作为候选 worktree。生成的 sidecar 不纳入版本管理(自动写入 .gitignore),并附带 JSON Schema。";
|
|
6
26
|
function isGitRepo(p) {
|
|
7
27
|
return existsSync(join(p, ".git"));
|
|
8
28
|
}
|
|
@@ -51,60 +71,44 @@ function writeSchema(sidecarDir) {
|
|
|
51
71
|
// ignore
|
|
52
72
|
}
|
|
53
73
|
}
|
|
54
|
-
export
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
worktrees: tool.schema
|
|
60
|
-
.array(tool.schema.object({
|
|
61
|
-
name: tool.schema.string().describe("仓库名,例如 frontend / backend"),
|
|
62
|
-
path: tool.schema.string().describe("绝对路径"),
|
|
63
|
-
description: tool.schema.string().optional().describe("该路径职责说明,会注入 AI 上下文"),
|
|
64
|
-
}))
|
|
65
|
-
.optional()
|
|
66
|
-
.describe("可选:显式指定 worktree 列表;缺省自动探测父目录兄弟 git 仓库"),
|
|
67
|
-
},
|
|
68
|
-
async execute(args, context) {
|
|
69
|
-
const anchor = context.worktree || context.directory;
|
|
70
|
-
const sidecarDir = join(anchor, ".opencode");
|
|
71
|
-
mkdirSync(sidecarDir, { recursive: true });
|
|
72
|
-
const worktrees = args.worktrees && args.worktrees.length > 0 ? args.worktrees : detectSiblingRepos(anchor);
|
|
73
|
-
if (worktrees.length === 0) {
|
|
74
|
-
return [
|
|
75
|
-
"未探测到兄弟 git 仓库,也未收到显式 worktrees 参数。",
|
|
76
|
-
"请传入 worktrees(name+path+description),或确认已在 workspace 根目录启动 opencode。",
|
|
77
|
-
].join("\n");
|
|
78
|
-
}
|
|
79
|
-
const filePath = join(sidecarDir, "workspace-scope.jsonc");
|
|
80
|
-
const content = {
|
|
81
|
-
$schema: "./workspace-scope.schema.json",
|
|
82
|
-
name: args.name || basename(anchor),
|
|
83
|
-
workspace: {
|
|
84
|
-
path: anchor,
|
|
85
|
-
description: args.workspaceDescription,
|
|
86
|
-
},
|
|
87
|
-
worktrees: worktrees.map((w) => ({
|
|
88
|
-
name: w.name,
|
|
89
|
-
path: w.path,
|
|
90
|
-
description: w.description,
|
|
91
|
-
})),
|
|
92
|
-
};
|
|
93
|
-
writeFileSync(filePath, JSON.stringify(content, null, 2));
|
|
94
|
-
writeSchema(sidecarDir);
|
|
95
|
-
ensureGitignore(sidecarDir, ["workspace-scope.jsonc", "workspace-scope.schema.json"]);
|
|
96
|
-
invalidateScope();
|
|
74
|
+
export function runInit(anchor, args) {
|
|
75
|
+
const sidecarDir = join(anchor, ".opencode");
|
|
76
|
+
mkdirSync(sidecarDir, { recursive: true });
|
|
77
|
+
const worktrees = args.worktrees && args.worktrees.length > 0 ? args.worktrees : detectSiblingRepos(anchor);
|
|
78
|
+
if (worktrees.length === 0) {
|
|
97
79
|
return [
|
|
98
|
-
|
|
99
|
-
"",
|
|
100
|
-
"内容:",
|
|
101
|
-
"```jsonc",
|
|
102
|
-
JSON.stringify(content, null, 2),
|
|
103
|
-
"```",
|
|
104
|
-
"",
|
|
105
|
-
"下一步:",
|
|
106
|
-
"1. 编辑该文件,为每个 worktree 补充 description(职责说明,会注入 AI 上下文);",
|
|
107
|
-
"2. 重启 opencode 使 external_directory 权限生效(上下文注入无需重启)。",
|
|
80
|
+
"未探测到兄弟 git 仓库,也未收到显式 worktrees 参数。",
|
|
81
|
+
"请传入 worktrees(name+path+description),或确认已在 workspace 根目录启动 opencode。",
|
|
108
82
|
].join("\n");
|
|
109
|
-
}
|
|
110
|
-
|
|
83
|
+
}
|
|
84
|
+
const filePath = join(sidecarDir, "workspace-scope.jsonc");
|
|
85
|
+
const content = {
|
|
86
|
+
$schema: "./workspace-scope.schema.json",
|
|
87
|
+
name: args.name || basename(anchor),
|
|
88
|
+
workspace: {
|
|
89
|
+
path: anchor,
|
|
90
|
+
description: args.workspaceDescription,
|
|
91
|
+
},
|
|
92
|
+
worktrees: worktrees.map((w) => ({
|
|
93
|
+
name: w.name,
|
|
94
|
+
path: w.path,
|
|
95
|
+
description: w.description,
|
|
96
|
+
})),
|
|
97
|
+
};
|
|
98
|
+
writeFileSync(filePath, JSON.stringify(content, null, 2));
|
|
99
|
+
writeSchema(sidecarDir);
|
|
100
|
+
ensureGitignore(sidecarDir, ["workspace-scope.jsonc", "workspace-scope.schema.json"]);
|
|
101
|
+
invalidateScope();
|
|
102
|
+
return [
|
|
103
|
+
`已生成 ${filePath}`,
|
|
104
|
+
"",
|
|
105
|
+
"内容:",
|
|
106
|
+
"```jsonc",
|
|
107
|
+
JSON.stringify(content, null, 2),
|
|
108
|
+
"```",
|
|
109
|
+
"",
|
|
110
|
+
"下一步:",
|
|
111
|
+
"1. 编辑该文件,为每个 worktree 补充 description(职责说明,会注入 AI 上下文);",
|
|
112
|
+
"2. 重启 opencode 使 external_directory 权限生效(上下文注入无需重启)。",
|
|
113
|
+
].join("\n");
|
|
114
|
+
}
|
package/dist/permissions.d.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import type { ScopeResult } from "./scope.js";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
/**
|
|
3
|
+
* 归一化 external_directory 资源:去掉结尾通配与斜杠、展开 ~,
|
|
4
|
+
* 得到目录边界路径(V2 中资源通常以 "dir/*" 形式出现且已规范化)。
|
|
5
|
+
*/
|
|
6
|
+
export declare function normalizeBoundary(resource: string): string;
|
|
7
|
+
/** 单个 external_directory 资源边界是否落在作用域声明的 worktree(词法路径或 realpath)内 */
|
|
8
|
+
export declare function inScope(resource: string, scope: ScopeResult): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* 全部资源都命中作用域才放行;部分命中时保持默认(ask),
|
|
11
|
+
* 避免把边界外资源一并放行造成越权放大。
|
|
12
|
+
*/
|
|
13
|
+
export declare function coveredByScope(resources: ReadonlyArray<string>, scope: ScopeResult): boolean;
|
package/dist/permissions.js
CHANGED
|
@@ -1,36 +1,32 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { isAbsolute, relative } from "node:path";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
/**
|
|
4
|
+
* 归一化 external_directory 资源:去掉结尾通配与斜杠、展开 ~,
|
|
5
|
+
* 得到目录边界路径(V2 中资源通常以 "dir/*" 形式出现且已规范化)。
|
|
6
|
+
*/
|
|
7
|
+
export function normalizeBoundary(resource) {
|
|
8
|
+
let p = resource.replaceAll("\\", "/");
|
|
9
|
+
if (p === "~" || p.startsWith("~/"))
|
|
10
|
+
p = homedir() + p.slice(1);
|
|
11
|
+
if (p.endsWith("/*"))
|
|
12
|
+
p = p.slice(0, -2);
|
|
13
|
+
return p.replace(/\/+$/, "");
|
|
4
14
|
}
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
rules[patternPath(wt.path)] = "allow";
|
|
11
|
-
if (wt.realpath !== wt.path)
|
|
12
|
-
rules[patternPath(wt.realpath)] = "allow";
|
|
13
|
-
}
|
|
14
|
-
return rules;
|
|
15
|
+
function isWithin(candidate, root) {
|
|
16
|
+
if (candidate === root)
|
|
17
|
+
return true;
|
|
18
|
+
const rel = relative(root, candidate);
|
|
19
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
15
20
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
if (typeof current === "string") {
|
|
21
|
-
return { "*": current, ...allows };
|
|
22
|
-
}
|
|
23
|
-
return allows;
|
|
21
|
+
/** 单个 external_directory 资源边界是否落在作用域声明的 worktree(词法路径或 realpath)内 */
|
|
22
|
+
export function inScope(resource, scope) {
|
|
23
|
+
const boundary = normalizeBoundary(resource);
|
|
24
|
+
return scope.config.worktrees.some((wt) => isWithin(boundary, wt.path) || isWithin(boundary, wt.realpath));
|
|
24
25
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const base = current;
|
|
32
|
-
return {
|
|
33
|
-
...base,
|
|
34
|
-
external_directory: mergeExternalDirectory(base.external_directory, external),
|
|
35
|
-
};
|
|
26
|
+
/**
|
|
27
|
+
* 全部资源都命中作用域才放行;部分命中时保持默认(ask),
|
|
28
|
+
* 避免把边界外资源一并放行造成越权放大。
|
|
29
|
+
*/
|
|
30
|
+
export function coveredByScope(resources, scope) {
|
|
31
|
+
return resources.length > 0 && resources.every((r) => inScope(r, scope));
|
|
36
32
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-workspace-scope",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "全局安装、sidecar 自激活的
|
|
3
|
+
"version": "0.2.0-beta.1",
|
|
4
|
+
"description": "全局安装、sidecar 自激活的 opencode2 插件:在工作区权限评估时动态放行已声明 worktree 的 external_directory,并把工作区路径与职责注入 AI 上下文,支撑多 worktree 并行开发互不干扰。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
},
|
|
22
22
|
"keywords": [
|
|
23
23
|
"opencode",
|
|
24
|
+
"opencode2",
|
|
24
25
|
"plugin",
|
|
25
26
|
"worktree",
|
|
26
27
|
"workspace",
|
|
@@ -28,7 +29,7 @@
|
|
|
28
29
|
],
|
|
29
30
|
"license": "MIT",
|
|
30
31
|
"dependencies": {
|
|
31
|
-
"@opencode-ai/plugin": "
|
|
32
|
+
"@opencode-ai/plugin": "0.0.0-beta-19151"
|
|
32
33
|
},
|
|
33
34
|
"devDependencies": {
|
|
34
35
|
"@types/node": "^22.0.0",
|