ticketrail 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.
- ticketrail/__init__.py +2 -0
- ticketrail/__main__.py +3 -0
- ticketrail/cli.py +396 -0
- ticketrail/config.py +326 -0
- ticketrail/digests.py +86 -0
- ticketrail/groups.py +263 -0
- ticketrail/i18n.py +238 -0
- ticketrail/labeler.py +213 -0
- ticketrail/links.py +237 -0
- ticketrail/live.py +44 -0
- ticketrail/model.py +234 -0
- ticketrail/signals.py +95 -0
- ticketrail/sources/__init__.py +4 -0
- ticketrail/sources/base.py +54 -0
- ticketrail/sources/claude.py +164 -0
- ticketrail/sources/codex.py +165 -0
- ticketrail/tui/__init__.py +1 -0
- ticketrail/tui/app.py +975 -0
- ticketrail/util.py +112 -0
- ticketrail-0.1.0.dist-info/METADATA +215 -0
- ticketrail-0.1.0.dist-info/RECORD +24 -0
- ticketrail-0.1.0.dist-info/WHEEL +4 -0
- ticketrail-0.1.0.dist-info/entry_points.txt +2 -0
- ticketrail-0.1.0.dist-info/licenses/LICENSE +21 -0
ticketrail/__init__.py
ADDED
ticketrail/__main__.py
ADDED
ticketrail/cli.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""ticketrail — a work tracker for coding-agent sessions: Claude Code and Codex CLI sessions of a workspace grouped
|
|
2
|
+
into work items by ticket or topic, with their status, links, and one-key resume.
|
|
3
|
+
|
|
4
|
+
ticketrail [pick] [query] interactive picker (Textual); opens the chosen session
|
|
5
|
+
ticketrail list [--days N] [--task KEY] [--all]
|
|
6
|
+
ticketrail find <words…> [--deep] [--brief] [--limit N]
|
|
7
|
+
ticketrail show <id-prefix | words in the name>
|
|
8
|
+
ticketrail index [--out FILE] markdown index of sessions by ticket
|
|
9
|
+
ticketrail label [--limit N] LLM labels (topic, role, status) for idle sessions
|
|
10
|
+
ticketrail dump [--json] groups as text
|
|
11
|
+
ticketrail config show|init effective configuration / write a starter .ticketrail.toml
|
|
12
|
+
ticketrail doctor check paths, tools and caches
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import collections
|
|
18
|
+
import datetime
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import shlex
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
|
|
26
|
+
from . import __version__, config, digests, groups as G, labeler as L
|
|
27
|
+
from .i18n import t
|
|
28
|
+
from .sources import SOURCES, claude
|
|
29
|
+
from .util import fmt_date, fmt_dt, fmt_epoch_date, one_line, rel_path
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def resume_cmds(d):
|
|
33
|
+
if d["tool"] == "codex":
|
|
34
|
+
return "codex resume %s" % d["id"], "codex fork %s" % d["id"]
|
|
35
|
+
return "claude --resume %s" % d["id"], "claude --fork-session --resume %s" % d["id"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------- list / find / show / index ----------
|
|
39
|
+
def cmd_list(args):
|
|
40
|
+
ds = digests.load_digests(args.source)
|
|
41
|
+
cutoff = None
|
|
42
|
+
if not args.all:
|
|
43
|
+
cutoff = (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=args.days)).isoformat()
|
|
44
|
+
rows = []
|
|
45
|
+
for d in ds:
|
|
46
|
+
if cutoff and (d["last_ts"] or "") < cutoff:
|
|
47
|
+
continue
|
|
48
|
+
if args.task:
|
|
49
|
+
k = args.task.upper()
|
|
50
|
+
if k not in d["tickets"] and k not in digests.title_of(d).upper():
|
|
51
|
+
continue
|
|
52
|
+
rows.append(d)
|
|
53
|
+
rows.sort(key=lambda d: d["last_ts"] or "", reverse=True)
|
|
54
|
+
print("%-13s %-6s %-8s %-4s %-6s %-55s %s" % (t("cli.h_date"), "tool", "id", "#p", "KB", t("cli.h_name"), t("cli.h_tickets")))
|
|
55
|
+
for d in rows:
|
|
56
|
+
a, b = fmt_date(d["first_ts"]), fmt_date(d["last_ts"])
|
|
57
|
+
rng = a + ("→" + b if b != a else "")
|
|
58
|
+
name = ("★ " if d["title_custom"] else " ") + one_line(digests.title_of(d), 52)
|
|
59
|
+
print("%-13s %-6s %-8s %-4d %-6d %-55s %s" % (rng, d["tool"], d["id"][:8], d["n_prompts"], d["size_kb"], name, " ".join(d["tickets"])))
|
|
60
|
+
print("\n" + t("cli.list_footer", n=len(rows)))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cmd_find(args):
|
|
64
|
+
query = " ".join(args.query).strip()
|
|
65
|
+
if not query:
|
|
66
|
+
sys.exit(t("cli.no_query"))
|
|
67
|
+
files = digests.list_files(args.source)
|
|
68
|
+
paths = [p for _, p in files]
|
|
69
|
+
matched = []
|
|
70
|
+
try:
|
|
71
|
+
for i in range(0, len(paths), 500): # stay under the argument-length limit
|
|
72
|
+
r = subprocess.run(["grep", "-l", "-i", "-F", "--", query] + paths[i:i + 500], capture_output=True, text=True)
|
|
73
|
+
matched += [ln for ln in r.stdout.splitlines() if ln]
|
|
74
|
+
except FileNotFoundError:
|
|
75
|
+
matched = paths
|
|
76
|
+
tool_of = {p: name for name, p in files}
|
|
77
|
+
by_path = {d["path"]: d for d in digests.load_digests(args.source)}
|
|
78
|
+
results = []
|
|
79
|
+
for p in matched:
|
|
80
|
+
d = by_path.get(p)
|
|
81
|
+
if not d:
|
|
82
|
+
continue
|
|
83
|
+
h = SOURCES[tool_of[p]].hits(p, query, args.deep)
|
|
84
|
+
total = sum(h.values())
|
|
85
|
+
if total:
|
|
86
|
+
results.append((total, h, d))
|
|
87
|
+
if args.brief:
|
|
88
|
+
results.sort(key=lambda r: r[2]["last_ts"] or "", reverse=True)
|
|
89
|
+
else:
|
|
90
|
+
results.sort(key=lambda r: (r[0], r[2]["last_ts"] or ""), reverse=True)
|
|
91
|
+
results = results[: args.limit]
|
|
92
|
+
|
|
93
|
+
print(t("cli.find_head", q=query, n=len(results), deep=" " + t("cli.find_deep") if args.deep else ""))
|
|
94
|
+
for total, h, d in results:
|
|
95
|
+
print("\n%s %s %s → %s %s%s" % (d["tool"], d["id"][:8], fmt_dt(d["first_ts"]), fmt_dt(d["last_ts"]),
|
|
96
|
+
"★ " if d["title_custom"] else "", digests.title_of(d)))
|
|
97
|
+
print(" " + t("cli.find_hits", total=total, p=h["prompt"], a=h["assistant"], tl=h["tool_input"],
|
|
98
|
+
r=(" · result %d" % h["tool_result"]) if args.deep else "", n=d["n_prompts"], kb=d["size_kb"],
|
|
99
|
+
branch=(" branch=" + d["branch"]) if d.get("branch") and d["branch"] != "HEAD" else ""))
|
|
100
|
+
if d["prompts"]:
|
|
101
|
+
print(" %s: %s" % (t("cli.first"), one_line(d["prompts"][0]["text"], 160)))
|
|
102
|
+
if d["last_assistant"]:
|
|
103
|
+
print(" %s: %s" % (t("cli.conclusion"), one_line(d["last_assistant"], 400 if args.brief else 300)))
|
|
104
|
+
|
|
105
|
+
# Claude sessions removed by retention: only their prompts are left in history.jsonl
|
|
106
|
+
if not args.brief and "claude" in digests.source_names(args.source):
|
|
107
|
+
ondisk = {d["id"] for d in by_path.values()}
|
|
108
|
+
gone = []
|
|
109
|
+
for sid, e in claude.deleted_history(config.current()).items():
|
|
110
|
+
if sid in ondisk:
|
|
111
|
+
continue
|
|
112
|
+
hits = [p for p in e["prompts"] if query.lower() in p.lower()]
|
|
113
|
+
if hits:
|
|
114
|
+
gone.append((e["first_ms"] or 0, sid, hits))
|
|
115
|
+
if gone:
|
|
116
|
+
print("\n" + t("cli.deleted_head", n=len(gone)))
|
|
117
|
+
for ms, sid, hits in sorted(gone, reverse=True)[:15]:
|
|
118
|
+
day = fmt_epoch_date(ms / 1000) if ms else "??"
|
|
119
|
+
print(" %s %s %s" % (day, (sid or "")[:8], one_line(hits[0], 120)))
|
|
120
|
+
if results:
|
|
121
|
+
r, fk = resume_cmds(results[0][2])
|
|
122
|
+
print("\n" + t("cli.resume_line", r=r, f=fk))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def cmd_show(args):
|
|
126
|
+
key = args.key.strip()
|
|
127
|
+
ds = digests.load_digests(args.source)
|
|
128
|
+
cands = [d for d in ds if d["id"].startswith(key)] or [d for d in ds if key.lower() in digests.title_of(d).lower()]
|
|
129
|
+
if not cands:
|
|
130
|
+
sys.exit(t("cli.no_match", key=key))
|
|
131
|
+
if len(cands) > 1:
|
|
132
|
+
print(t("cli.many"))
|
|
133
|
+
for d in sorted(cands, key=lambda d: d["last_ts"] or "", reverse=True):
|
|
134
|
+
print(" %s %s %s %s" % (d["tool"], d["id"][:8], fmt_date(d["first_ts"]), digests.title_of(d)))
|
|
135
|
+
return
|
|
136
|
+
d = cands[0]
|
|
137
|
+
print("# %s%s" % ("★ " if d["title_custom"] else "", digests.title_of(d)))
|
|
138
|
+
print("%-9s: %s" % (t("cli.tool"), d["tool"]))
|
|
139
|
+
print("id : %s" % d["id"])
|
|
140
|
+
print("%-9s: %s → %s (%d prompt, %d KB)" % (t("cli.time"), fmt_dt(d["first_ts"]), fmt_dt(d["last_ts"]), d["n_prompts"], d["size_kb"]))
|
|
141
|
+
if d.get("branch") or d.get("cwd"):
|
|
142
|
+
print("branch : %s cwd: %s" % (d.get("branch"), d.get("cwd")))
|
|
143
|
+
if d["tickets"]:
|
|
144
|
+
print("%-9s: %s" % (t("cli.h_tickets"), ", ".join("%s×%d" % kv for kv in d["tickets"].items())))
|
|
145
|
+
if d["plan_file"]:
|
|
146
|
+
print("plan : %s" % d["plan_file"])
|
|
147
|
+
print("\n## " + t("cli.prompts"))
|
|
148
|
+
for p in d["prompts"]:
|
|
149
|
+
print("- [%s] %s" % (fmt_dt(p["ts"]), p["text"]))
|
|
150
|
+
if d["files_written"]:
|
|
151
|
+
print("\n## " + t("cli.files", n=len(d["files_written"])) + (" — " + t("cli.codex_files") if d["tool"] == "codex" else ""))
|
|
152
|
+
for fp in d["files_written"]:
|
|
153
|
+
print("- " + rel_path(fp))
|
|
154
|
+
if d["memory_files"]:
|
|
155
|
+
print("\n## " + t("cli.memory"))
|
|
156
|
+
for fp in d["memory_files"]:
|
|
157
|
+
print("- " + os.path.basename(fp))
|
|
158
|
+
if d["artifacts"]:
|
|
159
|
+
print("\n## " + t("cli.artifacts"))
|
|
160
|
+
for a in d["artifacts"]:
|
|
161
|
+
print("- %s — %s" % (a.get("title"), a.get("url")))
|
|
162
|
+
if d["last_assistant"]:
|
|
163
|
+
print("\n## " + t("cli.final") + "\n" + d["last_assistant"].strip())
|
|
164
|
+
r, fk = resume_cmds(d)
|
|
165
|
+
print("\n%-6s: %s\n%-6s: %s" % (t("cli.resume"), r, t("cli.fork"), fk))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def cmd_index(args):
|
|
169
|
+
cfg = config.current()
|
|
170
|
+
ds = digests.load_digests(args.source)
|
|
171
|
+
by_ticket = collections.defaultdict(list)
|
|
172
|
+
no_ticket = []
|
|
173
|
+
for d in ds:
|
|
174
|
+
keys = set(d["tickets"]) | set(cfg.tickets.find(digests.title_of(d)))
|
|
175
|
+
if keys:
|
|
176
|
+
for k in keys:
|
|
177
|
+
by_ticket[k].append(d)
|
|
178
|
+
else:
|
|
179
|
+
no_ticket.append(d)
|
|
180
|
+
|
|
181
|
+
def row(d, with_range=True):
|
|
182
|
+
rng = fmt_date(d["first_ts"])
|
|
183
|
+
if with_range and fmt_date(d["last_ts"]) != fmt_date(d["first_ts"]):
|
|
184
|
+
rng += "→" + fmt_date(d["last_ts"])
|
|
185
|
+
first = one_line(d["prompts"][0]["text"], 110).replace("|", "\\|") if d["prompts"] else ""
|
|
186
|
+
return "| %s | %s | `%s` | %s%s | %s |" % (rng, d["tool"], d["id"][:8], "★ " if d["title_custom"] else "",
|
|
187
|
+
one_line(digests.title_of(d), 60).replace("|", "\\|"), first)
|
|
188
|
+
|
|
189
|
+
n_codex = sum(1 for d in ds if d["tool"] == "codex")
|
|
190
|
+
lines = ["# " + t("idx.title", name=os.path.basename(cfg.root)), "",
|
|
191
|
+
"> " + t("idx.note", when=config.now_local().strftime("%Y-%m-%d %H:%M"), n=len(ds), codex=n_codex,
|
|
192
|
+
claude=len(ds) - n_codex), ""]
|
|
193
|
+
hdr = ["| %s | Tool | id | %s | %s |" % (t("cli.h_date"), t("cli.h_name"), t("cli.first")), "|---|---|---|---|---|"]
|
|
194
|
+
for k in sorted(by_ticket, key=G.ticket_order):
|
|
195
|
+
lines += ["## %s" % k, ""] + hdr
|
|
196
|
+
for d in sorted(by_ticket[k], key=lambda d: d["last_ts"] or "", reverse=True):
|
|
197
|
+
lines.append(row(d))
|
|
198
|
+
lines.append("")
|
|
199
|
+
if no_ticket:
|
|
200
|
+
lines += ["## " + t("idx.no_ticket"), ""] + hdr
|
|
201
|
+
for d in sorted(no_ticket, key=lambda d: d["last_ts"] or "", reverse=True):
|
|
202
|
+
lines.append(row(d, with_range=False))
|
|
203
|
+
lines.append("")
|
|
204
|
+
out = cfg.abspath(args.out) if args.out else cfg.index_file
|
|
205
|
+
os.makedirs(os.path.dirname(out), exist_ok=True)
|
|
206
|
+
ignore = os.path.join(os.path.dirname(out), ".gitignore")
|
|
207
|
+
if os.path.basename(os.path.dirname(out)) == ".ticketrail" and not os.path.exists(ignore):
|
|
208
|
+
with open(ignore, "w", encoding="utf-8") as f: # the index quotes first prompts: keep it out of commits by default
|
|
209
|
+
f.write(os.path.basename(out) + "\n")
|
|
210
|
+
with open(out, "w", encoding="utf-8") as f:
|
|
211
|
+
f.write("\n".join(lines))
|
|
212
|
+
print(t("idx.written", path=out, tickets=len(by_ticket), rest=len(no_ticket)))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ---------- groups / labels ----------
|
|
216
|
+
def cmd_label(args):
|
|
217
|
+
L.run(limit=args.limit, retry_errors=args.retry_errors)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def cmd_dump(args):
|
|
221
|
+
ss = digests.load_sessions()
|
|
222
|
+
labels = {} if args.no_labels else L.load_labels()
|
|
223
|
+
groups, member_of, _, _ = G.resolve_groups(ss, labels, G.load_overrides()) # a broken override file raises here
|
|
224
|
+
if args.json:
|
|
225
|
+
json.dump({"groups": groups, "member_of": member_of}, sys.stdout, ensure_ascii=False, indent=1, default=list)
|
|
226
|
+
print()
|
|
227
|
+
return
|
|
228
|
+
by_id = {d["id"]: d for d in ss}
|
|
229
|
+
for g in sorted(groups.values(), key=lambda g: g["last"], reverse=True):
|
|
230
|
+
print("\n## %s — %s (%d)" % (g["key"], g["title"], len(g["sessions"])))
|
|
231
|
+
for sid in g["sessions"]:
|
|
232
|
+
d = by_id[sid]
|
|
233
|
+
lb = labels.get(sid) or {}
|
|
234
|
+
print(" %s %s %-6s %s" % (fmt_date(d["last_ts"]), sid[:8], d["tool"], one_line(lb.get("summary") or digests.title_of(d), 90)))
|
|
235
|
+
print("\n" + t("cli.dump_footer", n=len(ss), g=len(groups), unlabeled=sum(1 for d in ss if d["id"] not in labels)))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ---------- picker ----------
|
|
239
|
+
def cmd_pick(args):
|
|
240
|
+
from .model import Model
|
|
241
|
+
from .tui.app import Picker
|
|
242
|
+
act = Picker(Model(no_llm=args.no_llm), " ".join(args.query)).run()
|
|
243
|
+
if not act or act[0] == "quit":
|
|
244
|
+
return
|
|
245
|
+
_, cmd, cwd = act
|
|
246
|
+
shown = cmd if act[0] == "print" or len(cmd[-1]) < 200 else cmd[:-1] + ["<prompt: %d chars>" % len(cmd[-1])]
|
|
247
|
+
print("cd %s && %s" % (shlex.quote(cwd), shlex.join(shown)))
|
|
248
|
+
if act[0] == "print":
|
|
249
|
+
return
|
|
250
|
+
os.chdir(cwd)
|
|
251
|
+
try:
|
|
252
|
+
os.execvp(cmd[0], cmd)
|
|
253
|
+
except OSError as e:
|
|
254
|
+
sys.exit(t("cli.exec_failed", cmd=cmd[0], err=e))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ---------- config / doctor ----------
|
|
258
|
+
STARTER = '''# ticketrail workspace configuration — see docs/configuration.md
|
|
259
|
+
# lang = "en" # "en" or "vi"
|
|
260
|
+
|
|
261
|
+
[tickets]
|
|
262
|
+
# patterns = ['\\bPROJ-\\d+\\b'] # default: any Jira-style key (ABC-123)
|
|
263
|
+
# url = "https://jira.example.com/browse/{key}"
|
|
264
|
+
|
|
265
|
+
[naming]
|
|
266
|
+
# tags = ["OPS", "DOC"] # session-name prefixes for work without a ticket
|
|
267
|
+
|
|
268
|
+
[labeler]
|
|
269
|
+
# enabled = true
|
|
270
|
+
# context = "What this workspace is about, in one or two sentences."
|
|
271
|
+
'''
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def cmd_config(args):
|
|
275
|
+
cfg = config.current()
|
|
276
|
+
if args.action == "init":
|
|
277
|
+
p = os.path.join(cfg.root, config.CONFIG_FILE)
|
|
278
|
+
if os.path.exists(p):
|
|
279
|
+
sys.exit(t("cfg.exists", path=p))
|
|
280
|
+
with open(p, "w", encoding="utf-8") as f:
|
|
281
|
+
f.write(STARTER)
|
|
282
|
+
print(t("cfg.written", path=p))
|
|
283
|
+
return
|
|
284
|
+
print("root : " + cfg.root)
|
|
285
|
+
print("config files: " + (", ".join(cfg.sources) or "(defaults only)"))
|
|
286
|
+
print("cache : " + cfg.cache_dir)
|
|
287
|
+
print("groups file : " + cfg.groups_file)
|
|
288
|
+
print()
|
|
289
|
+
print(json.dumps(cfg.data, indent=2, ensure_ascii=False))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def cmd_doctor(args):
|
|
293
|
+
cfg = config.current()
|
|
294
|
+
ok = True
|
|
295
|
+
|
|
296
|
+
def line(good, label, detail=""):
|
|
297
|
+
nonlocal ok
|
|
298
|
+
ok = ok and good is not False
|
|
299
|
+
mark = {True: "ok ", False: "ERR", None: " - "}[good]
|
|
300
|
+
print("[%s] %-22s %s" % (mark, label, detail))
|
|
301
|
+
|
|
302
|
+
line(True, "workspace root", cfg.root)
|
|
303
|
+
line(True, "config files", ", ".join(cfg.sources) or "(defaults only)")
|
|
304
|
+
for name, src in SOURCES.items():
|
|
305
|
+
n = len(src.files(cfg))
|
|
306
|
+
line(True if n else None, "%s transcripts" % name, str(n))
|
|
307
|
+
for tool in ("claude", "codex"):
|
|
308
|
+
p = shutil.which(tool)
|
|
309
|
+
line(True if p else None, "%s on PATH" % tool, p or "not found (cannot resume %s sessions)" % tool)
|
|
310
|
+
if shutil.which("claude"):
|
|
311
|
+
from . import live
|
|
312
|
+
line(True if live.claude_live() is not None else None, "live state",
|
|
313
|
+
"claude agents --json" if live.claude_live() is not None else "unavailable (Claude Code < 2.1.139?): transcript activity is used")
|
|
314
|
+
if cfg.github_prs:
|
|
315
|
+
from . import signals
|
|
316
|
+
g = shutil.which("gh")
|
|
317
|
+
n = len(signals.github_prs(wait=True)["heads"]) if g else 0
|
|
318
|
+
line(True if g and n else False, "github pull requests", "%d branches with PRs" % n if g else "gh not found — install the GitHub CLI or set github.pull_requests = false")
|
|
319
|
+
if cfg.labeler_enabled:
|
|
320
|
+
p = shutil.which(cfg.labeler_command)
|
|
321
|
+
line(True if p else False, "labeler command", p or "%s not found — set labeler.enabled = false" % cfg.labeler_command)
|
|
322
|
+
else:
|
|
323
|
+
line(None, "labeler", "disabled")
|
|
324
|
+
line(True, "cache", cfg.cache_dir)
|
|
325
|
+
labels = L.load_labels()
|
|
326
|
+
line(True, "labels", str(len(labels)))
|
|
327
|
+
try:
|
|
328
|
+
ov = G.load_overrides()
|
|
329
|
+
line(True, "groups file", "%s (%d groups, %d moved sessions)" % (cfg.groups_file, len(ov["groups"]), len(ov["sessions"]))
|
|
330
|
+
if os.path.exists(cfg.groups_file) else "%s (not created yet)" % cfg.groups_file)
|
|
331
|
+
except G.OverrideError as e:
|
|
332
|
+
line(False, "groups file", str(e))
|
|
333
|
+
sys.exit(0 if ok else 1)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def build_parser():
|
|
337
|
+
ap = argparse.ArgumentParser(prog="ticketrail", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
338
|
+
ap.add_argument("--version", action="version", version="%(prog)s " + __version__)
|
|
339
|
+
ap.add_argument("--root", help="workspace root (default: nearest .ticketrail.toml, git toplevel, or cwd)")
|
|
340
|
+
ap.add_argument("--lang", choices=["en", "vi"], help="interface language")
|
|
341
|
+
sub = ap.add_subparsers(dest="cmd")
|
|
342
|
+
|
|
343
|
+
def add_source(p):
|
|
344
|
+
p.add_argument("--source", choices=["all", "codex", "claude"], default="all")
|
|
345
|
+
|
|
346
|
+
a = sub.add_parser("pick", help="interactive picker")
|
|
347
|
+
a.add_argument("query", nargs="*")
|
|
348
|
+
a.add_argument("--no-llm", action="store_true", help="never start the background labeler")
|
|
349
|
+
a.set_defaults(fn=cmd_pick)
|
|
350
|
+
a = sub.add_parser("list", help="list sessions")
|
|
351
|
+
a.add_argument("--days", type=int, default=30)
|
|
352
|
+
a.add_argument("--task", help="filter by ticket key or words in the name")
|
|
353
|
+
a.add_argument("--all", action="store_true", help="no date limit")
|
|
354
|
+
add_source(a)
|
|
355
|
+
a.set_defaults(fn=cmd_list)
|
|
356
|
+
a = sub.add_parser("find", help="full-text search")
|
|
357
|
+
a.add_argument("query", nargs="+")
|
|
358
|
+
a.add_argument("--deep", action="store_true", help="also count tool results (slower, noisier)")
|
|
359
|
+
a.add_argument("--brief", action="store_true", help="newest first, longer conclusions (for hand-offs)")
|
|
360
|
+
a.add_argument("--limit", type=int, default=12)
|
|
361
|
+
add_source(a)
|
|
362
|
+
a.set_defaults(fn=cmd_find)
|
|
363
|
+
a = sub.add_parser("show", help="digest of one session")
|
|
364
|
+
a.add_argument("key")
|
|
365
|
+
add_source(a)
|
|
366
|
+
a.set_defaults(fn=cmd_show)
|
|
367
|
+
a = sub.add_parser("index", help="write a markdown index by ticket")
|
|
368
|
+
a.add_argument("--out", help="output file (default: paths.index_file)")
|
|
369
|
+
add_source(a)
|
|
370
|
+
a.set_defaults(fn=cmd_index)
|
|
371
|
+
a = sub.add_parser("label", help="label idle sessions with the LLM")
|
|
372
|
+
a.add_argument("--limit", type=int, default=0)
|
|
373
|
+
a.add_argument("--retry-errors", action="store_true")
|
|
374
|
+
a.set_defaults(fn=cmd_label)
|
|
375
|
+
a = sub.add_parser("dump", help="print work items and their sessions")
|
|
376
|
+
a.add_argument("--json", action="store_true")
|
|
377
|
+
a.add_argument("--no-labels", action="store_true", help="ignore LLM labels (tickets and manual changes only)")
|
|
378
|
+
a.set_defaults(fn=cmd_dump)
|
|
379
|
+
a = sub.add_parser("config", help="show the effective configuration or write a starter file")
|
|
380
|
+
a.add_argument("action", nargs="?", choices=["show", "init"], default="show")
|
|
381
|
+
a.set_defaults(fn=cmd_config)
|
|
382
|
+
a = sub.add_parser("doctor", help="check paths, tools and caches")
|
|
383
|
+
a.set_defaults(fn=cmd_doctor)
|
|
384
|
+
return ap
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def main(argv=None):
|
|
388
|
+
ap = build_parser()
|
|
389
|
+
args = ap.parse_args(argv)
|
|
390
|
+
if args.cmd is None: # bare `ticketrail` opens the picker
|
|
391
|
+
args = ap.parse_args((argv or sys.argv[1:]) + ["pick"])
|
|
392
|
+
try:
|
|
393
|
+
config.use(config.load(root=args.root, overrides={"lang": args.lang} if args.lang else None))
|
|
394
|
+
except config.ConfigError as e:
|
|
395
|
+
sys.exit("config: %s" % e)
|
|
396
|
+
args.fn(args)
|