pythia-plsql 0.2.0__tar.gz → 0.3.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 (39) hide show
  1. {pythia_plsql-0.2.0/scripts/pythia_plsql.egg-info → pythia_plsql-0.3.0}/PKG-INFO +26 -10
  2. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/README.md +25 -9
  3. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/pyproject.toml +1 -1
  4. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/scripts/pythia.py +188 -35
  5. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0/scripts/pythia_plsql.egg-info}/PKG-INFO +26 -10
  6. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/skills/pythia-apply/SKILL.md +10 -0
  7. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/skills/pythia-setup/SKILL.md +9 -2
  8. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/skills/pythia-write/SKILL.md +8 -0
  9. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/tests/test_install.py +65 -30
  10. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/tests/test_phase1.py +1 -2
  11. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/tests/test_phase3.py +100 -0
  12. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/LICENSE +0 -0
  13. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/queries/compile-errors.sql +0 -0
  14. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/queries/dependencies.sql +0 -0
  15. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/queries/impact.sql +0 -0
  16. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/queries/invalid-objects.sql +0 -0
  17. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/queries/name-occupants.sql +0 -0
  18. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/queries/object-source.sql +0 -0
  19. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/queries/plscope-enabled.sql +0 -0
  20. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/queries/plscope-statements.sql +0 -0
  21. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/queries/plscope-usages.sql +0 -0
  22. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/queries/session-privileges.sql +0 -0
  23. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/queries/similar-candidates.sql +0 -0
  24. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/queries/source.sql +0 -0
  25. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/scripts/pythia_plsql.egg-info/SOURCES.txt +0 -0
  26. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/scripts/pythia_plsql.egg-info/dependency_links.txt +0 -0
  27. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/scripts/pythia_plsql.egg-info/entry_points.txt +0 -0
  28. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/scripts/pythia_plsql.egg-info/requires.txt +0 -0
  29. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/scripts/pythia_plsql.egg-info/top_level.txt +0 -0
  30. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/setup.cfg +0 -0
  31. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/skills/pythia-explore/SKILL.md +0 -0
  32. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/skills/pythia-explore/reference/data-dictionary.md +0 -0
  33. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/skills/pythia-impact/SKILL.md +0 -0
  34. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/skills/pythia-review/SKILL.md +0 -0
  35. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/skills/pythia-review/reference/antipatterns.md +0 -0
  36. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/skills/pythia-skill-author/SKILL.md +0 -0
  37. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/skills/pythia-write/reference/patterns.md +0 -0
  38. {pythia_plsql-0.2.0 → pythia_plsql-0.3.0}/tests/test_phase2.py +0 -0
  39. {pythia_plsql-0.2.0 → pythia_plsql-0.3.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.2.0
3
+ Version: 0.3.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
@@ -20,7 +20,7 @@ Dynamic: license-file
20
20
 
21
21
  > Oracle's MCP gives your agent a connection. **pythia gives it the judgment to use it.**
22
22
 
23
- **English** · [Tiếng Việt](README.vi.md)
23
+ **English** · [Tiếng Việt](README.vi.md) · **[The Complete Guide →](GUIDE.md)**
24
24
 
25
25
  [![ci](https://github.com/thaildhe172591/pythia/actions/workflows/ci.yml/badge.svg)](https://github.com/thaildhe172591/pythia/actions/workflows/ci.yml)
26
26
  [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
@@ -81,9 +81,10 @@ pythia check # fill in connections.json first, then verify
81
81
 
82
82
  The pip package is the whole kit: with Node.js present, `pythia install`
83
83
  runs `npx skills add` (77 agents, symlinked updates; `--source <git-url>`
84
- for internal mirrors) — without Node it copies the bundled skill pack into
85
- `.agents/skills/`, the universal layout Claude Code, Codex and Cursor all
86
- read (one copy, so no skill shows up twice). Skills alone: `npx skills add
84
+ for internal mirrors) — without Node it copies the bundled pack itself.
85
+ Prefer **global skills**: `pythia install -g` once per machine serves every
86
+ project, and per-project installs then skip the skills step automatically
87
+ one copy anywhere means no skill ever shows up twice in the agent's menu. Skills alone: `npx skills add
87
88
  thaildhe172591/pythia`, or `/plugin marketplace add thaildhe172591/pythia`.
88
89
 
89
90
  `pip install` is **once per machine**; `pythia install` is **once per
@@ -92,6 +93,15 @@ project** — run it in each repo's root to drop the skills and a fresh
92
93
  project you are standing in (searched upward from the current directory,
93
94
  no global fallback), so one global CLI never mixes up databases.
94
95
 
96
+ **Updating** follows the same split:
97
+
98
+ ```bash
99
+ pip install --upgrade pythia-plsql # new CLI version (once per machine)
100
+ pythia install # refresh a project's skills; config never touched
101
+ ```
102
+
103
+ (`npx skills update` also works for skills installed via npx.)
104
+
95
105
  Running from a clone works too — `python scripts/pythia.py <command>`; every
96
106
  printed follow-up command matches how you invoked it. Windows, macOS, Linux
97
107
  and WSL are all CI-tested.
@@ -121,12 +131,18 @@ every agent read them first (`pythia conventions` shows both).
121
131
 
122
132
  **The account is the real security layer** — the policy file is an application-side
123
133
  fence. Give the agent its own revocable credential with proxy authentication
124
- (`agent_user[schema_owner]`, no `ANY` privileges, no owner password shared).
125
- `pythia agent-user` generates that SQL for your current connection — password
126
- included, `--save` drops the credential into `connections.json` — but it is
127
- optional convenience: running
134
+ (`agent_user[schema_owner]`, no `ANY` privileges, no owner password shared):
135
+
136
+ ```bash
137
+ pythia agent-user --save # SQL for the DBA + matching credential saved as <conn>_agent
138
+ pythia check # after the DBA ran it: proxy session, warning gone
139
+ ```
140
+
141
+ One run does both — the password is regenerated each run, so the SQL and the
142
+ saved config must come from the same run. Optional convenience: doing it by
143
+ hand with
128
144
  [`examples/agent-user-setup.example.sql`](examples/agent-user-setup.example.sql)
129
- by hand works just as well.
145
+ works just as well.
130
146
  `pythia check` warns when the session runs with more power than the task needs.
131
147
 
132
148
  Per-group write policy, `.pythia/policy.json` (defaults shown):
@@ -6,7 +6,7 @@
6
6
 
7
7
  > Oracle's MCP gives your agent a connection. **pythia gives it the judgment to use it.**
8
8
 
9
- **English** · [Tiếng Việt](README.vi.md)
9
+ **English** · [Tiếng Việt](README.vi.md) · **[The Complete Guide →](GUIDE.md)**
10
10
 
11
11
  [![ci](https://github.com/thaildhe172591/pythia/actions/workflows/ci.yml/badge.svg)](https://github.com/thaildhe172591/pythia/actions/workflows/ci.yml)
12
12
  [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
@@ -67,9 +67,10 @@ pythia check # fill in connections.json first, then verify
67
67
 
68
68
  The pip package is the whole kit: with Node.js present, `pythia install`
69
69
  runs `npx skills add` (77 agents, symlinked updates; `--source <git-url>`
70
- for internal mirrors) — without Node it copies the bundled skill pack into
71
- `.agents/skills/`, the universal layout Claude Code, Codex and Cursor all
72
- read (one copy, so no skill shows up twice). Skills alone: `npx skills add
70
+ for internal mirrors) — without Node it copies the bundled pack itself.
71
+ Prefer **global skills**: `pythia install -g` once per machine serves every
72
+ project, and per-project installs then skip the skills step automatically
73
+ one copy anywhere means no skill ever shows up twice in the agent's menu. Skills alone: `npx skills add
73
74
  thaildhe172591/pythia`, or `/plugin marketplace add thaildhe172591/pythia`.
74
75
 
75
76
  `pip install` is **once per machine**; `pythia install` is **once per
@@ -78,6 +79,15 @@ project** — run it in each repo's root to drop the skills and a fresh
78
79
  project you are standing in (searched upward from the current directory,
79
80
  no global fallback), so one global CLI never mixes up databases.
80
81
 
82
+ **Updating** follows the same split:
83
+
84
+ ```bash
85
+ pip install --upgrade pythia-plsql # new CLI version (once per machine)
86
+ pythia install # refresh a project's skills; config never touched
87
+ ```
88
+
89
+ (`npx skills update` also works for skills installed via npx.)
90
+
81
91
  Running from a clone works too — `python scripts/pythia.py <command>`; every
82
92
  printed follow-up command matches how you invoked it. Windows, macOS, Linux
83
93
  and WSL are all CI-tested.
@@ -107,12 +117,18 @@ every agent read them first (`pythia conventions` shows both).
107
117
 
108
118
  **The account is the real security layer** — the policy file is an application-side
109
119
  fence. Give the agent its own revocable credential with proxy authentication
110
- (`agent_user[schema_owner]`, no `ANY` privileges, no owner password shared).
111
- `pythia agent-user` generates that SQL for your current connection — password
112
- included, `--save` drops the credential into `connections.json` — but it is
113
- optional convenience: running
120
+ (`agent_user[schema_owner]`, no `ANY` privileges, no owner password shared):
121
+
122
+ ```bash
123
+ pythia agent-user --save # SQL for the DBA + matching credential saved as <conn>_agent
124
+ pythia check # after the DBA ran it: proxy session, warning gone
125
+ ```
126
+
127
+ One run does both — the password is regenerated each run, so the SQL and the
128
+ saved config must come from the same run. Optional convenience: doing it by
129
+ hand with
114
130
  [`examples/agent-user-setup.example.sql`](examples/agent-user-setup.example.sql)
115
- by hand works just as well.
131
+ works just as well.
116
132
  `pythia check` warns when the session runs with more power than the task needs.
117
133
 
118
134
  Per-group write policy, `.pythia/policy.json` (defaults shown):
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pythia-plsql"
7
- version = "0.2.0"
7
+ version = "0.3.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" }
@@ -201,6 +201,19 @@ def invocation():
201
201
  return pathlib.Path(prog).name
202
202
 
203
203
 
204
+ def human_at_the_keyboard():
205
+ """True only when a person is typing: stdin is a TTY. An agent driving
206
+ the CLI through a subprocess has no TTY and cannot fake one — which is
207
+ exactly why the loosening actions below key on this. PYTHIA_CI=1 is the
208
+ documented escape for real pipelines."""
209
+ if os.environ.get("PYTHIA_CI"):
210
+ return True
211
+ try:
212
+ return sys.stdin.isatty()
213
+ except (AttributeError, ValueError):
214
+ return False
215
+
216
+
204
217
  def forbid_write_flag(argv):
205
218
  if "--write" in argv:
206
219
  sys.exit(f"There is no --write flag. The write path is `{invocation()} "
@@ -1110,6 +1123,12 @@ def run_apply(conn, schema, ns, file_text, origin=None):
1110
1123
  {"s": schema, "n": name, "t": otype})
1111
1124
  db_source = "".join(cell(r[0]) for r in rows)
1112
1125
  token = apply_token(otype, name, file_text, db_source)
1126
+ if ns.yes and not human_at_the_keyboard():
1127
+ sys.exit("--yes is the developer's flag, and no terminal is attached "
1128
+ "to this session.\nAgents preview, relay the diff and the "
1129
+ "impact verbatim, STOP, and pass --confirm <token> only "
1130
+ "after the developer's explicit approval in chat.\n"
1131
+ "(Real pipelines set PYTHIA_CI=1.)")
1113
1132
  confirmed = bool(ns.yes) or ns.confirm == token
1114
1133
  if ns.confirm and ns.confirm != token:
1115
1134
  sys.exit("The confirmation token does not match: the file or the "
@@ -1120,6 +1139,9 @@ def run_apply(conn, schema, ns, file_text, origin=None):
1120
1139
  invalid_before = [(r[0], r[1]) for r in inv_rows]
1121
1140
  meta = {"schema": schema, "connection": ns.conn_name, "group": group,
1122
1141
  "token": token, "applied": False,
1142
+ "confirmed_via": ("yes" if ns.yes else
1143
+ "token" if confirmed else "preview"),
1144
+ "tty": human_at_the_keyboard(),
1123
1145
  "invalid_before": invalid_before, **(origin or {})}
1124
1146
  entry = write_journal_entry(ns.project_root, otype, name, db_source,
1125
1147
  file_text, meta)
@@ -1263,6 +1285,16 @@ def cmd_policy(conn, schema, ns):
1263
1285
  "values: allow, confirm, deny.")
1264
1286
  if ns.action == "set":
1265
1287
  eff = load_policy(ns.project_root)
1288
+ RANK = {"deny": 0, "confirm": 1, "allow": 2}
1289
+ if (RANK[ns.value] > RANK[eff[ns.group][0]]
1290
+ and not human_at_the_keyboard()):
1291
+ sys.exit(f"Loosening the write policy ({ns.group}: "
1292
+ f"{eff[ns.group][0]} → {ns.value}) is the developer's "
1293
+ "decision, and no terminal is attached to this session."
1294
+ "\nAsk the developer to run this themselves:\n"
1295
+ f" {invocation()} policy set {ns.group} {ns.value}\n"
1296
+ "(Tightening is always allowed; pipelines set "
1297
+ "PYTHIA_CI=1.)")
1266
1298
  eff[ns.group] = (ns.value, "policy.json") # validated by argparse choices
1267
1299
  path = policy_path(ns.project_root)
1268
1300
  path.parent.mkdir(parents=True, exist_ok=True)
@@ -1355,7 +1387,7 @@ AGENT_USER_SQL = """\
1355
1387
  -- privileges, utility grants — the agent needs none of them to develop
1356
1388
  -- PL/SQL, and every extra grant widens the blast radius.
1357
1389
 
1358
- CREATE USER {agent} IDENTIFIED BY "{password}";
1390
+ {create_line}
1359
1391
  GRANT CREATE SESSION TO {agent};
1360
1392
  ALTER USER {owner} GRANT CONNECT THROUGH {agent};
1361
1393
 
@@ -1365,6 +1397,23 @@ ALTER USER {owner} GRANT CONNECT THROUGH {agent};
1365
1397
  """
1366
1398
 
1367
1399
 
1400
+ def agent_user_sql(owner, agent, password, exists):
1401
+ """exists True: the user is already there — CREATE would be ORA-01920,
1402
+ so reset the password and unlock in one statement (harmless if it was
1403
+ never locked). exists None: no database to ask — emit CREATE plus the
1404
+ fallback as a comment so the DBA can pick."""
1405
+ if exists:
1406
+ line = (f'ALTER USER {agent} IDENTIFIED BY "{password}" ACCOUNT '
1407
+ f"UNLOCK; -- user already exists; CREATE would be ORA-01920")
1408
+ else:
1409
+ line = f'CREATE USER {agent} IDENTIFIED BY "{password}";'
1410
+ if exists is None:
1411
+ line += ("\n-- (if the user already exists — ORA-01920 — run "
1412
+ "instead:\n-- ALTER USER "
1413
+ f'{agent} IDENTIFIED BY "{password}" ACCOUNT UNLOCK;)')
1414
+ return AGENT_USER_SQL.format(owner=owner, agent=agent, create_line=line)
1415
+
1416
+
1368
1417
  def agent_password():
1369
1418
  """Random password satisfying common Oracle verify functions: letter
1370
1419
  first, upper+lower+digit+special, 16+ chars. Always quoted in SQL."""
@@ -1403,18 +1452,51 @@ def cmd_agent_user(conn, schema, ns):
1403
1452
  f"({base['user']}) — nothing to set up.")
1404
1453
  agent = (ns.name or f"{owner}_AGENT").upper()
1405
1454
  password = agent_password()
1406
- print(AGENT_USER_SQL.format(owner=owner, agent=agent, password=password))
1455
+ exists, dangerous = None, None
1456
+ if conn is not None:
1457
+ _, rows = run_query(conn, "select username from all_users "
1458
+ "where username = :n", {"n": agent})
1459
+ exists = bool(rows)
1460
+ _, prows = run_query(conn, load_query("session-privileges.sql"))
1461
+ dangerous = [r[0] for r in prows]
1462
+ sql = agent_user_sql(owner, agent, password, exists)
1463
+ saved = None
1407
1464
  if ns.save:
1408
- name = save_agent_connection(root, base_name, base, agent, password)
1409
- print(f"Saved connection '{name}' (now the default) in "
1410
- f"{pathlib.Path(root) / CONFIG_DIR / CONFIG_NAME};\n"
1465
+ saved = save_agent_connection(root, base_name, base, agent, password)
1466
+ if ns.json:
1467
+ print(json.dumps({
1468
+ "owner": owner, "agent": agent, "password": password, "sql": sql,
1469
+ "agent_user_exists": exists, "saved_connection": saved,
1470
+ "owner_connection": base_name,
1471
+ "check_will_warn": bool(dangerous) if dangerous is not None else None,
1472
+ "owner_dangerous_privs": dangerous,
1473
+ "next": ["have a DBA run the sql", f"{invocation()} check"]}))
1474
+ return
1475
+ print(sql)
1476
+ print("-- The password is regenerated on EVERY run — only the SQL from")
1477
+ print("-- this exact run matches what --save writes. One run, not two.")
1478
+ if dangerous:
1479
+ print(f"\n! Even after this, {invocation()} check will STILL warn: "
1480
+ f"the owner {owner} holds\n {', '.join(dangerous[:3])}"
1481
+ + ("…" if len(dangerous) > 3 else "")
1482
+ + " — every proxy session inherits it.\n"
1483
+ " Trim the owner's grants to reach the no-warning goal state "
1484
+ "(see README, Security).")
1485
+ elif dangerous is not None:
1486
+ print(f"\nOwner grants look clean: after the DBA runs this, "
1487
+ f"{invocation()} check should pass with no warning.")
1488
+ if saved:
1489
+ print(f"\nSaved connection '{saved}' (now the default) in "
1490
+ f"{pathlib.Path(root) / CONFIG_DIR / CONFIG_NAME} — its "
1491
+ f"password matches the SQL above;\n"
1411
1492
  f"the owner entry '{base_name}' is untouched — switch back "
1412
1493
  f"any time with --conn {base_name}.")
1413
- print(f"\nNext: run the SQL above as a DBA, then: {invocation()} check")
1494
+ print(f"\nNext: relay the SQL above to the developer verbatim for a "
1495
+ f"DBA to run, then: {invocation()} check")
1414
1496
  else:
1415
- print(f"-- Rerun with --save to add this credential to "
1416
- f"connections.json as '{base_name}_agent' and make it the "
1417
- f"default once the DBA has run it.")
1497
+ print(f"-- Nothing was saved. Use --save to also write this "
1498
+ f"credential to connections.json as '{base_name}_agent' in "
1499
+ f"the same run.")
1418
1500
 
1419
1501
 
1420
1502
  # unistr escapes. Single quote doubles to '' per SQL (a \' is ORA-01756 for
@@ -1489,7 +1571,7 @@ def scaffold_config(root):
1489
1571
  return path, True
1490
1572
 
1491
1573
 
1492
- def run_skills_add(source, interactive=False):
1574
+ def run_skills_add(source, interactive=False, glob=False):
1493
1575
  """Install the skill pack via the skills CLI. Returns its exit code, or
1494
1576
  None when npx is absent — the caller then copies the bundled pack, so a
1495
1577
  machine with only Python still gets the whole kit. At a TTY the CLI's own
@@ -1499,10 +1581,22 @@ def run_skills_add(source, interactive=False):
1499
1581
  npx = shutil.which("npx")
1500
1582
  if npx is None:
1501
1583
  return None
1584
+ return subprocess.run(skills_add_cmd(npx, source, interactive,
1585
+ glob=glob)).returncode
1586
+
1587
+
1588
+ def skills_add_cmd(npx, source, interactive, glob=False):
1589
+ """Interactive: the CLI's own agent picker. Non-interactive project
1590
+ installs pin one agent target so nothing is double-installed; global
1591
+ installs let the CLI place per-agent copies under the home directory."""
1502
1592
  cmd = [npx, "skills", "add", source]
1593
+ if glob:
1594
+ cmd.append("-g")
1503
1595
  if not interactive:
1504
1596
  cmd.append("-y")
1505
- return subprocess.run(cmd).returncode
1597
+ if not glob:
1598
+ cmd += ["-a", "claude-code"]
1599
+ return cmd
1506
1600
 
1507
1601
 
1508
1602
  LEGACY_SKILLS = ("plsql-setup", "plsql-explore", "plsql-impact",
@@ -1510,43 +1604,100 @@ LEGACY_SKILLS = ("plsql-setup", "plsql-explore", "plsql-impact",
1510
1604
  "plsql-skill-author")
1511
1605
 
1512
1606
 
1513
- def copy_bundled_skills(root):
1514
- """No-Node fallback: copy the wheel-bundled pack into .agents/skills/
1515
- ONLY the universal layout that Claude Code, Codex, Cursor, Copilot and
1516
- friends all read. A second copy in .claude/skills/ would show every
1517
- skill twice in Claude Code's menu. The copy merges: only the pack's own
1518
- skills are refreshed, anything else in the directory is untouched.
1519
- Stale copies under the pack's old plsql-* names are removed from both
1520
- conventional roots (real directories only, never symlinks)."""
1607
+ def pack_names():
1608
+ return [p.name for p in sorted(SKILLS_DIR.iterdir())
1609
+ if (p / "SKILL.md").is_file()]
1610
+
1611
+
1612
+ def global_pack_present(home=None):
1613
+ """Is the skill pack already installed machine-wide? Then a project copy
1614
+ would only duplicate every entry in the agent's menu."""
1615
+ home = pathlib.Path(home) if home else pathlib.Path.home()
1616
+ return any((home / rel / "pythia-apply" / "SKILL.md").is_file()
1617
+ for rel in (".claude/skills", ".agents/skills",
1618
+ ".config/agents/skills"))
1619
+
1620
+
1621
+ def copy_bundled_skills(base_dir):
1622
+ """No-Node fallback: copy the wheel-bundled pack into
1623
+ <base>/.claude/skills/ ONLY — the directory Claude Code reliably reads
1624
+ in both scopes (field evidence: project .agents/skills is invisible to
1625
+ some Claude Code versions, and a second copy doubles every menu entry).
1626
+ base_dir is the project root, or the home directory for -g. The copy
1627
+ merges: only the pack's own skills are refreshed; stale copies of the
1628
+ pack under .agents/skills and old plsql-* names are cleaned."""
1521
1629
  import shutil
1522
- dest_root = pathlib.Path(root) / ".agents" / "skills"
1630
+ dest_root = pathlib.Path(base_dir) / ".claude" / "skills"
1523
1631
  for pack in sorted(SKILLS_DIR.iterdir()):
1524
1632
  if not (pack / "SKILL.md").is_file():
1525
1633
  continue
1526
1634
  shutil.copytree(pack, dest_root / pack.name, dirs_exist_ok=True)
1635
+ clean_legacy_skills(base_dir)
1636
+ for name in pack_names(): # canonical copy lives in .claude now
1637
+ _remove_link_first(pathlib.Path(base_dir) / ".agents" / "skills" / name)
1638
+ return [dest_root]
1639
+
1640
+
1641
+ def _remove_link_first(path):
1642
+ """Windows junctions defeat is_symlink(); unlink/rmdir take out a file,
1643
+ symlink, junction or empty dir without touching any target — only a
1644
+ real populated directory needs rmtree."""
1645
+ import shutil
1646
+ if not os.path.lexists(path):
1647
+ return
1648
+ try:
1649
+ path.unlink()
1650
+ except OSError:
1651
+ try:
1652
+ os.rmdir(path)
1653
+ except OSError:
1654
+ shutil.rmtree(path)
1655
+
1656
+
1657
+ def clean_legacy_skills(base_dir):
1658
+ """Remove stale copies under the pack's old plsql-* names from both
1659
+ conventional roots below base_dir (project root or home)."""
1527
1660
  for rel in (".agents/skills", ".claude/skills"):
1528
1661
  for old_name in LEGACY_SKILLS:
1529
- legacy = pathlib.Path(root) / rel / old_name
1530
- if legacy.is_dir() and not legacy.is_symlink():
1531
- shutil.rmtree(legacy)
1532
- return [dest_root]
1662
+ _remove_link_first(pathlib.Path(base_dir) / rel / old_name)
1533
1663
 
1534
1664
 
1535
1665
  def cmd_install(conn, schema, ns):
1536
1666
  en = getattr(ns, "color", False)
1537
1667
  if en:
1538
1668
  sys.stdout.write(banner(en))
1669
+ interactive = sys.stdin.isatty() and sys.stdout.isatty()
1670
+ if ns.glob:
1671
+ code = run_skills_add(ns.source, interactive, glob=True)
1672
+ if code is None:
1673
+ for tgt in copy_bundled_skills(pathlib.Path.home()):
1674
+ print(f"Copied the bundled skill pack into {tgt}")
1675
+ elif code:
1676
+ sys.exit(code)
1677
+ else:
1678
+ clean_legacy_skills(pathlib.Path.home())
1679
+ print("\nGlobal skills serve every project — per-project installs "
1680
+ "will now skip the skills step automatically.")
1681
+ return
1539
1682
  path, created = scaffold_config(ns.project_root)
1540
1683
  print(f"{'Created' if created else 'Kept existing'} {path}")
1541
- interactive = sys.stdin.isatty() and sys.stdout.isatty()
1542
- code = run_skills_add(ns.source, interactive)
1543
- if code is None:
1544
- for t in copy_bundled_skills(ns.project_root):
1545
- print(f"Copied the bundled skill pack into {t}")
1546
- print("(npx not found — with Node.js, `npx skills add` reaches 77 "
1547
- "agents with symlinked updates.)")
1548
- elif code:
1549
- sys.exit(code)
1684
+ if global_pack_present():
1685
+ print("Skill pack found in the global install — it already serves "
1686
+ "this project,\nso no project copy is made (a second copy "
1687
+ f"doubles every menu entry).\nRefresh it with: {invocation()} "
1688
+ "install -g")
1689
+ clean_legacy_skills(ns.project_root)
1690
+ else:
1691
+ code = run_skills_add(ns.source, interactive)
1692
+ if code is None:
1693
+ for tgt in copy_bundled_skills(ns.project_root):
1694
+ print(f"Copied the bundled skill pack into {tgt}")
1695
+ print("(npx not found — with Node.js, `npx skills add` reaches "
1696
+ "77 agents with symlinked updates.)")
1697
+ elif code:
1698
+ sys.exit(code)
1699
+ else:
1700
+ clean_legacy_skills(ns.project_root)
1550
1701
  print(f"\nNext: fill in {path}")
1551
1702
  print(f"Then: {invocation()} check")
1552
1703
 
@@ -1559,8 +1710,7 @@ COMMANDS = {"check": cmd_check, "ls": cmd_ls, "src": cmd_src, "args": cmd_args,
1559
1710
  "conventions": cmd_conventions, "install": cmd_install,
1560
1711
  "unistr": cmd_unistr, "agent-user": cmd_agent_user}
1561
1712
 
1562
- NO_DB_COMMANDS = {"policy", "journal", "conventions", "install", "unistr",
1563
- "agent-user"}
1713
+ NO_DB_COMMANDS = {"policy", "journal", "conventions", "install", "unistr"}
1564
1714
 
1565
1715
 
1566
1716
  # --- CLI ---------------------------------------------------------------------
@@ -1674,6 +1824,9 @@ def build_parser():
1674
1824
  help="wrap as 'loi:'||unistr(...)||':loi'")
1675
1825
  s = sub.add_parser("install", parents=[common()],
1676
1826
  help="install the skill pack and scaffold .pythia/ config")
1827
+ s.add_argument("-g", "--global", dest="glob", action="store_true",
1828
+ help="install the skill pack machine-wide (serves every "
1829
+ "project; per-project installs then skip skills)")
1677
1830
  s.add_argument("--source", default=DEFAULT_SKILLS_SOURCE,
1678
1831
  help="skills repo for `npx skills add` "
1679
1832
  f"(default {DEFAULT_SKILLS_SOURCE})")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythia-plsql
3
- Version: 0.2.0
3
+ Version: 0.3.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
@@ -20,7 +20,7 @@ Dynamic: license-file
20
20
 
21
21
  > Oracle's MCP gives your agent a connection. **pythia gives it the judgment to use it.**
22
22
 
23
- **English** · [Tiếng Việt](README.vi.md)
23
+ **English** · [Tiếng Việt](README.vi.md) · **[The Complete Guide →](GUIDE.md)**
24
24
 
25
25
  [![ci](https://github.com/thaildhe172591/pythia/actions/workflows/ci.yml/badge.svg)](https://github.com/thaildhe172591/pythia/actions/workflows/ci.yml)
26
26
  [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
@@ -81,9 +81,10 @@ pythia check # fill in connections.json first, then verify
81
81
 
82
82
  The pip package is the whole kit: with Node.js present, `pythia install`
83
83
  runs `npx skills add` (77 agents, symlinked updates; `--source <git-url>`
84
- for internal mirrors) — without Node it copies the bundled skill pack into
85
- `.agents/skills/`, the universal layout Claude Code, Codex and Cursor all
86
- read (one copy, so no skill shows up twice). Skills alone: `npx skills add
84
+ for internal mirrors) — without Node it copies the bundled pack itself.
85
+ Prefer **global skills**: `pythia install -g` once per machine serves every
86
+ project, and per-project installs then skip the skills step automatically
87
+ one copy anywhere means no skill ever shows up twice in the agent's menu. Skills alone: `npx skills add
87
88
  thaildhe172591/pythia`, or `/plugin marketplace add thaildhe172591/pythia`.
88
89
 
89
90
  `pip install` is **once per machine**; `pythia install` is **once per
@@ -92,6 +93,15 @@ project** — run it in each repo's root to drop the skills and a fresh
92
93
  project you are standing in (searched upward from the current directory,
93
94
  no global fallback), so one global CLI never mixes up databases.
94
95
 
96
+ **Updating** follows the same split:
97
+
98
+ ```bash
99
+ pip install --upgrade pythia-plsql # new CLI version (once per machine)
100
+ pythia install # refresh a project's skills; config never touched
101
+ ```
102
+
103
+ (`npx skills update` also works for skills installed via npx.)
104
+
95
105
  Running from a clone works too — `python scripts/pythia.py <command>`; every
96
106
  printed follow-up command matches how you invoked it. Windows, macOS, Linux
97
107
  and WSL are all CI-tested.
@@ -121,12 +131,18 @@ every agent read them first (`pythia conventions` shows both).
121
131
 
122
132
  **The account is the real security layer** — the policy file is an application-side
123
133
  fence. Give the agent its own revocable credential with proxy authentication
124
- (`agent_user[schema_owner]`, no `ANY` privileges, no owner password shared).
125
- `pythia agent-user` generates that SQL for your current connection — password
126
- included, `--save` drops the credential into `connections.json` — but it is
127
- optional convenience: running
134
+ (`agent_user[schema_owner]`, no `ANY` privileges, no owner password shared):
135
+
136
+ ```bash
137
+ pythia agent-user --save # SQL for the DBA + matching credential saved as <conn>_agent
138
+ pythia check # after the DBA ran it: proxy session, warning gone
139
+ ```
140
+
141
+ One run does both — the password is regenerated each run, so the SQL and the
142
+ saved config must come from the same run. Optional convenience: doing it by
143
+ hand with
128
144
  [`examples/agent-user-setup.example.sql`](examples/agent-user-setup.example.sql)
129
- by hand works just as well.
145
+ works just as well.
130
146
  `pythia check` warns when the session runs with more power than the task needs.
131
147
 
132
148
  Per-group write policy, `.pythia/policy.json` (defaults shown):
@@ -21,6 +21,16 @@ Applies to every write: new objects, fixes, restores, batch runs. No
21
21
  exceptions for "trivial" changes — a one-line change can invalidate twelve
22
22
  dependents.
23
23
 
24
+ Three moves are FORBIDDEN for agents, and the CLI enforces the first two
25
+ (no terminal attached → refusal):
26
+
27
+ - `--yes` — it is the developer's flag, never yours.
28
+ - `pythia policy set <group>` to anything LOOSER — loosening policy is the
29
+ developer's decision; hand them the exact command to run themselves.
30
+ - Passing `--confirm` in the same turn as the preview. The preview ends
31
+ your turn; the token is only used after the developer's approval
32
+ arrives as a NEW message.
33
+
24
34
  ## The Workflow
25
35
 
26
36
  **Before step 1:** if this conversation has not yet seen a standalone impact
@@ -41,8 +41,15 @@ connects *through* the schema owner. The agent never learns the owner's
41
41
  password, revocation is one statement, the audit trail shows who really
42
42
  connected, and the blast radius is the one development schema.
43
43
 
44
- Run `examples/agent-user-setup.example.sql` (as a DBA, names adapted), then
45
- set the connection's user to `"agent_user[schema_owner]"`.
44
+ Run `pythia agent-user --save` ONE run, in the project directory. It
45
+ prints the three-statement proxy SQL with a generated password and saves
46
+ the matching credential as connection `<conn>_agent` (the new default; the
47
+ owner entry stays untouched). The password is regenerated on every run, so
48
+ never preview first and save later — the SQL and the saved config must
49
+ come from the same run. Relay the SQL to the developer verbatim for a DBA
50
+ to execute, then verify with `pythia check`. `--json` gives the same
51
+ result machine-readable. Manual alternative:
52
+ `examples/agent-user-setup.example.sql`, names adapted.
46
53
 
47
54
  Oracle's own guidance for LLM access, follow it: grant minimum privileges,
48
55
  never point an LLM at a production database, audit its activity regularly.
@@ -40,6 +40,14 @@ existing ones — not to introduce a better style.
40
40
  `reference/patterns.md` — cursor and bulk patterns, exception discipline,
41
41
  bind variables, commit ownership.
42
42
 
43
+ ## Scope is the developer's sentence, not yours
44
+
45
+ Change ONLY the objects the developer named. When impact shows dependents
46
+ that will break, you REPORT them with the list and a proposal — fixing
47
+ them is a separate request that needs its own explicit approval. "While I
48
+ was there" is how an asked-for table edit becomes seven unasked procedure
49
+ rewrites.
50
+
43
51
  ## Conventions outrank preferences
44
52
 
45
53
  If the codebase writes explicit cursors where you would write `FOR r IN`,
@@ -35,11 +35,11 @@ def test_scaffold_creates_config_once_and_never_clobbers():
35
35
 
36
36
 
37
37
  def test_missing_npx_falls_back_to_the_bundled_pack():
38
- """No Node -> the whole kit still installs. One target only
39
- (.agents/skills, the universal layout) a second copy in
40
- .claude/skills would double every skill in Claude Code's menu. The
41
- copy merges: foreign skills survive, and stale plsql-* copies of the
42
- pack's old names are cleaned from both conventional roots."""
38
+ """No Node -> the whole kit still installs, into .claude/skills ONLY
39
+ the directory Claude Code reliably reads (field evidence: project
40
+ .agents/skills is invisible to some versions, and any second copy
41
+ doubles every menu entry). Foreign skills survive; stale plsql-* and
42
+ old .agents pack copies are cleaned."""
43
43
  import shutil
44
44
  old = shutil.which
45
45
  shutil.which = lambda name: None
@@ -50,36 +50,39 @@ def test_missing_npx_falls_back_to_the_bundled_pack():
50
50
  with tempfile.TemporaryDirectory() as td:
51
51
  agents = pathlib.Path(td) / ".agents" / "skills"
52
52
  claude = pathlib.Path(td) / ".claude" / "skills"
53
- # pre-existing content: a custom skill, and stale old-name copies
54
53
  (agents / "my-team-skill").mkdir(parents=True)
55
54
  (agents / "my-team-skill" / "SKILL.md").write_text("x", encoding="utf-8")
56
- (agents / "plsql-apply").mkdir()
55
+ (agents / "pythia-apply").mkdir() # stale copy from 0.2.0-0.2.3
57
56
  (claude / "plsql-review").mkdir(parents=True)
58
57
  targets = pythia.copy_bundled_skills(td)
59
- assert targets == [agents]
60
- assert (agents / "pythia-apply" / "SKILL.md").is_file()
61
- assert (agents / "pythia-review" / "reference" / "antipatterns.md").is_file()
62
- assert not (claude / "pythia-apply").exists() # single target
58
+ assert targets == [claude]
59
+ assert (claude / "pythia-apply" / "SKILL.md").is_file()
60
+ assert (claude / "pythia-review" / "reference" / "antipatterns.md").is_file()
61
+ assert not (agents / "pythia-apply").exists() # single copy
63
62
  assert (agents / "my-team-skill" / "SKILL.md").read_text(
64
- encoding="utf-8") == "x" # merge, not wipe
65
- assert not (agents / "plsql-apply").exists() # legacy cleaned
66
- assert not (claude / "plsql-review").exists() # in both roots
67
- pythia.copy_bundled_skills(td) # idempotent
68
-
69
-
70
- def test_invocation_names_the_dash_m_form():
71
- """`python -m pythia` runs with argv[0] = a site-packages path nobody
72
- typed; the printed follow-ups must use the -m form instead."""
73
- import types
74
- real_main = sys.modules.get("__main__")
75
- fake = types.ModuleType("__main__")
76
- fake.__spec__ = types.SimpleNamespace(name="pythia")
77
- sys.modules["__main__"] = fake
78
- try:
79
- interp = pathlib.Path(sys.executable).stem
80
- assert pythia.invocation() == f"{interp} -m pythia"
81
- finally:
82
- sys.modules["__main__"] = real_main
63
+ encoding="utf-8") == "x" # merge, not wipe
64
+ assert not (claude / "plsql-review").exists() # legacy cleaned
65
+ pythia.copy_bundled_skills(td) # idempotent
66
+
67
+
68
+ def test_global_pack_detection_skips_project_copies():
69
+ with tempfile.TemporaryDirectory() as home:
70
+ assert not pythia.global_pack_present(home)
71
+ d = pathlib.Path(home) / ".claude" / "skills" / "pythia-apply"
72
+ d.mkdir(parents=True)
73
+ (d / "SKILL.md").write_text("x", encoding="utf-8")
74
+ assert pythia.global_pack_present(home)
75
+
76
+
77
+ def test_skills_add_cmd_scopes():
78
+ """Unattended project installs pin one agent (no double-install);
79
+ global installs use -g; the interactive picker stays untouched."""
80
+ assert pythia.skills_add_cmd("npx", "o/r", interactive=False) == \
81
+ ["npx", "skills", "add", "o/r", "-y", "-a", "claude-code"]
82
+ assert pythia.skills_add_cmd("npx", "o/r", interactive=False, glob=True) == \
83
+ ["npx", "skills", "add", "o/r", "-g", "-y"]
84
+ assert pythia.skills_add_cmd("npx", "o/r", interactive=True) == \
85
+ ["npx", "skills", "add", "o/r"]
83
86
 
84
87
 
85
88
  def test_pack_dirs_resolve_in_the_source_layout():
@@ -87,6 +90,38 @@ def test_pack_dirs_resolve_in_the_source_layout():
87
90
  assert (pythia.SKILLS_DIR / "pythia-apply" / "SKILL.md").is_file()
88
91
 
89
92
 
93
+ def test_agent_user_json_saves_the_same_password_it_prints():
94
+ """The agent flow: one --json --save run must be self-consistent — the
95
+ password inside the SQL is the password written to connections.json."""
96
+ import argparse
97
+ import contextlib
98
+ import io
99
+ import os
100
+ with tempfile.TemporaryDirectory() as td:
101
+ root = pathlib.Path(td)
102
+ (root / ".pythia").mkdir()
103
+ (root / ".pythia" / "connections.json").write_text(json.dumps(
104
+ {"dev": {"host": "h", "port": 1521, "service_name": "s",
105
+ "user": "OWNER", "password": "p", "schema": "OWNER"}}),
106
+ encoding="utf-8")
107
+ cwd = os.getcwd()
108
+ os.chdir(td)
109
+ try:
110
+ ns = argparse.Namespace(conn=None, json=True, save=True, name=None)
111
+ buf = io.StringIO()
112
+ with contextlib.redirect_stdout(buf):
113
+ pythia.cmd_agent_user(None, None, ns)
114
+ d = json.loads(buf.getvalue())
115
+ assert d["saved_connection"] == "dev_agent"
116
+ assert d["password"] in d["sql"]
117
+ cfg = json.loads((root / ".pythia" / "connections.json")
118
+ .read_text(encoding="utf-8"))
119
+ assert cfg["dev_agent"]["password"] == d["password"]
120
+ assert cfg["dev"]["password"] == "p" # owner untouched
121
+ finally:
122
+ os.chdir(cwd)
123
+
124
+
90
125
  def test_one_version_across_every_manifest():
91
126
  """pyproject, plugin.json, marketplace.json and npm/package.json must all
92
127
  carry the same version — the release workflow publishes from one tag."""
@@ -254,8 +254,7 @@ def test_to_unistr():
254
254
 
255
255
 
256
256
  def test_agent_user_sql_is_least_privilege():
257
- sql = pythia.AGENT_USER_SQL.format(owner="APP", agent="APP_AGENT",
258
- password="x")
257
+ sql = pythia.agent_user_sql("APP", "APP_AGENT", "x", exists=False)
259
258
  assert "CREATE USER APP_AGENT" in sql
260
259
  assert "GRANT CREATE SESSION TO APP_AGENT" in sql
261
260
  assert "GRANT CONNECT THROUGH APP_AGENT" in sql
@@ -8,6 +8,9 @@ import pathlib
8
8
  import sys
9
9
  import tempfile
10
10
 
11
+ import os
12
+ os.environ.setdefault("PYTHIA_CI", "1") # the suites are headless by design
13
+
11
14
  sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "scripts"))
12
15
  import pythia # noqa: E402
13
16
 
@@ -558,6 +561,103 @@ def test_journal_prune_keeps_applied_entries():
558
561
  assert pythia.list_journal_entries(td) == [kept]
559
562
 
560
563
 
564
+ def test_agent_user_alter_form_and_warning_prediction():
565
+ """DB-aware agent-user: an existing agent user gets the ALTER+UNLOCK
566
+ form (CREATE would be ORA-01920), and a dirty owner is called out —
567
+ check will STILL warn — before the DBA runs anything."""
568
+ import argparse
569
+ import contextlib
570
+ import io
571
+ import os
572
+ script = {"from all_users": ([("USERNAME",)], [("APP_AGENT",)]),
573
+ "from session_privs": ([("PRIVILEGE",)], [("DROP ANY TABLE",)])}
574
+ with tempfile.TemporaryDirectory() as td:
575
+ (pathlib.Path(td) / ".pythia").mkdir()
576
+ (pathlib.Path(td) / ".pythia" / "connections.json").write_text(
577
+ json.dumps({"dev": {"host": "h", "user": "APP", "password": "p",
578
+ "schema": "APP"}}), encoding="utf-8")
579
+ cwd = os.getcwd()
580
+ os.chdir(td)
581
+ try:
582
+ ns = argparse.Namespace(conn=None, json=True, save=False, name=None)
583
+ buf = io.StringIO()
584
+ with contextlib.redirect_stdout(buf):
585
+ pythia.cmd_agent_user(FakeConn(script), "APP", ns)
586
+ d = json.loads(buf.getvalue())
587
+ finally:
588
+ os.chdir(cwd)
589
+ assert d["agent_user_exists"] is True
590
+ assert "ALTER USER APP_AGENT IDENTIFIED BY" in d["sql"]
591
+ assert "ACCOUNT UNLOCK" in d["sql"]
592
+ assert "CREATE USER" not in d["sql"]
593
+ assert d["check_will_warn"] is True
594
+ assert d["owner_dangerous_privs"] == ["DROP ANY TABLE"]
595
+
596
+
597
+ class _NoTTY:
598
+ """A headless agent session: PYTHIA_CI cleared, stdin not a terminal."""
599
+ def __enter__(self):
600
+ import io
601
+ self.ci = os.environ.pop("PYTHIA_CI", None)
602
+ self.stdin = sys.stdin
603
+ sys.stdin = io.StringIO()
604
+ return self
605
+
606
+ def __exit__(self, *a):
607
+ sys.stdin = self.stdin
608
+ if self.ci is not None:
609
+ os.environ["PYTHIA_CI"] = self.ci
610
+ return False
611
+
612
+
613
+ def test_headless_yes_is_refused_before_anything_is_written():
614
+ """--yes belongs to a human at a terminal. An agent (no TTY) must be
615
+ told to preview, stop, and wait for the developer — and nothing may
616
+ reach the database."""
617
+ with tempfile.TemporaryDirectory() as td, _NoTTY():
618
+ conn = FakeConn(base_script())
619
+ try:
620
+ pythia.run_apply(conn, "APP", apply_ns(td, file="f.sql", yes=True),
621
+ NEW_FILE)
622
+ except SystemExit as e:
623
+ msg = str(e)
624
+ assert "developer" in msg and "--confirm" in msg
625
+ assert "PYTHIA_CI" in msg
626
+ else:
627
+ raise AssertionError("expected SystemExit, none raised")
628
+ assert wrote_ddl(conn) == [] # refused before the write
629
+ # the developer at a real terminal keeps --yes (simulated via PYTHIA_CI)
630
+ with tempfile.TemporaryDirectory() as td:
631
+ conn = FakeConn(base_script())
632
+ assert pythia.run_apply(conn, "APP",
633
+ apply_ns(td, file="f.sql", yes=True),
634
+ NEW_FILE) == 0
635
+
636
+
637
+ def test_headless_policy_loosening_is_refused_tightening_allowed():
638
+ import argparse
639
+ import contextlib
640
+ import io
641
+
642
+ def policy_ns(root, group, value):
643
+ return argparse.Namespace(action="set", group=group, value=value,
644
+ json=False, project_root=root)
645
+
646
+ with tempfile.TemporaryDirectory() as td, _NoTTY():
647
+ # deny -> confirm is loosening: the developer's decision
648
+ try:
649
+ pythia.cmd_policy(None, None, policy_ns(td, "structural", "confirm"))
650
+ except SystemExit as e:
651
+ assert "developer" in str(e) and "policy set structural" in str(e)
652
+ else:
653
+ raise AssertionError("expected SystemExit, none raised")
654
+ assert not pythia.policy_path(td).is_file() # nothing written
655
+ # confirm -> deny is tightening: always allowed, even headless
656
+ with contextlib.redirect_stdout(io.StringIO()):
657
+ pythia.cmd_policy(None, None, policy_ns(td, "plsql_source", "deny"))
658
+ assert pythia.load_policy(td)["plsql_source"][0] == "deny"
659
+
660
+
561
661
  def main():
562
662
  failed = 0
563
663
  for name, fn in sorted(globals().items()):
File without changes
File without changes