pythia-plsql 0.12.0__tar.gz → 0.13.0__tar.gz

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 (45) hide show
  1. {pythia_plsql-0.12.0/scripts/pythia_plsql.egg-info → pythia_plsql-0.13.0}/PKG-INFO +6 -2
  2. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/README.md +5 -1
  3. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/pyproject.toml +1 -1
  4. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/scripts/pythia.py +132 -14
  5. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0/scripts/pythia_plsql.egg-info}/PKG-INFO +6 -2
  6. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/skills/pythia-apply/SKILL.md +1 -1
  7. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/tests/test_install.py +104 -2
  8. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/LICENSE +0 -0
  9. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/queries/compile-errors.sql +0 -0
  10. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/queries/current-schema.sql +0 -0
  11. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/queries/dependencies.sql +0 -0
  12. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/queries/impact.sql +0 -0
  13. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/queries/invalid-objects.sql +0 -0
  14. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/queries/name-occupants.sql +0 -0
  15. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/queries/object-names.sql +0 -0
  16. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/queries/object-source.sql +0 -0
  17. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/queries/plscope-enabled.sql +0 -0
  18. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/queries/plscope-statements.sql +0 -0
  19. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/queries/plscope-usages.sql +0 -0
  20. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/queries/session-privileges.sql +0 -0
  21. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/queries/similar-candidates.sql +0 -0
  22. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/queries/source.sql +0 -0
  23. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/scripts/pythia_plsql.egg-info/SOURCES.txt +0 -0
  24. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/scripts/pythia_plsql.egg-info/dependency_links.txt +0 -0
  25. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/scripts/pythia_plsql.egg-info/entry_points.txt +0 -0
  26. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/scripts/pythia_plsql.egg-info/requires.txt +0 -0
  27. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/scripts/pythia_plsql.egg-info/top_level.txt +0 -0
  28. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/setup.cfg +0 -0
  29. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/skills/pythia-comment/SKILL.md +0 -0
  30. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/skills/pythia-conventions/SKILL.md +0 -0
  31. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/skills/pythia-explore/SKILL.md +0 -0
  32. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/skills/pythia-explore/reference/data-dictionary.md +0 -0
  33. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/skills/pythia-impact/SKILL.md +0 -0
  34. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/skills/pythia-review/SKILL.md +0 -0
  35. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/skills/pythia-review/reference/antipatterns.md +0 -0
  36. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/skills/pythia-setup/SKILL.md +0 -0
  37. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/skills/pythia-skill-author/SKILL.md +0 -0
  38. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/skills/pythia-spec/SKILL.md +0 -0
  39. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/skills/pythia-write/SKILL.md +0 -0
  40. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/skills/pythia-write/reference/patterns.md +0 -0
  41. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/skills/using-pythia/SKILL.md +0 -0
  42. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/tests/test_phase1.py +0 -0
  43. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/tests/test_phase2.py +0 -0
  44. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/tests/test_phase3.py +0 -0
  45. {pythia_plsql-0.12.0 → pythia_plsql-0.13.0}/tests/test_phase5.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythia-plsql
3
- Version: 0.12.0
3
+ Version: 0.13.0
4
4
  Summary: PL/SQL development for AI agents on Oracle Database - expert data-dictionary queries, impact analysis, and a snapshot-verified write path with honest rollback.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/thaildhe172591/pythia
@@ -128,7 +128,7 @@ Or the same thing piecewise:
128
128
 
129
129
  ```bash
130
130
  pip install pythia-plsql # the CLI (thin driver — no Oracle Instant Client needed)
131
- python -m pythia install # skills into your agent + .pythia/ scaffold + Claude Code hooks
131
+ python -m pythia install # skills + .pythia/ scaffold + Claude Code hooks + Codex AGENTS.md
132
132
  pythia check # fill in connections.json first, then verify
133
133
  ```
134
134
 
@@ -212,6 +212,10 @@ stops it prompting for the read-only commands and asks it to pause on
212
212
  writes — optional, and yours to install
