codex-flow 2.1.13__py3-none-any.whl
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.
- codex_flow/__init__.py +28 -0
- codex_flow/__main__.py +9 -0
- codex_flow/cli.py +242 -0
- codex_flow/data/LICENSE +21 -0
- codex_flow/data/README.en.md +303 -0
- codex_flow/data/README.md +305 -0
- codex_flow/data/VERSION +1 -0
- codex_flow/data/apps/chatgpt-mcp/README.md +86 -0
- codex_flow/data/apps/chatgpt-mcp/__init__.py +1 -0
- codex_flow/data/apps/chatgpt-mcp/adapter.py +458 -0
- codex_flow/data/apps/chatgpt-mcp/server.py +358 -0
- codex_flow/data/apps/chatgpt-mcp/widget.html +927 -0
- codex_flow/data/apps/macos-overlay/README.en.md +121 -0
- codex_flow/data/apps/macos-overlay/README.md +123 -0
- codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayRuntimeState.swift +126 -0
- codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayScreenGeometry.swift +82 -0
- codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayWindowController.swift +1052 -0
- codex_flow/data/apps/macos-overlay/Sources/Localization.swift +197 -0
- codex_flow/data/apps/macos-overlay/Sources/Models/TelemetryData.swift +1557 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/AccountSnapshotService.swift +1101 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/FlowPilotInstanceLock.swift +153 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/IPCServer.swift +298 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/TelemetryQueryEngine.swift +800 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/TelemetryWatcher.swift +135 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/UpdateService.swift +610 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/AccountView.swift +610 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/AnalyticsView.swift +566 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/AutostartView.swift +293 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/BubbleView.swift +317 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/HistoryView.swift +1124 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/HoverRevealText.swift +165 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/InspectorSkillsToolsView.swift +121 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/LogoView.swift +182 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/SleekSwitch.swift +117 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/StrategyModeView.swift +561 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/SummaryView.swift +1273 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/UpdateView.swift +352 -0
- codex_flow/data/apps/macos-overlay/Sources/main.swift +340 -0
- codex_flow/data/apps/macos-overlay/Tests/OverlayScreenGeometryTests.swift +163 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryPhase1ContractTests.swift +357 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryQueryEngineConcurrencyTests.swift +221 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryQuotaSelectionTests.swift +158 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryWorkerTokenTests.swift +122 -0
- codex_flow/data/apps/macos-overlay/build.sh +75 -0
- codex_flow/data/benchmark/corpus.json +103 -0
- codex_flow/data/benchmark/manifest.example.json +41 -0
- codex_flow/data/benchmark/manifest.schema.json +137 -0
- codex_flow/data/benchmark/prices/gpt-5.6-2026-08-30.json +5 -0
- codex_flow/data/benchmark/profiles.json +90 -0
- codex_flow/data/benchmark/schema.json +77 -0
- codex_flow/data/benchmark/tasks.json +50 -0
- codex_flow/data/completions/codex-flow.bash +34 -0
- codex_flow/data/completions/codex-flow.zsh +52 -0
- codex_flow/data/glama.json +6 -0
- codex_flow/data/install-release.ps1 +126 -0
- codex_flow/data/install-release.sh +155 -0
- codex_flow/data/install.ps1 +349 -0
- codex_flow/data/install.sh +362 -0
- codex_flow/data/policy/benchmark.toml +49 -0
- codex_flow/data/policy/defaults.toml +70 -0
- codex_flow/data/scripts/analyze-benchmark.py +510 -0
- codex_flow/data/scripts/benchmark-local.py +171 -0
- codex_flow/data/scripts/check-recommendation.py +277 -0
- codex_flow/data/scripts/doctor.py +449 -0
- codex_flow/data/scripts/generate-release-manifest.py +74 -0
- codex_flow/data/scripts/localization.py +192 -0
- codex_flow/data/scripts/manage-hooks.py +448 -0
- codex_flow/data/scripts/manage-instructions.py +389 -0
- codex_flow/data/scripts/manage-shell.py +151 -0
- codex_flow/data/scripts/materialize-corpus.py +193 -0
- codex_flow/data/scripts/menu.py +646 -0
- codex_flow/data/scripts/migrations/0001_update_settings.py +80 -0
- codex_flow/data/scripts/package-release.py +132 -0
- codex_flow/data/scripts/render-benchmark-report.py +292 -0
- codex_flow/data/scripts/run-benchmark.py +829 -0
- codex_flow/data/scripts/strategies/__init__.py +28 -0
- codex_flow/data/scripts/strategies/balanced.py +115 -0
- codex_flow/data/scripts/strategies/base.py +363 -0
- codex_flow/data/scripts/strategies/efficient.py +158 -0
- codex_flow/data/scripts/strategies/lifecycle_runtime.py +590 -0
- codex_flow/data/scripts/strategies/quality.py +209 -0
- codex_flow/data/scripts/strategies/speed.py +108 -0
- codex_flow/data/scripts/strategies/task_budget_runtime.py +644 -0
- codex_flow/data/scripts/strategies/task_phase_runtime.py +341 -0
- codex_flow/data/scripts/strategies/work_unit_runtime.py +421 -0
- codex_flow/data/scripts/strategy_runtime.py +1091 -0
- codex_flow/data/scripts/telemetry.py +400 -0
- codex_flow/data/scripts/telemetry_core/__init__.py +192 -0
- codex_flow/data/scripts/telemetry_core/app_server.py +1192 -0
- codex_flow/data/scripts/telemetry_core/collector.py +1247 -0
- codex_flow/data/scripts/telemetry_core/common.py +421 -0
- codex_flow/data/scripts/telemetry_core/latency.py +593 -0
- codex_flow/data/scripts/telemetry_core/query.py +427 -0
- codex_flow/data/scripts/telemetry_core/quota_ledger.py +598 -0
- codex_flow/data/scripts/telemetry_core/render.py +460 -0
- codex_flow/data/scripts/telemetry_core/repair.py +223 -0
- codex_flow/data/scripts/ui.py +266 -0
- codex_flow/data/scripts/update-homebrew-formula.py +146 -0
- codex_flow/data/scripts/update_runtime_config.py +134 -0
- codex_flow/data/scripts/updater.py +1718 -0
- codex_flow/data/smithery.yaml +18 -0
- codex_flow/data/templates/agents/worker-explorer.toml +24 -0
- codex_flow/data/templates/agents/worker-implementer.toml +49 -0
- codex_flow/data/templates/agents/worker-reviewer.toml +25 -0
- codex_flow/data/templates/flow-pilot-instructions.md +35 -0
- codex_flow/data/templates/skills/flow-pilot/SKILL.md +577 -0
- codex_flow/mcp.py +35 -0
- codex_flow-2.1.13.dist-info/METADATA +342 -0
- codex_flow-2.1.13.dist-info/RECORD +113 -0
- codex_flow-2.1.13.dist-info/WHEEL +5 -0
- codex_flow-2.1.13.dist-info/entry_points.txt +3 -0
- codex_flow-2.1.13.dist-info/licenses/LICENSE +21 -0
- codex_flow-2.1.13.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared language resolution and policy persistence for codex-flow."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import locale
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
SUPPORTED_LANGUAGES = {"auto", "zh", "en"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def configure_stdio() -> None:
|
|
18
|
+
"""Use UTF-8 for user-facing output on legacy Windows consoles/pipes."""
|
|
19
|
+
for name in ("stdout", "stderr"):
|
|
20
|
+
stream = getattr(sys, name, None)
|
|
21
|
+
if stream is None or not hasattr(stream, "reconfigure"):
|
|
22
|
+
continue
|
|
23
|
+
try:
|
|
24
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
25
|
+
except (OSError, ValueError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
configure_stdio()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def normalize_language(value: str | None, *, allow_auto: bool = True) -> str:
|
|
33
|
+
raw = (value or "").strip().lower().replace("_", "-")
|
|
34
|
+
if allow_auto and raw in ("", "auto", "system", "default"):
|
|
35
|
+
return "auto"
|
|
36
|
+
if raw == "zh" or raw.startswith("zh-") or raw.startswith("chinese"):
|
|
37
|
+
return "zh"
|
|
38
|
+
if raw == "en" or raw.startswith("en-") or raw.startswith("english"):
|
|
39
|
+
return "en"
|
|
40
|
+
raise ValueError("language must be auto, zh, or en")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _language_from_text(value: str | None) -> str | None:
|
|
44
|
+
if not value:
|
|
45
|
+
return None
|
|
46
|
+
lowered = value.strip().lower().replace("_", "-")
|
|
47
|
+
if lowered.startswith("zh") or "chinese" in lowered:
|
|
48
|
+
return "zh"
|
|
49
|
+
if lowered.startswith("en") or "english" in lowered:
|
|
50
|
+
return "en"
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def detect_system_language() -> str:
|
|
55
|
+
if sys.platform == "darwin":
|
|
56
|
+
try:
|
|
57
|
+
result = subprocess.run(
|
|
58
|
+
["defaults", "read", "-g", "AppleLanguages"],
|
|
59
|
+
check=False,
|
|
60
|
+
stdout=subprocess.PIPE,
|
|
61
|
+
stderr=subprocess.DEVNULL,
|
|
62
|
+
text=True,
|
|
63
|
+
timeout=1.0,
|
|
64
|
+
)
|
|
65
|
+
match = re.search(r'"([^"\n]+)"', result.stdout or "")
|
|
66
|
+
detected = _language_from_text(match.group(1) if match else result.stdout)
|
|
67
|
+
if detected:
|
|
68
|
+
return detected
|
|
69
|
+
except (OSError, subprocess.SubprocessError):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
env_candidates = (
|
|
73
|
+
os.environ.get("LC_ALL"),
|
|
74
|
+
os.environ.get("LC_MESSAGES"),
|
|
75
|
+
os.environ.get("LANGUAGE"),
|
|
76
|
+
os.environ.get("LANG"),
|
|
77
|
+
)
|
|
78
|
+
for candidate in env_candidates:
|
|
79
|
+
detected = _language_from_text(candidate)
|
|
80
|
+
if detected:
|
|
81
|
+
return detected
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
current = locale.getlocale()[0]
|
|
85
|
+
except (ValueError, TypeError):
|
|
86
|
+
current = None
|
|
87
|
+
detected = _language_from_text(current)
|
|
88
|
+
return detected or "en"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def policy_path() -> Path:
|
|
92
|
+
codex_home = Path(os.environ.get("CODEX_HOME", Path.home() / ".codex"))
|
|
93
|
+
return codex_home / "codex-flow.toml"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def configured_language(path: Path | None = None) -> str:
|
|
97
|
+
path = path or policy_path()
|
|
98
|
+
try:
|
|
99
|
+
text = path.read_text(encoding="utf-8")
|
|
100
|
+
except OSError:
|
|
101
|
+
return "auto"
|
|
102
|
+
section = re.search(r"(?ms)^\[ui\]\s*\n(.*?)(?=^\[[^\n]+\]\s*$|\Z)", text)
|
|
103
|
+
if not section:
|
|
104
|
+
return "auto"
|
|
105
|
+
match = re.search(r'(?m)^\s*language\s*=\s*"?([^"\s#]+)"?', section.group(1))
|
|
106
|
+
if not match:
|
|
107
|
+
return "auto"
|
|
108
|
+
try:
|
|
109
|
+
return normalize_language(match.group(1))
|
|
110
|
+
except ValueError:
|
|
111
|
+
return "auto"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def resolve_language(path: Path | None = None) -> str:
|
|
115
|
+
override = os.environ.get("CODEX_FLOW_LANGUAGE")
|
|
116
|
+
if override:
|
|
117
|
+
try:
|
|
118
|
+
value = normalize_language(override)
|
|
119
|
+
except ValueError:
|
|
120
|
+
value = "auto"
|
|
121
|
+
else:
|
|
122
|
+
value = configured_language(path)
|
|
123
|
+
return detect_system_language() if value == "auto" else value
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def set_configured_language(value: str, path: Path | None = None) -> str:
|
|
127
|
+
normalized = normalize_language(value)
|
|
128
|
+
path = path or policy_path()
|
|
129
|
+
try:
|
|
130
|
+
text = path.read_text(encoding="utf-8")
|
|
131
|
+
except OSError:
|
|
132
|
+
text = ""
|
|
133
|
+
|
|
134
|
+
section_re = re.compile(r"(?ms)^\[ui\]\s*\n(.*?)(?=^\[[^\n]+\]\s*$|\Z)")
|
|
135
|
+
match = section_re.search(text)
|
|
136
|
+
line = f'language = "{normalized}"'
|
|
137
|
+
if match:
|
|
138
|
+
body = match.group(1)
|
|
139
|
+
key_re = re.compile(r"(?m)^\s*language\s*=.*$")
|
|
140
|
+
if key_re.search(body):
|
|
141
|
+
body = key_re.sub(line, body)
|
|
142
|
+
else:
|
|
143
|
+
if body and not body.endswith("\n"):
|
|
144
|
+
body += "\n"
|
|
145
|
+
body += line + "\n"
|
|
146
|
+
text = text[: match.start(1)] + body + text[match.end(1) :]
|
|
147
|
+
else:
|
|
148
|
+
if text and not text.endswith("\n"):
|
|
149
|
+
text += "\n"
|
|
150
|
+
if text and not text.endswith("\n\n"):
|
|
151
|
+
text += "\n"
|
|
152
|
+
text += f"[ui]\n{line}\n"
|
|
153
|
+
|
|
154
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
path.write_text(text, encoding="utf-8")
|
|
156
|
+
return normalized
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def tr(english: str, chinese: str, *, lang: str | None = None) -> str:
|
|
160
|
+
selected = lang or resolve_language()
|
|
161
|
+
return chinese if selected == "zh" else english
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _main() -> int:
|
|
165
|
+
parser = argparse.ArgumentParser(add_help=False)
|
|
166
|
+
parser.add_argument("--policy", type=Path, default=None)
|
|
167
|
+
parser.add_argument("--resolved", action="store_true")
|
|
168
|
+
parser.add_argument("--configured", action="store_true")
|
|
169
|
+
parser.add_argument("--system", action="store_true")
|
|
170
|
+
parser.add_argument("--normalize")
|
|
171
|
+
parser.add_argument("--set")
|
|
172
|
+
args = parser.parse_args()
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
if args.normalize is not None:
|
|
176
|
+
print(normalize_language(args.normalize))
|
|
177
|
+
elif args.set is not None:
|
|
178
|
+
print(set_configured_language(args.set, args.policy))
|
|
179
|
+
elif args.configured:
|
|
180
|
+
print(configured_language(args.policy))
|
|
181
|
+
elif args.system:
|
|
182
|
+
print(detect_system_language())
|
|
183
|
+
else:
|
|
184
|
+
print(resolve_language(args.policy))
|
|
185
|
+
except ValueError as exc:
|
|
186
|
+
print(str(exc), file=sys.stderr)
|
|
187
|
+
return 2
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if __name__ == "__main__":
|
|
192
|
+
raise SystemExit(_main())
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Install/remove FlowPilot lifecycle hooks and inspect Codex hook trust state.
|
|
3
|
+
|
|
4
|
+
Trust detection intentionally mirrors Codex's current hook identity rules:
|
|
5
|
+
- one persisted state key per event/group/handler
|
|
6
|
+
- normalized command-hook config before hashing
|
|
7
|
+
- canonical JSON + SHA-256 with the ``sha256:`` prefix
|
|
8
|
+
|
|
9
|
+
The detector is read-only. It never grants trust on the user's behalf.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
EVENTS = ("UserPromptSubmit", "SubagentStart", "SubagentStop", "Stop")
|
|
22
|
+
MARKER = "codex-flow/telemetry.py"
|
|
23
|
+
DEFAULT_TIMEOUT_SEC = 600
|
|
24
|
+
SESSION_END_DEFAULT_TIMEOUT_SEC = 1
|
|
25
|
+
SESSION_END_MAX_TIMEOUT_SEC = 3
|
|
26
|
+
DEFAULT_ADDITIONAL_CONTEXT_LIMIT = 2500
|
|
27
|
+
ADDITIONAL_CONTEXT_EVENTS = {
|
|
28
|
+
"PreToolUse",
|
|
29
|
+
"PostToolUse",
|
|
30
|
+
"SessionStart",
|
|
31
|
+
"UserPromptSubmit",
|
|
32
|
+
"SubagentStart",
|
|
33
|
+
}
|
|
34
|
+
EVENT_KEY_LABELS = {
|
|
35
|
+
"PreToolUse": "pre_tool_use",
|
|
36
|
+
"PermissionRequest": "permission_request",
|
|
37
|
+
"PostToolUse": "post_tool_use",
|
|
38
|
+
"PreCompact": "pre_compact",
|
|
39
|
+
"PostCompact": "post_compact",
|
|
40
|
+
"SessionStart": "session_start",
|
|
41
|
+
"SessionEnd": "session_end",
|
|
42
|
+
"UserPromptSubmit": "user_prompt_submit",
|
|
43
|
+
"SubagentStart": "subagent_start",
|
|
44
|
+
"SubagentStop": "subagent_stop",
|
|
45
|
+
"Stop": "stop",
|
|
46
|
+
"Interrupt": "interrupt",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load(path: Path) -> dict[str, Any]:
|
|
51
|
+
if not path.exists():
|
|
52
|
+
return {"hooks": {}}
|
|
53
|
+
try:
|
|
54
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
55
|
+
except json.JSONDecodeError as exc:
|
|
56
|
+
raise SystemExit(f"invalid hooks JSON in {path}: {exc}")
|
|
57
|
+
if not isinstance(value, dict):
|
|
58
|
+
raise SystemExit(f"invalid hooks JSON in {path}: root must be an object")
|
|
59
|
+
value.setdefault("hooks", {})
|
|
60
|
+
if not isinstance(value["hooks"], dict):
|
|
61
|
+
raise SystemExit(f"invalid hooks JSON in {path}: hooks must be an object")
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_managed_hook(hook: Any) -> bool:
|
|
66
|
+
if not isinstance(hook, dict):
|
|
67
|
+
return False
|
|
68
|
+
command = hook.get("command")
|
|
69
|
+
return isinstance(command, str) and MARKER in command.replace("\\", "/")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def is_managed_entry(entry: Any) -> bool:
|
|
73
|
+
if not isinstance(entry, dict):
|
|
74
|
+
return False
|
|
75
|
+
hooks = entry.get("hooks", [])
|
|
76
|
+
return isinstance(hooks, list) and any(is_managed_hook(hook) for hook in hooks)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def remove_managed(data: dict[str, Any]) -> None:
|
|
80
|
+
hooks = data.get("hooks", {})
|
|
81
|
+
if not isinstance(hooks, dict):
|
|
82
|
+
return
|
|
83
|
+
for event in list(hooks):
|
|
84
|
+
entries = hooks[event]
|
|
85
|
+
if not isinstance(entries, list):
|
|
86
|
+
continue
|
|
87
|
+
kept = []
|
|
88
|
+
for entry in entries:
|
|
89
|
+
# Keep malformed entries and entries with malformed `hooks` values
|
|
90
|
+
# unchanged; only a valid hook list is safe to filter.
|
|
91
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("hooks"), list):
|
|
92
|
+
kept.append(entry)
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
entry_hooks = entry["hooks"]
|
|
96
|
+
filtered_hooks = [hook for hook in entry_hooks if not is_managed_hook(hook)]
|
|
97
|
+
if len(filtered_hooks) == len(entry_hooks):
|
|
98
|
+
kept.append(entry)
|
|
99
|
+
elif filtered_hooks:
|
|
100
|
+
entry["hooks"] = filtered_hooks
|
|
101
|
+
kept.append(entry)
|
|
102
|
+
if kept:
|
|
103
|
+
hooks[event] = kept
|
|
104
|
+
else:
|
|
105
|
+
hooks.pop(event, None)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def command_for(script: Path) -> str:
|
|
109
|
+
# Codex command hooks use a shell command string. Double quotes cover the
|
|
110
|
+
# normal user-home path cases on Unix and Windows.
|
|
111
|
+
escaped = str(script).replace('"', '\\"')
|
|
112
|
+
return f'python3 "{escaped}"'
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def install(path: Path, script: Path) -> None:
|
|
116
|
+
data = load(path)
|
|
117
|
+
remove_managed(data)
|
|
118
|
+
command = command_for(script)
|
|
119
|
+
for event in EVENTS:
|
|
120
|
+
data["hooks"].setdefault(event, []).append(
|
|
121
|
+
{
|
|
122
|
+
"hooks": [
|
|
123
|
+
{
|
|
124
|
+
"type": "command",
|
|
125
|
+
"command": command,
|
|
126
|
+
"timeout": 15,
|
|
127
|
+
"statusMessage": "FlowPilot telemetry",
|
|
128
|
+
}
|
|
129
|
+
]
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
write(path, data)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def uninstall(path: Path) -> None:
|
|
136
|
+
if not path.exists():
|
|
137
|
+
return
|
|
138
|
+
data = load(path)
|
|
139
|
+
remove_managed(data)
|
|
140
|
+
write(path, data)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def write(path: Path, data: dict[str, Any]) -> None:
|
|
144
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
tmp = path.with_name(path.name + ".codex-flow.tmp")
|
|
146
|
+
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
147
|
+
os.replace(tmp, path)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def check(path: Path) -> int:
|
|
151
|
+
if not path.exists():
|
|
152
|
+
return 1
|
|
153
|
+
data = load(path)
|
|
154
|
+
hooks = data.get("hooks", {})
|
|
155
|
+
for event in EVENTS:
|
|
156
|
+
entries = hooks.get(event, [])
|
|
157
|
+
if not isinstance(entries, list) or not any(is_managed_entry(entry) for entry in entries):
|
|
158
|
+
return 1
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _absolute_display_path(path: Path) -> str:
|
|
163
|
+
# Codex builds hook keys from an absolute source path. Do not resolve
|
|
164
|
+
# symlinks here: the config-layer path is made absolute, not canonicalized.
|
|
165
|
+
return os.path.abspath(os.path.expanduser(str(path)))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _event_key_label(event: str) -> str:
|
|
169
|
+
return EVENT_KEY_LABELS.get(event, re.sub(r"(?<!^)(?=[A-Z])", "_", event).lower())
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _normalized_timeout(event: str, value: Any) -> int:
|
|
173
|
+
try:
|
|
174
|
+
timeout = int(value) if value is not None else None
|
|
175
|
+
except (TypeError, ValueError):
|
|
176
|
+
timeout = None
|
|
177
|
+
if event in {"SessionEnd", "Interrupt"}:
|
|
178
|
+
timeout = SESSION_END_DEFAULT_TIMEOUT_SEC if timeout is None else timeout
|
|
179
|
+
return max(1, min(timeout, SESSION_END_MAX_TIMEOUT_SEC))
|
|
180
|
+
timeout = DEFAULT_TIMEOUT_SEC if timeout is None else timeout
|
|
181
|
+
return max(1, timeout)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _canonical_hash(value: dict[str, Any]) -> str:
|
|
185
|
+
serialized = json.dumps(
|
|
186
|
+
value,
|
|
187
|
+
ensure_ascii=False,
|
|
188
|
+
sort_keys=True,
|
|
189
|
+
separators=(",", ":"),
|
|
190
|
+
).encode("utf-8")
|
|
191
|
+
return "sha256:" + hashlib.sha256(serialized).hexdigest()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _normalized_command_identity(
|
|
195
|
+
event: str,
|
|
196
|
+
matcher: Any,
|
|
197
|
+
handler: dict[str, Any],
|
|
198
|
+
) -> dict[str, Any]:
|
|
199
|
+
command = handler.get("command")
|
|
200
|
+
if os.name == "nt" and isinstance(handler.get("commandWindows"), str):
|
|
201
|
+
command = handler["commandWindows"]
|
|
202
|
+
if not isinstance(command, str):
|
|
203
|
+
command = ""
|
|
204
|
+
|
|
205
|
+
normalized_handler: dict[str, Any] = {
|
|
206
|
+
"type": "command",
|
|
207
|
+
"command": command,
|
|
208
|
+
"timeout": _normalized_timeout(event, handler.get("timeout")),
|
|
209
|
+
"async": bool(handler.get("async", False)),
|
|
210
|
+
}
|
|
211
|
+
status_message = handler.get("statusMessage")
|
|
212
|
+
if isinstance(status_message, str):
|
|
213
|
+
normalized_handler["statusMessage"] = status_message
|
|
214
|
+
|
|
215
|
+
if event in ADDITIONAL_CONTEXT_EVENTS:
|
|
216
|
+
limit = handler.get("additionalContextLimit")
|
|
217
|
+
if isinstance(limit, int) and not isinstance(limit, bool) and limit != DEFAULT_ADDITIONAL_CONTEXT_LIMIT:
|
|
218
|
+
normalized_handler["additionalContextLimit"] = limit
|
|
219
|
+
|
|
220
|
+
identity: dict[str, Any] = {
|
|
221
|
+
"event_name": _event_key_label(event),
|
|
222
|
+
"hooks": [normalized_handler],
|
|
223
|
+
}
|
|
224
|
+
if isinstance(matcher, str):
|
|
225
|
+
identity["matcher"] = matcher
|
|
226
|
+
return identity
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _managed_hook_entries(path: Path) -> list[dict[str, Any]]:
|
|
230
|
+
data = load(path)
|
|
231
|
+
hooks = data.get("hooks", {})
|
|
232
|
+
if not isinstance(hooks, dict):
|
|
233
|
+
return []
|
|
234
|
+
source = _absolute_display_path(path)
|
|
235
|
+
result: list[dict[str, Any]] = []
|
|
236
|
+
for event, groups in hooks.items():
|
|
237
|
+
if not isinstance(event, str) or not isinstance(groups, list):
|
|
238
|
+
continue
|
|
239
|
+
for group_index, group in enumerate(groups):
|
|
240
|
+
if not isinstance(group, dict):
|
|
241
|
+
continue
|
|
242
|
+
matcher = group.get("matcher")
|
|
243
|
+
handlers = group.get("hooks", [])
|
|
244
|
+
if not isinstance(handlers, list):
|
|
245
|
+
continue
|
|
246
|
+
for handler_index, handler in enumerate(handlers):
|
|
247
|
+
if not is_managed_hook(handler):
|
|
248
|
+
continue
|
|
249
|
+
assert isinstance(handler, dict)
|
|
250
|
+
key = f"{source}:{_event_key_label(event)}:{group_index}:{handler_index}"
|
|
251
|
+
current_hash = _canonical_hash(_normalized_command_identity(event, matcher, handler))
|
|
252
|
+
result.append(
|
|
253
|
+
{
|
|
254
|
+
"event": event,
|
|
255
|
+
"group_index": group_index,
|
|
256
|
+
"handler_index": handler_index,
|
|
257
|
+
"key": key,
|
|
258
|
+
"current_hash": current_hash,
|
|
259
|
+
}
|
|
260
|
+
)
|
|
261
|
+
return result
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _fallback_hook_states(text: str) -> dict[str, dict[str, Any]]:
|
|
265
|
+
"""Minimal parser for hook trust state when stdlib tomllib is unavailable."""
|
|
266
|
+
states: dict[str, dict[str, Any]] = {}
|
|
267
|
+
current: str | None = None
|
|
268
|
+
header = re.compile(r'^\s*\[hooks\.state\.(?P<key>.+)\]\s*$')
|
|
269
|
+
for raw_line in text.splitlines():
|
|
270
|
+
match = header.match(raw_line)
|
|
271
|
+
if match:
|
|
272
|
+
token = match.group("key").strip()
|
|
273
|
+
current = None
|
|
274
|
+
if len(token) >= 2 and token[0] == token[-1] == "'":
|
|
275
|
+
current = token[1:-1]
|
|
276
|
+
elif len(token) >= 2 and token[0] == token[-1] == '"':
|
|
277
|
+
try:
|
|
278
|
+
current = json.loads(token)
|
|
279
|
+
except json.JSONDecodeError:
|
|
280
|
+
current = token[1:-1].replace('\\"', '"').replace("\\\\", "\\")
|
|
281
|
+
if current is not None:
|
|
282
|
+
states.setdefault(current, {})
|
|
283
|
+
continue
|
|
284
|
+
if current is None:
|
|
285
|
+
continue
|
|
286
|
+
trusted = re.match(r'^\s*trusted_hash\s*=\s*"([^"]+)"\s*(?:#.*)?$', raw_line)
|
|
287
|
+
if trusted:
|
|
288
|
+
states[current]["trusted_hash"] = trusted.group(1)
|
|
289
|
+
continue
|
|
290
|
+
enabled = re.match(r"^\s*enabled\s*=\s*(true|false)\s*(?:#.*)?$", raw_line, re.IGNORECASE)
|
|
291
|
+
if enabled:
|
|
292
|
+
states[current]["enabled"] = enabled.group(1).lower() == "true"
|
|
293
|
+
return states
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _load_hook_states(config: Path) -> tuple[dict[str, dict[str, Any]], str | None]:
|
|
297
|
+
if not config.exists():
|
|
298
|
+
return {}, None
|
|
299
|
+
try:
|
|
300
|
+
text = config.read_text(encoding="utf-8-sig")
|
|
301
|
+
except OSError as exc:
|
|
302
|
+
return {}, str(exc)
|
|
303
|
+
try:
|
|
304
|
+
import tomllib # Python 3.11+
|
|
305
|
+
except ImportError:
|
|
306
|
+
return _fallback_hook_states(text), None
|
|
307
|
+
try:
|
|
308
|
+
parsed = tomllib.loads(text)
|
|
309
|
+
except Exception as exc: # invalid config should not crash install/update
|
|
310
|
+
fallback = _fallback_hook_states(text)
|
|
311
|
+
return fallback, None if fallback else str(exc)
|
|
312
|
+
hooks = parsed.get("hooks", {}) if isinstance(parsed, dict) else {}
|
|
313
|
+
state = hooks.get("state", {}) if isinstance(hooks, dict) else {}
|
|
314
|
+
if not isinstance(state, dict):
|
|
315
|
+
return {}, None
|
|
316
|
+
result: dict[str, dict[str, Any]] = {}
|
|
317
|
+
for key, value in state.items():
|
|
318
|
+
if isinstance(key, str) and isinstance(value, dict):
|
|
319
|
+
result[key] = value
|
|
320
|
+
return result, None
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def trust_report(path: Path, config: Path) -> dict[str, Any]:
|
|
324
|
+
if not path.exists():
|
|
325
|
+
return {
|
|
326
|
+
"status": "missing",
|
|
327
|
+
"ready": False,
|
|
328
|
+
"authorization_required": False,
|
|
329
|
+
"total": 0,
|
|
330
|
+
"trusted": 0,
|
|
331
|
+
"untrusted": 0,
|
|
332
|
+
"modified": 0,
|
|
333
|
+
"disabled": 0,
|
|
334
|
+
"entries": [],
|
|
335
|
+
"error": None,
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
entries = _managed_hook_entries(path)
|
|
339
|
+
if not entries:
|
|
340
|
+
return {
|
|
341
|
+
"status": "missing",
|
|
342
|
+
"ready": False,
|
|
343
|
+
"authorization_required": False,
|
|
344
|
+
"total": 0,
|
|
345
|
+
"trusted": 0,
|
|
346
|
+
"untrusted": 0,
|
|
347
|
+
"modified": 0,
|
|
348
|
+
"disabled": 0,
|
|
349
|
+
"entries": [],
|
|
350
|
+
"error": None,
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
states, state_error = _load_hook_states(config)
|
|
354
|
+
counts = {"trusted": 0, "untrusted": 0, "modified": 0, "disabled": 0}
|
|
355
|
+
for entry in entries:
|
|
356
|
+
state = states.get(entry["key"], {})
|
|
357
|
+
enabled = state.get("enabled") is not False
|
|
358
|
+
trusted_hash = state.get("trusted_hash") if isinstance(state.get("trusted_hash"), str) else None
|
|
359
|
+
if not enabled:
|
|
360
|
+
status = "disabled"
|
|
361
|
+
elif trusted_hash == entry["current_hash"]:
|
|
362
|
+
status = "trusted"
|
|
363
|
+
elif trusted_hash:
|
|
364
|
+
status = "modified"
|
|
365
|
+
else:
|
|
366
|
+
status = "untrusted"
|
|
367
|
+
counts[status] += 1
|
|
368
|
+
entry["status"] = status
|
|
369
|
+
entry["enabled"] = enabled
|
|
370
|
+
entry["trusted_hash"] = trusted_hash
|
|
371
|
+
|
|
372
|
+
if state_error:
|
|
373
|
+
aggregate = "unknown"
|
|
374
|
+
elif counts["disabled"]:
|
|
375
|
+
aggregate = "disabled"
|
|
376
|
+
elif counts["modified"]:
|
|
377
|
+
aggregate = "modified"
|
|
378
|
+
elif counts["untrusted"]:
|
|
379
|
+
aggregate = "untrusted"
|
|
380
|
+
else:
|
|
381
|
+
aggregate = "trusted"
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
"status": aggregate,
|
|
385
|
+
"ready": aggregate == "trusted",
|
|
386
|
+
"authorization_required": bool(counts["untrusted"] or counts["modified"]),
|
|
387
|
+
"total": len(entries),
|
|
388
|
+
**counts,
|
|
389
|
+
"entries": entries,
|
|
390
|
+
"error": state_error,
|
|
391
|
+
"hooks_path": _absolute_display_path(path),
|
|
392
|
+
"config_path": _absolute_display_path(config),
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _print_trust_report(report: dict[str, Any]) -> None:
|
|
397
|
+
status = report.get("status", "unknown")
|
|
398
|
+
total = int(report.get("total", 0) or 0)
|
|
399
|
+
trusted = int(report.get("trusted", 0) or 0)
|
|
400
|
+
if status == "trusted":
|
|
401
|
+
print(f"FlowPilot hook trust: trusted ({trusted}/{total})")
|
|
402
|
+
elif status == "modified":
|
|
403
|
+
print("FlowPilot hook trust: changed since approval; review required with /hooks")
|
|
404
|
+
elif status == "untrusted":
|
|
405
|
+
print("FlowPilot hook trust: approval required; review with /hooks")
|
|
406
|
+
elif status == "disabled":
|
|
407
|
+
print("FlowPilot hook trust: one or more managed hooks are disabled in Codex")
|
|
408
|
+
elif status == "missing":
|
|
409
|
+
print("FlowPilot hook trust: managed hooks are not installed")
|
|
410
|
+
else:
|
|
411
|
+
detail = report.get("error") or "trust state could not be verified"
|
|
412
|
+
print(f"FlowPilot hook trust: unknown ({detail})")
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def main() -> int:
|
|
416
|
+
parser = argparse.ArgumentParser()
|
|
417
|
+
parser.add_argument("action", choices=("install", "uninstall", "check", "status"))
|
|
418
|
+
parser.add_argument("--hooks", required=True, type=Path)
|
|
419
|
+
parser.add_argument("--script", type=Path)
|
|
420
|
+
parser.add_argument("--config", type=Path)
|
|
421
|
+
parser.add_argument("--json", action="store_true")
|
|
422
|
+
args = parser.parse_args()
|
|
423
|
+
if args.action == "install":
|
|
424
|
+
if args.script is None:
|
|
425
|
+
parser.error("--script is required for install")
|
|
426
|
+
install(args.hooks, args.script)
|
|
427
|
+
return 0
|
|
428
|
+
if args.action == "uninstall":
|
|
429
|
+
uninstall(args.hooks)
|
|
430
|
+
return 0
|
|
431
|
+
if args.action == "check":
|
|
432
|
+
return check(args.hooks)
|
|
433
|
+
|
|
434
|
+
config = args.config or args.hooks.parent / "config.toml"
|
|
435
|
+
report = trust_report(args.hooks, config)
|
|
436
|
+
if args.json:
|
|
437
|
+
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
|
|
438
|
+
else:
|
|
439
|
+
_print_trust_report(report)
|
|
440
|
+
if report["ready"]:
|
|
441
|
+
return 0
|
|
442
|
+
if report["status"] == "missing":
|
|
443
|
+
return 1
|
|
444
|
+
return 2
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
if __name__ == "__main__":
|
|
448
|
+
raise SystemExit(main())
|