wizard-central-toolkit 0.2.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.
Files changed (77) hide show
  1. wiz_central_toolkit/__init__.py +2 -0
  2. wiz_central_toolkit/bannerwiz/__main__.py +6 -0
  3. wiz_central_toolkit/bannerwiz/cli.py +79 -0
  4. wiz_central_toolkit/bannerwiz/lib/__init__.py +1 -0
  5. wiz_central_toolkit/bannerwiz/lib/banner_wizard.py +999 -0
  6. wiz_central_toolkit/bannerwiz/lib/banner_wrapper.py +199 -0
  7. wiz_central_toolkit/bannerwiz/lib/cli.py +79 -0
  8. wiz_central_toolkit/codeguardwiz/__init__.py +1 -0
  9. wiz_central_toolkit/codeguardwiz/__main__.py +19 -0
  10. wiz_central_toolkit/codeguardwiz/cli.py +971 -0
  11. wiz_central_toolkit/codeguardwiz/codeguard_wizard.py +269 -0
  12. wiz_central_toolkit/codeguardwiz/rust_engine.py +348 -0
  13. wiz_central_toolkit/codeguardwiz/static_engine.py +307 -0
  14. wiz_central_toolkit/common/__init__.py +1 -0
  15. wiz_central_toolkit/debugwiz/__init__.py +1 -0
  16. wiz_central_toolkit/debugwiz/__main__.py +360 -0
  17. wiz_central_toolkit/debugwiz/debug_wizard.py +364 -0
  18. wiz_central_toolkit/diffwiz/__init__.py +0 -0
  19. wiz_central_toolkit/diffwiz/__main__.py +21 -0
  20. wiz_central_toolkit/diffwiz/cli.py +134 -0
  21. wiz_central_toolkit/diffwiz/lib/__init__.py +1 -0
  22. wiz_central_toolkit/diffwiz/lib/cli.py +144 -0
  23. wiz_central_toolkit/diffwiz/lib/diff_wizard.py +89 -0
  24. wiz_central_toolkit/godfatherwiz/__init__.py +0 -0
  25. wiz_central_toolkit/godfatherwiz/__main__.py +18 -0
  26. wiz_central_toolkit/godfatherwiz/ai.py +136 -0
  27. wiz_central_toolkit/godfatherwiz/lib/__init__.py +1 -0
  28. wiz_central_toolkit/godfatherwiz/lib/cli.py +925 -0
  29. wiz_central_toolkit/godfatherwiz/lib/godfather_wizard.py +1552 -0
  30. wiz_central_toolkit/jsonwiz/__init__.py +0 -0
  31. wiz_central_toolkit/jsonwiz/__main__.py +21 -0
  32. wiz_central_toolkit/jsonwiz/cli.py +4 -0
  33. wiz_central_toolkit/jsonwiz/lib/__init__.py +1 -0
  34. wiz_central_toolkit/jsonwiz/lib/cli.py +138 -0
  35. wiz_central_toolkit/jsonwiz/lib/json_wizard.py +133 -0
  36. wiz_central_toolkit/pythonwiz/__init__.py +0 -0
  37. wiz_central_toolkit/pythonwiz/__main__.py +21 -0
  38. wiz_central_toolkit/pythonwiz/lib/__init__.py +1 -0
  39. wiz_central_toolkit/pythonwiz/lib/cli.py +247 -0
  40. wiz_central_toolkit/pythonwiz/lib/python_wizard.py +353 -0
  41. wiz_central_toolkit/readwiz/__init__.py +5 -0
  42. wiz_central_toolkit/readwiz/__main__.py +15 -0
  43. wiz_central_toolkit/readwiz/cli.py +46 -0
  44. wiz_central_toolkit/readwiz/lib/__init__.py +1 -0
  45. wiz_central_toolkit/readwiz/lib/cli.py +47 -0
  46. wiz_central_toolkit/readwiz/lib/read_wizard.py +121 -0
  47. wiz_central_toolkit/rustwiz/__init__.py +5 -0
  48. wiz_central_toolkit/rustwiz/__main__.py +7 -0
  49. wiz_central_toolkit/rustwiz/cli.py +4 -0
  50. wiz_central_toolkit/rustwiz/lib/__init__.py +1 -0
  51. wiz_central_toolkit/rustwiz/lib/cli.py +53 -0
  52. wiz_central_toolkit/rustwiz/lib/rust_wizard.py +128 -0
  53. wiz_central_toolkit/tmuxwiz/__init__.py +0 -0
  54. wiz_central_toolkit/tmuxwiz/__main__.py +15 -0
  55. wiz_central_toolkit/tmuxwiz/cli.py +7 -0
  56. wiz_central_toolkit/tmuxwiz/lib/__init__.py +1 -0
  57. wiz_central_toolkit/tmuxwiz/lib/cli.py +10 -0
  58. wiz_central_toolkit/tmuxwiz/lib/tmux_wizard.py +229 -0
  59. wiz_central_toolkit/todowiz/__init__.py +0 -0
  60. wiz_central_toolkit/todowiz/__main__.py +22 -0
  61. wiz_central_toolkit/todowiz/cli.py +4 -0
  62. wiz_central_toolkit/todowiz/lib/__init__.py +1 -0
  63. wiz_central_toolkit/todowiz/lib/cli.py +82 -0
  64. wiz_central_toolkit/todowiz/lib/todo_wizard.py +134 -0
  65. wiz_central_toolkit/tools_registry.py +62 -0
  66. wiz_central_toolkit/wiz/__init__.py +0 -0
  67. wiz_central_toolkit/wiz/__main__.py +141 -0
  68. wiz_central_toolkit/writewiz/__init__.py +1 -0
  69. wiz_central_toolkit/writewiz/__main__.py +9 -0
  70. wiz_central_toolkit/writewiz/cli.py +264 -0
  71. wiz_central_toolkit/writewiz/write_wizard.py +384 -0
  72. wizard_central_toolkit-0.2.0.dist-info/METADATA +203 -0
  73. wizard_central_toolkit-0.2.0.dist-info/RECORD +77 -0
  74. wizard_central_toolkit-0.2.0.dist-info/WHEEL +5 -0
  75. wizard_central_toolkit-0.2.0.dist-info/entry_points.txt +50 -0
  76. wizard_central_toolkit-0.2.0.dist-info/licenses/LICENSE +201 -0
  77. wizard_central_toolkit-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,128 @@
