pythia-plsql 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pythia.py ADDED
@@ -0,0 +1,1566 @@
1
+ #!/usr/bin/env python3
2
+ """pythia — ask an Oracle Database directly instead of reading stale dumps.
3
+
4
+ Reads are plain commands; sql accepts SELECT/WITH only. Writes go through
5
+ `apply` — snapshot, impact, preview, apply, verify, report — gated by
6
+ .pythia/policy.json. There is no --write flag: the write path is `apply`,
7
+ nothing else.
8
+
9
+ Connection resolution order:
10
+ 1. --conn NAME
11
+ 2. PYTHIA_CONNECTION (name of an entry in connections.json)
12
+ 3. PYTHIA_USER / PYTHIA_PASSWORD / PYTHIA_DSN (+ optional PYTHIA_SCHEMA)
13
+ 4. .pythia/connections.json, searched upward from the current directory
14
+ (override the file path with PYTHIA_CONFIG):
15
+ - a single entry is used as-is
16
+ - with several entries, the path segment directly under the project
17
+ root picks one (root/DEV/anything -> DEV)
18
+ - failing that, the entry named by a top-level "default": "<name>"
19
+ - anything still ambiguous is an error, never a guess
20
+
21
+ Output is capped so large objects cannot swallow a context window; every cut
22
+ is announced with "-- truncated ..." (text) or "truncated": true (JSON), so
23
+ you always know whether you saw everything.
24
+
25
+ pythia check
26
+ pythia ls "PKG_%"
27
+ pythia src MY_PACKAGE --body
28
+ pythia args MY_PROCEDURE
29
+ pythia ddl TABLE MY_TABLE
30
+ pythia cols MY_TABLE
31
+ pythia grep "some_identifier"
32
+ pythia sql "select count(*) from all_views where owner = user"
33
+ pythia invalid
34
+ pythia errors MY_PACKAGE
35
+ pythia deps MY_PACKAGE --depth 2
36
+ pythia impact MY_TABLE
37
+ pythia similar PKG_ORDER_TOTAL_LIST
38
+ pythia plscope MY_TABLE
39
+ pythia apply PKG_ORDER_BODY.sql
40
+ pythia apply PKG_ORDER_BODY.sql --confirm 7f3a91
41
+ pythia journal list
42
+ pythia journal restore <id>
43
+ pythia policy
44
+ pythia install
45
+ """
46
+ import argparse
47
+ import json
48
+ import os
49
+ import pathlib
50
+ import re
51
+ import sys
52
+
53
+ READONLY = re.compile(r"^\s*(select|with)\b", re.I)
54
+ CONFIG_DIR = ".pythia"
55
+ CONFIG_NAME = "connections.json"
56
+ INT_MAX = 2147483647
57
+
58
+ def _pack_dir(source_name, installed_name):
59
+ """Repo layout first (reviewable dirs next to scripts/), wheel layout
60
+ second (package data installed beside this module)."""
61
+ here = pathlib.Path(__file__).resolve().parent
62
+ source = here.parent / source_name
63
+ return source if source.is_dir() else here / installed_name
64
+
65
+
66
+ QUERY_DIR = _pack_dir("queries", "pythia_queries")
67
+ SKILLS_DIR = _pack_dir("skills", "pythia_skills")
68
+
69
+ # Bind contract: what each query file is allowed to use. tests/test_phase2.py
70
+ # fails on any drift in either direction — that is how queries/ stays reviewable
71
+ # by PR without a database.
72
+ QUERY_BINDS = {
73
+ "invalid-objects.sql": {"s"},
74
+ "compile-errors.sql": {"s", "n"},
75
+ "dependencies.sql": {"s", "n", "depth", "with_sys"},
76
+ "impact.sql": {"s", "n", "depth"},
77
+ "similar-candidates.sql": {"s"},
78
+ "plscope-usages.sql": {"s", "n"},
79
+ "plscope-statements.sql": {"s", "n"},
80
+ "plscope-enabled.sql": {"s"},
81
+ "source.sql": {"s", "n"},
82
+ "object-source.sql": {"s", "n", "t"},
83
+ "session-privileges.sql": set(),
84
+ "name-occupants.sql": {"s", "n"},
85
+ }
86
+
87
+
88
+ # --- pure helpers (covered by tests/test_phase1.py) --------------------------
89
+
90
+ def is_readonly_sql(stmt):
91
+ return bool(READONLY.match(stmt))
92
+
93
+
94
+ # --- terminal presentation ---------------------------------------------------
95
+
96
+ ANSI = {"red": "31", "green": "32", "yellow": "33", "cyan": "36",
97
+ "bold": "1", "dim": "2"}
98
+
99
+
100
+ def color_enabled(stream=None, env=None):
101
+ """ANSI color only for a human at a TTY. NO_COLOR (the standard) always
102
+ wins; FORCE_COLOR opts back in; pipes stay plain so agents parse exactly
103
+ what they saw."""
104
+ env = os.environ if env is None else env
105
+ if env.get("FORCE_COLOR"):
106
+ return True # explicit opt-in outranks the ambient opt-out below
107
+ if env.get("NO_COLOR"):
108
+ return False
109
+ stream = sys.stdout if stream is None else stream
110
+ return bool(getattr(stream, "isatty", lambda: False)())
111
+
112
+
113
+ def paint(text, color, enabled):
114
+ if not enabled or not color:
115
+ return text
116
+ return f"\x1b[{ANSI[color]}m{text}\x1b[0m"
117
+
118
+
119
+ BLOCK_LOGO = """\
120
+ ██████╗ ██╗ ██╗████████╗██╗ ██╗██╗ █████╗
121
+ ██╔══██╗╚██╗ ██╔╝╚══██╔══╝██║ ██║██║██╔══██╗
122
+ ██████╔╝ ╚████╔╝ ██║ ███████║██║███████║
123
+ ██╔═══╝ ╚██╔╝ ██║ ██╔══██║██║██╔══██║
124
+ ██║ ██║ ██║ ██║ ██║██║██║ ██║
125
+ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═╝"""
126
+
127
+
128
+ LOGO_STOPS = ((34, 211, 238), (167, 139, 250), (244, 114, 182)) # cyan → violet → pink
129
+ LOGO_256 = (51, 45, 39, 105, 141, 177) # same ramp, 256-color
130
+
131
+
132
+ def _logo_rgb(t):
133
+ """Interpolate the gradient stops at t in [0, 1]."""
134
+ seg = t * (len(LOGO_STOPS) - 1)
135
+ i = min(int(seg), len(LOGO_STOPS) - 2)
136
+ f = seg - i
137
+ a, b = LOGO_STOPS[i], LOGO_STOPS[i + 1]
138
+ return tuple(round(a[k] + (b[k] - a[k]) * f) for k in range(3))
139
+
140
+
141
+ def banner(enabled, env=None):
142
+ """A hello on `check`, TTY only — agents piping output never see it.
143
+ Solid blocks get a diagonal color gradient; the box-drawing outline stays
144
+ dim for depth. Truecolor when the terminal advertises it (COLORTERM, or
145
+ WT_SESSION — Windows Terminal supports 24-bit but never says so),
146
+ a 256-color ramp everywhere else."""
147
+ if not enabled:
148
+ return ""
149
+ env = os.environ if env is None else env
150
+ truecolor = (env.get("COLORTERM", "").lower() in ("truecolor", "24bit")
151
+ or bool(env.get("WT_SESSION")))
152
+ lines = BLOCK_LOGO.splitlines()
153
+ out = []
154
+ for y, ln in enumerate(lines):
155
+ width = max(len(ln), 1)
156
+ chunk = []
157
+ for x, ch in enumerate(ln):
158
+ if ch == " ":
159
+ chunk.append(ch)
160
+ elif ch == "█":
161
+ if truecolor:
162
+ r, g, b = _logo_rgb((x / width + y / len(lines)) / 2)
163
+ chunk.append(f"\x1b[38;2;{r};{g};{b}m{ch}\x1b[0m")
164
+ else:
165
+ chunk.append(f"\x1b[38;5;{LOGO_256[min(y, len(LOGO_256) - 1)]}m{ch}\x1b[0m")
166
+ else:
167
+ chunk.append(f"\x1b[2m{ch}\x1b[0m") # outline: dim, for depth
168
+ out.append("".join(chunk))
169
+ tag = paint("judgment for your agent's Oracle connection", "dim", enabled)
170
+ return "\n" + "\n".join(out) + f"\n{tag}\n\n"
171
+
172
+
173
+ def paint_diff_line(ln, enabled):
174
+ if ln.startswith(("+++", "---")):
175
+ return paint(ln, "dim", enabled)
176
+ if ln.startswith("+"):
177
+ return paint(ln, "green", enabled)
178
+ if ln.startswith("-"):
179
+ return paint(ln, "red", enabled)
180
+ if ln.startswith("@@"):
181
+ return paint(ln, "cyan", enabled)
182
+ return ln
183
+
184
+
185
+ def invocation():
186
+ """How to invoke this tool, exactly as the user actually ran it. Every
187
+ printed command must be paste-able: a bare `pythia ...` is
188
+ CommandNotFound for anyone running from source."""
189
+ main_spec = getattr(sys.modules.get("__main__"), "__spec__", None)
190
+ if getattr(main_spec, "name", "") == "pythia":
191
+ # `python -m pythia` — argv[0] is the module's site-packages path,
192
+ # which nobody typed; the -m form is the paste-able one
193
+ return f"{pathlib.Path(sys.executable).stem} -m pythia"
194
+ prog = sys.argv[0] or "pythia"
195
+ if prog.lower().endswith(".py"):
196
+ # the interpreter actually running us: "python3" on most Linux/macOS,
197
+ # "python" on Windows — a hardcoded "python" would not paste on Ubuntu
198
+ return f"{pathlib.Path(sys.executable).stem} {prog}"
199
+ return pathlib.Path(prog).name
200
+
201
+
202
+ def forbid_write_flag(argv):
203
+ if "--write" in argv:
204
+ sys.exit(f"There is no --write flag. The write path is `{invocation()} "
205
+ "apply <file>` — snapshot, preview and verify included; "
206
+ "nothing else writes.")
207
+
208
+
209
+ def load_query(name):
210
+ """Read a statement from queries/. All SQL lives there so it can be
211
+ reviewed, tested and contributed to without reading Python."""
212
+ path = QUERY_DIR / name
213
+ if not path.is_file():
214
+ sys.exit(f"Missing query file: {path}")
215
+ return path.read_text(encoding="utf-8")
216
+
217
+
218
+ def query_binds(sql):
219
+ """Bind names a statement actually uses, ignoring comments and string
220
+ literals — a date format like 'hh24:mi:ss' is not two binds."""
221
+ sql = re.sub(r"--[^\n]*", " ", sql)
222
+ sql = re.sub(r"'(?:[^']|'')*'", " ", sql)
223
+ return set(re.findall(r"(?<![:\w]):([a-z_][a-z0-9_]*)", sql, re.I))
224
+
225
+
226
+ def find_config(cwd, env):
227
+ """Return (config dict, project root) or (None, None).
228
+
229
+ PYTHIA_CONFIG points straight at a JSON file; otherwise walk upward from
230
+ cwd for .pythia/connections.json. The project root is the directory
231
+ holding .pythia/ — path-based connection inference is anchored to it.
232
+ """
233
+ override = env.get("PYTHIA_CONFIG")
234
+ if override:
235
+ path = pathlib.Path(override)
236
+ if not path.is_file():
237
+ sys.exit(f"PYTHIA_CONFIG points to a missing file: {path}")
238
+ root = path.parent.parent if path.parent.name == CONFIG_DIR else path.parent
239
+ return _load_config(path), root
240
+ for d in [cwd, *cwd.parents]:
241
+ path = d / CONFIG_DIR / CONFIG_NAME
242
+ if path.is_file():
243
+ return _load_config(path), d
244
+ return None, None
245
+
246
+
247
+ def _load_config(path):
248
+ if os.name == "posix":
249
+ mode = path.stat().st_mode & 0o777
250
+ if mode & 0o077:
251
+ print(f"-- warning: {path} is readable by other users "
252
+ f"(mode {mode:o}); chmod 600 recommended", file=sys.stderr)
253
+ try:
254
+ return json.loads(path.read_text(encoding="utf-8"))
255
+ except ValueError as e:
256
+ sys.exit(f"Cannot parse {path}: {e}")
257
+
258
+
259
+ def resolve_connection(cfg, explicit, env, cwd, root):
260
+ """Pick a connection. Precedence: --conn, PYTHIA_CONNECTION, PYTHIA_* env
261
+ credentials, then the config file (single entry, the path segment directly
262
+ under the project root, or the entry named by the top-level "default").
263
+ Ambiguity is an error, never a guess."""
264
+ cfg = dict(cfg or {})
265
+ fallback = cfg.pop("default", None)
266
+ if fallback is not None and not isinstance(fallback, str):
267
+ example = next((k for k in cfg), "dev")
268
+ sys.exit(f'"default" must name a connection, for example '
269
+ f'"default": "{example}" — got {json.dumps(fallback)}.')
270
+
271
+ lookup = {}
272
+ for k, v in cfg.items():
273
+ if not isinstance(v, dict):
274
+ sys.exit(f"Connection {k!r} in {CONFIG_NAME} must be an object of "
275
+ f"settings, got {json.dumps(v)}.")
276
+ if k.upper() in lookup:
277
+ sys.exit(f"Connection names collide ignoring case in {CONFIG_NAME}: "
278
+ f"{lookup[k.upper()][0]!r} vs {k!r}")
279
+ lookup[k.upper()] = (k, v)
280
+ names = ", ".join(sorted(k for k, _ in lookup.values()))
281
+
282
+ if fallback and fallback.upper() not in lookup:
283
+ sys.exit(f'"default" names {fallback!r}, which is not a connection. '
284
+ f"Available: {names or 'none'}.")
285
+
286
+ wanted = explicit or env.get("PYTHIA_CONNECTION")
287
+ if wanted:
288
+ hit = lookup.get(wanted.upper())
289
+ if not hit:
290
+ sys.exit(f"Unknown connection {wanted!r}. Available: {names or 'none'}.")
291
+ return hit
292
+
293
+ if env.get("PYTHIA_USER") and env.get("PYTHIA_PASSWORD") and env.get("PYTHIA_DSN"):
294
+ c = {"user": env["PYTHIA_USER"], "password": env["PYTHIA_PASSWORD"],
295
+ "dsn": env["PYTHIA_DSN"]}
296
+ if env.get("PYTHIA_SCHEMA"):
297
+ c["schema"] = env["PYTHIA_SCHEMA"]
298
+ return "env", c
299
+
300
+ if not lookup:
301
+ sys.exit(f"No connection configured. Create {CONFIG_DIR}/{CONFIG_NAME} "
302
+ "(see examples/connections.example.json) or set "
303
+ "PYTHIA_USER / PYTHIA_PASSWORD / PYTHIA_DSN.")
304
+
305
+ if len(lookup) == 1:
306
+ return next(iter(lookup.values()))
307
+
308
+ if root is not None:
309
+ try:
310
+ seg = cwd.resolve().relative_to(pathlib.Path(root).resolve()).parts
311
+ except ValueError:
312
+ seg = ()
313
+ if seg:
314
+ hit = lookup.get(seg[0].upper())
315
+ if hit:
316
+ return hit
317
+
318
+ # Nothing in the path to go on. The top-level "default" is a choice the user
319
+ # wrote down rather than a guess, so it is safe to fall back to — and the
320
+ # connection actually used is always echoed on stderr.
321
+ if fallback:
322
+ return lookup[fallback.upper()]
323
+
324
+ misplaced = sorted(n for n, v in lookup.values() if "default" in v)
325
+ if misplaced:
326
+ sys.exit(f'Connection {misplaced[0]!r} has a "default" key inside it. '
327
+ f"The default belongs at the top level of {CONFIG_NAME}, "
328
+ f'alongside the connections: "default": "{misplaced[0]}"')
329
+
330
+ sys.exit(f"Cannot infer a connection from {cwd}. Available: {names}.\n"
331
+ "Use --conn NAME, set PYTHIA_CONNECTION, or name one at the top "
332
+ f'level of {CONFIG_NAME}: "default": "<name>"')
333
+
334
+
335
+ def clip(rows, limit, offset=0):
336
+ """Window a result; truncated=True whenever anything beyond the window exists."""
337
+ end = offset + limit if limit else len(rows)
338
+ return rows[offset:end], len(rows) > end
339
+
340
+
341
+ def filter_units(rows, body, spec):
342
+ """rows: (type, line, text). --body keeps *BODY units, --spec the rest."""
343
+ if body == spec:
344
+ return rows
345
+ return [r for r in rows if r[0].upper().endswith("BODY") == body]
346
+
347
+
348
+ def format_source(rows, raw):
349
+ """Number source with Oracle's own ALL_SOURCE line values so they map 1:1
350
+ to compiler `line N` positions; numbering restarts per unit (spec/body)."""
351
+ if raw:
352
+ return "".join(str(t).rstrip("\n") + "\n" for _, _, t in rows)
353
+ multi = len({u for u, _, _ in rows}) > 1
354
+ out, seen = [], None
355
+ for unit, line, text in rows:
356
+ if multi and unit != seen:
357
+ if seen is not None:
358
+ out.append("\n")
359
+ out.append(f"-- {unit}\n")
360
+ seen = unit
361
+ s = str(text).rstrip("\n")
362
+ out.append(f"{line:>6} {s}\n")
363
+ return "".join(out)
364
+
365
+
366
+ def format_errors(rows):
367
+ """rows: (name, type, sequence, line, position, attribute, text).
368
+ One header per object, then 'line:col SEVERITY message' per error."""
369
+ out, seen = [], None
370
+ for name, otype, _seq, line, pos, attr, text in rows:
371
+ if (name, otype) != seen:
372
+ out.append(f"{name} ({otype})\n")
373
+ seen = (name, otype)
374
+ out.append(f" {line}:{pos} {attr} {str(text).strip()}\n")
375
+ return "".join(out)
376
+
377
+
378
+ def render_tree(rows, root):
379
+ """rows: (lvl, owner, name, type, ...) in hierarchical order; trailing
380
+ columns are ignored so deps and impact share one renderer. The same object
381
+ can appear on more than one path — that is the graph, not a bug."""
382
+ out = [f"{root}\n"]
383
+ for row in rows:
384
+ lvl, owner, name, otype = row[:4]
385
+ out.append(f"{' ' * int(lvl)}{owner}.{name} ({otype})\n")
386
+ return "".join(out)
387
+
388
+
389
+ def impact_summary(rows):
390
+ """rows: (lvl, owner, name, type, status, dependency_type). Counts distinct
391
+ objects — one object reached by three paths is still one object that has to
392
+ recompile."""
393
+ seen = {}
394
+ for row in rows:
395
+ _lvl, owner, name, otype, status = row[:5]
396
+ seen[(owner, name, otype)] = status
397
+ valid = sum(1 for s in seen.values() if s == "VALID")
398
+ return f"-- impact: {len(seen)} dependent objects, {valid} currently VALID"
399
+
400
+
401
+ def rank_similar(target, candidates):
402
+ """candidates: (object_name, object_type, status, last_ddl). A codebase's
403
+ naming convention lives in the underscore-separated tokens of its names, so
404
+ shared tokens are the cheapest honest signal of 'written the same way'.
405
+ Returns each match with the shared tokens appended, best first."""
406
+ target = str(target).upper()
407
+ want = {t for t in target.split("_") if t}
408
+ scored = []
409
+ for row in candidates:
410
+ name = str(row[0]).upper()
411
+ if name == target:
412
+ continue
413
+ shared = want & {t for t in name.split("_") if t}
414
+ if shared:
415
+ scored.append((len(shared), name, (*row, " ".join(sorted(shared)))))
416
+ scored.sort(key=lambda x: (-x[0], x[1]))
417
+ return [row for _score, _name, row in scored]
418
+
419
+
420
+ def plscope_message(name, has_any_data):
421
+ """What to say when an identifier lookup returns nothing. Never run the
422
+ ALTER: recompiling a shared schema is the team's call, not the tool's."""
423
+ if has_any_data:
424
+ return (f"No PL/Scope entry for {name!r}. Either it does not exist, or the "
425
+ "objects using it were compiled before PL/Scope was enabled.")
426
+ return ("PL/Scope has no data for this schema, so this question cannot be "
427
+ "answered exactly yet.\n"
428
+ "To enable it, and then recompile the objects you care about:\n"
429
+ " ALTER SESSION SET plscope_settings='IDENTIFIERS:ALL, STATEMENTS:ALL';\n"
430
+ " ALTER PROCEDURE <name> COMPILE;\n"
431
+ "Recompiling on a shared schema affects everyone using it — agree it with "
432
+ "the team first. pythia will not run these for you.\n"
433
+ f"Until then, the approximate answer is: {invocation()} grep \"<text>\"")
434
+
435
+
436
+ # --- write layer: pure decision functions (tests/test_phase3.py) -------------
437
+
438
+ GROUPS = ("plsql_source", "data_dml", "structural", "grants", "session")
439
+
440
+ PLSQL_SOURCE_RE = re.compile(
441
+ r"^create\s+(?:or\s+replace\s+)?(?:(?:no)?editionable\s+|(?:no)?force\s+)*"
442
+ r"(procedure|function|package\s+body|package|trigger|view|type\s+body|type)\b",
443
+ re.I | re.S)
444
+
445
+
446
+ def skip_leading_noise(sql):
447
+ """Drop leading whitespace and comments so classification sees the first
448
+ keyword. Only leading ones: stripping comments globally would corrupt
449
+ string literals like '-- not a comment'."""
450
+ while True:
451
+ sql = sql.lstrip()
452
+ if sql.startswith("--"):
453
+ nl = sql.find("\n")
454
+ sql = "" if nl < 0 else sql[nl + 1:]
455
+ elif sql.startswith("/*"):
456
+ end = sql.find("*/")
457
+ if end < 0:
458
+ return ""
459
+ sql = sql[end + 2:]
460
+ else:
461
+ return sql
462
+
463
+
464
+ def classify(sql):
465
+ """Which policy group a statement belongs to, or "anonymous" for a bare
466
+ BEGIN/DECLARE block (it can EXECUTE IMMEDIATE anything, so giving it a
467
+ group would be self-deception), or None for anything unrecognized.
468
+ Unrecognized means refused: a classifier that guesses generously is a
469
+ classifier that lets deny be bypassed."""
470
+ s = skip_leading_noise(sql)
471
+ if re.match(r"^alter\s+session\b", s, re.I):
472
+ return "session" # before the generic ALTER below
473
+ if PLSQL_SOURCE_RE.match(s):
474
+ return "plsql_source"
475
+ if re.match(r"^(insert|update|delete|merge)\b", s, re.I):
476
+ return "data_dml"
477
+ if re.match(r"^(grant|revoke)\b", s, re.I):
478
+ return "grants"
479
+ if re.match(r"^(alter|drop|truncate|rename|create)\b", s, re.I):
480
+ return "structural"
481
+ if re.match(r"^(begin|declare)\b", s, re.I):
482
+ return "anonymous"
483
+ return None
484
+
485
+
486
+ def parse_object(sql):
487
+ """(type, name, schema|None) from a plsql_source statement. The parsed
488
+ identity drives the snapshot — getting it wrong would snapshot the wrong
489
+ object and silently destroy the only undo, hence the strict match."""
490
+ s = skip_leading_noise(sql)
491
+ m = PLSQL_SOURCE_RE.match(s)
492
+ if not m:
493
+ sys.exit("Cannot parse the object type from this CREATE statement.")
494
+ otype = " ".join(m.group(1).upper().split())
495
+ rest = s[m.end():]
496
+ ident = r'(?:"([^"]+)"|([A-Za-z][\w$#]*))'
497
+ m2 = re.match(r"\s*" + ident + r"(?:\s*\.\s*" + ident + r")?", rest)
498
+ if not m2:
499
+ sys.exit(f"Cannot parse the {otype.lower()} name from this statement.")
500
+ q1, p1, q2, p2 = m2.groups()
501
+ first = q1 if q1 is not None else p1.upper()
502
+ if q2 is None and p2 is None:
503
+ return otype, first, None
504
+ second = q2 if q2 is not None else p2.upper()
505
+ return otype, second, first
506
+
507
+
508
+ def prepare_statement(sql, group):
509
+ """What actually gets executed. A trailing line holding only / is a
510
+ SQL*Plus directive, not SQL. The trailing ; belongs to a PL/SQL block but
511
+ must go for everything else — specified per group because getting it
512
+ backwards produces baffling compile errors. Content after the terminator
513
+ means two statements in one file: refused, one object per file."""
514
+ lines = sql.replace("\r\n", "\n").split("\n")
515
+ while lines and not lines[-1].strip():
516
+ lines.pop()
517
+ if lines and lines[-1].strip() == "/":
518
+ lines.pop()
519
+ if any(ln.strip() == "/" for ln in lines):
520
+ sys.exit("The file contains more than one statement (a / separator "
521
+ "remains mid-file). pythia apply takes exactly one statement "
522
+ "per file — split it.")
523
+ text = "\n".join(lines).rstrip()
524
+ if group != "plsql_source":
525
+ body = text.rstrip(";").rstrip()
526
+ if ";" in body:
527
+ sys.exit("The file contains more than one statement. pythia apply "
528
+ "takes exactly one statement per file — split it.")
529
+ return body
530
+ return text
531
+
532
+
533
+ POLICY_DEFAULTS = {"plsql_source": "confirm", "data_dml": "deny",
534
+ "structural": "deny", "grants": "deny", "session": "allow"}
535
+
536
+ ROLLBACK_TABLE = """\
537
+ Is rollback real? (this table also appears in README.md and plsql-apply)
538
+ plsql_source Yes - completely. Source is recoverable from ALL_SOURCE.
539
+ data_dml No. After commit only Flashback Query remains, within undo retention.
540
+ structural Almost never. DROP COLUMN is permanent; a dropped table may be in the Recycle Bin.
541
+ grants Yes, but by hand.
542
+ session Not needed."""
543
+
544
+
545
+ def apply_token(object_type, name, file_text, db_source):
546
+ """6 hex chars binding the write to what was previewed: file or database
547
+ changing since the preview yields a different token, so what gets applied
548
+ is exactly what was seen. A consistency check, not a secret — it is
549
+ compared against one recomputed value, so length would cost usability and
550
+ buy nothing."""
551
+ import hashlib
552
+ payload = "\n".join([object_type, name,
553
+ file_text.replace("\r\n", "\n"),
554
+ db_source.replace("\r\n", "\n")])
555
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:6]
556
+
557
+
558
+ def effective_policy(raw):
559
+ """Merge policy.json over the defaults, remembering where each value came
560
+ from. Config is a trust boundary: unknown groups and values are refused
561
+ with the accepted spelling, not ignored."""
562
+ eff = {g: (v, "default") for g, v in POLICY_DEFAULTS.items()}
563
+ for k, v in (raw or {}).items():
564
+ if k not in POLICY_DEFAULTS:
565
+ sys.exit(f"Unknown policy group {k!r} in policy.json. "
566
+ f"Groups: {', '.join(POLICY_DEFAULTS)}.")
567
+ if v not in ("allow", "confirm", "deny"):
568
+ sys.exit(f"Policy {k!r} must be allow, confirm or deny — got {v!r}.")
569
+ eff[k] = (v, "policy.json")
570
+ return eff
571
+
572
+
573
+ def policy_path(root):
574
+ return pathlib.Path(root) / CONFIG_DIR / "policy.json"
575
+
576
+
577
+ def load_policy(root):
578
+ path = policy_path(root)
579
+ if not path.is_file():
580
+ return effective_policy(None)
581
+ try:
582
+ return effective_policy(json.loads(path.read_text(encoding="utf-8")))
583
+ except ValueError as e:
584
+ sys.exit(f"Cannot parse {path}: {e}")
585
+
586
+
587
+ def journal_root(root):
588
+ return pathlib.Path(root) / CONFIG_DIR / "journal"
589
+
590
+
591
+ def render_restore(obj_type, name, before_text):
592
+ """The statement that puts things back. For an object that did not exist,
593
+ undo means DROP — a genuinely different promise than restoring source, so
594
+ the caller records created=True and the report says it plainly."""
595
+ if before_text.strip():
596
+ return "CREATE OR REPLACE " + before_text.rstrip() + "\n"
597
+ return f"DROP {obj_type} {name}\n"
598
+
599
+
600
+ def write_journal_entry(root, obj_type, name, before, after, meta, now=None):
601
+ """Snapshot on disk before anything touches the database. Nothing can turn
602
+ this off: DDL commits itself, so this directory is the only undo there is."""
603
+ import datetime
604
+ now = now or datetime.datetime.now()
605
+ eid = (now.strftime("%Y-%m-%dT%H-%M-%S")
606
+ + f"_{name}_{obj_type.replace(' ', '-')}")
607
+ d = journal_root(root) / eid
608
+ n = 1
609
+ while d.exists(): # apply + restore can land in the same second;
610
+ n += 1 # overwriting the previous entry would destroy
611
+ d = journal_root(root) / f"{eid}-{n}" # the only undo there is
612
+ eid = d.name
613
+ d.mkdir(parents=True)
614
+ (d / "before.sql").write_text(before, encoding="utf-8")
615
+ (d / "after.sql").write_text(after, encoding="utf-8")
616
+ (d / "restore.sql").write_text(render_restore(obj_type, name, before),
617
+ encoding="utf-8")
618
+ full = {"object": name, "type": obj_type, "created": not before.strip(),
619
+ "entry": eid, **meta}
620
+ (d / "meta.json").write_text(json.dumps(full, indent=2, default=str) + "\n",
621
+ encoding="utf-8")
622
+ return eid
623
+
624
+
625
+ def read_journal_entry(root, entry_id):
626
+ d = journal_root(root) / entry_id
627
+ if not d.is_dir():
628
+ available = ", ".join(list_journal_entries(root)[:5]) or "none"
629
+ sys.exit(f"No journal entry {entry_id!r}. Recent: {available}. "
630
+ f"Use: {invocation()} journal list")
631
+ return {"before": (d / "before.sql").read_text(encoding="utf-8"),
632
+ "after": (d / "after.sql").read_text(encoding="utf-8"),
633
+ "restore": (d / "restore.sql").read_text(encoding="utf-8"),
634
+ "meta": json.loads((d / "meta.json").read_text(encoding="utf-8"))}
635
+
636
+
637
+ def list_journal_entries(root):
638
+ d = journal_root(root)
639
+ if not d.is_dir():
640
+ return []
641
+ return sorted((p.name for p in d.iterdir() if p.is_dir()), reverse=True)
642
+
643
+
644
+ def newly_invalid(before_rows, after_rows):
645
+ """(name, type) pairs INVALID now that were not before. The whole point of
646
+ step 5: the agent must not report success while this list is non-empty."""
647
+ return sorted(set(map(tuple, after_rows)) - set(map(tuple, before_rows)))
648
+
649
+
650
+ def render_diff(before, after):
651
+ import difflib
652
+ lines = list(difflib.unified_diff(before.splitlines(), after.splitlines(),
653
+ lineterm=""))
654
+ changed = sum(1 for ln in lines
655
+ if ln[:1] in "+-" and not ln.startswith(("+++", "---")))
656
+ return "\n".join(lines), changed
657
+
658
+
659
+ def json_envelope(command, connection, schema, cols, rows, truncated, **extra):
660
+ payload = {"ok": True, "command": command, "connection": connection,
661
+ "schema": schema, "rows": [dict(zip(cols, r)) for r in rows],
662
+ "truncated": bool(truncated), **extra}
663
+ return json.dumps(payload, default=str, ensure_ascii=False)
664
+
665
+
666
+ # --- database access ---------------------------------------------------------
667
+
668
+ def connect_failure_message(exc, conn_name):
669
+ """A failure to connect should say which entry failed and what to check —
670
+ a driver stack trace tells the reader nothing actionable."""
671
+ return (f"Could not connect using connection {conn_name!r}: {exc}\n"
672
+ "Check host/port/service_name, the credentials, and that the database "
673
+ "is reachable from here. Use --conn NAME to try a different entry.")
674
+
675
+
676
+ def open_pool(c):
677
+ import oracledb
678
+ if not c.get("user") or not c.get("password"):
679
+ sys.exit(f"Connection is missing 'user'/'password' — fill in {CONFIG_NAME}.")
680
+ if not (c.get("dsn") or c.get("host")):
681
+ sys.exit(f"Connection needs 'dsn' or 'host' — fill in {CONFIG_NAME}.")
682
+ dsn = c.get("dsn") or oracledb.makedsn(
683
+ c["host"], int(c.get("port", 1521)),
684
+ service_name=c.get("service_name") or None, sid=c.get("sid") or None)
685
+ # ponytail: a pool only helps within one invocation; cross-invocation reuse
686
+ # needs the MCP backend (persistent session) or a future resident mode.
687
+ return oracledb.create_pool(user=c["user"], password=c["password"], dsn=dsn,
688
+ min=1, max=2)
689
+
690
+
691
+ def session_should_be_readonly(command, action=""):
692
+ """Read commands get SET TRANSACTION READ ONLY as a second line of
693
+ defence. The write path must not: DML under a read-only transaction dies
694
+ with ORA-01456, and its defences are the classifier, the policy gate, the
695
+ token and the snapshot — not a transaction attribute."""
696
+ if command == "apply":
697
+ return False
698
+ if command == "journal" and action == "restore":
699
+ return False
700
+ return True
701
+
702
+
703
+ def acquire(pool, readonly=True):
704
+ conn = pool.acquire()
705
+ if readonly:
706
+ with conn.cursor() as cur: # defence 2: Oracle itself rejects
707
+ cur.execute("set transaction read only") # DML/DDL in this transaction.
708
+ return conn # NOT inherited by autonomous
709
+ # transactions inside called PL/SQL.
710
+
711
+
712
+ def cell(v):
713
+ if v is None:
714
+ return ""
715
+ if hasattr(v, "read"): # LOB
716
+ v = v.read()
717
+ return str(v).replace("\t", " ")
718
+
719
+
720
+ def run_query(conn, sql, binds=None):
721
+ with conn.cursor() as cur:
722
+ cur.execute(sql, binds or {})
723
+ return [d[0] for d in cur.description], cur.fetchall()
724
+
725
+
726
+ def fetch_n(limit):
727
+ # bind one row beyond the limit so truncation is detected, never guessed
728
+ return limit + 1 if limit else INT_MAX
729
+
730
+
731
+ # --- output ------------------------------------------------------------------
732
+
733
+ def emit_table(ns, cols, rows, truncated):
734
+ if ns.json:
735
+ print(json_envelope(ns.command, ns.conn_name, ns.schema, cols, rows, truncated))
736
+ return
737
+ print("\t".join(cols))
738
+ for r in rows:
739
+ print("\t".join(cell(v) for v in r))
740
+ if truncated:
741
+ print(f"-- truncated at {len(rows)} rows (raise --limit, or --limit 0 for no cap)")
742
+ print(f"-- {len(rows)} rows", file=sys.stderr)
743
+
744
+
745
+ def emit_source(ns, rows, truncated, total):
746
+ if ns.json:
747
+ clean = [(u, ln, str(t).rstrip("\n")) for u, ln, t in rows]
748
+ print(json_envelope(ns.command, ns.conn_name, ns.schema,
749
+ ("TYPE", "LINE", "TEXT"), clean, truncated, total_lines=total))
750
+ return
751
+ sys.stdout.write(format_source(rows, ns.raw))
752
+ if truncated:
753
+ print(f"-- truncated: showing {len(rows)} of {total} source lines "
754
+ f"(use --offset {ns.offset + len(rows)} to continue, or --max-lines 0 for all)")
755
+
756
+
757
+ # --- subcommands (SQL kept verbatim from the field-proven v1 tool) -----------
758
+
759
+ def cmd_check(conn, schema, ns):
760
+ en = getattr(ns, "color", False)
761
+ if en:
762
+ sys.stdout.write(banner(en))
763
+ cols, rows = run_query(conn, """
764
+ select user connected_as, :s owner_used,
765
+ sys_context('userenv','db_name') db,
766
+ (select count(*) from all_objects where owner = :s) objects,
767
+ (select count(*) from all_views where owner = :s) views,
768
+ (select count(*) from all_types where owner = :s) types,
769
+ (select count(*) from all_indexes where owner = :s) indexes,
770
+ (select count(*) from all_triggers where owner = :s) triggers
771
+ from dual""", {"s": schema})
772
+ emit_table(ns, cols, rows, False)
773
+ warn = privilege_warning(conn, schema, ns.conn_user)
774
+ if warn:
775
+ print("\n" + paint(warn, "yellow", color_enabled(sys.stderr)),
776
+ file=sys.stderr)
777
+
778
+
779
+ def cmd_ls(conn, schema, ns):
780
+ cols, rows = run_query(conn, """
781
+ select object_name, object_type, status, to_char(last_ddl_time,'yyyy-mm-dd') last_ddl
782
+ from all_objects where owner = :s and object_name like upper(:p)
783
+ order by object_type, object_name fetch first :n rows only""",
784
+ {"s": schema, "p": ns.pattern, "n": fetch_n(ns.limit)})
785
+ rows, truncated = clip(rows, ns.limit)
786
+ emit_table(ns, cols, rows, truncated)
787
+
788
+
789
+ def cmd_src(conn, schema, ns):
790
+ _, rows = run_query(conn, load_query("source.sql"), {"s": schema, "n": ns.name})
791
+ rows = [(t, ln, cell(x)) for t, ln, x in rows]
792
+ rows = filter_units(rows, ns.body, ns.spec)
793
+ if not rows:
794
+ sys.exit(f"No source found for {ns.name!r} in schema {schema}.")
795
+ total = len(rows)
796
+ shown, truncated = clip(rows, ns.max_lines, ns.offset)
797
+ emit_source(ns, shown, truncated, total)
798
+
799
+
800
+ def cmd_args(conn, schema, ns):
801
+ cols, rows = run_query(conn, """
802
+ select position, argument_name, in_out, data_type,
803
+ type_name, type_subname, data_level, defaulted
804
+ from all_arguments
805
+ where owner = :s and object_name = upper(:n) and argument_name is not null
806
+ order by position""", {"s": schema, "n": ns.name})
807
+ emit_table(ns, cols, rows, False)
808
+
809
+
810
+ def cmd_ddl(conn, schema, ns):
811
+ with conn.cursor() as cur: # strip STORAGE/SEGMENT noise before it burns context
812
+ cur.execute("begin dbms_metadata.set_transform_param("
813
+ "dbms_metadata.session_transform,'STORAGE',false); "
814
+ "dbms_metadata.set_transform_param("
815
+ "dbms_metadata.session_transform,'SEGMENT_ATTRIBUTES',false); end;")
816
+ _, rows = run_query(conn, "select dbms_metadata.get_ddl(upper(:t), upper(:n), :s) from dual",
817
+ {"t": ns.type, "n": ns.name, "s": schema})
818
+ lines = cell(rows[0][0]).split("\n") if rows else []
819
+ shown, truncated = clip(lines, ns.max_lines, ns.offset)
820
+ if ns.json:
821
+ print(json_envelope(ns.command, ns.conn_name, ns.schema, ("DDL",),
822
+ [("\n".join(shown),)], truncated, total_lines=len(lines)))
823
+ return
824
+ print("\n".join(shown))
825
+ if truncated:
826
+ print(f"-- truncated: showing {len(shown)} of {len(lines)} lines "
827
+ f"(use --offset {ns.offset + len(shown)} to continue, or --max-lines 0 for all)")
828
+
829
+
830
+ def cmd_cols(conn, schema, ns):
831
+ cols, rows = run_query(conn, """
832
+ select column_id, column_name, data_type, data_length, data_precision,
833
+ data_scale, nullable, char_used, data_default
834
+ from all_tab_columns where owner = :s and table_name = upper(:n)
835
+ order by column_id""", {"s": schema, "n": ns.name})
836
+ emit_table(ns, cols, rows, False)
837
+
838
+
839
+ def cmd_grep(conn, schema, ns):
840
+ cols, rows = run_query(conn, """
841
+ select name, type, line, trim(text) text from all_source
842
+ where owner = :s and upper(text) like upper('%' || :p || '%')
843
+ order by name, line fetch first :n rows only""",
844
+ {"s": schema, "p": ns.pattern, "n": fetch_n(ns.limit)})
845
+ rows, truncated = clip(rows, ns.limit)
846
+ emit_table(ns, cols, rows, truncated)
847
+
848
+
849
+ def cmd_sql(conn, schema, ns):
850
+ stmt = " ".join(ns.statement).strip().rstrip(";")
851
+ if not is_readonly_sql(stmt):
852
+ sys.exit("Only SELECT/WITH statements are allowed; pythia is read-only.")
853
+ cols, rows = run_query(conn, stmt)
854
+ rows, truncated = clip(rows, ns.limit)
855
+ if ns.raw and not ns.json:
856
+ for r in rows:
857
+ sys.stdout.write(cell(r[0]).rstrip("\n") + "\n")
858
+ if truncated:
859
+ print(f"-- truncated at {len(rows)} rows (raise --limit, or --limit 0 for no cap)")
860
+ return
861
+ emit_table(ns, cols, rows, truncated)
862
+
863
+
864
+ def cmd_invalid(conn, schema, ns):
865
+ cols, rows = run_query(conn, load_query("invalid-objects.sql"), {"s": schema})
866
+ rows, truncated = clip(rows, ns.limit)
867
+ emit_table(ns, cols, rows, truncated)
868
+
869
+
870
+ def cmd_errors(conn, schema, ns):
871
+ cols, rows = run_query(conn, load_query("compile-errors.sql"),
872
+ {"s": schema, "n": ns.name})
873
+ rows, truncated = clip(rows, ns.limit)
874
+ if ns.json:
875
+ print(json_envelope(ns.command, ns.conn_name, ns.schema, cols, rows, truncated))
876
+ return
877
+ if not rows:
878
+ target = ns.name or f"schema {schema}"
879
+ print(f"-- no compilation errors for {target}")
880
+ return
881
+ sys.stdout.write(format_errors(rows))
882
+ if truncated:
883
+ print(f"-- truncated at {len(rows)} rows (raise --limit, or --limit 0 for no cap)")
884
+
885
+
886
+ def cmd_deps(conn, schema, ns):
887
+ cols, rows = run_query(conn, load_query("dependencies.sql"),
888
+ {"s": schema, "n": ns.name, "depth": ns.depth,
889
+ "with_sys": 1 if ns.with_sys else 0})
890
+ rows, truncated = clip(rows, ns.limit)
891
+ if ns.json:
892
+ print(json_envelope(ns.command, ns.conn_name, ns.schema, cols, rows, truncated))
893
+ return
894
+ if not rows:
895
+ print(f"-- {ns.name.upper()} depends on nothing (or does not exist in {schema})")
896
+ return
897
+ sys.stdout.write(render_tree(rows, f"{schema}.{ns.name.upper()}"))
898
+ if truncated:
899
+ print(f"-- truncated at {len(rows)} rows (raise --limit, or --limit 0 for no cap)")
900
+
901
+
902
+ def cmd_impact(conn, schema, ns):
903
+ cols, rows = run_query(conn, load_query("impact.sql"),
904
+ {"s": schema, "n": ns.name, "depth": ns.depth})
905
+ shown, truncated = clip(rows, ns.limit)
906
+ if ns.json:
907
+ print(json_envelope(ns.command, ns.conn_name, ns.schema, cols, shown, truncated,
908
+ summary=impact_summary(rows)))
909
+ return
910
+ if not rows:
911
+ print(f"-- nothing depends on {ns.name.upper()} "
912
+ f"(within {schema}, depth {ns.depth})")
913
+ return
914
+ sys.stdout.write(render_tree(shown, f"{schema}.{ns.name.upper()}"))
915
+ if truncated:
916
+ print(f"-- truncated at {len(shown)} rows (raise --limit, or --limit 0 for no cap)")
917
+ print(impact_summary(rows))
918
+
919
+
920
+ def cmd_similar(conn, schema, ns):
921
+ cols, rows = run_query(conn, load_query("similar-candidates.sql"), {"s": schema})
922
+ ranked = rank_similar(ns.name, rows)
923
+ shown, truncated = clip(ranked, ns.limit)
924
+ if not shown and not ns.json:
925
+ print(f"-- nothing in {schema} shares a name token with {ns.name.upper()}")
926
+ return
927
+ emit_table(ns, [*cols, "MATCHED_TOKENS"], shown, truncated)
928
+
929
+
930
+ def cmd_plscope(conn, schema, ns):
931
+ cols, rows = run_query(conn, load_query("plscope-usages.sql"),
932
+ {"s": schema, "n": ns.name})
933
+ if not rows:
934
+ _, probe = run_query(conn, load_query("plscope-enabled.sql"), {"s": schema})
935
+ sys.exit(plscope_message(ns.name, bool(probe)))
936
+ stmt_cols, stmt_rows = (), []
937
+ if any(str(r[3]).upper() == "TABLE" for r in rows): # TYPE column
938
+ stmt_cols, stmt_rows = run_query(conn, load_query("plscope-statements.sql"),
939
+ {"s": schema, "n": ns.name})
940
+ shown, truncated = clip(rows, ns.limit)
941
+ if ns.json:
942
+ print(json_envelope(ns.command, ns.conn_name, ns.schema, cols, shown, truncated,
943
+ statements=[dict(zip(stmt_cols, r)) for r in stmt_rows]))
944
+ return
945
+ emit_table(ns, cols, shown, truncated)
946
+ if stmt_rows:
947
+ print("\n-- SQL statements touching this table")
948
+ emit_table(ns, stmt_cols, *clip(stmt_rows, ns.limit))
949
+
950
+
951
+ def load_conventions(root):
952
+ """Project house style from .pythia/conventions.json — the machine half of
953
+ the customization surface (the prose half is conventions.md, for agents).
954
+ Config is a trust boundary: unknown keys and broken regexes are refused
955
+ with the fix, not skipped."""
956
+ path = pathlib.Path(root) / CONFIG_DIR / "conventions.json"
957
+ if not path.is_file():
958
+ return None
959
+ try:
960
+ conv = json.loads(path.read_text(encoding="utf-8"))
961
+ except ValueError as e:
962
+ sys.exit(f"Cannot parse {path}: {e}")
963
+ unknown = set(conv) - {"naming"}
964
+ if unknown:
965
+ sys.exit(f"Unknown key {sorted(unknown)[0]!r} in {path} — "
966
+ "supported: \"naming\".")
967
+ for otype, pattern in conv.get("naming", {}).items():
968
+ try:
969
+ re.compile(pattern)
970
+ except re.error as e:
971
+ sys.exit(f"Bad regex for {otype!r} in {path}: {e}")
972
+ return conv
973
+
974
+
975
+ def naming_violation(otype, name, conv):
976
+ """The warning line for a name outside the project's pattern, or None.
977
+ Style warns; only policy blocks."""
978
+ pattern = ((conv or {}).get("naming") or {}).get(otype)
979
+ if pattern and not re.match(pattern, name):
980
+ return (f"naming: {name} does not match this project's {otype} "
981
+ f"pattern {pattern} — see .pythia/conventions.md")
982
+ return None
983
+
984
+
985
+ # Which main-namespace occupants may legally coexist with each object type.
986
+ # Spec and body pair up; triggers have a namespace of their own (None = nothing
987
+ # in the main namespace can block them).
988
+ NAMESPACE_COEXIST = {
989
+ "PROCEDURE": {"PROCEDURE"},
990
+ "FUNCTION": {"FUNCTION"},
991
+ "PACKAGE": {"PACKAGE", "PACKAGE BODY"},
992
+ "PACKAGE BODY": {"PACKAGE", "PACKAGE BODY"},
993
+ "TYPE": {"TYPE", "TYPE BODY"},
994
+ "TYPE BODY": {"TYPE", "TYPE BODY"},
995
+ "VIEW": {"VIEW"},
996
+ "TRIGGER": None,
997
+ }
998
+
999
+
1000
+ def name_conflicts(otype, occupants):
1001
+ """Occupant types that CREATE OR REPLACE <otype> cannot replace — the
1002
+ ORA-00955 the preview must predict instead of discovering at apply time."""
1003
+ allowed = NAMESPACE_COEXIST.get(otype)
1004
+ if allowed is None:
1005
+ return []
1006
+ return sorted(set(occupants) - allowed)
1007
+
1008
+
1009
+ def privilege_warning(conn, schema, conn_user):
1010
+ """One line, only when true. The policy file is an application-side fence;
1011
+ Oracle grants are the only layer that cannot be walked around, so say when
1012
+ this session is running with more power than the task needs."""
1013
+ _, rows = run_query(conn, load_query("session-privileges.sql"))
1014
+ dangerous = [r[0] for r in rows]
1015
+ owner = bool(conn_user) and conn_user.upper() == schema
1016
+ if not owner and not dangerous:
1017
+ return None
1018
+ what = ("the schema owner" if owner else
1019
+ f"a user holding {', '.join(dangerous[:3])}"
1020
+ + ("…" if len(dangerous) > 3 else ""))
1021
+ return (f"! Connected as {what}: this account can do far more than apply "
1022
+ "PL/SQL.\n A least-privilege account is safer — pythia policy "
1023
+ "explains what is at stake.")
1024
+
1025
+
1026
+ def run_apply(conn, schema, ns, file_text, origin=None):
1027
+ """The six steps: SNAPSHOT, IMPACT, PREVIEW, APPLY, VERIFY, REPORT.
1028
+ Returns the exit code. Refusals raise SystemExit (exit 1)."""
1029
+ group = classify(file_text)
1030
+ if group == "anonymous":
1031
+ sys.exit("Anonymous PL/SQL blocks are refused: a BEGIN...END block can "
1032
+ "EXECUTE IMMEDIATE anything, so no policy group honestly fits.\n"
1033
+ "Wrap the logic in a named procedure and apply that instead.")
1034
+ if group is None:
1035
+ sys.exit("Cannot classify this statement, so it is refused rather than "
1036
+ "guessed at. pythia apply takes one CREATE OR REPLACE / DML / "
1037
+ "DDL / GRANT statement per file.")
1038
+ action = load_policy(ns.project_root)[group][0]
1039
+ if action == "deny":
1040
+ extra = ("no snapshot can undo it after commit"
1041
+ if group in ("data_dml", "structural", "grants")
1042
+ else "policy forbids it")
1043
+ sys.exit(f"Refused: {group} is set to deny — {extra}.\n"
1044
+ f"To allow it once you have weighed that: "
1045
+ f"{invocation()} policy set {group} confirm")
1046
+ stmt = prepare_statement(file_text, group)
1047
+
1048
+ if group == "session":
1049
+ # Nothing persistent changes; and the setting dies with this process's
1050
+ # connection, so say so instead of pretending it did something lasting.
1051
+ with conn.cursor() as cur:
1052
+ cur.execute(stmt)
1053
+ print("Session parameter set — note it lasts only for this "
1054
+ "invocation's connection, which is now over.")
1055
+ return 0
1056
+
1057
+ if group == "plsql_source":
1058
+ otype, name, file_schema = parse_object(file_text)
1059
+ if file_schema and file_schema.upper() != schema:
1060
+ sys.exit(f"The file names schema {file_schema.upper()!r} but this "
1061
+ f"connection targets {schema!r}. Refused: applying across "
1062
+ "schemas hides which database object actually changes.\n"
1063
+ f"Use --conn to select the {file_schema.upper()!r} connection.")
1064
+ _, occ_rows = run_query(conn, load_query("name-occupants.sql"),
1065
+ {"s": schema, "n": name})
1066
+ blockers = name_conflicts(otype, [r[0] for r in occ_rows])
1067
+ if blockers:
1068
+ sys.exit(f"{name} already exists as {', '.join(blockers)} in {schema} "
1069
+ "— CREATE OR REPLACE cannot change an object's type "
1070
+ "(ORA-00955 would follow).\n"
1071
+ "Changing the type means DROP first — structural, and the "
1072
+ "policy on that group applies:\n"
1073
+ f" {invocation()} policy set structural confirm "
1074
+ "(only if you accept losing the old object)")
1075
+ else:
1076
+ # confirm-mode DML/DDL/grants: no object identity, no snapshot — the
1077
+ # journal records the statement itself so at least *what ran* is kept.
1078
+ otype, name = group.upper(), "STATEMENT"
1079
+
1080
+ # 1. SNAPSHOT — before anything else, unconditionally.
1081
+ db_source = ""
1082
+ if group == "plsql_source":
1083
+ _, rows = run_query(conn, load_query("object-source.sql"),
1084
+ {"s": schema, "n": name, "t": otype})
1085
+ db_source = "".join(cell(r[0]) for r in rows)
1086
+ token = apply_token(otype, name, file_text, db_source)
1087
+ confirmed = bool(ns.yes) or ns.confirm == token
1088
+ if ns.confirm and ns.confirm != token:
1089
+ sys.exit("The confirmation token does not match: the file or the "
1090
+ "database object changed since that preview. Preview again:\n"
1091
+ f" {invocation()} apply {ns.file}")
1092
+
1093
+ _, inv_rows = run_query(conn, load_query("invalid-objects.sql"), {"s": schema})
1094
+ invalid_before = [(r[0], r[1]) for r in inv_rows]
1095
+ meta = {"schema": schema, "connection": ns.conn_name, "group": group,
1096
+ "token": token, "applied": False,
1097
+ "invalid_before": invalid_before, **(origin or {})}
1098
+ entry = write_journal_entry(ns.project_root, otype, name, db_source,
1099
+ file_text, meta)
1100
+ created = not db_source.strip()
1101
+
1102
+ # 2. IMPACT
1103
+ summary = ""
1104
+ if group == "plsql_source":
1105
+ _, dep_rows = run_query(conn, load_query("impact.sql"),
1106
+ {"s": schema, "n": name, "depth": ns.depth})
1107
+ summary = impact_summary(dep_rows)
1108
+
1109
+ # 3. PREVIEW — diff like against like: ALL_SOURCE never stores the
1110
+ # CREATE OR REPLACE header, so prepend it before comparing, or an
1111
+ # unchanged object would show a phantom two-line change forever.
1112
+ base = ("CREATE OR REPLACE " + db_source) if db_source.strip() else ""
1113
+ diff_text, changed = render_diff(base, stmt)
1114
+ warn = privilege_warning(conn, schema, ns.conn_user)
1115
+ style = (naming_violation(otype, name, load_conventions(ns.project_root))
1116
+ if group == "plsql_source" else None)
1117
+ if ns.json:
1118
+ print(json.dumps({"ok": True, "object": name, "type": otype,
1119
+ "created": created, "changed_lines": changed,
1120
+ "summary": summary, "warning": warn,
1121
+ "naming_warning": style, "token": token,
1122
+ "journal": entry, "will_apply": confirmed}))
1123
+ else:
1124
+ en = getattr(ns, "color", False)
1125
+ if created:
1126
+ head = "new object"
1127
+ elif changed == 0:
1128
+ head = "no source change (recompile)"
1129
+ else:
1130
+ head = f"{changed} lines changed"
1131
+ print(f"\n {paint(f'{name} ({otype})', 'bold', en)} in {schema} — {head}")
1132
+ if summary:
1133
+ print(f" {summary.lstrip('- ')}")
1134
+ if warn:
1135
+ print(f"\n {paint(warn, 'yellow', en)}")
1136
+ if style:
1137
+ print(f"\n {paint('! ' + style, 'yellow', en)}")
1138
+ if diff_text:
1139
+ print()
1140
+ for ln in diff_text.splitlines():
1141
+ print(f" {paint_diff_line(ln, en)}")
1142
+ print(paint(f"\n Snapshot saved: {journal_root(ns.project_root) / entry}",
1143
+ "dim", en))
1144
+ if not confirmed:
1145
+ print(f"\n To apply:\n "
1146
+ + paint(f"{invocation()} apply {ns.file} --confirm {token}",
1147
+ "cyan", en))
1148
+ if not confirmed:
1149
+ return 0
1150
+
1151
+ # 4. APPLY
1152
+ with conn.cursor() as cur:
1153
+ cur.execute(stmt)
1154
+
1155
+ # 5. VERIFY
1156
+ err_rows = []
1157
+ invalid_after = invalid_before
1158
+ if group == "plsql_source":
1159
+ _, err_rows = run_query(conn, load_query("compile-errors.sql"),
1160
+ {"s": schema, "n": name})
1161
+ _, inv_rows = run_query(conn, load_query("invalid-objects.sql"), {"s": schema})
1162
+ invalid_after = [(r[0], r[1]) for r in inv_rows]
1163
+ broke = newly_invalid(invalid_before, invalid_after)
1164
+ meta.update(applied=True, invalid_after=invalid_after, newly_invalid=broke,
1165
+ compile_errors=[list(r) for r in err_rows])
1166
+ # update the SAME entry in place — a second write_journal_entry would race
1167
+ # the timestamp and either collide or split one apply across two entries
1168
+ (journal_root(ns.project_root) / entry / "meta.json").write_text(
1169
+ json.dumps({"object": name, "type": otype, "created": created,
1170
+ "entry": entry, **meta}, indent=2, default=str) + "\n",
1171
+ encoding="utf-8")
1172
+
1173
+ # 6. REPORT
1174
+ own_errors = [r for r in err_rows if str(r[0]).upper() == name.upper()]
1175
+ ok = not own_errors and not broke
1176
+ if ns.json:
1177
+ print(json.dumps({"ok": ok, "applied": True, "object": name,
1178
+ "type": otype, "errors": [list(r) for r in own_errors],
1179
+ "newly_invalid": [list(x) for x in broke],
1180
+ "restore": f"{invocation()} journal restore {entry}",
1181
+ "exit": 0 if ok else 3}))
1182
+ else:
1183
+ en = getattr(ns, "color", False)
1184
+ if ok:
1185
+ print(paint(f"\n Applied {name} ({otype}).", "green", en))
1186
+ print(paint(" Compiled clean. No new INVALID objects.", "green", en))
1187
+ else:
1188
+ if own_errors:
1189
+ print(paint(f"\n Applied {name} ({otype}) — but it did not "
1190
+ "compile cleanly:", "red", en))
1191
+ for r in own_errors:
1192
+ print(paint(f" {r[3]}:{r[4]} {r[5]} {str(r[6]).strip()}",
1193
+ "red", en))
1194
+ else:
1195
+ print(paint(f"\n Applied {name} ({otype}) — it compiled, but "
1196
+ "broke other objects:", "red", en))
1197
+ if broke:
1198
+ print(f" {len(broke)} objects were VALID before and are INVALID now:")
1199
+ for n2, t2 in broke:
1200
+ print(paint(f" {n2} ({t2})", "red", en))
1201
+ undo = "dropping it (it did not exist before)" if created else None
1202
+ print(f"\n To undo{' — note: undo means ' + undo if undo else ''}:")
1203
+ print(" " + paint(f"{invocation()} journal restore {entry}", "cyan", en))
1204
+ return 0 if ok else 3
1205
+
1206
+
1207
+ def cmd_apply(conn, schema, ns):
1208
+ path = pathlib.Path(ns.file)
1209
+ if not path.is_file():
1210
+ sys.exit(f"No such file: {path}")
1211
+ code = run_apply(conn, schema, ns, path.read_text(encoding="utf-8"))
1212
+ if code:
1213
+ sys.exit(code)
1214
+
1215
+
1216
+ def run_restore(conn, schema, ns):
1217
+ """Restore is itself a write: feed the saved statement back through the
1218
+ same six steps. There is no second write path and no silent restore."""
1219
+ e = read_journal_entry(ns.project_root, ns.id)
1220
+ print(f"Restoring from {ns.id} — this is itself a write and goes through "
1221
+ "the full six steps.", file=sys.stderr)
1222
+ return run_apply(conn, schema, ns, e["restore"], origin={"restored_from": ns.id})
1223
+
1224
+
1225
+ def cmd_policy(conn, schema, ns):
1226
+ if ns.action == "set" and (not ns.group or not ns.value):
1227
+ sys.exit(f"Usage: {invocation()} policy set <group> <value>\n"
1228
+ f"Groups: {', '.join(sorted(POLICY_DEFAULTS))}; "
1229
+ "values: allow, confirm, deny.")
1230
+ if ns.action == "set":
1231
+ eff = load_policy(ns.project_root)
1232
+ eff[ns.group] = (ns.value, "policy.json") # validated by argparse choices
1233
+ path = policy_path(ns.project_root)
1234
+ path.parent.mkdir(parents=True, exist_ok=True)
1235
+ path.write_text(json.dumps({g: v for g, (v, _) in eff.items()}, indent=2)
1236
+ + "\n", encoding="utf-8")
1237
+ print(f"Wrote {path}")
1238
+ eff = load_policy(ns.project_root)
1239
+ if ns.json:
1240
+ print(json.dumps({g: {"value": v, "source": src}
1241
+ for g, (v, src) in eff.items()}))
1242
+ return
1243
+ print("Effective write policy:")
1244
+ for g, (v, src) in eff.items():
1245
+ print(f" {g:<13} {v:<8} ({src})")
1246
+ print()
1247
+ print(ROLLBACK_TABLE)
1248
+
1249
+
1250
+ def cmd_journal(conn, schema, ns):
1251
+ root = ns.project_root
1252
+ if ns.action == "list":
1253
+ ids = list_journal_entries(root)
1254
+ if ns.json:
1255
+ print(json.dumps(ids))
1256
+ return
1257
+ if not ids:
1258
+ print(f"-- journal is empty ({journal_root(root)})")
1259
+ return
1260
+ for eid in ids:
1261
+ meta = read_journal_entry(root, eid)["meta"]
1262
+ state = "applied" if meta.get("applied") else "preview"
1263
+ print(f"{eid} [{state}]")
1264
+ return
1265
+ if not ns.id:
1266
+ sys.exit(f"Usage: {invocation()} journal "
1267
+ "{list | show <id> | diff <id> | export <id> | restore <id>}")
1268
+ e = read_journal_entry(root, ns.id)
1269
+ if ns.action == "show":
1270
+ print(json.dumps(e["meta"], indent=2))
1271
+ elif ns.action == "diff":
1272
+ text, changed = render_diff(e["before"], e["after"])
1273
+ print(text or "-- no difference")
1274
+ print(f"-- {changed} lines changed", file=sys.stderr)
1275
+ elif ns.action == "export":
1276
+ out = pathlib.Path(f"{ns.id}_{ns.what}.sql")
1277
+ out.write_text(e[ns.what], encoding="utf-8")
1278
+ print(f"Wrote {out}")
1279
+ else:
1280
+ sys.exit("unreachable: restore is dispatched with a connection in main")
1281
+
1282
+
1283
+ def cmd_conventions(conn, schema, ns):
1284
+ conv = load_conventions(ns.project_root)
1285
+ if ns.json:
1286
+ print(json.dumps(conv or {}))
1287
+ return
1288
+ if not conv:
1289
+ print("No project conventions configured.")
1290
+ print(f"Create {pathlib.Path(ns.project_root) / CONFIG_DIR / 'conventions.json'} "
1291
+ "(see examples/conventions.example.json) and apply previews will "
1292
+ "warn when a new object's name drifts from your patterns.\n"
1293
+ "Put the prose rules in conventions.md next to it for your agents.")
1294
+ return
1295
+ print("Naming patterns — apply previews warn when a name drifts:")
1296
+ for otype, pattern in conv.get("naming", {}).items():
1297
+ print(f" {otype:<13} {pattern}")
1298
+ md = pathlib.Path(ns.project_root) / CONFIG_DIR / "conventions.md"
1299
+ if md.is_file():
1300
+ print(f"\nProse rules for agents: {md}")
1301
+
1302
+
1303
+ DEFAULT_SKILLS_SOURCE = "thaildhe172591/pythia"
1304
+
1305
+ # Kept byte-identical to examples/connections.example.json —
1306
+ # tests/test_install.py fails on any drift.
1307
+ CONNECTIONS_TEMPLATE = """{
1308
+ "default": "dev",
1309
+
1310
+ "dev": {
1311
+ "host": "",
1312
+ "port": 1521,
1313
+ "service_name": "",
1314
+ "sid": "",
1315
+ "dsn": "",
1316
+ "user": "",
1317
+ "password": "",
1318
+ "schema": ""
1319
+ },
1320
+ "staging": {
1321
+ "host": "",
1322
+ "port": 1521,
1323
+ "service_name": "",
1324
+ "sid": "",
1325
+ "dsn": "",
1326
+ "user": "",
1327
+ "password": "",
1328
+ "schema": ""
1329
+ }
1330
+ }
1331
+ """
1332
+
1333
+
1334
+ def scaffold_config(root):
1335
+ """Create .pythia/connections.json from the template. An existing file is
1336
+ never touched — it may hold real credentials."""
1337
+ path = pathlib.Path(root) / CONFIG_DIR / CONFIG_NAME
1338
+ if path.is_file():
1339
+ return path, False
1340
+ path.parent.mkdir(parents=True, exist_ok=True)
1341
+ path.write_text(CONNECTIONS_TEMPLATE, encoding="utf-8")
1342
+ return path, True
1343
+
1344
+
1345
+ def run_skills_add(source, interactive=False):
1346
+ """Install the skill pack via the skills CLI. Returns its exit code, or
1347
+ None when npx is absent — the caller then copies the bundled pack, so a
1348
+ machine with only Python still gets the whole kit. At a TTY the CLI's own
1349
+ interactive agent picker is left on; piped/CI runs get -y."""
1350
+ import shutil
1351
+ import subprocess
1352
+ npx = shutil.which("npx")
1353
+ if npx is None:
1354
+ return None
1355
+ cmd = [npx, "skills", "add", source]
1356
+ if not interactive:
1357
+ cmd.append("-y")
1358
+ return subprocess.run(cmd).returncode
1359
+
1360
+
1361
+ def copy_bundled_skills(root):
1362
+ """No-Node fallback: copy the wheel-bundled pack into the two
1363
+ conventional project layouts — Claude Code and the universal .agents one
1364
+ (Codex, Cursor, Copilot, Gemini CLI and friends all read it)."""
1365
+ import shutil
1366
+ targets = []
1367
+ for rel in (".claude/skills", ".agents/skills"):
1368
+ dest_root = pathlib.Path(root) / rel
1369
+ for pack in sorted(SKILLS_DIR.iterdir()):
1370
+ if not (pack / "SKILL.md").is_file():
1371
+ continue
1372
+ shutil.copytree(pack, dest_root / pack.name, dirs_exist_ok=True)
1373
+ targets.append(dest_root)
1374
+ return targets
1375
+
1376
+
1377
+ def cmd_install(conn, schema, ns):
1378
+ en = getattr(ns, "color", False)
1379
+ if en:
1380
+ sys.stdout.write(banner(en))
1381
+ path, created = scaffold_config(ns.project_root)
1382
+ print(f"{'Created' if created else 'Kept existing'} {path}")
1383
+ interactive = sys.stdin.isatty() and sys.stdout.isatty()
1384
+ code = run_skills_add(ns.source, interactive)
1385
+ if code is None:
1386
+ for t in copy_bundled_skills(ns.project_root):
1387
+ print(f"Copied the bundled skill pack into {t}")
1388
+ print("(npx not found — with Node.js, `npx skills add` reaches 77 "
1389
+ "agents with symlinked updates.)")
1390
+ elif code:
1391
+ sys.exit(code)
1392
+ print(f"\nNext: fill in {path}")
1393
+ print(f"Then: {invocation()} check")
1394
+
1395
+
1396
+ COMMANDS = {"check": cmd_check, "ls": cmd_ls, "src": cmd_src, "args": cmd_args,
1397
+ "ddl": cmd_ddl, "cols": cmd_cols, "grep": cmd_grep, "sql": cmd_sql,
1398
+ "invalid": cmd_invalid, "errors": cmd_errors, "deps": cmd_deps,
1399
+ "impact": cmd_impact, "similar": cmd_similar, "plscope": cmd_plscope,
1400
+ "policy": cmd_policy, "journal": cmd_journal, "apply": cmd_apply,
1401
+ "conventions": cmd_conventions, "install": cmd_install}
1402
+
1403
+ NO_DB_COMMANDS = {"policy", "journal", "conventions", "install"}
1404
+
1405
+
1406
+ # --- CLI ---------------------------------------------------------------------
1407
+
1408
+ def build_parser():
1409
+ def common():
1410
+ """A fresh set of shared options per subcommand. argparse's `parents`
1411
+ shares the very same action objects, so one subcommand's set_defaults
1412
+ would otherwise rewrite every other subcommand's default."""
1413
+ c = argparse.ArgumentParser(add_help=False)
1414
+ c.add_argument("--conn", help="connection name from connections.json")
1415
+ c.add_argument("--json", action="store_true", help="machine-readable output")
1416
+ c.add_argument("--limit", type=int, default=200,
1417
+ help="max rows for list output, 0 = no cap (default 200)")
1418
+ c.add_argument("--max-lines", type=int, default=2000, dest="max_lines",
1419
+ help="max source/DDL lines, 0 = no cap (default 2000)")
1420
+ c.add_argument("--offset", type=int, default=0,
1421
+ help="skip N lines/rows first (continue truncated output)")
1422
+ c.add_argument("--raw", action="store_true",
1423
+ help="plain text, no line numbers or unit headers")
1424
+ return c
1425
+
1426
+ p = argparse.ArgumentParser(
1427
+ prog="pythia", description=__doc__,
1428
+ formatter_class=argparse.RawDescriptionHelpFormatter)
1429
+ sub = p.add_subparsers(dest="command", required=True)
1430
+ sub.add_parser("check", parents=[common()],
1431
+ help="connectivity + object counts for the schema")
1432
+ s = sub.add_parser("ls", parents=[common()], help="find objects by name pattern")
1433
+ s.add_argument("pattern", help="LIKE pattern, e.g. \"PKG_%%\"")
1434
+ s = sub.add_parser("src", parents=[common()],
1435
+ help="PL/SQL source with Oracle line numbers")
1436
+ s.add_argument("name")
1437
+ s.add_argument("--body", action="store_true", help="only *BODY units")
1438
+ s.add_argument("--spec", action="store_true", help="only spec units")
1439
+ s = sub.add_parser("args", parents=[common()], help="procedure/function signature")
1440
+ s.add_argument("name")
1441
+ s = sub.add_parser("ddl", parents=[common()], help="DDL via DBMS_METADATA")
1442
+ s.add_argument("type", help="e.g. TABLE, INDEX, VIEW, PACKAGE_BODY")
1443
+ s.add_argument("name")
1444
+ s = sub.add_parser("cols", parents=[common()], help="columns and data types")
1445
+ s.add_argument("name")
1446
+ s = sub.add_parser("grep", parents=[common()], help="search all PL/SQL source")
1447
+ s.add_argument("pattern")
1448
+ s = sub.add_parser("sql", parents=[common()], help="free query (SELECT/WITH only)")
1449
+ s.add_argument("statement", nargs="+")
1450
+ sub.add_parser("invalid", parents=[common()],
1451
+ help="every INVALID object in the schema")
1452
+ s = sub.add_parser("errors", parents=[common()],
1453
+ help="compilation errors with line and column")
1454
+ s.add_argument("name", nargs="?", default=None,
1455
+ help="object name; omit for every object in the schema")
1456
+ s = sub.add_parser("deps", parents=[common()],
1457
+ help="what an object depends on")
1458
+ s.add_argument("name")
1459
+ s.add_argument("--depth", type=int, default=3,
1460
+ help="levels to walk (default 3)")
1461
+ s.add_argument("--with-sys", action="store_true", dest="with_sys",
1462
+ help="include SYS/PUBLIC built-ins, hidden by default")
1463
+ s = sub.add_parser("impact", parents=[common()],
1464
+ help="what depends on an object — run this before changing it")
1465
+ s.add_argument("name")
1466
+ s.add_argument("--depth", type=int, default=3,
1467
+ help="levels to walk (default 3)")
1468
+ s = sub.add_parser("similar", parents=[common()],
1469
+ help="programs named like this one — copy their conventions")
1470
+ s.add_argument("name")
1471
+ s.set_defaults(limit=20)
1472
+ s = sub.add_parser("plscope", parents=[common()],
1473
+ help="exact identifier usages from PL/Scope")
1474
+ s.add_argument("name")
1475
+ s = sub.add_parser("policy", parents=[common()],
1476
+ help="show or change the write policy")
1477
+ s.add_argument("action", nargs="?", choices=["show", "set"], default="show")
1478
+ s.add_argument("group", nargs="?", choices=sorted(POLICY_DEFAULTS))
1479
+ s.add_argument("value", nargs="?", choices=["allow", "confirm", "deny"])
1480
+ s = sub.add_parser("journal", parents=[common()],
1481
+ help="list, inspect, export and restore write snapshots")
1482
+ s.add_argument("action", nargs="?",
1483
+ choices=["list", "show", "diff", "export", "restore"],
1484
+ default="list")
1485
+ s.add_argument("id", nargs="?")
1486
+ s.add_argument("--what", choices=["after", "before", "restore"],
1487
+ default="after", help="which file export writes (default after)")
1488
+ s.add_argument("--confirm", metavar="TOKEN")
1489
+ s.add_argument("--yes", action="store_true")
1490
+ s.add_argument("--depth", type=int, default=3)
1491
+ s = sub.add_parser("apply", parents=[common()],
1492
+ help="preview and apply one statement with snapshot and verify")
1493
+ s.add_argument("file", help="file containing exactly one statement")
1494
+ s.add_argument("--confirm", metavar="TOKEN",
1495
+ help="token printed by the preview; applies only if nothing changed since")
1496
+ s.add_argument("--yes", action="store_true",
1497
+ help="apply without stopping; the full preview still prints and journals")
1498
+ s.add_argument("--depth", type=int, default=3,
1499
+ help="impact depth for the preview (default 3)")
1500
+ sub.add_parser("conventions", parents=[common()],
1501
+ help="show the project's house-style naming patterns")
1502
+ s = sub.add_parser("install", parents=[common()],
1503
+ help="install the skill pack and scaffold .pythia/ config")
1504
+ s.add_argument("--source", default=DEFAULT_SKILLS_SOURCE,
1505
+ help="skills repo for `npx skills add` "
1506
+ f"(default {DEFAULT_SKILLS_SOURCE})")
1507
+ return p
1508
+
1509
+
1510
+ def main(argv=None):
1511
+ argv = sys.argv[1:] if argv is None else argv
1512
+ forbid_write_flag(argv)
1513
+ for stream in (sys.stdout, sys.stderr):
1514
+ try:
1515
+ stream.reconfigure(encoding="utf-8")
1516
+ except AttributeError:
1517
+ pass
1518
+ ns = build_parser().parse_args(argv)
1519
+ ns.color = color_enabled() and not ns.json
1520
+ if ns.color and os.name == "nt":
1521
+ # Constant empty string, never user input — safe from injection. This
1522
+ # no-op shell call is the stdlib idiom that flips on ANSI (VT) escape
1523
+ # processing in legacy Windows consoles; Windows Terminal needs nothing.
1524
+ os.system("")
1525
+ cwd = pathlib.Path.cwd()
1526
+ cfg, root = find_config(cwd, os.environ)
1527
+ ns.project_root = root if root is not None else cwd
1528
+ if ns.command in NO_DB_COMMANDS and not (
1529
+ ns.command == "journal" and getattr(ns, "action", "") == "restore"):
1530
+ COMMANDS[ns.command](None, None, ns)
1531
+ return
1532
+ name, c = resolve_connection(cfg, ns.conn, os.environ, cwd, root)
1533
+ ns.conn_name = name
1534
+ ns.conn_user = c.get("user", "")
1535
+ ns.schema = (c.get("schema") or c["user"]).upper()
1536
+ print(f"-- connection={name} schema={ns.schema}", file=sys.stderr)
1537
+ try:
1538
+ import oracledb
1539
+ except ModuleNotFoundError:
1540
+ sys.exit("The 'oracledb' package is required to connect: pip install oracledb")
1541
+ try:
1542
+ pool = open_pool(c)
1543
+ conn = acquire(pool, session_should_be_readonly(
1544
+ ns.command, getattr(ns, "action", "") or ""))
1545
+ except (oracledb.Error, OSError) as e:
1546
+ # OSError too: a DNS or socket failure arrives raw from the socket layer,
1547
+ # not wrapped as an oracledb.Error.
1548
+ sys.exit(connect_failure_message(e, name))
1549
+ try:
1550
+ if ns.command == "journal": # only restore reaches here
1551
+ if not ns.id:
1552
+ sys.exit(f"Usage: {invocation()} journal restore <id>")
1553
+ ns.file = f"journal:{ns.id}"
1554
+ code = run_restore(conn, ns.schema, ns)
1555
+ if code:
1556
+ sys.exit(code)
1557
+ else:
1558
+ COMMANDS[ns.command](conn, ns.schema, ns)
1559
+ except oracledb.Error as e:
1560
+ sys.exit(f"Oracle error: {e}")
1561
+ finally:
1562
+ pool.close(force=True)
1563
+
1564
+
1565
+ if __name__ == "__main__":
1566
+ main()