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,389 @@
1
+ #!/usr/bin/env python3
2
+ """Install and inspect codex-flow's global prompt entry instructions.
3
+
4
+ The helper owns only the block between BEGIN_MARKER and END_MARKER in the
5
+ global AGENTS files. It reads and writes bytes so unrelated content,
6
+ including a missing final newline, survives an install/uninstall round trip.
7
+ """
8
+
9
+ from __future__ import print_function
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ import stat
15
+ import sys
16
+ import tempfile
17
+ from pathlib import Path
18
+
19
+
20
+ BEGIN_MARKER = b"<!-- codex-flow:begin -->"
21
+ END_MARKER = b"<!-- codex-flow:end -->"
22
+ STATE_FILE = "instructions-state.json"
23
+ TARGET_NAMES = ("AGENTS.md", "AGENTS.override.md")
24
+
25
+
26
+ class InstructionError(RuntimeError):
27
+ """A target cannot be safely inspected or updated."""
28
+
29
+
30
+ def default_codex_home():
31
+ return Path(os.environ.get("CODEX_HOME", str(Path.home() / ".codex"))).expanduser()
32
+
33
+
34
+ def default_template(codex_home):
35
+ local = Path(__file__).resolve().parents[1] / "templates" / "flow-pilot-instructions.md"
36
+ if local.is_file():
37
+ return local
38
+ return Path(codex_home) / "codex-flow" / "flow-pilot-instructions.md"
39
+
40
+
41
+ def _lexists(path):
42
+ return os.path.lexists(str(path))
43
+
44
+
45
+ def _read_target(path):
46
+ if not _lexists(path):
47
+ return None
48
+ if path.is_symlink():
49
+ raise InstructionError("refusing to manage symlink target: {}".format(path))
50
+ if not path.is_file():
51
+ raise InstructionError("refusing to manage non-file target: {}".format(path))
52
+ try:
53
+ return path.read_bytes()
54
+ except OSError as exc:
55
+ raise InstructionError("cannot read {}: {}".format(path, exc))
56
+
57
+
58
+ def _marker_range(data):
59
+ """Return the owned block range, or ``None`` when no markers exist.
60
+
61
+ A marker must occupy its own line. Any unmatched or repeated marker is a
62
+ hard error and is checked for every target before any target is changed.
63
+ """
64
+
65
+ begins = data.count(BEGIN_MARKER)
66
+ ends = data.count(END_MARKER)
67
+ if not begins and not ends:
68
+ return None
69
+ if begins != 1 or ends != 1:
70
+ raise InstructionError("malformed or duplicate codex-flow instruction markers")
71
+ begin = data.find(BEGIN_MARKER)
72
+ end = data.find(END_MARKER)
73
+ if end <= begin:
74
+ raise InstructionError("malformed codex-flow instruction marker order")
75
+ if end and data[end - 1 : end] not in (b"\n", b"\r"):
76
+ raise InstructionError("codex-flow end marker must start a line")
77
+ if begin and data[begin - 1 : begin] not in (b"\n", b"\r"):
78
+ raise InstructionError("codex-flow begin marker must start a line")
79
+ begin_after = begin + len(BEGIN_MARKER)
80
+ if begin_after >= len(data) or data[begin_after : begin_after + 1] not in (b"\n", b"\r"):
81
+ raise InstructionError("codex-flow begin marker must end a line")
82
+ end_after = end + len(END_MARKER)
83
+ if end_after < len(data) and data[end_after : end_after + 1] not in (b"\n", b"\r"):
84
+ raise InstructionError("codex-flow end marker must end a line")
85
+ if data[end_after : end_after + 2] == b"\r\n":
86
+ end_after += 2
87
+ elif data[end_after : end_after + 1] in (b"\n", b"\r"):
88
+ end_after += 1
89
+ return begin, end_after
90
+
91
+
92
+ def _remove_block(data):
93
+ span = _marker_range(data)
94
+ if span is None:
95
+ return data, False
96
+ start, end = span
97
+ return data[:start] + data[end:], True
98
+
99
+
100
+ def _expand_template(data, codex_home):
101
+ home = str(Path(codex_home).expanduser()).encode("utf-8")
102
+ # Keep the documented token and accept the two common spellings so a
103
+ # locally customized template remains useful across releases.
104
+ for token in (b"{{CODEX_HOME}}", b"${CODEX_HOME}", b"__CODEX_HOME__"):
105
+ data = data.replace(token, home)
106
+ return data
107
+
108
+
109
+ def _block_for(template, codex_home):
110
+ try:
111
+ template_data = Path(template).read_bytes()
112
+ except OSError as exc:
113
+ raise InstructionError("cannot read instruction template {}: {}".format(template, exc))
114
+ if BEGIN_MARKER in template_data or END_MARKER in template_data:
115
+ raise InstructionError("instruction template contains reserved codex-flow markers")
116
+ template_data = _expand_template(template_data, codex_home)
117
+ if not template_data.endswith((b"\n", b"\r")):
118
+ template_data += b"\n"
119
+ return BEGIN_MARKER + b"\n" + template_data + END_MARKER + b"\n"
120
+
121
+
122
+ def _state_path(codex_home):
123
+ return Path(codex_home) / "codex-flow" / STATE_FILE
124
+
125
+
126
+ def _load_state(codex_home):
127
+ path = _state_path(codex_home)
128
+ try:
129
+ value = json.loads(path.read_text(encoding="utf-8"))
130
+ except (OSError, ValueError, TypeError):
131
+ return {"created": []}
132
+ created = value.get("created") if isinstance(value, dict) else []
133
+ if not isinstance(created, list):
134
+ created = []
135
+ return {"created": [name for name in created if name in TARGET_NAMES]}
136
+
137
+
138
+ def _atomic_write(path, data, mode=None):
139
+ path.parent.mkdir(parents=True, exist_ok=True)
140
+ fd, name = tempfile.mkstemp(prefix=".codex-flow-", dir=str(path.parent))
141
+ temporary = Path(name)
142
+ try:
143
+ with os.fdopen(fd, "wb") as stream:
144
+ stream.write(data)
145
+ stream.flush()
146
+ os.fsync(stream.fileno())
147
+ if mode is not None and os.name != "nt":
148
+ temporary.chmod(mode)
149
+ os.replace(str(temporary), str(path))
150
+ finally:
151
+ try:
152
+ temporary.unlink()
153
+ except FileNotFoundError:
154
+ pass
155
+
156
+
157
+ def _atomic_write_json(path, value):
158
+ payload = (json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8")
159
+ _atomic_write(path, payload)
160
+
161
+
162
+ def _inspect(codex_home):
163
+ home = Path(codex_home)
164
+ result = {}
165
+ for name in TARGET_NAMES:
166
+ path = home / name
167
+ data = _read_target(path)
168
+ span = _marker_range(data) if data is not None else None
169
+ result[name] = {
170
+ "path": path,
171
+ "data": data,
172
+ "span": span,
173
+ "has_block": span is not None,
174
+ }
175
+ return result
176
+
177
+
178
+ def _effective_name(items):
179
+ override = items["AGENTS.override.md"]["data"]
180
+ if override is not None and override.strip():
181
+ return "AGENTS.override.md"
182
+ return "AGENTS.md"
183
+
184
+
185
+ def _status(codex_home, template=None):
186
+ items = _inspect(codex_home)
187
+ target = _effective_name(items)
188
+ active = items[target]
189
+ report = {
190
+ "status": "missing",
191
+ "target": target,
192
+ "paths": {name: str(items[name]["path"]) for name in TARGET_NAMES},
193
+ "has_base": items["AGENTS.md"]["data"] is not None,
194
+ "has_override": items["AGENTS.override.md"]["data"] is not None,
195
+ }
196
+ if active["data"] is None or not active["has_block"]:
197
+ if target == "AGENTS.override.md":
198
+ report["status"] = "shadowed"
199
+ return report
200
+ if template is not None:
201
+ expected = _block_for(template, codex_home)
202
+ begin, end = active["span"]
203
+ if active["data"][begin:end] != expected:
204
+ report["status"] = "stale"
205
+ return report
206
+ report["status"] = "installed"
207
+ return report
208
+
209
+
210
+ def check(codex_home, template=None):
211
+ """Return an effective-target status suitable for doctor and callers."""
212
+
213
+ return _status(Path(codex_home), Path(template) if template else None)
214
+
215
+
216
+ def install(codex_home, template):
217
+ """Install the managed block while preserving all unrelated bytes."""
218
+
219
+ home = Path(codex_home)
220
+ # Read/validate every possible target, including an inactive one, before
221
+ # computing or applying changes. This guarantees malformed markers cause
222
+ # zero managed-file writes.
223
+ items = _inspect(home)
224
+ block = _block_for(template, home)
225
+ target = _effective_name(items)
226
+ old_state = _load_state(home)
227
+ created = set(old_state.get("created", []))
228
+ changes = {}
229
+
230
+ for name in TARGET_NAMES:
231
+ item = items[name]
232
+ data = item["data"]
233
+ if data is None:
234
+ continue
235
+ remaining, had_block = _remove_block(data)
236
+ if had_block and not remaining and name in created and name != target:
237
+ changes[name] = None
238
+ created.discard(name)
239
+ elif had_block and remaining != data:
240
+ changes[name] = (remaining, item["path"].stat().st_mode)
241
+
242
+ target_item = items[target]
243
+ target_data = target_item["data"]
244
+ if target_data is None:
245
+ target_user_data = b""
246
+ target_mode = None
247
+ created.add(target)
248
+ else:
249
+ target_user_data, _ = _remove_block(target_data)
250
+ target_mode = stat.S_IMODE(target_item["path"].stat().st_mode)
251
+ desired = block + target_user_data
252
+ # The target receives the fresh block below; discard the intermediate
253
+ # cleanup entry produced by the all-target pass.
254
+ changes.pop(target, None)
255
+ if target_item["data"] != desired:
256
+ changes[target] = (desired, target_mode)
257
+
258
+ # Stage all replacement files before touching either target. The helper
259
+ # does not follow symlinks and refuses them during _inspect.
260
+ staged = []
261
+ originals = {name: items[name]["data"] for name in TARGET_NAMES}
262
+ modes = {
263
+ name: (stat.S_IMODE(items[name]["path"].stat().st_mode) if items[name]["data"] is not None else None)
264
+ for name in TARGET_NAMES
265
+ }
266
+ try:
267
+ for name, change in changes.items():
268
+ if change is None:
269
+ continue
270
+ data, mode = change
271
+ path = items[name]["path"]
272
+ path.parent.mkdir(parents=True, exist_ok=True)
273
+ fd, tmp_name = tempfile.mkstemp(prefix=".codex-flow-", dir=str(path.parent))
274
+ temp = Path(tmp_name)
275
+ with os.fdopen(fd, "wb") as stream:
276
+ stream.write(data)
277
+ stream.flush()
278
+ os.fsync(stream.fileno())
279
+ if mode is not None and os.name != "nt":
280
+ temp.chmod(mode)
281
+ staged.append((name, path, temp))
282
+ for name, change in changes.items():
283
+ path = items[name]["path"]
284
+ if change is None:
285
+ if _lexists(path):
286
+ path.unlink()
287
+ else:
288
+ temp = next(item[2] for item in staged if item[0] == name)
289
+ os.replace(str(temp), str(path))
290
+ new_state = {"created": sorted(created)}
291
+ if new_state != old_state or changes:
292
+ _atomic_write_json(_state_path(home), new_state)
293
+ except Exception:
294
+ # Restore only regular files that this invocation could have touched.
295
+ # Marker validation and symlink refusal happen before this section.
296
+ for _, _, temp in staged:
297
+ try:
298
+ temp.unlink()
299
+ except FileNotFoundError:
300
+ pass
301
+ for name in TARGET_NAMES:
302
+ path = items[name]["path"]
303
+ original = originals.get(name)
304
+ if original is None:
305
+ try:
306
+ if _lexists(path):
307
+ path.unlink()
308
+ except OSError:
309
+ pass
310
+ elif not path.is_symlink():
311
+ try:
312
+ _atomic_write(path, original, modes[name])
313
+ except OSError:
314
+ pass
315
+ raise
316
+ return _status(home, Path(template))
317
+
318
+
319
+ def uninstall(codex_home):
320
+ """Remove only managed blocks from both targets."""
321
+
322
+ home = Path(codex_home)
323
+ items = _inspect(home) # validates all markers and symlink targets first
324
+ state = _load_state(home)
325
+ created = set(state.get("created", []))
326
+ changes = {}
327
+ for name in TARGET_NAMES:
328
+ data = items[name]["data"]
329
+ if data is None or not items[name]["has_block"]:
330
+ continue
331
+ remaining, _ = _remove_block(data)
332
+ if not remaining and name in created:
333
+ changes[name] = None
334
+ created.discard(name)
335
+ else:
336
+ changes[name] = (remaining, stat.S_IMODE(items[name]["path"].stat().st_mode))
337
+ for name, change in changes.items():
338
+ path = items[name]["path"]
339
+ if change is None:
340
+ path.unlink()
341
+ else:
342
+ _atomic_write(path, change[0], change[1])
343
+ new_state = {"created": sorted(created)}
344
+ if new_state != state:
345
+ _atomic_write_json(_state_path(home), new_state)
346
+ return {"status": "removed" if changes else "absent", "removed": sorted(changes)}
347
+
348
+
349
+ def _print_result(result, as_json):
350
+ if as_json:
351
+ print(json.dumps(result, ensure_ascii=False, sort_keys=True))
352
+ else:
353
+ print(result.get("status", "unknown"))
354
+
355
+
356
+ def main(argv=None):
357
+ parser = argparse.ArgumentParser(description=__doc__)
358
+ parser.add_argument("--codex-home", default=None, help="Codex home containing AGENTS.md")
359
+ parser.add_argument("--template", default=None, help="flow-pilot-instructions.md path")
360
+ parser.add_argument("--json", action="store_true", dest="as_json")
361
+ parser.add_argument("command", choices=("install", "check", "uninstall"))
362
+ args = parser.parse_args(argv)
363
+ home = Path(args.codex_home).expanduser() if args.codex_home else default_codex_home()
364
+ try:
365
+ if args.command == "install":
366
+ template = Path(args.template).expanduser() if args.template else default_template(home)
367
+ result = install(home, template)
368
+ _print_result(result, args.as_json)
369
+ return 0
370
+ if args.command == "check":
371
+ template = Path(args.template).expanduser() if args.template else default_template(home)
372
+ result = check(home, template)
373
+ _print_result(result, args.as_json)
374
+ return 0 if result["status"] == "installed" else 1
375
+ result = uninstall(home)
376
+ _print_result(result, args.as_json)
377
+ return 0
378
+ except InstructionError as exc:
379
+ result = {"status": "error", "error": str(exc)}
380
+ _print_result(result, args.as_json)
381
+ return 2
382
+ except OSError as exc:
383
+ result = {"status": "error", "error": str(exc)}
384
+ _print_result(result, args.as_json)
385
+ return 2
386
+
387
+
388
+ if __name__ == "__main__":
389
+ sys.exit(main())
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env python3
2
+ """Install and remove the small shell integration owned by codex-flow."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import re
8
+ import shlex
9
+ from pathlib import Path
10
+
11
+
12
+ BEGIN = "# >>> codex-flow >>>"
13
+ END = "# <<< codex-flow <<<"
14
+ BLOCK_RE = re.compile(
15
+ rf"(?ms)^{re.escape(BEGIN)}[ \t]*\n.*?^{re.escape(END)}[ \t]*(?:\n|$)"
16
+ )
17
+
18
+
19
+ def read_text(path: Path) -> str:
20
+ try:
21
+ return path.read_text()
22
+ except FileNotFoundError:
23
+ return ""
24
+
25
+
26
+ def remove_blocks(text: str) -> str:
27
+ return BLOCK_RE.sub("", text)
28
+
29
+
30
+ def write_if_changed(path: Path, text: str) -> None:
31
+ if read_text(path) == text:
32
+ return
33
+ path.parent.mkdir(parents=True, exist_ok=True)
34
+ path.write_text(text)
35
+
36
+
37
+ def state_path(state_dir: Path, name: str) -> Path:
38
+ return state_dir / "shell" / name
39
+
40
+
41
+ def absolute_path(value: str) -> Path:
42
+ return Path(value).expanduser().resolve()
43
+
44
+
45
+ def old_rc_paths(state_dir: Path) -> list[Path]:
46
+ metadata = state_path(state_dir, "rc_paths")
47
+ if not metadata.exists():
48
+ return []
49
+ return [Path(line) for line in metadata.read_text().splitlines() if line]
50
+
51
+
52
+ def shell_rc_paths(shell: str, config_dir: Path) -> list[Path]:
53
+ if shell == "zsh":
54
+ return [config_dir / ".zshrc"]
55
+ if shell == "bash":
56
+ paths = [config_dir / ".bashrc"]
57
+ for name in (".bash_profile", ".bash_login", ".profile"):
58
+ candidate = config_dir / name
59
+ if candidate.exists():
60
+ paths.append(candidate)
61
+ break
62
+ else:
63
+ # A login bash reads one of these files instead of .bashrc. Keep a
64
+ # managed entry point so new login terminals receive the setup.
65
+ paths.append(config_dir / ".bash_profile")
66
+ return paths
67
+ raise ValueError(f"unsupported shell: {shell}")
68
+
69
+
70
+ def make_init(state_dir: Path, shell: str, bin_dir: Path) -> str:
71
+ completion = state_path(state_dir, "codex-flow.zsh" if shell == "zsh" else "codex-flow.bash")
72
+ quoted_bin = shlex.quote(str(bin_dir))
73
+ quoted_completion = shlex.quote(str(completion))
74
+ return f"""#!{'/usr/bin/env zsh' if shell == 'zsh' else '/usr/bin/env bash'}
75
+ # Generated by codex-flow. This file is sourced by the managed rc block.
76
+ case :${{PATH-}}: in
77
+ *:{quoted_bin}:*) ;;
78
+ *) PATH={quoted_bin}:$PATH ;;
79
+ esac
80
+ export PATH
81
+ source {quoted_completion}
82
+ """
83
+
84
+
85
+ def managed_block(state_dir: Path, shell: str) -> str:
86
+ init = shlex.quote(str(state_path(state_dir, "init.sh")))
87
+ if shell == "bash":
88
+ return (
89
+ f"{BEGIN}\n"
90
+ f"if [ -n \"${{BASH_VERSION:-}}\" ] && [ -r {init} ]; then\n"
91
+ f" . {init}\n"
92
+ f"fi\n"
93
+ f"{END}\n"
94
+ )
95
+ return f"{BEGIN}\nsource {init}\n{END}\n"
96
+
97
+
98
+ def install(args: argparse.Namespace) -> None:
99
+ state_dir = absolute_path(args.state_dir)
100
+ config_dir = absolute_path(args.config_dir)
101
+ bin_dir = absolute_path(args.bin_dir)
102
+ shell_dir = state_dir / "shell"
103
+ shell_dir.mkdir(parents=True, exist_ok=True)
104
+
105
+ # Remove blocks from paths recorded by the previous install first. This
106
+ # also handles an install where the selected shell or config dir changed.
107
+ for path in old_rc_paths(state_dir):
108
+ write_if_changed(path, remove_blocks(read_text(path)))
109
+
110
+ rc_paths = shell_rc_paths(args.shell, config_dir)
111
+ init_path = state_path(state_dir, "init.sh")
112
+ write_if_changed(init_path, make_init(state_dir, args.shell, bin_dir))
113
+ init_path.chmod(0o755)
114
+ write_if_changed(state_path(state_dir, "shell"), f"{args.shell}\n")
115
+ write_if_changed(state_path(state_dir, "bin_dir"), f"{bin_dir}\n")
116
+
117
+ block = managed_block(state_dir, args.shell)
118
+ for path in rc_paths:
119
+ text = remove_blocks(read_text(path))
120
+ if text and not text.endswith("\n"):
121
+ text += "\n"
122
+ if text and not text.endswith("\n\n"):
123
+ text += "\n"
124
+ write_if_changed(path, text + block)
125
+ write_if_changed(state_path(state_dir, "rc_paths"), "".join(f"{path}\n" for path in rc_paths))
126
+
127
+
128
+ def uninstall(args: argparse.Namespace) -> None:
129
+ state_dir = absolute_path(args.state_dir)
130
+ for path in old_rc_paths(state_dir):
131
+ write_if_changed(path, remove_blocks(read_text(path)))
132
+
133
+
134
+ def main() -> None:
135
+ parser = argparse.ArgumentParser()
136
+ parser.add_argument("action", choices=("install", "uninstall"))
137
+ parser.add_argument("--state-dir", required=True)
138
+ parser.add_argument("--shell", choices=("bash", "zsh"))
139
+ parser.add_argument("--config-dir")
140
+ parser.add_argument("--bin-dir")
141
+ args = parser.parse_args()
142
+ if args.action == "install":
143
+ if not args.config_dir or not args.bin_dir or not args.shell:
144
+ parser.error("install requires --shell, --config-dir, and --bin-dir")
145
+ install(args)
146
+ else:
147
+ uninstall(args)
148
+
149
+
150
+ if __name__ == "__main__":
151
+ main()