1
+ """rust-wizard: honest Rust validator. No tracebacks, verdicts only."""
2
+ import subprocess
3
+ import tempfile
4
+ from pathlib import Path
5
+
6
+ LAW_TEXT = """RUST LAW (Rust Wizard, the honest validator)
7
+ 1. The Rust Wizard judges Rust; it never writes it. Verdicts only.
8
+ 2. Every verdict comes from the real toolchain (rustc, cargo).
9
+ No toolchain, no verdict: a plain message, exit 2.
10
+ 3. fmt speaks in --check: it reports drift, it never reformats.
11
+ 4. clean is the one delete the Rust Wizard performs: a crate's
12
+ own target/ directory, and nothing else, ever.
13
+ 5. Mortal and stateless: one command, one verdict, then death.
14
+ """
15
+
16
+
17
+ def package_version():
18
+ try:
19
+ from importlib import metadata
20
+ for _n in ("wizard-central-toolkit", "wiz-central-toolkit"):
21
+ try:
22
+ return metadata.version(_n)
23
+ except metadata.PackageNotFoundError:
24
+ continue
25
+ except Exception:
26
+ pass
27
+ try:
28
+ from wiz_central_toolkit import __version__
29
+ return __version__
30
+ except Exception:
31
+ return "unknown"
32
+
33
+
34
+ class RustWizard:
35
+ NAME = "rustwiz"
36
+
37
+ def check(self, targets):
38
+ files = []
39
+ for target in targets:
40
+ t = Path(target).expanduser()
41
+ if t.is_dir():
42
+ files.extend(sorted(t.rglob("*.rs")))
43
+ elif t.suffix == ".rs" and t.is_file():
44
+ files.append(t)
45
+ else:
46
+ print(f"out of scope - not a .rs file or directory: {target}")
47
+ return 2
48
+ if not files:
49
+ print("[FAIL] no .rs files found")
50
+ return 1
51
+ bad = 0
52
+ for f in files:
53
+ with tempfile.TemporaryDirectory() as tmp:
54
+ try:
55
+ r = subprocess.run(
56
+ ["rustc", "--edition", "2021", "-A", "warnings",
57
+ "--crate-type", "lib", "--emit=metadata",
58
+ "--out-dir", tmp, str(f)],
59
+ capture_output=True, text=True, timeout=120)
60
+ except (FileNotFoundError, OSError):
61
+ print("[FAIL] rustc not found — install Rust to use rust_wizard check")
62
+ return 2
63
+ if r.returncode == 0:
64
+ print(f"[OK] {f}")
65
+ else:
66
+ bad += 1
67
+ print(f"[FAIL] {f}")
68
+ first = (r.stderr or "").strip().splitlines()
69
+ for line in first[:5]:
70
+ print(f" {line}")
71
+ print(f"{len(files) - bad}/{len(files)} valid, {bad} invalid")
72
+ return 1 if bad else 0
73
+
74
+ def _cargo(self, crate_dir, args, verb, show_stdout=False):
75
+ """Run one cargo verb against a crate. One verdict shape for all."""
76
+ d = Path(crate_dir).expanduser()
77
+ if not (d / "Cargo.toml").is_file():
78
+ print(f"[FAIL] no Cargo.toml in {d}")
79
+ return 1
80
+ try:
81
+ r = subprocess.run(["cargo"] + args, cwd=str(d),
82
+ capture_output=True, text=True, timeout=600)
83
+ except (FileNotFoundError, OSError):
84
+ print(f"[FAIL] cargo not found — install Rust to use rust_wizard {verb}")
85
+ return 2
86
+ if r.returncode == 0:
87
+ print(f"[OK] cargo {args[0]} passed: {d}")
88
+ return 0
89
+ print(f"[FAIL] cargo {args[0]} failed: {d}")
90
+ stream = (r.stdout if show_stdout else r.stderr) or ""
91
+ for line in stream.strip().splitlines()[:8]:
92
+ print(f" {line}")
93
+ return 1
94
+
95
+ def build(self, crate_dir):
96
+ return self._cargo(crate_dir, ["check", "--quiet"], "build")
97
+
98
+ def test(self, crate_dir):
99
+ return self._cargo(crate_dir, ["test", "--quiet"], "test")
100
+
101
+ def clippy(self, crate_dir):
102
+ return self._cargo(crate_dir, ["clippy", "--quiet"], "clippy")
103
+
104
+ def doc(self, crate_dir):
105
+ return self._cargo(crate_dir, ["doc", "--no-deps"], "doc")
106
+
107
+ def fmt(self, crate_dir):
108
+ # --check only: fmt reports drift, it never reformats (RUST LAW 3).
109
+ return self._cargo(crate_dir, ["fmt", "--check"], "fmt",
110
+ show_stdout=True)
111
+
112
+ def clean(self, crate_dir):
113
+ # The one delete (RUST LAW 4): cargo clean removes target/ only.
114
+ return self._cargo(crate_dir, ["clean"], "clean")
115
+
116
+ def run(self, crate_dir):
117
+ d = Path(crate_dir).expanduser()
118
+ if not (d / "Cargo.toml").is_file():
119
+ print(f"[FAIL] no Cargo.toml in {d}")
120
+ return 1
121
+ try:
122
+ # No capture: the program's own output belongs on the terminal.
123
+ r = subprocess.run(["cargo", "run", "--quiet"], cwd=str(d),
124
+ timeout=600)
125
+ except (FileNotFoundError, OSError):
126
+ print("[FAIL] cargo not found — install Rust to use rust_wizard run")
127
+ return 2
128
+ return r.returncode
File without changes
@@ -0,0 +1,15 @@
1
+ """Entry point for tmuxwiz dispatch."""
2
+ import sys
3
+ from .lib import cli as internal_cli
4
+
5
+ def main(argv=None):
6
+ old_argv = sys.argv
7
+ if argv is not None:
8
+ sys.argv = [sys.argv[0]] + list(argv)
9
+ try:
10
+ return internal_cli.main()
11
+ finally:
12
+ sys.argv = old_argv
13
+
14
+ if __name__ == "__main__":
15
+ sys.exit(main())
@@ -0,0 +1,7 @@
1
+ """cli.py - entry module for tmuxwiz (contains main())."""
2
+ import sys
3
+
4
+ from tmux_wizard import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1 @@
1
+ # wizard package init
@@ -0,0 +1,10 @@
1
+ """cli.py - entry module for tmuxwiz."""
2
+ import sys
3
+ from .tmux_wizard import main as tmux_main
4
+
5
+ def main():
6
+ """CLI entry point - wraps tmux_main with sys.argv."""
7
+ return tmux_main(sys.argv[1:])
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
@@ -0,0 +1,229 @@
1
+ import sys
2
+ """tmux_wizard - sovereign tmux session manager (stdlib only)."""
3
+ import subprocess
4
+
5
+ SUBCOMMANDS = ("new", "attach", "kill", "list", "census", "rename",
6
+ "capture", "detach", "law", "version")
7
+
8
+ LAW_TEXT = """TMUX LAW (Tmux Wizard, the session keeper)
9
+ 1. The Tmux Wizard manages sessions; the truth about them comes
10
+ from tmux itself, never from memory or invention.
11
+ 2. tmux is always called with an argument list, never through
12
+ a shell.
13
+ 3. capture reads a pane; it changes nothing.
14
+ 4. detach frees the client; the session and its work keep
15
+ running.
16
+ 5. kill is the only destroyer, and it names its session.
17
+ """
18
+
19
+
20
+ def package_version():
21
+ try:
22
+ from importlib import metadata
23
+ for _n in ("wizard-central-toolkit", "wiz-central-toolkit"):
24
+ try:
25
+ return metadata.version(_n)
26
+ except metadata.PackageNotFoundError:
27
+ continue
28
+ except Exception:
29
+ pass
30
+ try:
31
+ from wiz_central_toolkit import __version__
32
+ return __version__
33
+ except Exception:
34
+ return "unknown"
35
+
36
+
37
+ def _run(args):
38
+ """Run tmux with an arg list. Never shell=True (injection law)."""
39
+ try:
40
+ proc = subprocess.run(
41
+ ["tmux"] + args, capture_output=True, text=True, timeout=10
42
+ )
43
+ return proc.returncode, proc.stdout.strip(), proc.stderr.strip()
44
+ except FileNotFoundError:
45
+ return 127, "", "tmux not installed"
46
+ except subprocess.TimeoutExpired:
47
+ return 124, "", "tmux timed out"
48
+
49
+
50
+ def _sessions():
51
+ """Live session names. Ground truth from tmux itself, zero fabrication."""
52
+ rc, out, _ = _run(["ls", "-F", "#{session_name}"])
53
+ if rc != 0:
54
+ return []
55
+ return [line for line in out.splitlines() if line]
56
+
57
+
58
+ def cmd_new(name, start_dir):
59
+ if not name or "/" in name or ":" in name:
60
+ print("[FAIL] invalid session name")
61
+ return 1
62
+ if name in _sessions():
63
+ print("[FAIL] session '%s' already exists" % name)
64
+ return 1
65
+ rc, _, err = _run(["new-session", "-d", "-s", name, "-c", start_dir])
66
+ if rc == 0:
67
+ print("[OK] session '%s' created" % name)
68
+ else:
69
+ print("[FAIL] %s" % err)
70
+ return rc
71
+
72
+
73
+ def cmd_attach(name):
74
+ if name not in _sessions():
75
+ print("[FAIL] no session '%s'" % name)
76
+ return 1
77
+ rc, _, err = _run(["attach-session", "-t", name])
78
+ if rc == 0:
79
+ print("[OK] attached to '%s'" % name)
80
+ else:
81
+ print("[FAIL] attach to '%s' failed%s" % (name, (": " + err) if err else ""))
82
+ return rc
83
+
84
+
85
+ def cmd_kill(name):
86
+ if name not in _sessions():
87
+ print("[FAIL] no session '%s'" % name)
88
+ return 1
89
+ rc, _, _ = _run(["kill-session", "-t", name])
90
+ print("[OK] session '%s' killed" % name if rc == 0 else "[FAIL] kill failed")
91
+ return rc
92
+
93
+
94
+ def cmd_list():
95
+ sessions = _sessions()
96
+ if not sessions:
97
+ print("no sessions running")
98
+ return 0
99
+ for s in sessions:
100
+ print(s)
101
+ return 0
102
+
103
+
104
+ def cmd_census():
105
+ """Fleet-style census: every session, window count, attach state."""
106
+ sessions = _sessions()
107
+ total = len(sessions)
108
+ print("sessions total: %d" % total)
109
+ ok = 0
110
+ for s in sessions:
111
+ rc, out, _ = _run(
112
+ ["list-windows", "-t", s, "-F", "#{window_index}:#{window_name}"]
113
+ )
114
+ windows = out.splitlines() if rc == 0 else []
115
+ rc2, out2, _ = _run(["display-message", "-p", "-t", s, "#{session_attached}"])
116
+ attached = "ATTACHED" if out2 == "1" else "detached"
117
+ print("%s %-20s windows=%2d %s" % ("[OK]" if rc == 0 else "[FAIL]", s, len(windows), attached))
118
+ if rc == 0:
119
+ ok += 1
120
+ print("census verdict: %d/%d healthy" % (ok, total))
121
+ return 0 if ok == total else 1
122
+
123
+
124
+ def cmd_capture(name, lines=200):
125
+ if name not in _sessions():
126
+ print("[FAIL] no session '%s'" % name)
127
+ return 1
128
+ rc, out, err = _run(["capture-pane", "-t", name, "-p", "-S", "-%d" % lines])
129
+ if rc == 0:
130
+ print(out)
131
+ else:
132
+ print("[FAIL] capture from '%s' failed%s" % (name, (": " + err) if err else ""))
133
+ return rc
134
+
135
+
136
+ def cmd_detach(name):
137
+ if name not in _sessions():
138
+ print("[FAIL] no session '%s'" % name)
139
+ return 1
140
+ rc, _, err = _run(["detach-client", "-s", name])
141
+ if rc == 0:
142
+ print("[OK] clients detached from '%s' (session keeps running)" % name)
143
+ else:
144
+ print("[FAIL] detach from '%s' failed%s" % (name, (": " + err) if err else ""))
145
+ return rc
146
+
147
+
148
+ def cmd_rename(old, new):
149
+ if old not in _sessions():
150
+ print("[FAIL] no session '%s'" % old)
151
+ return 1
152
+ if not new or "/" in new or ":" in new:
153
+ print("[FAIL] invalid new name")
154
+ return 1
155
+ rc, _, err = _run(["rename-session", "-t", old, new])
156
+ print("[OK] '%s' -> '%s'" % (old, new) if rc == 0 else "[FAIL] %s" % err)
157
+ return rc
158
+
159
+
160
+ def main(argv):
161
+ if not argv or argv[0] in ("-h", "--help", "help"):
162
+ print("Tmux Wizard — sovereign tmux session manager")
163
+ print("alias: tmuxwiz")
164
+ print("usage: tmux_wizard <new|attach|kill|list|census|rename|capture|detach|law|version> [args]")
165
+ print(" new <name> [dir] create session (default cwd)")
166
+ print(" attach <name> join session")
167
+ print(" kill <name> destroy session")
168
+ print(" list names of live sessions")
169
+ print(" census health of every session")
170
+ print(" rename <old> <new> rename session")
171
+ print(" capture <name> [lines] print a pane's recent output (read-only)")
172
+ print(" detach <name> detach clients; the session keeps running")
173
+ print(" law print the TMUX LAW alone")
174
+ print(" version print the version")
175
+ return 0
176
+ cmd, rest = argv[0], argv[1:]
177
+ if cmd == "law":
178
+ print(LAW_TEXT)
179
+ return 0
180
+ if cmd == "version":
181
+ print("tmux_wizard %s" % package_version())
182
+ return 0
183
+ if cmd not in SUBCOMMANDS:
184
+ print("out of scope - unknown command: %s" % cmd)
185
+ return 2
186
+ if cmd == "new":
187
+ if not rest:
188
+ print("[FAIL] new requires a session name")
189
+ return 2
190
+ return cmd_new(rest[0], rest[1] if len(rest) > 1 else ".")
191
+ if cmd == "attach":
192
+ if not rest:
193
+ print("[FAIL] attach requires a name")
194
+ return 2
195
+ return cmd_attach(rest[0])
196
+ if cmd == "kill":
197
+ if not rest:
198
+ print("[FAIL] kill requires a name")
199
+ return 2
200
+ return cmd_kill(rest[0])
201
+ if cmd == "rename":
202
+ if len(rest) < 2:
203
+ print("[FAIL] rename requires old and new names")
204
+ return 2
205
+ return cmd_rename(rest[0], rest[1])
206
+ if cmd == "capture":
207
+ if not rest:
208
+ print("[FAIL] capture requires a session name")
209
+ return 2
210
+ lines = 200
211
+ if len(rest) > 1:
212
+ try:
213
+ lines = int(rest[1])
214
+ except ValueError:
215
+ print("[FAIL] capture lines must be a number")
216
+ return 2
217
+ return cmd_capture(rest[0], lines)
218
+ if cmd == "detach":
219
+ if not rest:
220
+ print("[FAIL] detach requires a name")
221
+ return 2
222
+ return cmd_detach(rest[0])
223
+ if cmd == "list":
224
+ return cmd_list()
225
+ return cmd_census()
226
+
227
+
228
+ if __name__ == "__main__":
229
+ raise SystemExit(main(__import__("sys").argv[1:]))
File without changes
@@ -0,0 +1,22 @@
1
+ """Entry point for wiz dispatch."""
2
+ import sys
3
+ from .lib import cli as internal_cli
4
+ from .lib.todo_wizard import TodoWizard # noqa: F401 (re-exported for tests)
5
+
6
+ # Wrap main() to accept argv (wiz dispatcher calls main(args))
7
+ def main(argv=None):
8
+ # Save original sys.argv
9
+ old_argv = sys.argv
10
+ # Replace with provided argv (or empty list)
11
+ if argv is not None:
12
+ sys.argv = [sys.argv[0]] + list(argv)
13
+ try:
14
+ return internal_cli.main()
15
+ finally:
16
+ sys.argv = old_argv
17
+
18
+ # Also expose for module-level access
19
+ __all__ = ['main', 'TodoWizard']
20
+
21
+ if __name__ == "__main__":
22
+ sys.exit(main())
@@ -0,0 +1,4 @@
1
+ """todowiz CLI entry point; the implementation lives in lib/cli.py."""
2
+ from .lib.cli import main
3
+
4
+ __all__ = ["main"]
@@ -0,0 +1 @@
1
+ # wizard package init
@@ -0,0 +1,82 @@
1
+ """todo-wizard CLI front-end."""
2
+ import sys
3
+ import json
4
+ from .todo_wizard import TodoWizard
5
+
6
+ def main() -> int:
7
+ args = sys.argv[1:]
8
+ if not args or args[0] in ("-h", "--help", "help"):
9
+ print("Todo Wizard — standalone todo wizard")
10
+ print("alias: todowiz")
11
+ print("usage: todo_wizard <command> [args]")
12
+ print(" add <text> add a task (default priority normal)")
13
+ print(" list list all tasks")
14
+ print(" done <id> complete a task")
15
+ print(" undo <id> reopen a completed task")
16
+ print(" note <id> <text> append a progress note to a task")
17
+ print(" oldest show the stalest pending task")
18
+ print(" overdue [days] pending tasks older than N days (default 7)")
19
+ print(" archive-done move completed tasks to archive")
20
+ print(" rm <id> remove a task")
21
+ print(" stats JSON summary")
22
+ print(" export full JSON dump")
23
+ return 0
24
+
25
+ wiz = TodoWizard()
26
+ cmd = args[0]
27
+
28
+ if cmd == "add":
29
+ text = " ".join(args[1:]) or "untitled"
30
+ item = wiz.add(text)
31
+ print(f"[+] #{item['id']} {item['text']}")
32
+ elif cmd == "list":
33
+ for t in wiz.list_todos():
34
+ mark = "x" if t["done"] else " "
35
+ n_notes = len(t.get("notes", []))
36
+ suffix = f" [{n_notes} notes]" if n_notes else ""
37
+ print(f"[{mark}] #{t['id']} ({t['priority']}) {t['text']}{suffix}")
38
+ elif cmd == "done":
39
+ ok = wiz.complete(int(args[1]))
40
+ print("[OK] completed" if ok else "[FAIL] not found or already done")
41
+ return 0 if ok else 1
42
+ elif cmd == "undo":
43
+ ok = wiz.undo_complete(int(args[1]))
44
+ print("[OK] reopened" if ok else "[FAIL] not found or not done")
45
+ return 0 if ok else 1
46
+ elif cmd == "note":
47
+ if len(args) < 3:
48
+ print("out of scope - note requires id and text")
49
+ return 2
50
+ ok = wiz.note(int(args[1]), " ".join(args[2:]))
51
+ print("[OK] noted" if ok else "[FAIL] no such task")
52
+ return 0 if ok else 1
53
+ elif cmd == "oldest":
54
+ t = wiz.oldest()
55
+ if t is None:
56
+ print("no pending tasks")
57
+ else:
58
+ print(f"#{t['id']} {t['text']} (created {t['created']})")
59
+ elif cmd == "overdue":
60
+ days = int(args[1]) if len(args) > 1 else 7
61
+ results = wiz.overdue(days)
62
+ if not results:
63
+ print(f"no pending tasks older than {days} days")
64
+ else:
65
+ for t, age in results:
66
+ print(f"#{t['id']} {t['text']} — {age:.1f} days old (created {t['created']})")
67
+ return 1 if results else 0
68
+ elif cmd == "archive-done":
69
+ moved = wiz.archive_done()
70
+ print("[OK] archived %d completed tasks" % moved if moved else "no completed tasks to archive")
71
+ elif cmd == "rm":
72
+ ok = wiz.remove(int(args[1]))
73
+ print("[OK] removed" if ok else "[FAIL] not found")
74
+ return 0 if ok else 1
75
+ elif cmd == "stats":
76
+ print(json.dumps(wiz.stats()))
77
+ elif cmd == "export":
78
+ print(json.dumps(wiz.list_todos(), indent=2))
79
+ else:
80
+ print("out of scope")
81
+ return 2
82
+ return 0
@@ -0,0 +1,134 @@
1
+ """todo-wizard core: standalone, importable todo engine."""
2
+ import json
3
+ import hashlib
4
+ import datetime as dt
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ class TodoWizard:
9
+ """Per-project persistent todo engine. Storage lives in ~/.todo-wizard/."""
10
+
11
+ BANK = Path.home() / ".todo-wizard"
12
+
13
+ def __init__(self, project_dir: Optional[str] = None, bank: Optional[Path] = None):
14
+ self.project = Path(project_dir or Path.cwd()).resolve()
15
+ self.bank = bank or self.BANK
16
+ self.store_path = self._store_for(self.project)
17
+
18
+ def _store_for(self, path: Path) -> Path:
19
+ key = hashlib.sha256(str(path).encode()).hexdigest()[:16]
20
+ return self.bank / f"{path.name}-{key}.json"
21
+
22
+ def _load(self) -> dict:
23
+ if self.store_path.exists():
24
+ return json.loads(self.store_path.read_text())
25
+ return {"project": str(self.project), "todos": [], "created": self._now()}
26
+
27
+ def _save(self, data: dict) -> None:
28
+ self.bank.mkdir(parents=True, exist_ok=True)
29
+ tmp = self.store_path.with_suffix(".tmp")
30
+ tmp.write_text(json.dumps(data, indent=2))
31
+ tmp.replace(self.store_path)
32
+
33
+ @staticmethod
34
+ def _now() -> str:
35
+ return dt.datetime.now().isoformat(timespec="seconds")
36
+
37
+ def add(self, text: str, priority: str = "normal") -> dict:
38
+ data = self._load()
39
+ item = {
40
+ "id": max((t["id"] for t in data["todos"] + data.get("archive", [])), default=0) + 1,
41
+ "text": text,
42
+ "priority": priority,
43
+ "done": False,
44
+ "created": self._now(),
45
+ "completed": None,
46
+ }
47
+ data["todos"].append(item)
48
+ data["last_modified"] = self._now()
49
+ self._save(data)
50
+ return item
51
+
52
+ def complete(self, todo_id: int) -> bool:
53
+ data = self._load()
54
+ for item in data["todos"]:
55
+ if item["id"] == todo_id and not item["done"]:
56
+ item["done"] = True
57
+ item["completed"] = self._now()
58
+ self._save(data)
59
+ return True
60
+ return False
61
+
62
+ def remove(self, todo_id: int) -> bool:
63
+ data = self._load()
64
+ before = len(data["todos"])
65
+ data["todos"] = [t for t in data["todos"] if t["id"] != todo_id]
66
+ if len(data["todos"]) < before:
67
+ self._save(data)
68
+ return True
69
+ return False
70
+
71
+ def list_todos(self, pending_only: bool = False) -> list:
72
+ todos = self._load()["todos"]
73
+ return [t for t in todos if not t["done"]] if pending_only else todos
74
+
75
+ def stats(self) -> dict:
76
+ todos = self.list_todos()
77
+ done = sum(1 for t in todos if t["done"])
78
+ return {"total": len(todos), "done": done, "pending": len(todos) - done}
79
+
80
+ def undo_complete(self, todo_id: int) -> bool:
81
+ """Reopen a closed task."""
82
+ data = self._load()
83
+ for item in data["todos"]:
84
+ if item["id"] == todo_id and item["done"]:
85
+ item["done"] = False
86
+ item["completed"] = None
87
+ data["last_modified"] = self._now()
88
+ self._save(data)
89
+ return True
90
+ return False
91
+
92
+ def note(self, todo_id: int, text: str) -> bool:
93
+ """Append a progress note to a task (evidence trail)."""
94
+ data = self._load()
95
+ for item in data["todos"]:
96
+ if item["id"] == todo_id:
97
+ notes = item.setdefault("notes", [])
98
+ notes.append({"text": text, "at": self._now()})
99
+ data["last_modified"] = self._now()
100
+ self._save(data)
101
+ return True
102
+ return False
103
+
104
+ def oldest(self) -> Optional[dict]:
105
+ """Return the stalest pending task."""
106
+ pending = self.list_todos(pending_only=True)
107
+ if not pending:
108
+ return None
109
+ return min(pending, key=lambda t: t["created"])
110
+
111
+ def archive_done(self) -> int:
112
+ """Move completed tasks to the archive list (never destroy)."""
113
+ data = self._load()
114
+ done_items = [t for t in data["todos"] if t["done"]]
115
+ if not done_items:
116
+ return 0
117
+ archive = data.setdefault("archive", [])
118
+ archive.extend(done_items)
119
+ archive.sort(key=lambda t: t.get("completed") or "")
120
+ data["todos"] = [t for t in data["todos"] if not t["done"]]
121
+ data["last_modified"] = self._now()
122
+ self._save(data)
123
+ return len(done_items)
124
+
125
+ def overdue(self, days: int = 7) -> list:
126
+ """Pending tasks older than N days."""
127
+ cutoff = dt.datetime.now() - dt.timedelta(days=days)
128
+ result = []
129
+ for t in self.list_todos(pending_only=True):
130
+ created = dt.datetime.fromisoformat(t["created"])
131
+ age_days = (dt.datetime.now() - created).total_seconds() / 86400
132
+ if created < cutoff:
133
+ result.append((t, age_days))
134
+ return result