pythia-plsql 0.12.0__tar.gz → 0.14.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.
- {pythia_plsql-0.12.0/scripts/pythia_plsql.egg-info → pythia_plsql-0.14.0}/PKG-INFO +6 -2
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/README.md +5 -1
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/pyproject.toml +1 -1
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/scripts/pythia.py +382 -16
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0/scripts/pythia_plsql.egg-info}/PKG-INFO +6 -2
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/scripts/pythia_plsql.egg-info/SOURCES.txt +1 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/skills/pythia-apply/SKILL.md +1 -1
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/tests/test_install.py +104 -2
- pythia_plsql-0.14.0/tests/test_mcp.py +309 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/LICENSE +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/queries/compile-errors.sql +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/queries/current-schema.sql +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/queries/dependencies.sql +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/queries/impact.sql +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/queries/invalid-objects.sql +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/queries/name-occupants.sql +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/queries/object-names.sql +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/queries/object-source.sql +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/queries/plscope-enabled.sql +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/queries/plscope-statements.sql +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/queries/plscope-usages.sql +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/queries/session-privileges.sql +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/queries/similar-candidates.sql +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/queries/source.sql +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/scripts/pythia_plsql.egg-info/dependency_links.txt +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/scripts/pythia_plsql.egg-info/entry_points.txt +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/scripts/pythia_plsql.egg-info/requires.txt +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/scripts/pythia_plsql.egg-info/top_level.txt +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/setup.cfg +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/skills/pythia-comment/SKILL.md +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/skills/pythia-conventions/SKILL.md +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/skills/pythia-explore/SKILL.md +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/skills/pythia-explore/reference/data-dictionary.md +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/skills/pythia-impact/SKILL.md +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/skills/pythia-review/SKILL.md +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/skills/pythia-review/reference/antipatterns.md +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/skills/pythia-setup/SKILL.md +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/skills/pythia-skill-author/SKILL.md +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/skills/pythia-spec/SKILL.md +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/skills/pythia-write/SKILL.md +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/skills/pythia-write/reference/patterns.md +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/skills/using-pythia/SKILL.md +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/tests/test_phase1.py +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/tests/test_phase2.py +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/tests/test_phase3.py +0 -0
- {pythia_plsql-0.12.0 → pythia_plsql-0.14.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.
|
|
3
|
+
Version: 0.14.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
|
|
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
|
|
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.
|
|
7
|
+
version = "0.14.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
|
|
|
@@ -2876,6 +2921,10 @@ apply -> verify -> report.
|
|
|
2876
2921
|
AND to the developer's approval
|
|
2877
2922
|
journal restore undo, through the same six steps and the same gate
|
|
2878
2923
|
unistr exact non-ASCII literals for what you are writing
|
|
2924
|
+
mcp Codex only: a write-free MCP approver. The agent
|
|
2925
|
+
calls its pythia_approve tool, Codex shows the card
|
|
2926
|
+
as a select prompt, and the developer's Approve mints
|
|
2927
|
+
the grant. It never writes; apply still verifies it.
|
|
2879
2928
|
install · agent-user · guide setting the harness itself up
|
|
2880
2929
|
|
|
2881
2930
|
The CLI enforces the gates: headless --yes is refused, policy cannot be
|
|
@@ -3236,29 +3285,36 @@ def global_pack_present(home=None):
|
|
|
3236
3285
|
|
|
3237
3286
|
|
|
3238
3287
|
def copy_bundled_skills(base_dir):
|
|
3239
|
-
"""No-Node fallback: copy the wheel-bundled pack into
|
|
3240
|
-
<base>/.claude/skills/
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3288
|
+
"""No-Node fallback: copy the wheel-bundled pack into BOTH conventional
|
|
3289
|
+
roots — <base>/.claude/skills/ (Claude Code) and <base>/.agents/skills/
|
|
3290
|
+
(Codex, and ~/.agents/skills on -g). base_dir is the project root, or the
|
|
3291
|
+
home directory for -g. Node users get the same reach from `npx skills
|
|
3292
|
+
add`; this is the machine that has only Python.
|
|
3293
|
+
|
|
3294
|
+
Trade-off, stated plainly: this reverses an earlier one-root-only choice.
|
|
3295
|
+
A Claude Code version that reads both roots may list a pack entry twice —
|
|
3296
|
+
accepted, because a Codex user without Node otherwise gets no skills at
|
|
3297
|
+
all, and Codex reads only .agents/skills.
|
|
3244
3298
|
|
|
3245
3299
|
Both destinations are cleared link-first before the copy. An earlier
|
|
3246
3300
|
`npx skills add` leaves .claude/skills/<name> as a symlink into
|
|
3247
|
-
.agents/skills/<name>;
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
skills in those directories are left alone."""
|
|
3301
|
+
.agents/skills/<name>; removing both links first means each copytree
|
|
3302
|
+
writes a fresh real directory, never through a link into a target the
|
|
3303
|
+
other copy would then overwrite. Only the pack's own names are touched;
|
|
3304
|
+
other skills in those directories are left alone."""
|
|
3251
3305
|
import shutil
|
|
3252
3306
|
base = pathlib.Path(base_dir)
|
|
3253
|
-
|
|
3307
|
+
claude_root = base / ".claude" / "skills" # Claude Code reads this
|
|
3308
|
+
agents_root = base / ".agents" / "skills" # Codex reads this
|
|
3254
3309
|
for pack in sorted(SKILLS_DIR.iterdir()):
|
|
3255
3310
|
if not (pack / "SKILL.md").is_file():
|
|
3256
3311
|
continue
|
|
3257
|
-
_remove_link_first(
|
|
3258
|
-
_remove_link_first(
|
|
3259
|
-
shutil.copytree(pack,
|
|
3312
|
+
_remove_link_first(claude_root / pack.name)
|
|
3313
|
+
_remove_link_first(agents_root / pack.name)
|
|
3314
|
+
shutil.copytree(pack, claude_root / pack.name)
|
|
3315
|
+
shutil.copytree(pack, agents_root / pack.name)
|
|
3260
3316
|
clean_legacy_skills(base_dir)
|
|
3261
|
-
return [
|
|
3317
|
+
return [claude_root, agents_root]
|
|
3262
3318
|
|
|
3263
3319
|
|
|
3264
3320
|
def _remove_link_first(path):
|
|
@@ -3302,6 +3358,258 @@ CLAUDE_DENY = ["Bash(pythia approve --hook*)",
|
|
|
3302
3358
|
# is recognised by what it runs, not by how it invokes it
|
|
3303
3359
|
HOOK_MARKS = {"SessionStart": "guide --brief", "PostToolUse": "approve --hook"}
|
|
3304
3360
|
|
|
3361
|
+
# Codex loads hooks from <repo>/.codex/hooks.json once the layer is trusted
|
|
3362
|
+
# (/hooks). Only the session-start guide is wired — Codex has no answer-reading
|
|
3363
|
+
# PostToolUse and no question tool, so the chat approve-mint cannot port; the
|
|
3364
|
+
# developer approves in the terminal. Same shape and merge discipline as the
|
|
3365
|
+
# Claude hooks.
|
|
3366
|
+
CODEX_HOOKS = {
|
|
3367
|
+
"SessionStart": [{"hooks": [
|
|
3368
|
+
{"type": "command", "command": "python -m pythia guide --brief"}]}],
|
|
3369
|
+
}
|
|
3370
|
+
CODEX_HOOK_MARKS = {"SessionStart": "guide --brief"}
|
|
3371
|
+
|
|
3372
|
+
|
|
3373
|
+
def merge_codex_hooks(path, events=None):
|
|
3374
|
+
"""Merge pythia's Codex hooks into a hooks.json ({"hooks": {...}}). Every
|
|
3375
|
+
other key survives, a hook already present in any spelling is recognised by
|
|
3376
|
+
what it runs, and a file that will not parse is left byte-identical.
|
|
3377
|
+
Returns (path, added|None)."""
|
|
3378
|
+
path = pathlib.Path(path)
|
|
3379
|
+
try:
|
|
3380
|
+
s = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
|
|
3381
|
+
hooks = s.setdefault("hooks", {})
|
|
3382
|
+
added = []
|
|
3383
|
+
for event in events or CODEX_HOOKS:
|
|
3384
|
+
present = hooks.setdefault(event, [])
|
|
3385
|
+
cmds = " ".join(h.get("command", "")
|
|
3386
|
+
for e in present for h in e.get("hooks", []))
|
|
3387
|
+
if CODEX_HOOK_MARKS[event] not in cmds:
|
|
3388
|
+
present.extend(json.loads(json.dumps(CODEX_HOOKS[event])))
|
|
3389
|
+
added.append(f"hooks.{event}")
|
|
3390
|
+
except (ValueError, TypeError, AttributeError):
|
|
3391
|
+
return path, None
|
|
3392
|
+
if added:
|
|
3393
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
3394
|
+
path.write_text(json.dumps(s, indent=2, ensure_ascii=False) + "\n",
|
|
3395
|
+
encoding="utf-8")
|
|
3396
|
+
return path, added
|
|
3397
|
+
|
|
3398
|
+
|
|
3399
|
+
# The MCP approver: a write-free stdio server whose one tool, pythia_approve,
|
|
3400
|
+
# mints the same grant `approve` mints — gated by an MCP elicitation the
|
|
3401
|
+
# developer answers in Codex. Writes never go through here; apply still
|
|
3402
|
+
# verifies the grant. Hand-rolled JSON-RPC (stdlib only).
|
|
3403
|
+
MCP_TOOL = "pythia_approve"
|
|
3404
|
+
MCP_PROTOCOL = "2025-06-18"
|
|
3405
|
+
MCP_ELICIT_METHOD = "elicitation/create" # the one spelling to adjust if a
|
|
3406
|
+
# live Codex disagrees (isolated)
|
|
3407
|
+
_MCP_TOOL_DEF = {
|
|
3408
|
+
"name": MCP_TOOL,
|
|
3409
|
+
"description": ("Ask the developer to approve a pythia apply preview by its "
|
|
3410
|
+
"token. Shows pythia's approval card and mints the one-time "
|
|
3411
|
+
"grant only if the developer picks Approve. Call it after "
|
|
3412
|
+
"`pythia apply <file>` prints a token; then run "
|
|
3413
|
+
"`pythia apply <file> --confirm <token>`."),
|
|
3414
|
+
"inputSchema": {"type": "object",
|
|
3415
|
+
"properties": {"token": {"type": "string"}},
|
|
3416
|
+
"required": ["token"]},
|
|
3417
|
+
}
|
|
3418
|
+
|
|
3419
|
+
|
|
3420
|
+
def _rpc_result(mid, result):
|
|
3421
|
+
return {"jsonrpc": "2.0", "id": mid, "result": result}
|
|
3422
|
+
|
|
3423
|
+
|
|
3424
|
+
def _rpc_error(mid, code, message):
|
|
3425
|
+
return {"jsonrpc": "2.0", "id": mid, "error": {"code": code, "message": message}}
|
|
3426
|
+
|
|
3427
|
+
|
|
3428
|
+
def mcp_handle(msg, state):
|
|
3429
|
+
"""Dispatch one incoming JSON-RPC message that is NOT a tools/call (those
|
|
3430
|
+
need the elicitation round-trip and are handled in the loop). Returns
|
|
3431
|
+
(outgoing_messages, done). A notification (no id) is answered with []."""
|
|
3432
|
+
method = msg.get("method")
|
|
3433
|
+
mid = msg.get("id")
|
|
3434
|
+
if method == "initialize":
|
|
3435
|
+
proto = (msg.get("params") or {}).get("protocolVersion") or MCP_PROTOCOL
|
|
3436
|
+
return [_rpc_result(mid, {
|
|
3437
|
+
"protocolVersion": proto,
|
|
3438
|
+
"capabilities": {"tools": {}},
|
|
3439
|
+
"serverInfo": {"name": "pythia",
|
|
3440
|
+
"version": getattr(sys.modules[__name__],
|
|
3441
|
+
"__version__", "0")}})], False
|
|
3442
|
+
if method == "notifications/initialized":
|
|
3443
|
+
return [], False
|
|
3444
|
+
if method == "tools/list":
|
|
3445
|
+
return [_rpc_result(mid, {"tools": [_MCP_TOOL_DEF]})], False
|
|
3446
|
+
if mid is None:
|
|
3447
|
+
return [], False # any other notification
|
|
3448
|
+
return [_rpc_error(mid, -32601, f"method not found: {method}")], False
|
|
3449
|
+
|
|
3450
|
+
|
|
3451
|
+
def _mcp_text(text, is_error=False):
|
|
3452
|
+
return {"content": [{"type": "text", "text": text}], "isError": is_error}
|
|
3453
|
+
|
|
3454
|
+
|
|
3455
|
+
def mcp_approve_decision(root, token, elicit):
|
|
3456
|
+
"""Mint on a developer's Approve, delivered through an MCP elicitation.
|
|
3457
|
+
|
|
3458
|
+
Transport-free: `elicit(message, schema) -> (action, content)` performs the
|
|
3459
|
+
round-trip (a fake in tests). The card is pythia's own, built from the
|
|
3460
|
+
journal — so there is nothing for the agent to paraphrase and no verbatim
|
|
3461
|
+
check to run. Fail-closed everywhere: the only path that mints is an
|
|
3462
|
+
explicit accept whose decision is exactly Approve."""
|
|
3463
|
+
token = str(token or "").strip().lower()
|
|
3464
|
+
try:
|
|
3465
|
+
_, meta, body = approval_card(root, token) # refuses unknown/applied
|
|
3466
|
+
except SystemExit as e:
|
|
3467
|
+
return _mcp_text(str(e), is_error=True)
|
|
3468
|
+
message = f"{invocation()} approve {token}\n" + "\n".join(body)
|
|
3469
|
+
schema = {"type": "object",
|
|
3470
|
+
"properties": {"decision": {"type": "string",
|
|
3471
|
+
"enum": ["Approve", "Reject"]}},
|
|
3472
|
+
"required": ["decision"]}
|
|
3473
|
+
try:
|
|
3474
|
+
action, content = elicit(message, schema)
|
|
3475
|
+
except Exception as e: # noqa: BLE001 — fail closed
|
|
3476
|
+
return _mcp_text(f"No grant minted: the approval prompt could not be "
|
|
3477
|
+
f"completed ({e}). Approve in a terminal instead: "
|
|
3478
|
+
f"{invocation()} approve {token}", is_error=True)
|
|
3479
|
+
if action != "accept" or (content or {}).get("decision") != "Approve":
|
|
3480
|
+
return _mcp_text(f"No grant minted for {token}: the developer did not "
|
|
3481
|
+
"approve (answered "
|
|
3482
|
+
f"{(content or {}).get('decision') or action!r}). "
|
|
3483
|
+
"Do not apply; ask what should change.", is_error=True)
|
|
3484
|
+
prune_expired_grants(root)
|
|
3485
|
+
mint_grant(root, token, meta.get("connection") or "",
|
|
3486
|
+
revalidate=fingerprint_text(meta.get("row_set")), approver="mcp")
|
|
3487
|
+
return _mcp_text(f"Approved — grant minted for {token}, single use, expires "
|
|
3488
|
+
f"in {GRANT_TTL_MINUTES} minutes. Now run: "
|
|
3489
|
+
f"{invocation()} apply <file> --confirm {token}")
|
|
3490
|
+
|
|
3491
|
+
|
|
3492
|
+
def mcp_serve(reader, writer, root):
|
|
3493
|
+
"""The stdio loop. Newline-delimited JSON-RPC in and out. A tools/call for
|
|
3494
|
+
pythia_approve runs the elicitation round-trip inline: send
|
|
3495
|
+
elicitation/create, then read until the matching-id response. One call in
|
|
3496
|
+
flight at a time (Codex calls tools serially in a turn)."""
|
|
3497
|
+
counter = [1000]
|
|
3498
|
+
|
|
3499
|
+
def send(msg):
|
|
3500
|
+
writer.write(json.dumps(msg) + "\n")
|
|
3501
|
+
writer.flush()
|
|
3502
|
+
|
|
3503
|
+
def read_msg():
|
|
3504
|
+
line = reader.readline()
|
|
3505
|
+
if not line:
|
|
3506
|
+
return None
|
|
3507
|
+
try:
|
|
3508
|
+
return json.loads(line)
|
|
3509
|
+
except ValueError:
|
|
3510
|
+
return {} # skip a malformed line
|
|
3511
|
+
|
|
3512
|
+
def elicit(message, schema):
|
|
3513
|
+
counter[0] += 1
|
|
3514
|
+
eid = counter[0]
|
|
3515
|
+
send({"jsonrpc": "2.0", "id": eid, "method": MCP_ELICIT_METHOD,
|
|
3516
|
+
"params": {"message": message, "requestedSchema": schema}})
|
|
3517
|
+
while True:
|
|
3518
|
+
m = read_msg()
|
|
3519
|
+
if m is None:
|
|
3520
|
+
raise RuntimeError("client closed before answering the elicitation")
|
|
3521
|
+
if m.get("id") == eid and ("result" in m or "error" in m):
|
|
3522
|
+
if "error" in m:
|
|
3523
|
+
raise RuntimeError(str(m["error"]))
|
|
3524
|
+
r = m["result"] or {}
|
|
3525
|
+
return r.get("action"), r.get("content") or {}
|
|
3526
|
+
# ignore anything else that arrives mid-elicitation
|
|
3527
|
+
|
|
3528
|
+
while True:
|
|
3529
|
+
msg = read_msg()
|
|
3530
|
+
if msg is None:
|
|
3531
|
+
return
|
|
3532
|
+
if msg.get("method") == "tools/call":
|
|
3533
|
+
mid = msg.get("id")
|
|
3534
|
+
params = msg.get("params") or {}
|
|
3535
|
+
if params.get("name") != MCP_TOOL:
|
|
3536
|
+
send(_rpc_error(mid, -32601, f"no such tool: {params.get('name')}"))
|
|
3537
|
+
continue
|
|
3538
|
+
token = (params.get("arguments") or {}).get("token", "")
|
|
3539
|
+
result = mcp_approve_decision(root, token, elicit)
|
|
3540
|
+
send(_rpc_result(mid, result))
|
|
3541
|
+
continue
|
|
3542
|
+
out, _ = mcp_handle(msg, {})
|
|
3543
|
+
for m in out:
|
|
3544
|
+
send(m)
|
|
3545
|
+
|
|
3546
|
+
|
|
3547
|
+
def cmd_mcp(conn, schema, ns):
|
|
3548
|
+
"""Run the MCP approver on stdio. Launched by Codex per its
|
|
3549
|
+
[mcp_servers.pythia] config; lives for the session. Touches no database."""
|
|
3550
|
+
mcp_serve(sys.stdin, sys.stdout, ns.project_root)
|
|
3551
|
+
|
|
3552
|
+
|
|
3553
|
+
# Codex config is TOML, and the stdlib cannot parse or write it on 3.9
|
|
3554
|
+
# (tomllib is read-only, 3.11+). So these merges never parse TOML: they append
|
|
3555
|
+
# a marker-delimited block when the target tables are absent, and refuse
|
|
3556
|
+
# (writing nothing) when a table already exists — the installer then prints
|
|
3557
|
+
# what to add. ponytail: literal-text collision check, not a TOML engine; a
|
|
3558
|
+
# table header inside a string/comment is not worth a parser here.
|
|
3559
|
+
CODEX_MCP_BEGIN = "# pythia:begin (managed by `pythia install`)"
|
|
3560
|
+
CODEX_MCP_END = "# pythia:end"
|
|
3561
|
+
CODEX_MCP_BLOCK = (f"{CODEX_MCP_BEGIN}\n"
|
|
3562
|
+
"[mcp_servers.pythia]\n"
|
|
3563
|
+
'command = "python"\n'
|
|
3564
|
+
'args = ["-m", "pythia", "mcp"]\n\n'
|
|
3565
|
+
"[approval_policy.granular]\n"
|
|
3566
|
+
"mcp_elicitations = true\n"
|
|
3567
|
+
f"{CODEX_MCP_END}")
|
|
3568
|
+
# requirements.toml is a separate admin-enforced file; forbidding these two is
|
|
3569
|
+
# what closes the danger-full-access elicitation auto-approve hole.
|
|
3570
|
+
CODEX_REQUIREMENTS = (f"{CODEX_MCP_BEGIN}\n"
|
|
3571
|
+
"# pythia needs elicitations to reach a human, so the two\n"
|
|
3572
|
+
"# modes that would auto-answer or skip them are disallowed.\n"
|
|
3573
|
+
'allowed_approval_policies = ["untrusted", "on-request", "on-failure"]\n'
|
|
3574
|
+
'allowed_sandbox_modes = ["read-only", "workspace-write"]\n'
|
|
3575
|
+
f"{CODEX_MCP_END}")
|
|
3576
|
+
|
|
3577
|
+
|
|
3578
|
+
def merge_codex_mcp_config(path):
|
|
3579
|
+
"""Append the MCP block to <project>/.codex/config.toml. Returns
|
|
3580
|
+
(path, action): 'created' | 'appended' | 'present' | 'conflict'. Refuses
|
|
3581
|
+
(writes nothing) if either table already exists without our marker."""
|
|
3582
|
+
path = pathlib.Path(path)
|
|
3583
|
+
old = path.read_text(encoding="utf-8") if path.is_file() else None
|
|
3584
|
+
if old is not None and CODEX_MCP_BEGIN in old:
|
|
3585
|
+
return path, "present"
|
|
3586
|
+
if old is not None and ("[mcp_servers.pythia]" in old
|
|
3587
|
+
or "[approval_policy.granular]" in old):
|
|
3588
|
+
return path, "conflict"
|
|
3589
|
+
if old is None:
|
|
3590
|
+
new, action = CODEX_MCP_BLOCK + "\n", "created"
|
|
3591
|
+
else:
|
|
3592
|
+
tail = "" if old.endswith("\n\n") else "\n" if old.endswith("\n") else "\n\n"
|
|
3593
|
+
new, action = old + tail + CODEX_MCP_BLOCK + "\n", "appended"
|
|
3594
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
3595
|
+
path.write_text(new, encoding="utf-8")
|
|
3596
|
+
return path, action
|
|
3597
|
+
|
|
3598
|
+
|
|
3599
|
+
def merge_codex_requirements(path):
|
|
3600
|
+
"""Write <project>/.codex/requirements.toml forbidding danger-full-access
|
|
3601
|
+
and never-approval. Returns (path, action): 'created' | 'present' |
|
|
3602
|
+
'conflict'. An existing file without our marker is left untouched."""
|
|
3603
|
+
path = pathlib.Path(path)
|
|
3604
|
+
old = path.read_text(encoding="utf-8") if path.is_file() else None
|
|
3605
|
+
if old is not None and CODEX_MCP_BEGIN in old:
|
|
3606
|
+
return path, "present"
|
|
3607
|
+
if old is not None:
|
|
3608
|
+
return path, "conflict"
|
|
3609
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
3610
|
+
path.write_text(CODEX_REQUIREMENTS + "\n", encoding="utf-8")
|
|
3611
|
+
return path, "created"
|
|
3612
|
+
|
|
3305
3613
|
|
|
3306
3614
|
def install_claude_hooks(base_dir, events=None):
|
|
3307
3615
|
"""Merge the two hooks and the deny rule into <base>/.claude/settings.json
|
|
@@ -3357,6 +3665,58 @@ def report_claude_hooks(base_dir, ns, events=None):
|
|
|
3357
3665
|
print(f"\nClaude Code hooks already in {path}.")
|
|
3358
3666
|
|
|
3359
3667
|
|
|
3668
|
+
def report_codex_agents_md(project_root):
|
|
3669
|
+
"""Project scope: write the harness into <project>/AGENTS.md, the file
|
|
3670
|
+
Codex loads every session. Not done on -g — a global AGENTS.md is noise in
|
|
3671
|
+
every non-Oracle session, as the Claude guide is per-project."""
|
|
3672
|
+
path, action = merge_agents_md(pathlib.Path(project_root) / "AGENTS.md")
|
|
3673
|
+
verb = {"created": "Wrote the pythia harness into",
|
|
3674
|
+
"updated": "Updated the pythia block in",
|
|
3675
|
+
"unchanged": "pythia harness already current in"}[action]
|
|
3676
|
+
print(f"\n{verb} {path} (Codex and other AGENTS.md agents load it each session).")
|
|
3677
|
+
|
|
3678
|
+
|
|
3679
|
+
def report_codex_hooks(project_root, ns):
|
|
3680
|
+
"""Project scope: the Codex session-start guide hook. --no-hooks skips it,
|
|
3681
|
+
as with the Claude hooks. AGENTS.md already carries the guide, so this is
|
|
3682
|
+
reinforcement for hook-driven Codex users."""
|
|
3683
|
+
if getattr(ns, "no_hooks", False):
|
|
3684
|
+
return
|
|
3685
|
+
path, added = merge_codex_hooks(pathlib.Path(project_root) / ".codex" / "hooks.json")
|
|
3686
|
+
if added is None:
|
|
3687
|
+
print(f"\n! {path} is not valid JSON, so it was left untouched.")
|
|
3688
|
+
elif added:
|
|
3689
|
+
print(f"\nWired the Codex session-start hook into {path}.")
|
|
3690
|
+
print("In Codex, run /hooks once to trust this project's .codex layer.")
|
|
3691
|
+
else:
|
|
3692
|
+
print(f"\nCodex session-start hook already in {path}.")
|
|
3693
|
+
|
|
3694
|
+
|
|
3695
|
+
def report_codex_mcp(project_root, ns):
|
|
3696
|
+
"""Project scope: register the write-free MCP approver in Codex's config and
|
|
3697
|
+
pin the sandbox so elicitations always reach a human. --no-hooks skips it,
|
|
3698
|
+
as with the other Codex wiring. Needs Codex >= v0.120; older Codex ignores
|
|
3699
|
+
the server and the console approve path still works."""
|
|
3700
|
+
if getattr(ns, "no_hooks", False):
|
|
3701
|
+
return
|
|
3702
|
+
base = pathlib.Path(project_root) / ".codex"
|
|
3703
|
+
cfg_path, cfg = merge_codex_mcp_config(base / "config.toml")
|
|
3704
|
+
req_path, req = merge_codex_requirements(base / "requirements.toml")
|
|
3705
|
+
if cfg in ("created", "appended"):
|
|
3706
|
+
print(f"\nRegistered the pythia MCP approver in {cfg_path} "
|
|
3707
|
+
"(Codex >= v0.120; run /mcp to trust it).")
|
|
3708
|
+
elif cfg == "conflict":
|
|
3709
|
+
print(f"\n! {cfg_path} already defines [mcp_servers.pythia] or "
|
|
3710
|
+
"[approval_policy.granular]; add this yourself:\n"
|
|
3711
|
+
+ CODEX_MCP_BLOCK)
|
|
3712
|
+
if req == "created":
|
|
3713
|
+
print(f"Pinned the sandbox in {req_path} (no danger-full-access, so "
|
|
3714
|
+
"elicitations always ask a human).")
|
|
3715
|
+
elif req == "conflict":
|
|
3716
|
+
print(f"\n! {req_path} already exists; add these constraints to it:\n"
|
|
3717
|
+
+ CODEX_REQUIREMENTS)
|
|
3718
|
+
|
|
3719
|
+
|
|
3360
3720
|
def cmd_install(conn, schema, ns):
|
|
3361
3721
|
import shutil
|
|
3362
3722
|
en = getattr(ns, "color", False)
|
|
@@ -3398,6 +3758,9 @@ def cmd_install(conn, schema, ns):
|
|
|
3398
3758
|
else:
|
|
3399
3759
|
clean_legacy_skills(ns.project_root)
|
|
3400
3760
|
report_claude_hooks(ns.project_root, ns)
|
|
3761
|
+
report_codex_agents_md(ns.project_root)
|
|
3762
|
+
report_codex_hooks(ns.project_root, ns)
|
|
3763
|
+
report_codex_mcp(ns.project_root, ns)
|
|
3401
3764
|
print(f"\nNext: fill in {path}")
|
|
3402
3765
|
print(f"Then: {invocation()} check")
|
|
3403
3766
|
scripts_dir = installed_scripts_dir()
|
|
@@ -3418,10 +3781,10 @@ COMMANDS = {"check": cmd_check, "ls": cmd_ls, "src": cmd_src, "args": cmd_args,
|
|
|
3418
3781
|
"approve": cmd_approve,
|
|
3419
3782
|
"conventions": cmd_conventions, "guide": cmd_guide, "connections": cmd_connections, "install": cmd_install,
|
|
3420
3783
|
"unistr": cmd_unistr, "agent-user": cmd_agent_user,
|
|
3421
|
-
"history": cmd_history}
|
|
3784
|
+
"history": cmd_history, "mcp": cmd_mcp}
|
|
3422
3785
|
|
|
3423
3786
|
NO_DB_COMMANDS = {"policy", "journal", "install", "unistr", "guide", "connections",
|
|
3424
|
-
"history", "approve"}
|
|
3787
|
+
"history", "approve", "mcp"}
|
|
3425
3788
|
|
|
3426
3789
|
|
|
3427
3790
|
# --- CLI ---------------------------------------------------------------------
|
|
@@ -3565,6 +3928,9 @@ def build_parser():
|
|
|
3565
3928
|
s.add_argument("text", nargs="*", help="text; omit to read stdin")
|
|
3566
3929
|
s.add_argument("--loi", action="store_true",
|
|
3567
3930
|
help="wrap as 'loi:'||unistr(...)||':loi'")
|
|
3931
|
+
sub.add_parser("mcp", parents=[common()],
|
|
3932
|
+
help="run the MCP approver for Codex (stdio; Codex launches "
|
|
3933
|
+
"it — the developer approves via an elicitation)")
|
|
3568
3934
|
s = sub.add_parser("install", parents=[common()],
|
|
3569
3935
|
help="install the skill pack, scaffold .pythia/ config, wire the Claude Code hooks")
|
|
3570
3936
|
s.add_argument("-g", "--global", dest="glob", action="store_true",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pythia-plsql
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.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
|
|
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
|
|
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')
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""MCP approver self-checks — no database, no network, no live Codex.
|
|
3
|
+
|
|
4
|
+
Run: python tests/test_mcp.py
|
|
5
|
+
"""
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import pathlib
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
|
|
13
|
+
os.environ.setdefault("PYTHIA_CI", "1")
|
|
14
|
+
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "scripts"))
|
|
15
|
+
import pythia # noqa: E402
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# --- Task 1: transport handshake + tools/list -------------------------------
|
|
19
|
+
|
|
20
|
+
def test_initialize_echoes_protocol_and_declares_tools():
|
|
21
|
+
out, done = pythia.mcp_handle(
|
|
22
|
+
{"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
|
23
|
+
"params": {"protocolVersion": "2025-06-18", "capabilities": {},
|
|
24
|
+
"clientInfo": {"name": "codex", "version": "0.120.0"}}},
|
|
25
|
+
{})
|
|
26
|
+
assert done is False
|
|
27
|
+
assert len(out) == 1
|
|
28
|
+
r = out[0]
|
|
29
|
+
assert r["id"] == 1 and r["jsonrpc"] == "2.0"
|
|
30
|
+
assert r["result"]["protocolVersion"] == "2025-06-18" # echo client's
|
|
31
|
+
assert "tools" in r["result"]["capabilities"]
|
|
32
|
+
assert r["result"]["serverInfo"]["name"] == "pythia"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_initialized_notification_is_silent():
|
|
36
|
+
out, done = pythia.mcp_handle(
|
|
37
|
+
{"jsonrpc": "2.0", "method": "notifications/initialized"}, {})
|
|
38
|
+
assert out == [] and done is False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_tools_list_returns_only_pythia_approve():
|
|
42
|
+
out, _ = pythia.mcp_handle(
|
|
43
|
+
{"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, {})
|
|
44
|
+
tools = out[0]["result"]["tools"]
|
|
45
|
+
assert [t["name"] for t in tools] == ["pythia_approve"]
|
|
46
|
+
schema = tools[0]["inputSchema"]
|
|
47
|
+
assert schema["properties"]["token"]["type"] == "string"
|
|
48
|
+
assert schema["required"] == ["token"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_unknown_method_with_id_is_an_error_not_a_crash():
|
|
52
|
+
out, _ = pythia.mcp_handle(
|
|
53
|
+
{"jsonrpc": "2.0", "id": 3, "method": "resources/list"}, {})
|
|
54
|
+
assert out[0]["error"]["code"] == -32601 # method not found
|
|
55
|
+
assert out[0]["id"] == 3
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# --- Task 2: the elicitation round-trip and the mint (fail-closed) ----------
|
|
59
|
+
|
|
60
|
+
def _preview(root, token="a1b2c3"):
|
|
61
|
+
"""A pending plsql_source preview in the journal, the way apply writes one."""
|
|
62
|
+
pythia.write_journal_entry(
|
|
63
|
+
root, "PACKAGE BODY", "PKG_ORDER", "old\n",
|
|
64
|
+
"CREATE OR REPLACE PACKAGE BODY pkg_order AS\n new;\nEND;\n",
|
|
65
|
+
{"token": token, "connection": "DEV", "schema": "APP",
|
|
66
|
+
"group": "plsql_source", "applied": False})
|
|
67
|
+
return token
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_mint_only_on_accept_with_approve():
|
|
71
|
+
with tempfile.TemporaryDirectory() as td:
|
|
72
|
+
token = _preview(td)
|
|
73
|
+
seen = {}
|
|
74
|
+
|
|
75
|
+
def elicit(message, schema):
|
|
76
|
+
seen["message"] = message
|
|
77
|
+
seen["schema"] = schema
|
|
78
|
+
return "accept", {"decision": "Approve"}
|
|
79
|
+
|
|
80
|
+
res = pythia.mcp_approve_decision(td, token, elicit)
|
|
81
|
+
assert res["isError"] is False
|
|
82
|
+
assert token in res["content"][0]["text"]
|
|
83
|
+
assert "PKG_ORDER" in seen["message"] # the card was shown
|
|
84
|
+
assert seen["schema"]["properties"]["decision"]["enum"] == ["Approve", "Reject"]
|
|
85
|
+
g = pythia.read_grant(td, token)
|
|
86
|
+
assert g and g["approver"] == "mcp" and g["used_at"] is None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_no_mint_on_reject_decline_or_cancel():
|
|
90
|
+
for action, content in (("accept", {"decision": "Reject"}),
|
|
91
|
+
("decline", {}), ("cancel", {})):
|
|
92
|
+
with tempfile.TemporaryDirectory() as td:
|
|
93
|
+
token = _preview(td)
|
|
94
|
+
res = pythia.mcp_approve_decision(td, token, lambda m, s: (action, content))
|
|
95
|
+
assert res["isError"] is True
|
|
96
|
+
assert pythia.read_grant(td, token) is None # nothing minted
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_no_mint_for_unknown_token_and_elicit_is_not_even_called():
|
|
100
|
+
with tempfile.TemporaryDirectory() as td:
|
|
101
|
+
called = []
|
|
102
|
+
res = pythia.mcp_approve_decision(
|
|
103
|
+
td, "nope99",
|
|
104
|
+
lambda m, s: called.append(1) or ("accept", {"decision": "Approve"}))
|
|
105
|
+
assert res["isError"] is True
|
|
106
|
+
assert called == [] # refused before asking
|
|
107
|
+
assert pythia.read_grant(td, "nope99") is None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_elicit_error_fails_closed():
|
|
111
|
+
with tempfile.TemporaryDirectory() as td:
|
|
112
|
+
token = _preview(td)
|
|
113
|
+
|
|
114
|
+
def boom(message, schema):
|
|
115
|
+
raise RuntimeError("transport died")
|
|
116
|
+
|
|
117
|
+
res = pythia.mcp_approve_decision(td, token, boom)
|
|
118
|
+
assert res["isError"] is True
|
|
119
|
+
assert pythia.read_grant(td, token) is None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# --- Task 3: the server loop, the mcp subcommand, and the guide -------------
|
|
123
|
+
|
|
124
|
+
class ScriptedClient:
|
|
125
|
+
"""Feeds the server initialize -> tools/call, then answers the server's
|
|
126
|
+
elicitation/create with a fixed (action, decision). Acts as both reader
|
|
127
|
+
and writer for mcp_serve."""
|
|
128
|
+
def __init__(self, token, action="accept", decision="Approve"):
|
|
129
|
+
self.out = io.StringIO()
|
|
130
|
+
self.answered = False
|
|
131
|
+
self.action, self.decision = action, decision
|
|
132
|
+
self._pending = [
|
|
133
|
+
{"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
|
134
|
+
"params": {"protocolVersion": "2025-06-18", "capabilities": {}}},
|
|
135
|
+
{"jsonrpc": "2.0", "id": 2, "method": "tools/call",
|
|
136
|
+
"params": {"name": "pythia_approve", "arguments": {"token": token}}},
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
def readline(self):
|
|
140
|
+
if self._pending:
|
|
141
|
+
return json.dumps(self._pending.pop(0)) + "\n"
|
|
142
|
+
if not self.answered:
|
|
143
|
+
for line in reversed(self.out.getvalue().splitlines()):
|
|
144
|
+
m = json.loads(line)
|
|
145
|
+
if m.get("method") == pythia.MCP_ELICIT_METHOD:
|
|
146
|
+
self.answered = True
|
|
147
|
+
return json.dumps({"jsonrpc": "2.0", "id": m["id"],
|
|
148
|
+
"result": {"action": self.action,
|
|
149
|
+
"content": {"decision": self.decision}}}) + "\n"
|
|
150
|
+
return "" # EOF -> server loop ends
|
|
151
|
+
|
|
152
|
+
def write(self, s):
|
|
153
|
+
self.out.write(s)
|
|
154
|
+
|
|
155
|
+
def flush(self):
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
def messages(self):
|
|
159
|
+
return [json.loads(l) for l in self.out.getvalue().splitlines() if l.strip()]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_serve_end_to_end_mints_on_scripted_approve():
|
|
163
|
+
with tempfile.TemporaryDirectory() as td:
|
|
164
|
+
token = _preview(td)
|
|
165
|
+
client = ScriptedClient(token)
|
|
166
|
+
pythia.mcp_serve(client, client, td)
|
|
167
|
+
msgs = client.messages()
|
|
168
|
+
assert any(m.get("result", {}).get("serverInfo", {}).get("name") == "pythia"
|
|
169
|
+
for m in msgs)
|
|
170
|
+
assert any(m.get("method") == pythia.MCP_ELICIT_METHOD for m in msgs)
|
|
171
|
+
call_result = [m for m in msgs if m.get("id") == 2 and "result" in m][-1]
|
|
172
|
+
assert call_result["result"]["isError"] is False
|
|
173
|
+
assert pythia.read_grant(td, token)["approver"] == "mcp"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_serve_fails_closed_on_scripted_decline():
|
|
177
|
+
with tempfile.TemporaryDirectory() as td:
|
|
178
|
+
token = _preview(td)
|
|
179
|
+
client = ScriptedClient(token, action="decline", decision="")
|
|
180
|
+
pythia.mcp_serve(client, client, td)
|
|
181
|
+
call_result = [m for m in client.messages()
|
|
182
|
+
if m.get("id") == 2 and "result" in m][-1]
|
|
183
|
+
assert call_result["result"]["isError"] is True
|
|
184
|
+
assert pythia.read_grant(td, token) is None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_mcp_is_a_no_db_command_in_the_guide():
|
|
188
|
+
assert "mcp" in pythia.COMMANDS
|
|
189
|
+
assert "mcp" in pythia.NO_DB_COMMANDS
|
|
190
|
+
assert "mcp" in pythia.OPERATING_GUIDE
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# --- Task 4: config.toml and requirements.toml merges (TOML-safe) -----------
|
|
194
|
+
|
|
195
|
+
def test_merge_codex_mcp_config_appends_once_when_clean():
|
|
196
|
+
with tempfile.TemporaryDirectory() as td:
|
|
197
|
+
path = pathlib.Path(td) / ".codex" / "config.toml"
|
|
198
|
+
p, action = pythia.merge_codex_mcp_config(path)
|
|
199
|
+
assert p == path and action == "created"
|
|
200
|
+
body = path.read_text(encoding="utf-8")
|
|
201
|
+
assert "[mcp_servers.pythia]" in body
|
|
202
|
+
assert 'args = ["-m", "pythia", "mcp"]' in body
|
|
203
|
+
assert "mcp_elicitations = true" in body
|
|
204
|
+
assert pythia.merge_codex_mcp_config(path)[1] == "present" # idempotent
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_merge_codex_mcp_config_preserves_other_content():
|
|
208
|
+
with tempfile.TemporaryDirectory() as td:
|
|
209
|
+
path = pathlib.Path(td) / ".codex" / "config.toml"
|
|
210
|
+
path.parent.mkdir(parents=True)
|
|
211
|
+
path.write_text('model = "gpt-5"\n', encoding="utf-8")
|
|
212
|
+
_, action = pythia.merge_codex_mcp_config(path)
|
|
213
|
+
assert action == "appended"
|
|
214
|
+
body = path.read_text(encoding="utf-8")
|
|
215
|
+
assert body.startswith('model = "gpt-5"\n') # kept
|
|
216
|
+
assert "[mcp_servers.pythia]" in body
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_merge_codex_mcp_config_refuses_on_table_collision():
|
|
220
|
+
with tempfile.TemporaryDirectory() as td:
|
|
221
|
+
path = pathlib.Path(td) / ".codex" / "config.toml"
|
|
222
|
+
path.parent.mkdir(parents=True)
|
|
223
|
+
original = "[approval_policy.granular]\nmcp_elicitations = false\n"
|
|
224
|
+
path.write_text(original, encoding="utf-8")
|
|
225
|
+
p, action = pythia.merge_codex_mcp_config(path)
|
|
226
|
+
assert action == "conflict"
|
|
227
|
+
assert path.read_text(encoding="utf-8") == original # untouched
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_merge_codex_requirements_creates_and_is_idempotent():
|
|
231
|
+
with tempfile.TemporaryDirectory() as td:
|
|
232
|
+
path = pathlib.Path(td) / ".codex" / "requirements.toml"
|
|
233
|
+
p, action = pythia.merge_codex_requirements(path)
|
|
234
|
+
assert action == "created"
|
|
235
|
+
body = path.read_text(encoding="utf-8")
|
|
236
|
+
assert "allowed_approval_policies" in body and "allowed_sandbox_modes" in body
|
|
237
|
+
assert "danger-full-access" not in body # the forbidden mode is absent
|
|
238
|
+
assert '"never"' not in body # the forbidden policy is absent
|
|
239
|
+
assert pythia.merge_codex_requirements(path)[1] == "present"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_merge_codex_requirements_refuses_an_existing_file():
|
|
243
|
+
with tempfile.TemporaryDirectory() as td:
|
|
244
|
+
path = pathlib.Path(td) / ".codex" / "requirements.toml"
|
|
245
|
+
path.parent.mkdir(parents=True)
|
|
246
|
+
original = "# org policy\nallowed_sandbox_modes = []\n"
|
|
247
|
+
path.write_text(original, encoding="utf-8")
|
|
248
|
+
p, action = pythia.merge_codex_requirements(path)
|
|
249
|
+
assert action == "conflict"
|
|
250
|
+
assert path.read_text(encoding="utf-8") == original # untouched
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# --- Task 5: wire the MCP install into cmd_install (project scope) ----------
|
|
254
|
+
|
|
255
|
+
def test_cmd_install_registers_the_mcp_server_and_pins_the_sandbox():
|
|
256
|
+
import argparse
|
|
257
|
+
import contextlib
|
|
258
|
+
old = os.environ.get("PATH")
|
|
259
|
+
os.environ["PATH"] = ""
|
|
260
|
+
try:
|
|
261
|
+
with tempfile.TemporaryDirectory() as td:
|
|
262
|
+
ns = argparse.Namespace(project_root=td, glob=False, source=None,
|
|
263
|
+
color=False, json=False, no_hooks=False)
|
|
264
|
+
with contextlib.redirect_stdout(io.StringIO()):
|
|
265
|
+
pythia.cmd_install(None, None, ns)
|
|
266
|
+
cfg = pathlib.Path(td) / ".codex" / "config.toml"
|
|
267
|
+
req = pathlib.Path(td) / ".codex" / "requirements.toml"
|
|
268
|
+
assert "[mcp_servers.pythia]" in cfg.read_text(encoding="utf-8")
|
|
269
|
+
assert "mcp_elicitations = true" in cfg.read_text(encoding="utf-8")
|
|
270
|
+
assert "allowed_sandbox_modes" in req.read_text(encoding="utf-8")
|
|
271
|
+
finally:
|
|
272
|
+
if old is not None:
|
|
273
|
+
os.environ["PATH"] = old
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def test_cmd_install_no_hooks_skips_the_mcp_files():
|
|
277
|
+
import argparse
|
|
278
|
+
import contextlib
|
|
279
|
+
old = os.environ.get("PATH")
|
|
280
|
+
os.environ["PATH"] = ""
|
|
281
|
+
try:
|
|
282
|
+
with tempfile.TemporaryDirectory() as td:
|
|
283
|
+
ns = argparse.Namespace(project_root=td, glob=False, source=None,
|
|
284
|
+
color=False, json=False, no_hooks=True)
|
|
285
|
+
with contextlib.redirect_stdout(io.StringIO()):
|
|
286
|
+
pythia.cmd_install(None, None, ns)
|
|
287
|
+
assert not (pathlib.Path(td) / ".codex" / "config.toml").exists()
|
|
288
|
+
finally:
|
|
289
|
+
if old is not None:
|
|
290
|
+
os.environ["PATH"] = old
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def main():
|
|
294
|
+
failed = 0
|
|
295
|
+
for name, fn in sorted(globals().items()):
|
|
296
|
+
if name.startswith("test_") and callable(fn):
|
|
297
|
+
try:
|
|
298
|
+
fn()
|
|
299
|
+
print(f"PASS {name}")
|
|
300
|
+
except (Exception, SystemExit) as e: # noqa: BLE001 — keep going
|
|
301
|
+
failed += 1
|
|
302
|
+
print(f"FAIL {name}: {e!r}")
|
|
303
|
+
if failed:
|
|
304
|
+
sys.exit(f"{failed} test(s) failed")
|
|
305
|
+
print("OK")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
if __name__ == "__main__":
|
|
309
|
+
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/scripts/pythia_plsql.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pythia_plsql-0.12.0 → pythia_plsql-0.14.0}/skills/pythia-explore/reference/data-dictionary.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|