opencode-workspace-scope 0.1.0
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 +95 -0
- package/dist/context.d.ts +2 -0
- package/dist/context.js +13 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +49 -0
- package/dist/init.d.ts +21 -0
- package/dist/init.js +110 -0
- package/dist/jsonc.d.ts +1 -0
- package/dist/jsonc.js +87 -0
- package/dist/permissions.d.ts +4 -0
- package/dist/permissions.js +36 -0
- package/dist/schema.d.ts +49 -0
- package/dist/schema.js +31 -0
- package/dist/scope.d.ts +22 -0
- package/dist/scope.js +94 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# opencode-workspace-scope
|
|
2
|
+
|
|
3
|
+
全局安装、**sidecar 自激活**的 [opencode](https://opencode.ai) 插件,为多 git worktree 并行开发提供工作区作用域隔离。
|
|
4
|
+
|
|
5
|
+
每个并行工作区在 `.opencode/workspace-scope.jsonc` 里声明本工作区涉及的 worktree 路径与职责,插件据此:
|
|
6
|
+
|
|
7
|
+
- 自动注入 `permission.external_directory` 白名单,**只放开本工作区的 worktree**,其余外部路径保持默认 ask;
|
|
8
|
+
- 把"工作区根 + 各 worktree 路径及职责"注入 system prompt,让 AI 每轮都知道改哪里、哪些路径不能碰。
|
|
9
|
+
|
|
10
|
+
无 sidecar 的项目中插件完全 no-op,不影响任何正常使用。
|
|
11
|
+
|
|
12
|
+
## 安装
|
|
13
|
+
|
|
14
|
+
全局安装(推荐,配合 sidecar 自激活):
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
mkdir -p ~/.config/opencode/plugins
|
|
18
|
+
ln -s /path/to/opencode-workspace-scope/src/index.ts ~/.config/opencode/plugins/workspace-scope.ts
|
|
19
|
+
# 或按项目使用 npm:
|
|
20
|
+
# npm install opencode-workspace-scope
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
重启 opencode 后生效。
|
|
24
|
+
|
|
25
|
+
## 快速开始
|
|
26
|
+
|
|
27
|
+
1. 在某个工作区根目录启动 opencode,对它说:
|
|
28
|
+
|
|
29
|
+
> 初始化这个工作区的 scope 配置
|
|
30
|
+
|
|
31
|
+
插件会调用 `workspace_scope_init` 工具:自动探测父目录下的兄弟 git 仓库作为候选 worktree,生成 `.opencode/workspace-scope.jsonc`(并自动写入 `.opencode/.gitignore`,不纳入版本管理),同时生成配套 JSON Schema。
|
|
32
|
+
|
|
33
|
+
2. 编辑该文件,为每个 worktree 补充 `description`(职责说明,会注入 AI 上下文):
|
|
34
|
+
|
|
35
|
+
```jsonc
|
|
36
|
+
{
|
|
37
|
+
"$schema": "./workspace-scope.schema.json",
|
|
38
|
+
"name": "feature-x",
|
|
39
|
+
"workspace": {
|
|
40
|
+
"path": "/home/you/dev/wsp/feature-x",
|
|
41
|
+
"description": "编排根:opencode 配置/规则所在地"
|
|
42
|
+
},
|
|
43
|
+
"worktrees": [
|
|
44
|
+
{ "name": "frontend", "path": "/home/you/dev/wsp/feature-x/frontend", "description": "前端仓库 React/TS" },
|
|
45
|
+
{ "name": "backend", "path": "../backend", "description": "后端仓库 Go" }
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`path` 支持绝对路径,也支持相对**工作区根目录**的相对路径(如 `../backend`)。
|
|
51
|
+
|
|
52
|
+
3. **重启 opencode**,使 external_directory 权限生效(上下文注入无需重启)。
|
|
53
|
+
|
|
54
|
+
## 权限行为
|
|
55
|
+
|
|
56
|
+
- 注入到 `permission.external_directory`:`"<worktree>/**: allow"`,采用"末尾追加、last-match-wins"合并,不覆盖你已有的其他规则;对每个 worktree 同时写入**词法路径与 realpath 两种 pattern**,避免符号链接祖先导致白名单失效;
|
|
57
|
+
- 工作区根目录之外、且不在白名单内的路径:维持 opencode 默认 `ask`;
|
|
58
|
+
- sidecar 中不存在的路径会被跳过,不会崩溃;
|
|
59
|
+
- **sidecar 向上查找**:从启动目录逐级向上找 `.opencode/workspace-scope.jsonc`(最多 10 层),嵌套子项目会继承父工作区的作用域(对齐 opencode 自身配置发现行为);
|
|
60
|
+
- 注意:`opencode --auto` 会把 `ask` 自动放行。若并行会话需要硬隔离,请在配置里为 `external_directory` 追加 `"*": "deny"` 兜底。
|
|
61
|
+
|
|
62
|
+
## 注入的上下文
|
|
63
|
+
|
|
64
|
+
每轮请求会把以下块拼进 system prompt 主块(`output.system[0]`,无 `sessionID` 时不注入):
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
## Workspace Scope(由 workspace-scope 插件自动注入)
|
|
68
|
+
当前工作区:feature-x(工作区根:/home/you/dev/wsp/feature-x)
|
|
69
|
+
本次会话的修改范围仅限以下目录,其他路径一律视为外部目录、需用户确认后才可修改:
|
|
70
|
+
- frontend: /home/you/dev/wsp/feature-x/frontend — 前端仓库 React/TS
|
|
71
|
+
- backend: /home/you/dev/wsp/feature-x/backend — 后端仓库 Go
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## 设计约束
|
|
75
|
+
|
|
76
|
+
- **自激活**:无 sidecar 时插件必须完全 no-op;
|
|
77
|
+
- **权限只押 `external_directory`**:不依赖 read/edit 的路径 pattern(存在绝对/相对不对称问题,见 opencode issue #26524);
|
|
78
|
+
- **不改默认语义**:用户 `permission` 为字符串缩写(如 `"allow"`)时不改写;对象形式按"末尾追加 allow"合并;
|
|
79
|
+
- **system.transform 原地变更**:只拼 `output.system[0]`,禁止整体赋值(静默 no-op,issue #25754);
|
|
80
|
+
- **零新增运行时依赖**:仅依赖 `@opencode-ai/plugin`。
|
|
81
|
+
|
|
82
|
+
## 开发
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
npm install
|
|
86
|
+
npm run typecheck # tsc --noEmit
|
|
87
|
+
npm run build # 输出到 dist/
|
|
88
|
+
bun /tmp/opencode/smoke.ts # 冒烟测试(临时脚本,不入库)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
发布:`npm version patch && npm publish`(`prepublishOnly` 自动 typecheck + build)。
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function buildScopeBlock(scope) {
|
|
2
|
+
const { config } = scope;
|
|
3
|
+
const lines = [
|
|
4
|
+
"## Workspace Scope(由 workspace-scope 插件自动注入)",
|
|
5
|
+
`当前工作区:${config.name}(工作区根:${config.workspace.path})`,
|
|
6
|
+
"本次会话的修改范围仅限以下目录,其他路径一律视为外部目录、需用户确认后才可修改:",
|
|
7
|
+
...config.worktrees.map((w) => {
|
|
8
|
+
const desc = w.description ? ` — ${w.description}` : "";
|
|
9
|
+
return `- ${w.name}: ${w.path}${desc}`;
|
|
10
|
+
}),
|
|
11
|
+
];
|
|
12
|
+
return lines.join("\n");
|
|
13
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { loadScope } from "./scope.js";
|
|
2
|
+
import { buildExternalDirectory, mergePermission } from "./permissions.js";
|
|
3
|
+
import { buildScopeBlock } from "./context.js";
|
|
4
|
+
import { initTool } from "./init.js";
|
|
5
|
+
export const WorkspaceScopePlugin = async ({ directory, worktree, client }) => {
|
|
6
|
+
const anchor = worktree || directory;
|
|
7
|
+
const logApplied = (sidecarPath) => {
|
|
8
|
+
try {
|
|
9
|
+
const result = client.app?.log?.({
|
|
10
|
+
body: {
|
|
11
|
+
service: "workspace-scope",
|
|
12
|
+
level: "info",
|
|
13
|
+
message: `已应用工作区作用域 ${sidecarPath}`,
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
if (result && typeof result.catch === "function") {
|
|
17
|
+
result.catch(() => { });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// ignore
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
return {
|
|
25
|
+
config: async (cfg) => {
|
|
26
|
+
const scope = loadScope(anchor);
|
|
27
|
+
if (!scope)
|
|
28
|
+
return;
|
|
29
|
+
logApplied(scope.sidecarPath);
|
|
30
|
+
const external = buildExternalDirectory(scope);
|
|
31
|
+
if (Object.keys(external).length === 0)
|
|
32
|
+
return;
|
|
33
|
+
const anyCfg = cfg;
|
|
34
|
+
anyCfg.permission = mergePermission(anyCfg.permission, external);
|
|
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
|
+
};
|
|
49
|
+
export default WorkspaceScopePlugin;
|
package/dist/init.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export declare const initTool: {
|
|
2
|
+
description: string;
|
|
3
|
+
args: {
|
|
4
|
+
name: import("zod").ZodOptional<import("zod").ZodString>;
|
|
5
|
+
workspaceDescription: import("zod").ZodOptional<import("zod").ZodString>;
|
|
6
|
+
worktrees: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodObject<{
|
|
7
|
+
name: import("zod").ZodString;
|
|
8
|
+
path: import("zod").ZodString;
|
|
9
|
+
description: import("zod").ZodOptional<import("zod").ZodString>;
|
|
10
|
+
}, import("zod/v4/core").$strip>>>;
|
|
11
|
+
};
|
|
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
|
+
};
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
|
|
4
|
+
import { invalidateScope } from "./scope.js";
|
|
5
|
+
import { WORKSPACE_SCOPE_SCHEMA } from "./schema.js";
|
|
6
|
+
function isGitRepo(p) {
|
|
7
|
+
return existsSync(join(p, ".git"));
|
|
8
|
+
}
|
|
9
|
+
function detectSiblingRepos(anchor) {
|
|
10
|
+
const parent = dirname(anchor);
|
|
11
|
+
let names;
|
|
12
|
+
try {
|
|
13
|
+
names = readdirSync(parent, { withFileTypes: true })
|
|
14
|
+
.filter((d) => d.isDirectory())
|
|
15
|
+
.map((d) => d.name);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
const selfName = basename(anchor);
|
|
21
|
+
const out = [];
|
|
22
|
+
for (const name of names) {
|
|
23
|
+
if (name === selfName)
|
|
24
|
+
continue;
|
|
25
|
+
const p = join(parent, name);
|
|
26
|
+
if (isGitRepo(p))
|
|
27
|
+
out.push({ name, path: p });
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
function ensureGitignore(sidecarDir, lines) {
|
|
32
|
+
const gi = join(sidecarDir, ".gitignore");
|
|
33
|
+
try {
|
|
34
|
+
const cur = existsSync(gi) ? readFileSync(gi, "utf8") : "";
|
|
35
|
+
const existing = cur.split(/\r?\n/);
|
|
36
|
+
const missing = lines.filter((l) => !existing.includes(l));
|
|
37
|
+
if (missing.length > 0) {
|
|
38
|
+
const sep = cur !== "" && !cur.endsWith("\n") ? "\n" : "";
|
|
39
|
+
appendFileSync(gi, `${sep}${missing.join("\n")}\n`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// ignore
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function writeSchema(sidecarDir) {
|
|
47
|
+
try {
|
|
48
|
+
writeFileSync(join(sidecarDir, "workspace-scope.schema.json"), JSON.stringify(WORKSPACE_SCOPE_SCHEMA, null, 2));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// ignore
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export const initTool = tool({
|
|
55
|
+
description: "初始化或重建当前工作区的 workspace-scope 配置(.opencode/workspace-scope.jsonc)。不传 worktrees 时自动探测父目录下的兄弟 git 仓库作为候选 worktree。生成的 sidecar 不纳入版本管理(自动写入 .gitignore),并附带 JSON Schema。",
|
|
56
|
+
args: {
|
|
57
|
+
name: tool.schema.string().optional().describe("工作区名称,缺省使用当前目录名"),
|
|
58
|
+
workspaceDescription: tool.schema.string().optional().describe("工作区职责说明"),
|
|
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();
|
|
97
|
+
return [
|
|
98
|
+
`已生成 ${filePath}`,
|
|
99
|
+
"",
|
|
100
|
+
"内容:",
|
|
101
|
+
"```jsonc",
|
|
102
|
+
JSON.stringify(content, null, 2),
|
|
103
|
+
"```",
|
|
104
|
+
"",
|
|
105
|
+
"下一步:",
|
|
106
|
+
"1. 编辑该文件,为每个 worktree 补充 description(职责说明,会注入 AI 上下文);",
|
|
107
|
+
"2. 重启 opencode 使 external_directory 权限生效(上下文注入无需重启)。",
|
|
108
|
+
].join("\n");
|
|
109
|
+
},
|
|
110
|
+
});
|
package/dist/jsonc.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function parseJsonc(text: string): unknown;
|
package/dist/jsonc.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
function stripComments(text) {
|
|
2
|
+
let out = "";
|
|
3
|
+
let inString = false;
|
|
4
|
+
let i = 0;
|
|
5
|
+
while (i < text.length) {
|
|
6
|
+
const c = text[i];
|
|
7
|
+
const n = text[i + 1];
|
|
8
|
+
if (inString) {
|
|
9
|
+
out += c;
|
|
10
|
+
if (c === "\\" && n !== undefined) {
|
|
11
|
+
out += n;
|
|
12
|
+
i += 2;
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (c === '"')
|
|
16
|
+
inString = false;
|
|
17
|
+
i += 1;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (c === '"') {
|
|
21
|
+
inString = true;
|
|
22
|
+
out += c;
|
|
23
|
+
i += 1;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (c === "/" && n === "/") {
|
|
27
|
+
while (i < text.length && text[i] !== "\n")
|
|
28
|
+
i += 1;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (c === "/" && n === "*") {
|
|
32
|
+
i += 2;
|
|
33
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
|
|
34
|
+
i += 1;
|
|
35
|
+
i += 2;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
out += c;
|
|
39
|
+
i += 1;
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
function stripTrailingCommas(text) {
|
|
44
|
+
let out = "";
|
|
45
|
+
let inString = false;
|
|
46
|
+
let i = 0;
|
|
47
|
+
while (i < text.length) {
|
|
48
|
+
const c = text[i];
|
|
49
|
+
if (inString) {
|
|
50
|
+
out += c;
|
|
51
|
+
if (c === "\\") {
|
|
52
|
+
const n = text[i + 1];
|
|
53
|
+
if (n !== undefined) {
|
|
54
|
+
out += n;
|
|
55
|
+
i += 1;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else if (c === '"') {
|
|
59
|
+
inString = false;
|
|
60
|
+
}
|
|
61
|
+
i += 1;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (c === '"') {
|
|
65
|
+
inString = true;
|
|
66
|
+
out += c;
|
|
67
|
+
i += 1;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (c === ",") {
|
|
71
|
+
let j = i + 1;
|
|
72
|
+
while (j < text.length && (text[j] === " " || text[j] === "\t" || text[j] === "\n" || text[j] === "\r")) {
|
|
73
|
+
j += 1;
|
|
74
|
+
}
|
|
75
|
+
if (text[j] === "}" || text[j] === "]") {
|
|
76
|
+
i += 1;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
out += c;
|
|
81
|
+
i += 1;
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
export function parseJsonc(text) {
|
|
86
|
+
return JSON.parse(stripTrailingCommas(stripComments(text)));
|
|
87
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ScopeResult } from "./scope.js";
|
|
2
|
+
export declare function buildExternalDirectory(scope: ScopeResult): Record<string, string>;
|
|
3
|
+
export declare function mergeExternalDirectory(current: unknown, allows: Record<string, string>): Record<string, string>;
|
|
4
|
+
export declare function mergePermission(current: unknown, external: Record<string, string>): unknown;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
function patternPath(p) {
|
|
3
|
+
return p.replaceAll("\\", "/") + "/**";
|
|
4
|
+
}
|
|
5
|
+
export function buildExternalDirectory(scope) {
|
|
6
|
+
const rules = {};
|
|
7
|
+
for (const wt of scope.config.worktrees) {
|
|
8
|
+
if (!existsSync(wt.path))
|
|
9
|
+
continue;
|
|
10
|
+
rules[patternPath(wt.path)] = "allow";
|
|
11
|
+
if (wt.realpath !== wt.path)
|
|
12
|
+
rules[patternPath(wt.realpath)] = "allow";
|
|
13
|
+
}
|
|
14
|
+
return rules;
|
|
15
|
+
}
|
|
16
|
+
export function mergeExternalDirectory(current, allows) {
|
|
17
|
+
if (current && typeof current === "object" && !Array.isArray(current)) {
|
|
18
|
+
return { ...current, ...allows };
|
|
19
|
+
}
|
|
20
|
+
if (typeof current === "string") {
|
|
21
|
+
return { "*": current, ...allows };
|
|
22
|
+
}
|
|
23
|
+
return allows;
|
|
24
|
+
}
|
|
25
|
+
export function mergePermission(current, external) {
|
|
26
|
+
if (typeof current === "string")
|
|
27
|
+
return current;
|
|
28
|
+
if (!current || typeof current !== "object" || Array.isArray(current)) {
|
|
29
|
+
return { external_directory: external };
|
|
30
|
+
}
|
|
31
|
+
const base = current;
|
|
32
|
+
return {
|
|
33
|
+
...base,
|
|
34
|
+
external_directory: mergeExternalDirectory(base.external_directory, external),
|
|
35
|
+
};
|
|
36
|
+
}
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export declare const WORKSPACE_SCOPE_SCHEMA: {
|
|
2
|
+
$schema: string;
|
|
3
|
+
title: string;
|
|
4
|
+
type: string;
|
|
5
|
+
additionalProperties: boolean;
|
|
6
|
+
properties: {
|
|
7
|
+
name: {
|
|
8
|
+
type: string;
|
|
9
|
+
description: string;
|
|
10
|
+
};
|
|
11
|
+
workspace: {
|
|
12
|
+
type: string;
|
|
13
|
+
additionalProperties: boolean;
|
|
14
|
+
properties: {
|
|
15
|
+
path: {
|
|
16
|
+
type: string;
|
|
17
|
+
description: string;
|
|
18
|
+
};
|
|
19
|
+
description: {
|
|
20
|
+
type: string;
|
|
21
|
+
description: string;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
worktrees: {
|
|
26
|
+
type: string;
|
|
27
|
+
items: {
|
|
28
|
+
type: string;
|
|
29
|
+
additionalProperties: boolean;
|
|
30
|
+
required: string[];
|
|
31
|
+
properties: {
|
|
32
|
+
name: {
|
|
33
|
+
type: string;
|
|
34
|
+
description: string;
|
|
35
|
+
};
|
|
36
|
+
path: {
|
|
37
|
+
type: string;
|
|
38
|
+
description: string;
|
|
39
|
+
};
|
|
40
|
+
description: {
|
|
41
|
+
type: string;
|
|
42
|
+
description: string;
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
required: string[];
|
|
49
|
+
};
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export const WORKSPACE_SCOPE_SCHEMA = {
|
|
2
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
title: "WorkspaceScopeConfig",
|
|
4
|
+
type: "object",
|
|
5
|
+
additionalProperties: false,
|
|
6
|
+
properties: {
|
|
7
|
+
name: { type: "string", description: "工作区名称" },
|
|
8
|
+
workspace: {
|
|
9
|
+
type: "object",
|
|
10
|
+
additionalProperties: false,
|
|
11
|
+
properties: {
|
|
12
|
+
path: { type: "string", description: "工作区根路径" },
|
|
13
|
+
description: { type: "string", description: "工作区职责说明" },
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
worktrees: {
|
|
17
|
+
type: "array",
|
|
18
|
+
items: {
|
|
19
|
+
type: "object",
|
|
20
|
+
additionalProperties: false,
|
|
21
|
+
required: ["name", "path"],
|
|
22
|
+
properties: {
|
|
23
|
+
name: { type: "string", description: "仓库名,如 frontend / backend" },
|
|
24
|
+
path: { type: "string", description: "worktree 路径(绝对路径,或相对工作区根目录,如 ../frontend)" },
|
|
25
|
+
description: { type: "string", description: "该路径职责说明,会注入 AI 上下文" },
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
required: ["worktrees"],
|
|
31
|
+
};
|
package/dist/scope.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface WorktreeDef {
|
|
2
|
+
name: string;
|
|
3
|
+
path: string;
|
|
4
|
+
realpath: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface WorkspaceScope {
|
|
8
|
+
name: string;
|
|
9
|
+
workspace: {
|
|
10
|
+
path: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
};
|
|
13
|
+
worktrees: WorktreeDef[];
|
|
14
|
+
}
|
|
15
|
+
export interface ScopeResult {
|
|
16
|
+
config: WorkspaceScope;
|
|
17
|
+
sidecarPath: string;
|
|
18
|
+
sidecarDir: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function invalidateScope(): void;
|
|
21
|
+
export declare function findSidecarPath(anchor: string): string | null;
|
|
22
|
+
export declare function loadScope(anchor: string): ScopeResult | null;
|
package/dist/scope.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
2
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { parseJsonc } from "./jsonc.js";
|
|
5
|
+
let cached = null;
|
|
6
|
+
let cachedAnchor = null;
|
|
7
|
+
export function invalidateScope() {
|
|
8
|
+
cached = null;
|
|
9
|
+
cachedAnchor = null;
|
|
10
|
+
}
|
|
11
|
+
export function findSidecarPath(anchor) {
|
|
12
|
+
let dir = anchor;
|
|
13
|
+
for (let depth = 0; depth < 10; depth += 1) {
|
|
14
|
+
const candidate = join(dir, ".opencode", "workspace-scope.jsonc");
|
|
15
|
+
if (existsSync(candidate))
|
|
16
|
+
return candidate;
|
|
17
|
+
const parent = dirname(dir);
|
|
18
|
+
if (parent === dir)
|
|
19
|
+
return null;
|
|
20
|
+
dir = parent;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
function toAbsolutePath(p, base) {
|
|
25
|
+
const t = p.trim();
|
|
26
|
+
if (t.startsWith("~"))
|
|
27
|
+
return join(os.homedir(), t.slice(1));
|
|
28
|
+
if (isAbsolute(t))
|
|
29
|
+
return t;
|
|
30
|
+
return join(base, t);
|
|
31
|
+
}
|
|
32
|
+
function normalizePath(p) {
|
|
33
|
+
try {
|
|
34
|
+
return realpathSync(p);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return p;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function loadScope(anchor) {
|
|
41
|
+
if (cached && cachedAnchor === anchor)
|
|
42
|
+
return cached;
|
|
43
|
+
cached = null;
|
|
44
|
+
cachedAnchor = anchor;
|
|
45
|
+
const sidecarPath = findSidecarPath(anchor);
|
|
46
|
+
if (!sidecarPath)
|
|
47
|
+
return null;
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = parseJsonc(readFileSync(sidecarPath, "utf8"));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
56
|
+
return null;
|
|
57
|
+
const raw = parsed;
|
|
58
|
+
if (!Array.isArray(raw.worktrees))
|
|
59
|
+
return null;
|
|
60
|
+
const sidecarDir = dirname(sidecarPath);
|
|
61
|
+
const workspaceRoot = dirname(sidecarDir);
|
|
62
|
+
const wsRaw = raw.workspace && typeof raw.workspace === "object"
|
|
63
|
+
? raw.workspace
|
|
64
|
+
: {};
|
|
65
|
+
const workspacePath = normalizePath(typeof wsRaw.path === "string" && wsRaw.path
|
|
66
|
+
? toAbsolutePath(wsRaw.path, workspaceRoot)
|
|
67
|
+
: workspaceRoot);
|
|
68
|
+
const worktrees = raw.worktrees
|
|
69
|
+
.filter((w) => !!w && typeof w === "object" && typeof w.name === "string" && typeof w.path === "string")
|
|
70
|
+
.map((w) => {
|
|
71
|
+
const path = toAbsolutePath(w.path, workspacePath);
|
|
72
|
+
return {
|
|
73
|
+
name: w.name,
|
|
74
|
+
path,
|
|
75
|
+
realpath: normalizePath(path),
|
|
76
|
+
description: typeof w.description === "string" ? w.description : undefined,
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
if (worktrees.length === 0)
|
|
80
|
+
return null;
|
|
81
|
+
const name = typeof raw.name === "string" && raw.name ? raw.name : basename(anchor);
|
|
82
|
+
return {
|
|
83
|
+
config: {
|
|
84
|
+
name,
|
|
85
|
+
workspace: {
|
|
86
|
+
path: workspacePath,
|
|
87
|
+
description: typeof wsRaw.description === "string" ? wsRaw.description : undefined,
|
|
88
|
+
},
|
|
89
|
+
worktrees,
|
|
90
|
+
},
|
|
91
|
+
sidecarPath,
|
|
92
|
+
sidecarDir,
|
|
93
|
+
};
|
|
94
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-workspace-scope",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "全局安装、sidecar 自激活的 opencode 插件:按工作区配置自动生成 external_directory 权限,并把工作区 worktree 路径与职责注入 AI 上下文,支撑多 worktree 并行开发互不干扰。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc -p tsconfig.json",
|
|
19
|
+
"typecheck": "tsc --noEmit",
|
|
20
|
+
"prepublishOnly": "npm run typecheck && npm run build"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"opencode",
|
|
24
|
+
"plugin",
|
|
25
|
+
"worktree",
|
|
26
|
+
"workspace",
|
|
27
|
+
"permission"
|
|
28
|
+
],
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@opencode-ai/plugin": "^1.18.3"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^22.0.0",
|
|
35
|
+
"typescript": "^5.5.0"
|
|
36
|
+
}
|
|
37
|
+
}
|