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.
Files changed (113) hide show
  1. codex_flow/__init__.py +28 -0
  2. codex_flow/__main__.py +9 -0
  3. codex_flow/cli.py +242 -0
  4. codex_flow/data/LICENSE +21 -0
  5. codex_flow/data/README.en.md +303 -0
  6. codex_flow/data/README.md +305 -0
  7. codex_flow/data/VERSION +1 -0
  8. codex_flow/data/apps/chatgpt-mcp/README.md +86 -0
  9. codex_flow/data/apps/chatgpt-mcp/__init__.py +1 -0
  10. codex_flow/data/apps/chatgpt-mcp/adapter.py +458 -0
  11. codex_flow/data/apps/chatgpt-mcp/server.py +358 -0
  12. codex_flow/data/apps/chatgpt-mcp/widget.html +927 -0
  13. codex_flow/data/apps/macos-overlay/README.en.md +121 -0
  14. codex_flow/data/apps/macos-overlay/README.md +123 -0
  15. codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayRuntimeState.swift +126 -0
  16. codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayScreenGeometry.swift +82 -0
  17. codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayWindowController.swift +1052 -0
  18. codex_flow/data/apps/macos-overlay/Sources/Localization.swift +197 -0
  19. codex_flow/data/apps/macos-overlay/Sources/Models/TelemetryData.swift +1557 -0
  20. codex_flow/data/apps/macos-overlay/Sources/Services/AccountSnapshotService.swift +1101 -0
  21. codex_flow/data/apps/macos-overlay/Sources/Services/FlowPilotInstanceLock.swift +153 -0
  22. codex_flow/data/apps/macos-overlay/Sources/Services/IPCServer.swift +298 -0
  23. codex_flow/data/apps/macos-overlay/Sources/Services/TelemetryQueryEngine.swift +800 -0
  24. codex_flow/data/apps/macos-overlay/Sources/Services/TelemetryWatcher.swift +135 -0
  25. codex_flow/data/apps/macos-overlay/Sources/Services/UpdateService.swift +610 -0
  26. codex_flow/data/apps/macos-overlay/Sources/Views/AccountView.swift +610 -0
  27. codex_flow/data/apps/macos-overlay/Sources/Views/AnalyticsView.swift +566 -0
  28. codex_flow/data/apps/macos-overlay/Sources/Views/AutostartView.swift +293 -0
  29. codex_flow/data/apps/macos-overlay/Sources/Views/BubbleView.swift +317 -0
  30. codex_flow/data/apps/macos-overlay/Sources/Views/HistoryView.swift +1124 -0
  31. codex_flow/data/apps/macos-overlay/Sources/Views/HoverRevealText.swift +165 -0
  32. codex_flow/data/apps/macos-overlay/Sources/Views/InspectorSkillsToolsView.swift +121 -0
  33. codex_flow/data/apps/macos-overlay/Sources/Views/LogoView.swift +182 -0
  34. codex_flow/data/apps/macos-overlay/Sources/Views/SleekSwitch.swift +117 -0
  35. codex_flow/data/apps/macos-overlay/Sources/Views/StrategyModeView.swift +561 -0
  36. codex_flow/data/apps/macos-overlay/Sources/Views/SummaryView.swift +1273 -0
  37. codex_flow/data/apps/macos-overlay/Sources/Views/UpdateView.swift +352 -0
  38. codex_flow/data/apps/macos-overlay/Sources/main.swift +340 -0
  39. codex_flow/data/apps/macos-overlay/Tests/OverlayScreenGeometryTests.swift +163 -0
  40. codex_flow/data/apps/macos-overlay/Tests/TelemetryPhase1ContractTests.swift +357 -0
  41. codex_flow/data/apps/macos-overlay/Tests/TelemetryQueryEngineConcurrencyTests.swift +221 -0
  42. codex_flow/data/apps/macos-overlay/Tests/TelemetryQuotaSelectionTests.swift +158 -0
  43. codex_flow/data/apps/macos-overlay/Tests/TelemetryWorkerTokenTests.swift +122 -0
  44. codex_flow/data/apps/macos-overlay/build.sh +75 -0
  45. codex_flow/data/benchmark/corpus.json +103 -0
  46. codex_flow/data/benchmark/manifest.example.json +41 -0
  47. codex_flow/data/benchmark/manifest.schema.json +137 -0
  48. codex_flow/data/benchmark/prices/gpt-5.6-2026-08-30.json +5 -0
  49. codex_flow/data/benchmark/profiles.json +90 -0
  50. codex_flow/data/benchmark/schema.json +77 -0
  51. codex_flow/data/benchmark/tasks.json +50 -0
  52. codex_flow/data/completions/codex-flow.bash +34 -0
  53. codex_flow/data/completions/codex-flow.zsh +52 -0
  54. codex_flow/data/glama.json +6 -0
  55. codex_flow/data/install-release.ps1 +126 -0
  56. codex_flow/data/install-release.sh +155 -0
  57. codex_flow/data/install.ps1 +349 -0
  58. codex_flow/data/install.sh +362 -0
  59. codex_flow/data/policy/benchmark.toml +49 -0
  60. codex_flow/data/policy/defaults.toml +70 -0
  61. codex_flow/data/scripts/analyze-benchmark.py +510 -0
  62. codex_flow/data/scripts/benchmark-local.py +171 -0
  63. codex_flow/data/scripts/check-recommendation.py +277 -0
  64. codex_flow/data/scripts/doctor.py +449 -0
  65. codex_flow/data/scripts/generate-release-manifest.py +74 -0
  66. codex_flow/data/scripts/localization.py +192 -0
  67. codex_flow/data/scripts/manage-hooks.py +448 -0
  68. codex_flow/data/scripts/manage-instructions.py +389 -0
  69. codex_flow/data/scripts/manage-shell.py +151 -0
  70. codex_flow/data/scripts/materialize-corpus.py +193 -0
  71. codex_flow/data/scripts/menu.py +646 -0
  72. codex_flow/data/scripts/migrations/0001_update_settings.py +80 -0
  73. codex_flow/data/scripts/package-release.py +132 -0
  74. codex_flow/data/scripts/render-benchmark-report.py +292 -0
  75. codex_flow/data/scripts/run-benchmark.py +829 -0
  76. codex_flow/data/scripts/strategies/__init__.py +28 -0
  77. codex_flow/data/scripts/strategies/balanced.py +115 -0
  78. codex_flow/data/scripts/strategies/base.py +363 -0
  79. codex_flow/data/scripts/strategies/efficient.py +158 -0
  80. codex_flow/data/scripts/strategies/lifecycle_runtime.py +590 -0
  81. codex_flow/data/scripts/strategies/quality.py +209 -0
  82. codex_flow/data/scripts/strategies/speed.py +108 -0
  83. codex_flow/data/scripts/strategies/task_budget_runtime.py +644 -0
  84. codex_flow/data/scripts/strategies/task_phase_runtime.py +341 -0
  85. codex_flow/data/scripts/strategies/work_unit_runtime.py +421 -0
  86. codex_flow/data/scripts/strategy_runtime.py +1091 -0
  87. codex_flow/data/scripts/telemetry.py +400 -0
  88. codex_flow/data/scripts/telemetry_core/__init__.py +192 -0
  89. codex_flow/data/scripts/telemetry_core/app_server.py +1192 -0
  90. codex_flow/data/scripts/telemetry_core/collector.py +1247 -0
  91. codex_flow/data/scripts/telemetry_core/common.py +421 -0
  92. codex_flow/data/scripts/telemetry_core/latency.py +593 -0
  93. codex_flow/data/scripts/telemetry_core/query.py +427 -0
  94. codex_flow/data/scripts/telemetry_core/quota_ledger.py +598 -0
  95. codex_flow/data/scripts/telemetry_core/render.py +460 -0
  96. codex_flow/data/scripts/telemetry_core/repair.py +223 -0
  97. codex_flow/data/scripts/ui.py +266 -0
  98. codex_flow/data/scripts/update-homebrew-formula.py +146 -0
  99. codex_flow/data/scripts/update_runtime_config.py +134 -0
  100. codex_flow/data/scripts/updater.py +1718 -0
  101. codex_flow/data/smithery.yaml +18 -0
  102. codex_flow/data/templates/agents/worker-explorer.toml +24 -0
  103. codex_flow/data/templates/agents/worker-implementer.toml +49 -0
  104. codex_flow/data/templates/agents/worker-reviewer.toml +25 -0
  105. codex_flow/data/templates/flow-pilot-instructions.md +35 -0
  106. codex_flow/data/templates/skills/flow-pilot/SKILL.md +577 -0
  107. codex_flow/mcp.py +35 -0
  108. codex_flow-2.1.13.dist-info/METADATA +342 -0
  109. codex_flow-2.1.13.dist-info/RECORD +113 -0
  110. codex_flow-2.1.13.dist-info/WHEEL +5 -0
  111. codex_flow-2.1.13.dist-info/entry_points.txt +3 -0
  112. codex_flow-2.1.13.dist-info/licenses/LICENSE +21 -0
  113. 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())