@hunyed15/codecgc 0.1.8 → 0.1.9
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/INSTALLATION.md +6 -1
- package/README.md +10 -3
- package/codecgc/reference/execution-model.md +3 -1
- package/codecgc/reference/policy-routing.md +2 -1
- package/codecgc/reference/project-structure.md +9 -2
- package/codecgc/reference/quickstart.md +3 -1
- package/codecgc/reference/troubleshooting.md +3 -1
- package/codecgc/templates/claude/settings.local.json +27 -0
- package/codecgc/templates/codex/codecgcrc.json +22 -0
- package/codecgc/templates/gemini/codecgc-policy.toml +47 -0
- package/codexmcp/src/codexmcp/server.py +45 -0
- package/geminimcp/src/geminimcp/server.py +106 -24
- package/package.json +2 -1
- package/scripts/audit_codecgc_package_runtime.py +3 -0
- package/scripts/build_codecgc_task.py +7 -0
- package/scripts/codecgc_policy.py +180 -8
- package/scripts/codecgc_runtime/routing_template.py +3 -1
- package/scripts/install_codecgc.py +73 -14
package/INSTALLATION.md
CHANGED
|
@@ -48,11 +48,16 @@ cgc-install --mode local --workspace .
|
|
|
48
48
|
.mcp.json
|
|
49
49
|
model-routing.yaml
|
|
50
50
|
.claude/
|
|
51
|
-
settings.json
|
|
51
|
+
settings.local.json
|
|
52
52
|
hooks/
|
|
53
53
|
route-edit.ps1
|
|
54
54
|
commands/
|
|
55
55
|
cgc*.md
|
|
56
|
+
.codex/
|
|
57
|
+
codecgcrc.json
|
|
58
|
+
.gemini/
|
|
59
|
+
policies/
|
|
60
|
+
codecgc-policy.toml
|
|
56
61
|
codecgc/
|
|
57
62
|
START_HERE.md
|
|
58
63
|
features/
|
package/README.md
CHANGED
|
@@ -14,6 +14,8 @@ Claude /cgc -> CodeCGC MCP -> CodeCGC runtime -> Codex 或 Gemini 执行器
|
|
|
14
14
|
|
|
15
15
|
CLI 仍然保留,用于本地调试、CI 检查和 MCP 不可用时的回退执行。普通用户优先使用 Claude 内的 `/cgc`,或在命令行使用 `cgc`,不需要记住所有内部子命令。
|
|
16
16
|
|
|
17
|
+
安全边界:Claude 可以维护需求、规划、文档、审查和工作流状态;产品代码实现必须经由 CodeCGC 路由到 Codex 或 Gemini。项目 hook 会拦截 `Edit`、`Write`、`MultiEdit`、`Bash` 和 `PowerShell`,防止 Claude 用直接编辑或 shell 写入绕过路由。
|
|
18
|
+
|
|
17
19
|
## 安装
|
|
18
20
|
|
|
19
21
|
全局安装 CLI:
|
|
@@ -53,11 +55,16 @@ cgc-doctor
|
|
|
53
55
|
.mcp.json
|
|
54
56
|
model-routing.yaml
|
|
55
57
|
.claude/
|
|
56
|
-
settings.json
|
|
58
|
+
settings.local.json
|
|
57
59
|
hooks/
|
|
58
60
|
route-edit.ps1
|
|
59
61
|
commands/
|
|
60
62
|
cgc*.md
|
|
63
|
+
.codex/
|
|
64
|
+
codecgcrc.json
|
|
65
|
+
.gemini/
|
|
66
|
+
policies/
|
|
67
|
+
codecgc-policy.toml
|
|
61
68
|
codecgc/
|
|
62
69
|
START_HERE.md
|
|
63
70
|
features/
|
|
@@ -72,7 +79,7 @@ codecgc/
|
|
|
72
79
|
fixtures/
|
|
73
80
|
```
|
|
74
81
|
|
|
75
|
-
在 CodeCGC 源码仓库中,`.mcp.json`、`.claude/settings.json`、`.claude/commands/`、`codecgc/START_HERE.md` 以及实时 workflow 输出目录会被忽略,因为它们是机器相关或项目安装生成的内容。
|
|
82
|
+
在 CodeCGC 源码仓库中,`.mcp.json`、`.claude/settings.local.json`、`.claude/commands/`、`.codex/`、`.gemini/`、`codecgc/START_HERE.md` 以及实时 workflow 输出目录会被忽略,因为它们是机器相关或项目安装生成的内容。
|
|
76
83
|
|
|
77
84
|
源码仓库会保留可发布运行时、参考文档、命令模板、测试 fixtures,以及 `.claude/hooks/route-edit.ps1` 这个 hook 模板。
|
|
78
85
|
|
|
@@ -149,7 +156,7 @@ python scripts\audit_codecgc_release_readiness.py --format json
|
|
|
149
156
|
npm pack --dry-run --json
|
|
150
157
|
```
|
|
151
158
|
|
|
152
|
-
`cgc-release-readiness` 会通过临时项目安装探针验证发布包可用性。源码仓库本身不需要提交项目级 `.mcp.json
|
|
159
|
+
`cgc-release-readiness` 会通过临时项目安装探针验证发布包可用性。源码仓库本身不需要提交项目级 `.mcp.json`、`.claude/settings.local.json`、`.codex/` 或 `.gemini/`。
|
|
153
160
|
|
|
154
161
|
如果运行环境限制默认临时目录写入,可以显式指定探针目录:
|
|
155
162
|
|
|
@@ -44,7 +44,9 @@ CodeCGC 把“工作流控制”和“代码执行”明确分层。
|
|
|
44
44
|
运行时 guardrail 主要声明在:
|
|
45
45
|
|
|
46
46
|
- `.mcp.json`
|
|
47
|
-
- `.claude/settings.json`
|
|
47
|
+
- `.claude/settings.local.json`
|
|
48
|
+
- `.codex/codecgcrc.json`
|
|
49
|
+
- `.gemini/policies/codecgc-policy.toml`
|
|
48
50
|
- `.claude/hooks/route-edit.ps1`
|
|
49
51
|
|
|
50
52
|
这些文件一起负责“不能越界”。
|
|
@@ -44,11 +44,12 @@ This creates project-local `.mcp.json`, `.claude/`, `model-routing.yaml`, and th
|
|
|
44
44
|
`scripts/codecgc_policy.py` evaluates the policy for every entry point that needs write ownership:
|
|
45
45
|
|
|
46
46
|
- Claude hook guardrail via `.claude/hooks/route-edit.ps1` for `Edit`, `Write`, and `MultiEdit`
|
|
47
|
+
- Claude shell guardrail via the same hook for `Bash` and `PowerShell`
|
|
47
48
|
- task payload construction via `scripts/build_codecgc_task.py`
|
|
48
49
|
- build/fix/test wrappers before executor dispatch
|
|
49
50
|
- status and doctor checks through `model-routing.yaml` validation
|
|
50
51
|
|
|
51
|
-
The hook is intentionally thin. It
|
|
52
|
+
The hook is intentionally thin. It forwards Claude edit requests to the shared policy checker, and it only allows Claude shell commands that are CodeCGC entry commands, read-only inspection commands, or test/check commands. Direct shell writes, destructive commands, redirection, pipes, and chained shell commands are denied so Claude cannot bypass CodeCGC routing.
|
|
52
53
|
|
|
53
54
|
## Shared Paths
|
|
54
55
|
|
|
@@ -29,8 +29,10 @@ The source repository should not commit project-local install output. These file
|
|
|
29
29
|
|
|
30
30
|
```text
|
|
31
31
|
.mcp.json
|
|
32
|
-
.claude/settings.json
|
|
32
|
+
.claude/settings.local.json
|
|
33
33
|
.claude/commands/
|
|
34
|
+
.codex/codecgcrc.json
|
|
35
|
+
.gemini/policies/codecgc-policy.toml
|
|
34
36
|
codecgc/START_HERE.md
|
|
35
37
|
codecgc/features/
|
|
36
38
|
codecgc/issues/
|
|
@@ -52,11 +54,16 @@ Keep `codecgc/fixtures/`, `codecgc/reference/`, `codecgc/roadmap/`, and `codecgc
|
|
|
52
54
|
.mcp.json
|
|
53
55
|
model-routing.yaml
|
|
54
56
|
.claude/
|
|
55
|
-
settings.json
|
|
57
|
+
settings.local.json
|
|
56
58
|
hooks/
|
|
57
59
|
route-edit.ps1
|
|
58
60
|
commands/
|
|
59
61
|
cgc*.md
|
|
62
|
+
.codex/
|
|
63
|
+
codecgcrc.json
|
|
64
|
+
.gemini/
|
|
65
|
+
policies/
|
|
66
|
+
codecgc-policy.toml
|
|
60
67
|
codecgc/
|
|
61
68
|
features/
|
|
62
69
|
issues/
|
|
@@ -31,9 +31,11 @@ The project install syncs:
|
|
|
31
31
|
```text
|
|
32
32
|
.mcp.json
|
|
33
33
|
model-routing.yaml
|
|
34
|
-
.claude/settings.json
|
|
34
|
+
.claude/settings.local.json
|
|
35
35
|
.claude/hooks/route-edit.ps1
|
|
36
36
|
.claude/commands/cgc*.md
|
|
37
|
+
.codex/codecgcrc.json
|
|
38
|
+
.gemini/policies/codecgc-policy.toml
|
|
37
39
|
codecgc/START_HERE.md
|
|
38
40
|
codecgc/
|
|
39
41
|
```
|
|
@@ -45,7 +45,7 @@ The generated file is `codecgc/START_HERE.md`. It should use project-relative pa
|
|
|
45
45
|
|
|
46
46
|
## Claude Hook Blocks A Write
|
|
47
47
|
|
|
48
|
-
The hook is a guardrail. It blocks Claude direct writes to product source paths that should belong to Codex or Gemini
|
|
48
|
+
The hook is a guardrail. It blocks Claude direct writes to product source paths that should belong to Codex or Gemini, including direct shell writes through `Bash` or `PowerShell`.
|
|
49
49
|
|
|
50
50
|
Expected behavior:
|
|
51
51
|
|
|
@@ -56,6 +56,8 @@ Expected behavior:
|
|
|
56
56
|
|
|
57
57
|
If a write was blocked incorrectly, inspect `model-routing.yaml` first. It is the project-local policy source.
|
|
58
58
|
|
|
59
|
+
For shell commands, use `/cgc` or `cgc` for implementation work. The hook only allows CodeCGC entry commands, read-only inspection commands, and test/check commands.
|
|
60
|
+
|
|
59
61
|
## Codex Or Gemini Is Unavailable
|
|
60
62
|
|
|
61
63
|
Run:
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"WebSearch",
|
|
5
|
+
"Read(**)",
|
|
6
|
+
"Edit(**)",
|
|
7
|
+
"Update(**)",
|
|
8
|
+
"Write(**)",
|
|
9
|
+
"mcp__*",
|
|
10
|
+
"mcp__codecgc__*",
|
|
11
|
+
"mcp__codex__*",
|
|
12
|
+
"mcp__gemini__*",
|
|
13
|
+
"Bash(*)",
|
|
14
|
+
"PowerShell(*)",
|
|
15
|
+
"Edit *",
|
|
16
|
+
"Reading *",
|
|
17
|
+
"Added *",
|
|
18
|
+
"mcp__memos-mcp__add_message"
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"enabledMcpjsonServers": [
|
|
22
|
+
"codex",
|
|
23
|
+
"gemini",
|
|
24
|
+
"codecgc"
|
|
25
|
+
],
|
|
26
|
+
"enableAllProjectMcpServers": true
|
|
27
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": 1,
|
|
3
|
+
"owner": "CodeCGC",
|
|
4
|
+
"scope": "project",
|
|
5
|
+
"executor": "codex",
|
|
6
|
+
"role": "backend implementation and backend tests",
|
|
7
|
+
"enforcement": {
|
|
8
|
+
"primary": "CodeCGC MCP backend path validation",
|
|
9
|
+
"codex_cli": "sandbox and approval flags supplied by codexmcp",
|
|
10
|
+
"routing_policy": "model-routing.yaml"
|
|
11
|
+
},
|
|
12
|
+
"allowed_path_kinds": [
|
|
13
|
+
"backend"
|
|
14
|
+
],
|
|
15
|
+
"denied_path_kinds": [
|
|
16
|
+
"frontend"
|
|
17
|
+
],
|
|
18
|
+
"notes": [
|
|
19
|
+
"Codex CLI project execpolicy discovery is version dependent; CodeCGC treats this file as the project-local policy contract used by its MCP wrapper and status checks.",
|
|
20
|
+
"Do not edit frontend files, styling files, UI components, or mixed shared paths without a split plan."
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# CodeCGC project-local Gemini policy.
|
|
2
|
+
#
|
|
3
|
+
# This file is passed explicitly by GeminiMCP through `gemini --policy`.
|
|
4
|
+
# It is intentionally project-local so each repository can review and tune it.
|
|
5
|
+
|
|
6
|
+
[[rule]]
|
|
7
|
+
toolName = "run_shell_command"
|
|
8
|
+
commandPrefix = [
|
|
9
|
+
"rm -rf",
|
|
10
|
+
"del ",
|
|
11
|
+
"rmdir ",
|
|
12
|
+
"Remove-Item",
|
|
13
|
+
"git reset --hard",
|
|
14
|
+
"git clean"
|
|
15
|
+
]
|
|
16
|
+
decision = "deny"
|
|
17
|
+
priority = 900
|
|
18
|
+
denyMessage = "CodeCGC blocks destructive shell commands in Gemini executor sessions."
|
|
19
|
+
|
|
20
|
+
[[rule]]
|
|
21
|
+
toolName = [
|
|
22
|
+
"write_file",
|
|
23
|
+
"replace"
|
|
24
|
+
]
|
|
25
|
+
decision = "allow"
|
|
26
|
+
priority = 500
|
|
27
|
+
modes = ["autoEdit"]
|
|
28
|
+
|
|
29
|
+
[rule.safety_checker]
|
|
30
|
+
type = "in-process"
|
|
31
|
+
name = "allowed-path"
|
|
32
|
+
required_context = ["environment"]
|
|
33
|
+
|
|
34
|
+
[[rule]]
|
|
35
|
+
toolName = "run_shell_command"
|
|
36
|
+
commandPrefix = [
|
|
37
|
+
"npm test",
|
|
38
|
+
"npm run test",
|
|
39
|
+
"pnpm test",
|
|
40
|
+
"pnpm run test",
|
|
41
|
+
"yarn test",
|
|
42
|
+
"git diff",
|
|
43
|
+
"git status"
|
|
44
|
+
]
|
|
45
|
+
decision = "allow"
|
|
46
|
+
priority = 300
|
|
47
|
+
modes = ["autoEdit"]
|
|
@@ -51,6 +51,8 @@ FRONTEND_FILE_SUFFIXES = (
|
|
|
51
51
|
".svelte",
|
|
52
52
|
)
|
|
53
53
|
|
|
54
|
+
PROJECT_CODEX_POLICY_RELATIVE_PATH = Path(".codex") / "codecgcrc.json"
|
|
55
|
+
|
|
54
56
|
|
|
55
57
|
def _empty_str_to_none(value: str | None) -> str | None:
|
|
56
58
|
"""Convert empty strings to None for optional UUID parameters."""
|
|
@@ -64,6 +66,45 @@ def _normalize_path_text(path_value: Path | str) -> str:
|
|
|
64
66
|
return str(path_value).replace("\\", "/").strip()
|
|
65
67
|
|
|
66
68
|
|
|
69
|
+
def _load_project_codex_policy_context(cd: Path) -> str:
|
|
70
|
+
"""Load the CodeCGC project-local Codex policy contract as prompt context."""
|
|
71
|
+
policy_path = cd / PROJECT_CODEX_POLICY_RELATIVE_PATH
|
|
72
|
+
if not policy_path.is_file():
|
|
73
|
+
return ""
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
payload = json.loads(policy_path.read_text(encoding="utf-8"))
|
|
77
|
+
except Exception:
|
|
78
|
+
return ""
|
|
79
|
+
|
|
80
|
+
if not isinstance(payload, dict):
|
|
81
|
+
return ""
|
|
82
|
+
|
|
83
|
+
enforcement = payload.get("enforcement")
|
|
84
|
+
if not isinstance(enforcement, dict):
|
|
85
|
+
enforcement = {}
|
|
86
|
+
|
|
87
|
+
allowed_path_kinds = payload.get("allowed_path_kinds")
|
|
88
|
+
denied_path_kinds = payload.get("denied_path_kinds")
|
|
89
|
+
notes = payload.get("notes")
|
|
90
|
+
|
|
91
|
+
lines = [
|
|
92
|
+
"Project CodeCGC Codex policy contract:",
|
|
93
|
+
f"- Role: {payload.get('role', 'backend implementation and backend tests')}",
|
|
94
|
+
f"- Routing policy: {enforcement.get('routing_policy', 'model-routing.yaml')}",
|
|
95
|
+
f"- Primary enforcement: {enforcement.get('primary', 'CodeCGC MCP backend path validation')}",
|
|
96
|
+
f"- Codex CLI guardrail: {enforcement.get('codex_cli', 'sandbox and approval flags supplied by codexmcp')}",
|
|
97
|
+
]
|
|
98
|
+
if isinstance(allowed_path_kinds, list):
|
|
99
|
+
lines.append("- Allowed path kinds: " + ", ".join(str(item) for item in allowed_path_kinds))
|
|
100
|
+
if isinstance(denied_path_kinds, list):
|
|
101
|
+
lines.append("- Denied path kinds: " + ", ".join(str(item) for item in denied_path_kinds))
|
|
102
|
+
if isinstance(notes, list):
|
|
103
|
+
lines.extend(f"- {str(item)}" for item in notes if str(item).strip())
|
|
104
|
+
lines.append("Follow this project policy before making any code change.")
|
|
105
|
+
return "\n".join(lines).strip()
|
|
106
|
+
|
|
107
|
+
|
|
67
108
|
def _is_probably_frontend_path(path_value: Path | str) -> bool:
|
|
68
109
|
"""Best-effort check to keep backend-only Codex tasks away from frontend files."""
|
|
69
110
|
normalized = _normalize_path_text(path_value).lower().lstrip("./")
|
|
@@ -255,6 +296,10 @@ def _execute_codex_session(
|
|
|
255
296
|
if session_id:
|
|
256
297
|
cmd.extend(["resume", str(session_id)])
|
|
257
298
|
|
|
299
|
+
policy_context = _load_project_codex_policy_context(cd)
|
|
300
|
+
if policy_context:
|
|
301
|
+
prompt = f"{policy_context}\n\nUser task:\n{prompt}"
|
|
302
|
+
|
|
258
303
|
if os.name == "nt":
|
|
259
304
|
prompt = windows_escape(prompt)
|
|
260
305
|
cmd += ["--", prompt]
|
|
@@ -18,6 +18,10 @@ from mcp.server.fastmcp import FastMCP
|
|
|
18
18
|
from pydantic import BeforeValidator, Field
|
|
19
19
|
import shutil
|
|
20
20
|
|
|
21
|
+
DEFAULT_GEMINI_APPROVAL_MODE = "auto_edit"
|
|
22
|
+
DEFAULT_GEMINI_TIMEOUT_SECONDS = 600
|
|
23
|
+
PROJECT_GEMINI_POLICY_RELATIVE_PATH = Path(".gemini") / "policies" / "codecgc-policy.toml"
|
|
24
|
+
|
|
21
25
|
mcp = FastMCP("Gemini MCP Server-from guda.studio")
|
|
22
26
|
|
|
23
27
|
# Mirror of model-routing.yaml backend_paths — keep these hints in sync with
|
|
@@ -49,6 +53,14 @@ def _normalize_path_text(path_value: Path | str) -> str:
|
|
|
49
53
|
return str(path_value).replace("\\", "/").strip()
|
|
50
54
|
|
|
51
55
|
|
|
56
|
+
def _resolve_project_gemini_policy(cd: Path) -> Path | None:
|
|
57
|
+
"""Return the CodeCGC project-local Gemini policy if the workspace installed it."""
|
|
58
|
+
policy_path = cd / PROJECT_GEMINI_POLICY_RELATIVE_PATH
|
|
59
|
+
if policy_path.is_file():
|
|
60
|
+
return policy_path
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
52
64
|
def _is_probably_backend_path(path_value: Path | str) -> bool:
|
|
53
65
|
"""Best-effort check to keep frontend-only Gemini tasks away from backend files."""
|
|
54
66
|
normalized = _normalize_path_text(path_value).lower().lstrip("./")
|
|
@@ -126,7 +138,29 @@ def _validate_frontend_target_paths(target_paths: List[Path]) -> tuple[bool, Lis
|
|
|
126
138
|
return True, policy_checks, ""
|
|
127
139
|
|
|
128
140
|
|
|
129
|
-
def
|
|
141
|
+
def _terminate_process_tree(process: subprocess.Popen[str]) -> None:
|
|
142
|
+
"""Terminate a process and its children best-effort."""
|
|
143
|
+
if process.poll() is not None:
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
if os.name == "nt":
|
|
147
|
+
subprocess.run(
|
|
148
|
+
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
|
149
|
+
stdin=subprocess.DEVNULL,
|
|
150
|
+
stdout=subprocess.DEVNULL,
|
|
151
|
+
stderr=subprocess.DEVNULL,
|
|
152
|
+
check=False,
|
|
153
|
+
)
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
process.kill()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def run_shell_command(
|
|
160
|
+
cmd: list[str],
|
|
161
|
+
cwd: str | None = None,
|
|
162
|
+
timeout_seconds: int = DEFAULT_GEMINI_TIMEOUT_SECONDS,
|
|
163
|
+
) -> Generator[str, None, None]:
|
|
130
164
|
"""Execute a command and stream its output line-by-line.
|
|
131
165
|
|
|
132
166
|
Args:
|
|
@@ -158,6 +192,8 @@ def run_shell_command(cmd: list[str], cwd: str | None = None) -> Generator[str,
|
|
|
158
192
|
|
|
159
193
|
output_queue: queue.Queue[str | None] = queue.Queue()
|
|
160
194
|
GRACEFUL_SHUTDOWN_DELAY = 0.3
|
|
195
|
+
started_at = time.monotonic()
|
|
196
|
+
timed_out = False
|
|
161
197
|
|
|
162
198
|
def is_turn_completed(line: str) -> bool:
|
|
163
199
|
"""Check if the line indicates turn completion via JSON parsing."""
|
|
@@ -185,6 +221,11 @@ def run_shell_command(cmd: list[str], cwd: str | None = None) -> Generator[str,
|
|
|
185
221
|
|
|
186
222
|
# Yield lines while process is running
|
|
187
223
|
while True:
|
|
224
|
+
if timeout_seconds > 0 and time.monotonic() - started_at > timeout_seconds:
|
|
225
|
+
timed_out = True
|
|
226
|
+
_terminate_process_tree(process)
|
|
227
|
+
break
|
|
228
|
+
|
|
188
229
|
try:
|
|
189
230
|
line = output_queue.get(timeout=0.5)
|
|
190
231
|
if line is None:
|
|
@@ -197,7 +238,7 @@ def run_shell_command(cmd: list[str], cwd: str | None = None) -> Generator[str,
|
|
|
197
238
|
try:
|
|
198
239
|
process.wait(timeout=5)
|
|
199
240
|
except subprocess.TimeoutExpired:
|
|
200
|
-
process
|
|
241
|
+
_terminate_process_tree(process)
|
|
201
242
|
process.wait()
|
|
202
243
|
thread.join(timeout=5)
|
|
203
244
|
|
|
@@ -209,6 +250,13 @@ def run_shell_command(cmd: list[str], cwd: str | None = None) -> Generator[str,
|
|
|
209
250
|
except queue.Empty:
|
|
210
251
|
break
|
|
211
252
|
|
|
253
|
+
if timed_out:
|
|
254
|
+
raise TimeoutError(
|
|
255
|
+
f"Gemini CLI timed out after {timeout_seconds} seconds. "
|
|
256
|
+
"This usually means the CLI was waiting for interactive approval, "
|
|
257
|
+
"network/authentication, or a long-running tool call."
|
|
258
|
+
)
|
|
259
|
+
|
|
212
260
|
|
|
213
261
|
def _execute_gemini_session(
|
|
214
262
|
*,
|
|
@@ -218,6 +266,7 @@ def _execute_gemini_session(
|
|
|
218
266
|
session_id: str,
|
|
219
267
|
return_all_messages: bool,
|
|
220
268
|
model: str,
|
|
269
|
+
timeout_seconds: int = DEFAULT_GEMINI_TIMEOUT_SECONDS,
|
|
221
270
|
) -> Dict[str, Any]:
|
|
222
271
|
"""Execute Gemini CLI and return the parsed MCP response payload."""
|
|
223
272
|
if not cd.exists():
|
|
@@ -229,7 +278,21 @@ def _execute_gemini_session(
|
|
|
229
278
|
if os.name == "nt":
|
|
230
279
|
prompt = windows_escape(prompt)
|
|
231
280
|
|
|
232
|
-
|
|
281
|
+
effective_timeout_seconds = int(timeout_seconds or 0) or DEFAULT_GEMINI_TIMEOUT_SECONDS
|
|
282
|
+
cmd = [
|
|
283
|
+
"gemini",
|
|
284
|
+
"--skip-trust",
|
|
285
|
+
"--approval-mode",
|
|
286
|
+
DEFAULT_GEMINI_APPROVAL_MODE,
|
|
287
|
+
"--prompt",
|
|
288
|
+
prompt,
|
|
289
|
+
"-o",
|
|
290
|
+
"stream-json",
|
|
291
|
+
]
|
|
292
|
+
|
|
293
|
+
project_policy = _resolve_project_gemini_policy(cd)
|
|
294
|
+
if project_policy is not None:
|
|
295
|
+
cmd.extend(["--policy", project_policy.absolute().as_posix()])
|
|
233
296
|
|
|
234
297
|
if sandbox:
|
|
235
298
|
cmd.extend(["--sandbox"])
|
|
@@ -246,27 +309,36 @@ def _execute_gemini_session(
|
|
|
246
309
|
err_message = ""
|
|
247
310
|
thread_id: Optional[str] = None
|
|
248
311
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
)
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
312
|
+
try:
|
|
313
|
+
for line in run_shell_command(
|
|
314
|
+
cmd,
|
|
315
|
+
cwd=cd.absolute().as_posix(),
|
|
316
|
+
timeout_seconds=effective_timeout_seconds,
|
|
317
|
+
):
|
|
318
|
+
try:
|
|
319
|
+
line_dict = json.loads(line.strip())
|
|
320
|
+
all_messages.append(line_dict)
|
|
321
|
+
item_type = line_dict.get("type", "")
|
|
322
|
+
item_role = line_dict.get("role", "")
|
|
323
|
+
if item_type == "message" and item_role == "assistant":
|
|
324
|
+
if (
|
|
325
|
+
"The --prompt (-p) flag has been deprecated and will be removed in a future version. Please use a positional argument for your prompt. See gemini --help for more information.\n"
|
|
326
|
+
in line_dict.get("content", "")
|
|
327
|
+
):
|
|
328
|
+
continue
|
|
329
|
+
agent_messages = agent_messages + line_dict.get("content", "")
|
|
330
|
+
if line_dict.get("session_id") is not None:
|
|
331
|
+
thread_id = line_dict.get("session_id")
|
|
332
|
+
except json.JSONDecodeError:
|
|
333
|
+
err_message += "\n\n[json decode error] " + line
|
|
334
|
+
continue
|
|
335
|
+
except Exception as error:
|
|
336
|
+
err_message += "\n\n[unexpected error] " + f"Unexpected error: {error}. Line: {line!r}"
|
|
337
|
+
success = False
|
|
338
|
+
break
|
|
339
|
+
except TimeoutError as error:
|
|
340
|
+
success = False
|
|
341
|
+
err_message += "\n\n[timeout] " + str(error)
|
|
270
342
|
|
|
271
343
|
if thread_id is None:
|
|
272
344
|
success = False
|
|
@@ -359,6 +431,10 @@ async def gemini(
|
|
|
359
431
|
str,
|
|
360
432
|
"The model to use for the gemini session. This parameter is strictly prohibited unless explicitly specified by the user.",
|
|
361
433
|
] = "",
|
|
434
|
+
timeout_seconds: Annotated[
|
|
435
|
+
int,
|
|
436
|
+
Field(description="Maximum Gemini CLI process runtime in seconds. Defaults to 600."),
|
|
437
|
+
] = DEFAULT_GEMINI_TIMEOUT_SECONDS,
|
|
362
438
|
) -> Dict[str, Any]:
|
|
363
439
|
"""Execute a gemini CLI session and return the results."""
|
|
364
440
|
return await asyncio.to_thread(
|
|
@@ -370,6 +446,7 @@ async def gemini(
|
|
|
370
446
|
session_id=SESSION_ID,
|
|
371
447
|
return_all_messages=return_all_messages,
|
|
372
448
|
model=model,
|
|
449
|
+
timeout_seconds=timeout_seconds,
|
|
373
450
|
)
|
|
374
451
|
)
|
|
375
452
|
|
|
@@ -421,6 +498,10 @@ async def implement_frontend_task(
|
|
|
421
498
|
str,
|
|
422
499
|
"Optional model override. Only use when explicitly requested by the user.",
|
|
423
500
|
] = "",
|
|
501
|
+
timeout_seconds: Annotated[
|
|
502
|
+
int,
|
|
503
|
+
Field(description="Maximum Gemini CLI process runtime in seconds. Defaults to 600."),
|
|
504
|
+
] = DEFAULT_GEMINI_TIMEOUT_SECONDS,
|
|
424
505
|
) -> Dict[str, Any]:
|
|
425
506
|
"""Execute a frontend-only Gemini task with CodeCGC policy checks."""
|
|
426
507
|
valid, policy_checks, validation_error = _validate_frontend_target_paths(target_paths)
|
|
@@ -447,6 +528,7 @@ async def implement_frontend_task(
|
|
|
447
528
|
session_id=SESSION_ID,
|
|
448
529
|
return_all_messages=return_all_messages,
|
|
449
530
|
model=model,
|
|
531
|
+
timeout_seconds=timeout_seconds,
|
|
450
532
|
)
|
|
451
533
|
)
|
|
452
534
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hunyed15/codecgc",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Claude-hosted multi-model workflow product shell for CodeCGC.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"codecgc/compound/",
|
|
65
65
|
"codecgc/reference/",
|
|
66
66
|
"codecgc/roadmap/",
|
|
67
|
+
"codecgc/templates/",
|
|
67
68
|
"codecgcmcp/pyproject.toml",
|
|
68
69
|
"codecgcmcp/README.md",
|
|
69
70
|
"codecgcmcp/src/codecgcmcp/*.py",
|
|
@@ -22,6 +22,9 @@ RUNTIME_ENTRYPOINTS = [
|
|
|
22
22
|
|
|
23
23
|
RUNTIME_STATIC_REQUIREMENTS = [
|
|
24
24
|
".claude/hooks/route-edit.ps1",
|
|
25
|
+
"codecgc/templates/claude/settings.local.json",
|
|
26
|
+
"codecgc/templates/codex/codecgcrc.json",
|
|
27
|
+
"codecgc/templates/gemini/codecgc-policy.toml",
|
|
25
28
|
"model-routing.yaml",
|
|
26
29
|
"requirements.txt",
|
|
27
30
|
"scripts/codecgc_runtime/__init__.py",
|
|
@@ -209,6 +209,7 @@ def load_explicit_task_payload(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
209
209
|
"codex_sandbox": args.codex_sandbox or "workspace-write",
|
|
210
210
|
"gemini_sandbox": bool(args.gemini_sandbox),
|
|
211
211
|
"return_all_messages": bool(args.return_all_messages),
|
|
212
|
+
"timeout_seconds": int(getattr(args, "timeout_seconds", 0) or 0),
|
|
212
213
|
"source": None,
|
|
213
214
|
}
|
|
214
215
|
|
|
@@ -345,6 +346,10 @@ def build_tool_call(args: argparse.Namespace, routing: dict[str, Any]) -> dict[s
|
|
|
345
346
|
+ json.dumps(policy_result, ensure_ascii=False)
|
|
346
347
|
)
|
|
347
348
|
|
|
349
|
+
effective_timeout_seconds = int(payload_inputs.get("timeout_seconds", 0)) or int(
|
|
350
|
+
getattr(args, "timeout_seconds", 0) or 0
|
|
351
|
+
)
|
|
352
|
+
|
|
348
353
|
if kind == "frontend":
|
|
349
354
|
tool_name = "implement_frontend_task"
|
|
350
355
|
tool_args: dict[str, Any] = {
|
|
@@ -358,6 +363,7 @@ def build_tool_call(args: argparse.Namespace, routing: dict[str, Any]) -> dict[s
|
|
|
358
363
|
"sandbox": payload_inputs["gemini_sandbox"],
|
|
359
364
|
"return_all_messages": payload_inputs["return_all_messages"],
|
|
360
365
|
"model": payload_inputs["model"],
|
|
366
|
+
"timeout_seconds": effective_timeout_seconds,
|
|
361
367
|
}
|
|
362
368
|
elif kind == "backend":
|
|
363
369
|
tool_name = "implement_backend_task"
|
|
@@ -382,6 +388,7 @@ def build_tool_call(args: argparse.Namespace, routing: dict[str, Any]) -> dict[s
|
|
|
382
388
|
"target": kind,
|
|
383
389
|
"tool_name": tool_name,
|
|
384
390
|
"tool_args": tool_args,
|
|
391
|
+
"timeout_seconds": effective_timeout_seconds,
|
|
385
392
|
"route_notes": route_notes,
|
|
386
393
|
"policy": policy_result,
|
|
387
394
|
"routing_file": payload_inputs["routing_file"],
|
|
@@ -2,6 +2,8 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
4
|
import json
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
5
7
|
import sys
|
|
6
8
|
from dataclasses import dataclass
|
|
7
9
|
from fnmatch import fnmatch
|
|
@@ -29,6 +31,71 @@ class PathDecision:
|
|
|
29
31
|
recommended_action: str
|
|
30
32
|
|
|
31
33
|
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class HookRequest:
|
|
36
|
+
tool_name: str
|
|
37
|
+
file_path: str
|
|
38
|
+
command: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
EDIT_TOOL_NAMES = {"Edit", "Write", "MultiEdit"}
|
|
42
|
+
SHELL_TOOL_NAMES = {"Bash", "PowerShell"}
|
|
43
|
+
SHELL_DENIED_COMMAND_PATTERNS = (
|
|
44
|
+
r"\brm\s+-[^\n\r]*[rf]",
|
|
45
|
+
r"\bdel\s+",
|
|
46
|
+
r"\brmdir\s+",
|
|
47
|
+
r"\bremove-item\b",
|
|
48
|
+
r"\bset-content\b",
|
|
49
|
+
r"\badd-content\b",
|
|
50
|
+
r"\bout-file\b",
|
|
51
|
+
r"\bnew-item\b",
|
|
52
|
+
r"\bcopy-item\b",
|
|
53
|
+
r"\bmove-item\b",
|
|
54
|
+
r"\bgit\s+reset\s+--hard\b",
|
|
55
|
+
r"\bgit\s+clean\b",
|
|
56
|
+
r"\bpython\s+-c\b",
|
|
57
|
+
r"\bnode\s+-e\b",
|
|
58
|
+
r"\bpowershell(\.exe)?\s+-(command|encodedcommand)\b",
|
|
59
|
+
r"\bcmd(\.exe)?\s+/c\b",
|
|
60
|
+
)
|
|
61
|
+
SHELL_ALLOWED_COMMAND_PREFIXES = (
|
|
62
|
+
"git status",
|
|
63
|
+
"git diff",
|
|
64
|
+
"git log",
|
|
65
|
+
"git show",
|
|
66
|
+
"git branch",
|
|
67
|
+
"git rev-parse",
|
|
68
|
+
"git ls-files",
|
|
69
|
+
"git remote",
|
|
70
|
+
"rg",
|
|
71
|
+
"ls",
|
|
72
|
+
"dir",
|
|
73
|
+
"pwd",
|
|
74
|
+
"get-content",
|
|
75
|
+
"get-childitem",
|
|
76
|
+
"select-string",
|
|
77
|
+
"test-path",
|
|
78
|
+
"resolve-path",
|
|
79
|
+
"pytest",
|
|
80
|
+
"python -m pytest",
|
|
81
|
+
"python -m compileall",
|
|
82
|
+
"npm test",
|
|
83
|
+
"npm run test",
|
|
84
|
+
"npm run lint",
|
|
85
|
+
"npm run typecheck",
|
|
86
|
+
"npm run check",
|
|
87
|
+
"pnpm test",
|
|
88
|
+
"pnpm run test",
|
|
89
|
+
"pnpm run lint",
|
|
90
|
+
"pnpm run typecheck",
|
|
91
|
+
"yarn test",
|
|
92
|
+
"yarn lint",
|
|
93
|
+
"codex --help",
|
|
94
|
+
"gemini --help",
|
|
95
|
+
"gemini --version",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
32
99
|
def normalize_path_text(path_text: str) -> str:
|
|
33
100
|
normalized = str(path_text or "").replace("\\", "/").strip()
|
|
34
101
|
while normalized.startswith("./"):
|
|
@@ -207,18 +274,111 @@ def validate_executor_target(kind: str, target_paths: list[str], policy: dict[st
|
|
|
207
274
|
return result
|
|
208
275
|
|
|
209
276
|
|
|
210
|
-
def
|
|
277
|
+
def parse_hook_request(text: str) -> HookRequest:
|
|
211
278
|
if not text.strip():
|
|
212
|
-
return "", ""
|
|
279
|
+
return HookRequest(tool_name="", file_path="", command="")
|
|
213
280
|
payload = json.loads(text)
|
|
214
281
|
if not isinstance(payload, dict):
|
|
215
|
-
return "", ""
|
|
282
|
+
return HookRequest(tool_name="", file_path="", command="")
|
|
216
283
|
tool_name = str(payload.get("tool_name", "")).strip()
|
|
217
284
|
tool_input = payload.get("tool_input", {})
|
|
218
285
|
if not isinstance(tool_input, dict):
|
|
219
|
-
return tool_name, ""
|
|
286
|
+
return HookRequest(tool_name=tool_name, file_path="", command="")
|
|
220
287
|
file_path = str(tool_input.get("file_path") or tool_input.get("path") or "").strip()
|
|
221
|
-
|
|
288
|
+
command = str(tool_input.get("command") or tool_input.get("script") or "").strip()
|
|
289
|
+
return HookRequest(tool_name=tool_name, file_path=file_path, command=command)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def parse_hook_payload(text: str) -> tuple[str, str]:
|
|
293
|
+
request = parse_hook_request(text)
|
|
294
|
+
return request.tool_name, request.file_path
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def has_unquoted_shell_control_operator(command: str) -> bool:
|
|
298
|
+
in_single = False
|
|
299
|
+
in_double = False
|
|
300
|
+
escaped = False
|
|
301
|
+
for index, char in enumerate(command):
|
|
302
|
+
if escaped:
|
|
303
|
+
escaped = False
|
|
304
|
+
continue
|
|
305
|
+
if char == "\\":
|
|
306
|
+
escaped = True
|
|
307
|
+
continue
|
|
308
|
+
if char == "'" and not in_double:
|
|
309
|
+
in_single = not in_single
|
|
310
|
+
continue
|
|
311
|
+
if char == '"' and not in_single:
|
|
312
|
+
in_double = not in_double
|
|
313
|
+
continue
|
|
314
|
+
if in_single or in_double:
|
|
315
|
+
continue
|
|
316
|
+
if char in {"|", ";", ">", "<", "`", "&"}:
|
|
317
|
+
return True
|
|
318
|
+
if char == "$" and index + 1 < len(command) and command[index + 1] == "(":
|
|
319
|
+
return True
|
|
320
|
+
return False
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def normalize_shell_command_for_prefix(command: str) -> str:
|
|
324
|
+
try:
|
|
325
|
+
parts = shlex.split(command, posix=False)
|
|
326
|
+
except ValueError:
|
|
327
|
+
parts = command.split()
|
|
328
|
+
if not parts:
|
|
329
|
+
return ""
|
|
330
|
+
|
|
331
|
+
first = parts[0].strip().strip("'\"")
|
|
332
|
+
first_name = Path(first.replace("\\", "/")).name.lower()
|
|
333
|
+
for suffix in (".cmd", ".ps1", ".exe", ".bat"):
|
|
334
|
+
if first_name.endswith(suffix):
|
|
335
|
+
first_name = first_name[: -len(suffix)]
|
|
336
|
+
break
|
|
337
|
+
rest = " ".join(str(item).strip().strip("'\"").lower() for item in parts[1:])
|
|
338
|
+
return f"{first_name} {rest}".strip()
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def shell_prefix_matches(command: str, prefix: str) -> bool:
|
|
342
|
+
return command == prefix or command.startswith(f"{prefix} ")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def is_codecgc_cli_shell_command(normalized_command: str) -> bool:
|
|
346
|
+
first = normalized_command.split(" ", 1)[0]
|
|
347
|
+
return first == "cgc" or first == "codecgc" or first.startswith("cgc-")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def evaluate_shell_command(command: str, tool_name: str = "Bash") -> dict[str, Any]:
|
|
351
|
+
normalized_tool_name = str(tool_name or "").strip()
|
|
352
|
+
if not str(command or "").strip():
|
|
353
|
+
return {"allowed": True, "reason": "", "recommended_action": ""}
|
|
354
|
+
|
|
355
|
+
if has_unquoted_shell_control_operator(command):
|
|
356
|
+
return {
|
|
357
|
+
"allowed": False,
|
|
358
|
+
"reason": f"{normalized_tool_name} commands with shell control operators are not allowed for Claude",
|
|
359
|
+
"recommended_action": "route write work through /cgc or run a single read-only check",
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
lowered = command.lower()
|
|
363
|
+
if any(re.search(pattern, lowered) for pattern in SHELL_DENIED_COMMAND_PATTERNS):
|
|
364
|
+
return {
|
|
365
|
+
"allowed": False,
|
|
366
|
+
"reason": f"{normalized_tool_name} command looks like a direct write or destructive shell operation",
|
|
367
|
+
"recommended_action": "route implementation through /cgc so CodeCGC can dispatch Codex or Gemini",
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
normalized_command = normalize_shell_command_for_prefix(command)
|
|
371
|
+
if is_codecgc_cli_shell_command(normalized_command):
|
|
372
|
+
return {"allowed": True, "reason": "", "recommended_action": ""}
|
|
373
|
+
|
|
374
|
+
if any(shell_prefix_matches(normalized_command, prefix) for prefix in SHELL_ALLOWED_COMMAND_PREFIXES):
|
|
375
|
+
return {"allowed": True, "reason": "", "recommended_action": ""}
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
"allowed": False,
|
|
379
|
+
"reason": f"{normalized_tool_name} command is outside the CodeCGC Claude shell allowlist",
|
|
380
|
+
"recommended_action": "use /cgc for write work, or use an allowed read-only/test command",
|
|
381
|
+
}
|
|
222
382
|
|
|
223
383
|
|
|
224
384
|
def build_hook_response(allowed: bool, reason: str) -> dict[str, Any]:
|
|
@@ -247,11 +407,23 @@ def main() -> int:
|
|
|
247
407
|
policy = load_policy(policy_path)
|
|
248
408
|
|
|
249
409
|
if args.hook_check:
|
|
250
|
-
|
|
251
|
-
if tool_name
|
|
410
|
+
request = parse_hook_request(sys.stdin.read())
|
|
411
|
+
if request.tool_name in SHELL_TOOL_NAMES:
|
|
412
|
+
result = evaluate_shell_command(request.command, request.tool_name)
|
|
413
|
+
if result["allowed"]:
|
|
414
|
+
print_json(build_hook_response(True, ""))
|
|
415
|
+
return 0
|
|
416
|
+
hook_reason = f"CodeCGC: {result['reason']}."
|
|
417
|
+
recommended = str(result.get("recommended_action", "")).strip()
|
|
418
|
+
if recommended:
|
|
419
|
+
hook_reason += f" {recommended}."
|
|
420
|
+
print_json(build_hook_response(False, hook_reason))
|
|
421
|
+
return 0
|
|
422
|
+
|
|
423
|
+
if request.tool_name not in EDIT_TOOL_NAMES or not request.file_path:
|
|
252
424
|
print_json(build_hook_response(True, ""))
|
|
253
425
|
return 0
|
|
254
|
-
result = evaluate_paths([file_path], actor=args.actor, operation=args.operation, policy=policy)
|
|
426
|
+
result = evaluate_paths([request.file_path], actor=args.actor, operation=args.operation, policy=policy)
|
|
255
427
|
decision = result["decisions"][0]
|
|
256
428
|
reason = decision.get("reason", "")
|
|
257
429
|
recommended = decision.get("recommended_action", "")
|
|
@@ -34,7 +34,9 @@ DEFAULT_SHARED_PATHS = [
|
|
|
34
34
|
DEFAULT_ORCHESTRATION_PATHS = [
|
|
35
35
|
"codecgc/**",
|
|
36
36
|
".claude/commands/**",
|
|
37
|
-
".claude/settings.json",
|
|
37
|
+
".claude/settings.local.json",
|
|
38
|
+
".codex/codecgcrc.json",
|
|
39
|
+
".gemini/policies/**",
|
|
38
40
|
".mcp.json",
|
|
39
41
|
"model-routing.yaml",
|
|
40
42
|
]
|
|
@@ -25,10 +25,14 @@ SETTINGS_PATH = CLAUDE_DIR / "settings.json"
|
|
|
25
25
|
MCP_CONFIG_PATH = WORKSPACE / ".mcp.json"
|
|
26
26
|
PROJECT_HOOK_PATH = HOOKS_DIR / "route-edit.ps1"
|
|
27
27
|
PROJECT_ROUTING_PATH = WORKSPACE / "model-routing.yaml"
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
PROJECT_TEMPLATES_DIR = WORKSPACE / "codecgc" / "templates"
|
|
29
|
+
EDIT_GUARDRAIL_MATCHER = "Edit|Write|MultiEdit|Bash|PowerShell"
|
|
30
|
+
LEGACY_EDIT_GUARDRAIL_MATCHERS = {"Edit|Write", "Edit|Write|MultiEdit"}
|
|
30
31
|
PROJECT_ONBOARDING_RELATIVE_PATH = "codecgc/START_HERE.md"
|
|
31
32
|
PROJECT_ONBOARDING_MARKER = "<!-- codecgc:onboarding:v1 -->"
|
|
33
|
+
CLAUDE_SETTINGS_TEMPLATE = PROJECT_TEMPLATES_DIR / "claude" / "settings.local.json"
|
|
34
|
+
CODEX_POLICY_TEMPLATE = PROJECT_TEMPLATES_DIR / "codex" / "codecgcrc.json"
|
|
35
|
+
GEMINI_POLICY_TEMPLATE = PROJECT_TEMPLATES_DIR / "gemini" / "codecgc-policy.toml"
|
|
32
36
|
|
|
33
37
|
|
|
34
38
|
DEFAULT_HOOKS = {
|
|
@@ -98,16 +102,24 @@ def get_workspace_paths(override_workspace: str = "") -> dict[str, Path]:
|
|
|
98
102
|
root = resolve_workspace_root(override_workspace)
|
|
99
103
|
claude_dir = root / ".claude"
|
|
100
104
|
hooks_dir = claude_dir / "hooks"
|
|
105
|
+
codex_dir = root / ".codex"
|
|
106
|
+
gemini_dir = root / ".gemini"
|
|
101
107
|
return {
|
|
102
108
|
"root": root,
|
|
103
109
|
"claude_dir": claude_dir,
|
|
104
110
|
"hooks_dir": hooks_dir,
|
|
105
|
-
"settings": claude_dir / "settings.json",
|
|
111
|
+
"settings": claude_dir / "settings.local.json",
|
|
112
|
+
"legacy_settings": claude_dir / "settings.json",
|
|
106
113
|
"mcp": root / ".mcp.json",
|
|
107
114
|
"hook_script": hooks_dir / "route-edit.ps1",
|
|
108
115
|
"commands_dir": claude_dir / "commands",
|
|
109
116
|
"routing_file": root / "model-routing.yaml",
|
|
110
117
|
"onboarding_file": root / PROJECT_ONBOARDING_RELATIVE_PATH,
|
|
118
|
+
"codex_dir": codex_dir,
|
|
119
|
+
"codex_policy": codex_dir / "codecgcrc.json",
|
|
120
|
+
"gemini_dir": gemini_dir,
|
|
121
|
+
"gemini_policies_dir": gemini_dir / "policies",
|
|
122
|
+
"gemini_policy": gemini_dir / "policies" / "codecgc-policy.toml",
|
|
111
123
|
}
|
|
112
124
|
|
|
113
125
|
|
|
@@ -123,6 +135,12 @@ def load_text_file(path: Path) -> str:
|
|
|
123
135
|
return path.read_text(encoding="utf-8")
|
|
124
136
|
|
|
125
137
|
|
|
138
|
+
def copy_template_file(template_path: Path, target_path: Path) -> Path:
|
|
139
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
shutil.copyfile(template_path, target_path)
|
|
141
|
+
return target_path
|
|
142
|
+
|
|
143
|
+
|
|
126
144
|
def ensure_workspace_workflow_dirs(workspace_root: Path) -> list[str]:
|
|
127
145
|
created_or_existing: list[str] = []
|
|
128
146
|
for relative_path in PROJECT_WORKFLOW_DIRS:
|
|
@@ -174,9 +192,11 @@ cgc "新增一个登录页面,放在 src/components/LoginForm.tsx"
|
|
|
174
192
|
```text
|
|
175
193
|
.mcp.json
|
|
176
194
|
model-routing.yaml
|
|
177
|
-
.claude/settings.json
|
|
195
|
+
.claude/settings.local.json
|
|
178
196
|
.claude/hooks/route-edit.ps1
|
|
179
197
|
.claude/commands/cgc*.md
|
|
198
|
+
.codex/codecgcrc.json
|
|
199
|
+
.gemini/policies/codecgc-policy.toml
|
|
180
200
|
codecgc/features/
|
|
181
201
|
codecgc/issues/
|
|
182
202
|
codecgc/execution/
|
|
@@ -657,6 +677,16 @@ def write_json_file(path: Path, payload: dict[str, Any]) -> Path:
|
|
|
657
677
|
return path
|
|
658
678
|
|
|
659
679
|
|
|
680
|
+
def build_project_claude_settings(workspace_paths: dict[str, Path]) -> dict[str, Any]:
|
|
681
|
+
settings = load_json_file(CLAUDE_SETTINGS_TEMPLATE)
|
|
682
|
+
merged_settings, _ = merge_hook_settings(
|
|
683
|
+
settings,
|
|
684
|
+
build_workspace_hook_command(workspace_paths),
|
|
685
|
+
)
|
|
686
|
+
merged_settings, _ = merge_permission_settings(merged_settings, DEFAULT_ALLOWED_TOOLS)
|
|
687
|
+
return merged_settings
|
|
688
|
+
|
|
689
|
+
|
|
660
690
|
def shell_quote(value: str) -> str:
|
|
661
691
|
text = str(value)
|
|
662
692
|
if not text:
|
|
@@ -851,16 +881,12 @@ def install_local_runtime(override_workspace: str = "") -> dict[str, Any]:
|
|
|
851
881
|
workspace_paths["hooks_dir"].mkdir(parents=True, exist_ok=True)
|
|
852
882
|
workflow_dirs = ensure_workspace_workflow_dirs(workspace_paths["root"])
|
|
853
883
|
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
build_workspace_hook_command(workspace_paths),
|
|
884
|
+
write_json_file(
|
|
885
|
+
workspace_paths["settings"],
|
|
886
|
+
build_project_claude_settings(workspace_paths),
|
|
858
887
|
)
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
write_json_file(workspace_paths["settings"], merged_settings)
|
|
862
|
-
elif permissions_changed:
|
|
863
|
-
write_json_file(workspace_paths["settings"], merged_settings)
|
|
888
|
+
codex_policy_path = copy_template_file(CODEX_POLICY_TEMPLATE, workspace_paths["codex_policy"])
|
|
889
|
+
gemini_policy_path = copy_template_file(GEMINI_POLICY_TEMPLATE, workspace_paths["gemini_policy"])
|
|
864
890
|
|
|
865
891
|
if PROJECT_HOOK_PATH.resolve() != workspace_paths["hook_script"].resolve():
|
|
866
892
|
shutil.copyfile(PROJECT_HOOK_PATH, workspace_paths["hook_script"])
|
|
@@ -880,6 +906,8 @@ def install_local_runtime(override_workspace: str = "") -> dict[str, Any]:
|
|
|
880
906
|
"mcp_config": str(mcp_path),
|
|
881
907
|
"routing_file": str(routing_path),
|
|
882
908
|
"claude_settings": str(workspace_paths["settings"]),
|
|
909
|
+
"codex_policy": str(codex_policy_path),
|
|
910
|
+
"gemini_policy": str(gemini_policy_path),
|
|
883
911
|
"hook_script": str(workspace_paths["hook_script"]),
|
|
884
912
|
"commands_dir": str(workspace_paths["commands_dir"]),
|
|
885
913
|
"onboarding_file": str(onboarding_file),
|
|
@@ -890,7 +918,9 @@ def install_local_runtime(override_workspace: str = "") -> dict[str, Any]:
|
|
|
890
918
|
"Project-local model-routing.yaml was synchronized as the policy source of truth.",
|
|
891
919
|
"Project-local codecgc workflow directories were initialized.",
|
|
892
920
|
"Claude pre-edit guardrail hook was synchronized into the target workspace.",
|
|
893
|
-
"Claude
|
|
921
|
+
"Claude project permissions were rendered from codecgc/templates/claude/settings.local.json.",
|
|
922
|
+
"Project-local Codex policy contract was synchronized into .codex/codecgcrc.json.",
|
|
923
|
+
"Project-local Gemini policy was synchronized into .gemini/policies/codecgc-policy.toml.",
|
|
894
924
|
"Project-local Claude slash commands were synchronized into .claude/commands.",
|
|
895
925
|
"Project-local codecgc/START_HERE.md was written as the first-run entry guide.",
|
|
896
926
|
"This mode prepares project-level integration surfaces for the selected workspace.",
|
|
@@ -1026,6 +1056,8 @@ def build_install_mode_summary(result: dict[str, Any]) -> str:
|
|
|
1026
1056
|
f"- MCP 配置: {result.get('mcp_config', '')}",
|
|
1027
1057
|
f"- Routing 文件: {result.get('routing_file', '')}",
|
|
1028
1058
|
f"- Claude 设置: {result.get('claude_settings', '')}",
|
|
1059
|
+
f"- Codex 策略: {result.get('codex_policy', '')}",
|
|
1060
|
+
f"- Gemini 策略: {result.get('gemini_policy', '')}",
|
|
1029
1061
|
f"- Hook 脚本: {result.get('hook_script', '')}",
|
|
1030
1062
|
f"- Slash Commands: {result.get('commands_dir', '')}",
|
|
1031
1063
|
f"- 新手入口: {result.get('onboarding_file', '')}",
|
|
@@ -1084,9 +1116,14 @@ def collect_project_status(workspace_paths: dict[str, Path]) -> dict[str, Any]:
|
|
|
1084
1116
|
expected_mcp = build_mcp_config(workspace_paths["root"])
|
|
1085
1117
|
expected_hook_command = build_workspace_hook_command(workspace_paths)
|
|
1086
1118
|
expected_hook_text = load_text_file(PROJECT_HOOK_PATH)
|
|
1119
|
+
expected_settings = build_project_claude_settings(workspace_paths)
|
|
1120
|
+
expected_codex_policy = load_text_file(CODEX_POLICY_TEMPLATE)
|
|
1121
|
+
expected_gemini_policy = load_text_file(GEMINI_POLICY_TEMPLATE)
|
|
1087
1122
|
current_settings = load_json_file(workspace_paths["settings"])
|
|
1088
1123
|
current_mcp = load_json_file(workspace_paths["mcp"])
|
|
1089
1124
|
current_hook_text = load_text_file(workspace_paths["hook_script"])
|
|
1125
|
+
current_codex_policy = load_text_file(workspace_paths["codex_policy"])
|
|
1126
|
+
current_gemini_policy = load_text_file(workspace_paths["gemini_policy"])
|
|
1090
1127
|
routing_exists = workspace_paths["routing_file"].exists()
|
|
1091
1128
|
policy_valid = policy_file_is_valid(workspace_paths["routing_file"]) if routing_exists else False
|
|
1092
1129
|
workflow_dirs_ready = workspace_workflow_dirs_ready(workspace_paths["root"])
|
|
@@ -1094,8 +1131,15 @@ def collect_project_status(workspace_paths: dict[str, Path]) -> dict[str, Any]:
|
|
|
1094
1131
|
|
|
1095
1132
|
hook_registered = settings_have_hook_command(current_settings, expected_hook_command)
|
|
1096
1133
|
permissions_registered = settings_have_allowed_tools(current_settings, DEFAULT_ALLOWED_TOOLS)
|
|
1134
|
+
settings_matches = current_settings == expected_settings if workspace_paths["settings"].exists() else False
|
|
1097
1135
|
mcp_matches = current_mcp == expected_mcp if workspace_paths["mcp"].exists() else False
|
|
1098
1136
|
hook_file_matches = current_hook_text == expected_hook_text if workspace_paths["hook_script"].exists() else False
|
|
1137
|
+
codex_policy_matches = (
|
|
1138
|
+
current_codex_policy == expected_codex_policy if workspace_paths["codex_policy"].exists() else False
|
|
1139
|
+
)
|
|
1140
|
+
gemini_policy_matches = (
|
|
1141
|
+
current_gemini_policy == expected_gemini_policy if workspace_paths["gemini_policy"].exists() else False
|
|
1142
|
+
)
|
|
1099
1143
|
|
|
1100
1144
|
missing = []
|
|
1101
1145
|
if not routing_exists:
|
|
@@ -1106,6 +1150,8 @@ def collect_project_status(workspace_paths: dict[str, Path]) -> dict[str, Any]:
|
|
|
1106
1150
|
missing.append("workflow_dirs")
|
|
1107
1151
|
if not mcp_matches:
|
|
1108
1152
|
missing.append("mcp_json")
|
|
1153
|
+
if not settings_matches:
|
|
1154
|
+
missing.append("claude_settings_local")
|
|
1109
1155
|
if not hook_registered:
|
|
1110
1156
|
missing.append("claude_settings_hook")
|
|
1111
1157
|
if not permissions_registered:
|
|
@@ -1114,12 +1160,19 @@ def collect_project_status(workspace_paths: dict[str, Path]) -> dict[str, Any]:
|
|
|
1114
1160
|
missing.append("hook_script")
|
|
1115
1161
|
if not onboarding_ready:
|
|
1116
1162
|
missing.append("onboarding_file")
|
|
1163
|
+
if not codex_policy_matches:
|
|
1164
|
+
missing.append("codex_policy")
|
|
1165
|
+
if not gemini_policy_matches:
|
|
1166
|
+
missing.append("gemini_policy")
|
|
1117
1167
|
|
|
1118
1168
|
ready = not missing
|
|
1119
1169
|
return {
|
|
1120
1170
|
"mcp_json_path": str(workspace_paths["mcp"]),
|
|
1121
1171
|
"routing_file_path": str(workspace_paths["routing_file"]),
|
|
1122
1172
|
"claude_settings_path": str(workspace_paths["settings"]),
|
|
1173
|
+
"legacy_claude_settings_path": str(workspace_paths["legacy_settings"]),
|
|
1174
|
+
"codex_policy_path": str(workspace_paths["codex_policy"]),
|
|
1175
|
+
"gemini_policy_path": str(workspace_paths["gemini_policy"]),
|
|
1123
1176
|
"hook_script_path": str(workspace_paths["hook_script"]),
|
|
1124
1177
|
"onboarding_file_path": str(workspace_paths["onboarding_file"]),
|
|
1125
1178
|
"mcp_json_exists": workspace_paths["mcp"].exists(),
|
|
@@ -1129,12 +1182,18 @@ def collect_project_status(workspace_paths: dict[str, Path]) -> dict[str, Any]:
|
|
|
1129
1182
|
"onboarding_ready": onboarding_ready,
|
|
1130
1183
|
"workflow_dirs_expected": [str(workspace_paths["root"] / item) for item in PROJECT_WORKFLOW_DIRS],
|
|
1131
1184
|
"claude_settings_exists": workspace_paths["settings"].exists(),
|
|
1185
|
+
"legacy_claude_settings_exists": workspace_paths["legacy_settings"].exists(),
|
|
1186
|
+
"codex_policy_exists": workspace_paths["codex_policy"].exists(),
|
|
1187
|
+
"gemini_policy_exists": workspace_paths["gemini_policy"].exists(),
|
|
1132
1188
|
"hook_exists": workspace_paths["hook_script"].exists(),
|
|
1133
1189
|
"onboarding_exists": workspace_paths["onboarding_file"].exists(),
|
|
1134
1190
|
"mcp_matches_expected": mcp_matches,
|
|
1191
|
+
"claude_settings_matches_expected": settings_matches,
|
|
1135
1192
|
"hook_registered": hook_registered,
|
|
1136
1193
|
"permissions_registered": permissions_registered,
|
|
1137
1194
|
"hook_file_matches_expected": hook_file_matches,
|
|
1195
|
+
"codex_policy_matches_expected": codex_policy_matches,
|
|
1196
|
+
"gemini_policy_matches_expected": gemini_policy_matches,
|
|
1138
1197
|
"ready": ready,
|
|
1139
1198
|
"missing_or_outdated": missing,
|
|
1140
1199
|
"recommended_command": "" if ready else build_workspace_install_command(workspace_paths["root"]),
|