213
213
  ([why pythia does not](GUIDE.md#11-optional-claude-code-permission-settings)).
214
214
 
215
+ On Codex, the same command writes the harness into `AGENTS.md` (loaded every
216
+ session) and a session-start hook into `.codex/hooks.json`; approve previews in
217
+ the terminal with `pythia approve <token>`, since Codex has no chat-mint.
218
+
215
219
  Per-group write policy, `.pythia/policy.json` (defaults shown):
216
220
 
217
221
  | Group | Default | Is rollback real? |
@@ -114,7 +114,7 @@ Or the same thing piecewise:
114
114
 
115
115
  ```bash
116
116
  pip install pythia-plsql # the CLI (thin driver — no Oracle Instant Client needed)
117
- python -m pythia install # skills into your agent + .pythia/ scaffold + Claude Code hooks
117
+ python -m pythia install # skills + .pythia/ scaffold + Claude Code hooks + Codex AGENTS.md
118
118
  pythia check # fill in connections.json first, then verify
119
119
  ```
120
120
 
@@ -198,6 +198,10 @@ stops it prompting for the read-only commands and asks it to pause on
198
198
  writes — optional, and yours to install
199
199
  ([why pythia does not](GUIDE.md#11-optional-claude-code-permission-settings)).
200
200
 
201
+ On Codex, the same command writes the harness into `AGENTS.md` (loaded every
202
+ session) and a session-start hook into `.codex/hooks.json`; approve previews in
203
+ the terminal with `pythia approve <token>`, since Codex has no chat-mint.
204
+
201
205
  Per-group write policy, `.pythia/policy.json` (defaults shown):
202
206
 
203
207
  | Group | Default | Is rollback real? |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pythia-plsql"
7
- version = "0.12.0"
7
+ version = "0.13.0"
8
8
  description = "PL/SQL development for AI agents on Oracle Database - expert data-dictionary queries, impact analysis, and a snapshot-verified write path with honest rollback."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -2818,6 +2818,51 @@ DO writes go through `pythia apply` only - snapshot, token, verify. The
2818
2818
  """
2819
2819
 
2820
2820
 
2821
+ AGENTS_BEGIN = "<!-- pythia:begin (managed by `pythia install`; edit outside this block) -->"
2822
+ AGENTS_END = "<!-- pythia:end -->"
2823
+
2824
+
2825
+ def agents_md_block():
2826
+ """The pythia section for AGENTS.md — Codex's always-loaded instruction
2827
+ file. Body is BRIEF_GUIDE verbatim (the same constant `guide --brief`
2828
+ prints and the Claude SessionStart hook runs, so the two cannot drift),
2829
+ plus the one line Codex needs that Claude gets from its hook: where the
2830
+ approval door is, since Codex has no chat-mint. Marker-wrapped so a re-run
2831
+ replaces it in place and the developer's own text is untouched."""
2832
+ return (f"{AGENTS_BEGIN}\n"
2833
+ "## pythia — developing PL/SQL on Oracle\n\n"
2834
+ f"{BRIEF_GUIDE.rstrip()}\n\n"
2835
+ "On Codex there is no chat approval hook: the developer approves a "
2836
+ "preview in this terminal with `pythia approve <token>`, then the "
2837
+ "agent runs `pythia apply <file> --confirm <token>`.\n"
2838
+ f"{AGENTS_END}")
2839
+
2840
+
2841
+ def merge_agents_md(path):
2842
+ """Write the pythia block into an AGENTS.md. The file is the developer's,
2843
+ shared with every AGENTS.md-reading agent, so only the delimited block is
2844
+ ours: present -> replaced in place, absent -> appended below their text,
2845
+ everything else byte-identical. Returns (path, action) where action is
2846
+ 'created' | 'updated' | 'unchanged'."""
2847
+ path = pathlib.Path(path)
2848
+ block = agents_md_block()
2849
+ old = path.read_text(encoding="utf-8") if path.is_file() else None
2850
+ if old is None:
2851
+ new, action = block + "\n", "created"
2852
+ elif AGENTS_BEGIN in old and AGENTS_END in old:
2853
+ pre = old[:old.index(AGENTS_BEGIN)]
2854
+ post = old[old.index(AGENTS_END) + len(AGENTS_END):]
2855
+ new = pre + block + post
2856
+ action = "unchanged" if new == old else "updated"
2857
+ else:
2858
+ tail = "" if old.endswith("\n\n") else "\n" if old.endswith("\n") else "\n\n"
2859
+ new, action = old + tail + block + "\n", "updated"
2860
+ if new != old:
2861
+ path.parent.mkdir(parents=True, exist_ok=True)
2862
+ path.write_text(new, encoding="utf-8")
2863
+ return path, action
2864
+
2865
+
2821
2866
  OPERATING_GUIDE = """\
2822
2867
  THE OPERATING MODEL — Learn, Ask, Do (Hoc - Hoi - Lam)
2823
2868
 
@@ -3236,29 +3281,36 @@ def global_pack_present(home=None):
3236
3281
 
3237
3282
 
3238
3283
  def copy_bundled_skills(base_dir):
3239
- """No-Node fallback: copy the wheel-bundled pack into
3240
- <base>/.claude/skills/ ONLY — the directory Claude Code reliably reads
3241
- in both scopes (field evidence: a project's .agents/skills is invisible
3242
- to some Claude Code versions, and a second copy doubles every menu
3243
- entry). base_dir is the project root, or the home directory for -g.
3284
+ """No-Node fallback: copy the wheel-bundled pack into BOTH conventional
3285
+ roots — <base>/.claude/skills/ (Claude Code) and <base>/.agents/skills/
3286
+ (Codex, and ~/.agents/skills on -g). base_dir is the project root, or the
3287
+ home directory for -g. Node users get the same reach from `npx skills
3288
+ add`; this is the machine that has only Python.
3289
+
3290
+ Trade-off, stated plainly: this reverses an earlier one-root-only choice.
3291
+ A Claude Code version that reads both roots may list a pack entry twice —
3292
+ accepted, because a Codex user without Node otherwise gets no skills at
3293
+ all, and Codex reads only .agents/skills.
3244
3294
 
3245
3295
  Both destinations are cleared link-first before the copy. An earlier
3246
3296
  `npx skills add` leaves .claude/skills/<name> as a symlink into
3247
- .agents/skills/<name>; copying into that symlink would write through to
3248
- its target, which the .agents cleanup then deletes — leaving a dangling
3249
- link and no pack at all. Only the pack's own names are touched; other
3250
- skills in those directories are left alone."""
3297
+ .agents/skills/<name>; removing both links first means each copytree
3298
+ writes a fresh real directory, never through a link into a target the
3299
+ other copy would then overwrite. Only the pack's own names are touched;
3300
+ other skills in those directories are left alone."""
3251
3301
  import shutil
3252
3302
  base = pathlib.Path(base_dir)
3253
- dest_root = base / ".claude" / "skills"
3303
+ claude_root = base / ".claude" / "skills" # Claude Code reads this
3304
+ agents_root = base / ".agents" / "skills" # Codex reads this
3254
3305
  for pack in sorted(SKILLS_DIR.iterdir()):
3255
3306
  if not (pack / "SKILL.md").is_file():
3256
3307
  continue
3257
- _remove_link_first(dest_root / pack.name)
3258
- _remove_link_first(base / ".agents" / "skills" / pack.name)
3259
- shutil.copytree(pack, dest_root / pack.name)
3308
+ _remove_link_first(claude_root / pack.name)
3309
+ _remove_link_first(agents_root / pack.name)
3310
+ shutil.copytree(pack, claude_root / pack.name)
3311
+ shutil.copytree(pack, agents_root / pack.name)
3260
3312
  clean_legacy_skills(base_dir)
3261
- return [dest_root]
3313
+ return [claude_root, agents_root]
3262
3314
 
3263
3315
 
3264
3316
  def _remove_link_first(path):
@@ -3302,6 +3354,43 @@ CLAUDE_DENY = ["Bash(pythia approve --hook*)",
3302
3354
  # is recognised by what it runs, not by how it invokes it
3303
3355
  HOOK_MARKS = {"SessionStart": "guide --brief", "PostToolUse": "approve --hook"}
3304
3356
 
3357
+ # Codex loads hooks from <repo>/.codex/hooks.json once the layer is trusted
3358
+ # (/hooks). Only the session-start guide is wired — Codex has no answer-reading
3359
+ # PostToolUse and no question tool, so the chat approve-mint cannot port; the
3360
+ # developer approves in the terminal. Same shape and merge discipline as the
3361
+ # Claude hooks.
3362
+ CODEX_HOOKS = {
3363
+ "SessionStart": [{"hooks": [
3364
+ {"type": "command", "command": "python -m pythia guide --brief"}]}],
3365
+ }
3366
+ CODEX_HOOK_MARKS = {"SessionStart": "guide --brief"}
3367
+
3368
+
3369
+ def merge_codex_hooks(path, events=None):
3370
+ """Merge pythia's Codex hooks into a hooks.json ({"hooks": {...}}). Every
3371
+ other key survives, a hook already present in any spelling is recognised by
3372
+ what it runs, and a file that will not parse is left byte-identical.
3373
+ Returns (path, added|None)."""
3374
+ path = pathlib.Path(path)
3375
+ try:
3376
+ s = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
3377
+ hooks = s.setdefault("hooks", {})
3378
+ added = []
3379
+ for event in events or CODEX_HOOKS:
3380
+ present = hooks.setdefault(event, [])
3381
+ cmds = " ".join(h.get("command", "")
3382
+ for e in present for h in e.get("hooks", []))
3383
+ if CODEX_HOOK_MARKS[event] not in cmds:
3384
+ present.extend(json.loads(json.dumps(CODEX_HOOKS[event])))
3385
+ added.append(f"hooks.{event}")
3386
+ except (ValueError, TypeError, AttributeError):
3387
+ return path, None
3388
+ if added:
3389
+ path.parent.mkdir(parents=True, exist_ok=True)
3390
+ path.write_text(json.dumps(s, indent=2, ensure_ascii=False) + "\n",
3391
+ encoding="utf-8")
3392
+ return path, added
3393
+
3305
3394
 
3306
3395
  def install_claude_hooks(base_dir, events=None):
3307
3396
  """Merge the two hooks and the deny rule into <base>/.claude/settings.json
@@ -3357,6 +3446,33 @@ def report_claude_hooks(base_dir, ns, events=None):
3357
3446
  print(f"\nClaude Code hooks already in {path}.")
3358
3447
 
3359
3448
 
3449
+ def report_codex_agents_md(project_root):
3450
+ """Project scope: write the harness into <project>/AGENTS.md, the file
3451
+ Codex loads every session. Not done on -g — a global AGENTS.md is noise in
3452
+ every non-Oracle session, as the Claude guide is per-project."""
3453
+ path, action = merge_agents_md(pathlib.Path(project_root) / "AGENTS.md")
3454
+ verb = {"created": "Wrote the pythia harness into",
3455
+ "updated": "Updated the pythia block in",
3456
+ "unchanged": "pythia harness already current in"}[action]
3457
+ print(f"\n{verb} {path} (Codex and other AGENTS.md agents load it each session).")
3458
+
3459
+
3460
+ def report_codex_hooks(project_root, ns):
3461
+ """Project scope: the Codex session-start guide hook. --no-hooks skips it,
3462
+ as with the Claude hooks. AGENTS.md already carries the guide, so this is
3463
+ reinforcement for hook-driven Codex users."""
3464
+ if getattr(ns, "no_hooks", False):
3465
+ return
3466
+ path, added = merge_codex_hooks(pathlib.Path(project_root) / ".codex" / "hooks.json")
3467
+ if added is None:
3468
+ print(f"\n! {path} is not valid JSON, so it was left untouched.")
3469
+ elif added:
3470
+ print(f"\nWired the Codex session-start hook into {path}.")
3471
+ print("In Codex, run /hooks once to trust this project's .codex layer.")
3472
+ else:
3473
+ print(f"\nCodex session-start hook already in {path}.")
3474
+
3475
+
3360
3476
  def cmd_install(conn, schema, ns):
3361
3477
  import shutil
3362
3478
  en = getattr(ns, "color", False)
@@ -3398,6 +3514,8 @@ def cmd_install(conn, schema, ns):
3398
3514
  else:
3399
3515
  clean_legacy_skills(ns.project_root)
3400
3516
  report_claude_hooks(ns.project_root, ns)
3517
+ report_codex_agents_md(ns.project_root)
3518
+ report_codex_hooks(ns.project_root, ns)
3401
3519
  print(f"\nNext: fill in {path}")
3402
3520
  print(f"Then: {invocation()} check")
3403
3521
  scripts_dir = installed_scripts_dir()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythia-plsql
3
- Version: 0.12.0
3
+ Version: 0.13.0
4
4
  Summary: PL/SQL development for AI agents on Oracle Database - expert data-dictionary queries, impact analysis, and a snapshot-verified write path with honest rollback.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/thaildhe172591/pythia
@@ -128,7 +128,7 @@ Or the same thing piecewise:
128
128
 
129
129
  ```bash
130
130
  pip install pythia-plsql # the CLI (thin driver — no Oracle Instant Client needed)
131
- python -m pythia install # skills into your agent + .pythia/ scaffold + Claude Code hooks
131
+ python -m pythia install # skills + .pythia/ scaffold + Claude Code hooks + Codex AGENTS.md
132
132
  pythia check # fill in connections.json first, then verify
133
133
  ```
134
134
 
@@ -212,6 +212,10 @@ stops it prompting for the read-only commands and asks it to pause on
212
212
  writes — optional, and yours to install
213
213
  ([why pythia does not](GUIDE.md#11-optional-claude-code-permission-settings)).
214
214
 
215
+ On Codex, the same command writes the harness into `AGENTS.md` (loaded every
216
+ session) and a session-start hook into `.codex/hooks.json`; approve previews in
217
+ the terminal with `pythia approve <token>`, since Codex has no chat-mint.
218
+
215
219
  Per-group write policy, `.pythia/policy.json` (defaults shown):
216
220
 
217
221
  | Group | Default | Is rollback real? |
@@ -60,7 +60,7 @@ confirms a number you already knew.
60
60
  in the option descriptions; the hook does not read those. Header `pythia`;
61
61
  options exactly `Approve` / `Reject`, no "(Recommended)"; up to four tokens
62
62
  per call. Anything but `Approve` is not approval: stop and ask what should
63
- change. File changed → step 1. No hook? Relay `pythia approve <token>`.
63
+ change. File changed → step 1. No hook (Codex, or none)? Relay `pythia approve <token>` in the terminal — Codex has no chat-mint.
64
64
  4. **Apply** by running the exact `then the agent:` line pythia printed. Two
65
65
  refusals are normal here, and neither is a malfunction:
66
66
  - *"no developer approval is on file"* — the answer was not `Approve`, or
@@ -57,16 +57,27 @@ def test_missing_npx_falls_back_to_the_bundled_pack():
57
57
  (agents / "pythia-apply").mkdir() # stale copy from 0.2.0-0.2.3
58
58
  (claude / "plsql-review").mkdir(parents=True)
59
59
  targets = pythia.copy_bundled_skills(td)
60
- assert targets == [claude]
60
+ assert targets == [claude, agents]
61
61
  assert (claude / "pythia-apply" / "SKILL.md").is_file()
62
62
  assert (claude / "pythia-review" / "reference" / "antipatterns.md").is_file()
63
- assert not (agents / "pythia-apply").exists() # single copy
63
+ assert (agents / "pythia-apply" / "SKILL.md").is_file() # Codex reads this
64
64
  assert (agents / "my-team-skill" / "SKILL.md").read_text(
65
65
  encoding="utf-8") == "x" # merge, not wipe
66
66
  assert not (claude / "plsql-review").exists() # legacy cleaned
67
67
  pythia.copy_bundled_skills(td) # idempotent
68
68
 
69
69
 
70
+ def test_fallback_serves_both_claude_and_codex():
71
+ """Claude reads .claude/skills; Codex reads .agents/skills. The no-Node
72
+ fallback must populate both, or a Codex user without Node gets nothing."""
73
+ with tempfile.TemporaryDirectory() as td:
74
+ base = pathlib.Path(td)
75
+ targets = pythia.copy_bundled_skills(td)
76
+ assert targets == [base / ".claude" / "skills", base / ".agents" / "skills"]
77
+ for root in targets:
78
+ assert (root / "pythia-apply" / "SKILL.md").is_file()
79
+
80
+
70
81
  def test_copy_survives_a_symlinked_destination():
71
82
  """`npx skills add` leaves .claude/skills/<name> as a symlink into
72
83
  .agents/skills/<name>. Copying through that link and then cleaning the
@@ -350,6 +361,97 @@ def test_global_install_wires_the_approve_hook_only():
350
361
  assert s["hooks"]["PostToolUse"] == pythia.CLAUDE_HOOKS["PostToolUse"]
351
362
 
352
363
 
364
+ def test_cmd_install_serves_codex_in_the_project_scope():
365
+ """Project install writes the harness into AGENTS.md always, and the
366
+ session-start hook unless --no-hooks. (The .agents/skills copy is gated by
367
+ global_pack_present() — a machine with a global pack skips it — so that is
368
+ proven directly in test_fallback_serves_both_claude_and_codex, not here.)"""
369
+ import argparse
370
+ import contextlib
371
+ import io
372
+ old = os.environ.get("PATH")
373
+ os.environ["PATH"] = "" # no npx: bundled fallback
374
+ try:
375
+ for no_hooks in (False, True):
376
+ with tempfile.TemporaryDirectory() as td:
377
+ ns = argparse.Namespace(project_root=td, glob=False, source=None,
378
+ color=False, json=False, no_hooks=no_hooks)
379
+ with contextlib.redirect_stdout(io.StringIO()):
380
+ pythia.cmd_install(None, None, ns)
381
+ base = pathlib.Path(td)
382
+ agents_md = base / "AGENTS.md"
383
+ assert agents_md.is_file() # always
384
+ assert pythia.BRIEF_GUIDE.rstrip() in agents_md.read_text(encoding="utf-8")
385
+ hooks = base / ".codex" / "hooks.json"
386
+ assert hooks.is_file() != no_hooks # skipped iff --no-hooks
387
+ finally:
388
+ if old is not None:
389
+ os.environ["PATH"] = old
390
+
391
+
392
+ # --- Codex: AGENTS.md harness (TP1) ------------------------------------------
393
+
394
+ def test_agents_md_block_is_the_guide_verbatim():
395
+ block = pythia.agents_md_block()
396
+ assert block.startswith(pythia.AGENTS_BEGIN)
397
+ assert block.rstrip().endswith(pythia.AGENTS_END)
398
+ assert pythia.BRIEF_GUIDE.rstrip() in block # one source, no drift
399
+ assert "pythia approve <token>" in block # the Codex approval door
400
+
401
+
402
+ def test_merge_agents_md_creates_updates_and_is_idempotent():
403
+ with tempfile.TemporaryDirectory() as td:
404
+ path = pathlib.Path(td) / "AGENTS.md"
405
+ p, action = pythia.merge_agents_md(path)
406
+ assert p == path and action == "created"
407
+ assert pythia.BRIEF_GUIDE.rstrip() in path.read_text(encoding="utf-8")
408
+ assert pythia.merge_agents_md(path)[1] == "unchanged"
409
+
410
+
411
+ def test_merge_agents_md_preserves_the_developers_text():
412
+ with tempfile.TemporaryDirectory() as td:
413
+ path = pathlib.Path(td) / "AGENTS.md"
414
+ path.write_text("# House rules\n\nUse tabs.\n", encoding="utf-8")
415
+ pythia.merge_agents_md(path)
416
+ body = path.read_text(encoding="utf-8")
417
+ assert body.startswith("# House rules\n\nUse tabs.\n") # kept, on top
418
+ assert pythia.AGENTS_BEGIN in body and pythia.BRIEF_GUIDE.rstrip() in body
419
+ pythia.merge_agents_md(path) # a stale block
420
+ assert path.read_text(encoding="utf-8").count(pythia.AGENTS_BEGIN) == 1
421
+
422
+
423
+ # --- Codex: session-start hook (TP3) -----------------------------------------
424
+
425
+ def test_merge_codex_hooks_creates_and_is_idempotent():
426
+ with tempfile.TemporaryDirectory() as td:
427
+ path = pathlib.Path(td) / ".codex" / "hooks.json"
428
+ p, added = pythia.merge_codex_hooks(path)
429
+ assert p == path and added == ["hooks.SessionStart"]
430
+ s = json.loads(path.read_text(encoding="utf-8"))
431
+ assert s["hooks"]["SessionStart"] == pythia.CODEX_HOOKS["SessionStart"]
432
+ assert pythia.merge_codex_hooks(path) == (path, [])
433
+
434
+
435
+ def test_merge_codex_hooks_recognises_an_existing_spelling():
436
+ with tempfile.TemporaryDirectory() as td:
437
+ path = pathlib.Path(td) / ".codex" / "hooks.json"
438
+ path.parent.mkdir(parents=True)
439
+ path.write_text(json.dumps({"hooks": {"SessionStart": [
440
+ {"hooks": [{"type": "command",
441
+ "command": "python3 -m pythia guide --brief"}]}]}}),
442
+ encoding="utf-8")
443
+ assert pythia.merge_codex_hooks(path) == (path, []) # not doubled
444
+
445
+
446
+ def test_merge_codex_hooks_refuses_a_file_it_cannot_parse():
447
+ with tempfile.TemporaryDirectory() as td:
448
+ path = pathlib.Path(td) / ".codex" / "hooks.json"
449
+ path.parent.mkdir(parents=True)
450
+ path.write_text("{not json", encoding="utf-8")
451
+ assert pythia.merge_codex_hooks(path) == (path, None)
452
+ assert path.read_text(encoding="utf-8") == "{not json"
453
+
454
+
353
455
  # Native paths: on POSIX ':' is the PATH separator and would cut a
354
456
  # Windows path in half, so these are built from os.sep.
355
457
  SCRIPTS = os.path.join(os.sep + 'opt', 'a', 'Scripts')
File without changes
File without changes