factorio-mod-workshop 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.
fmw/cli.py ADDED
@@ -0,0 +1,1431 @@
1
+ """fmw command line interface.
2
+
3
+ Every command accepts --json so an agent can consume structured output;
4
+ without it the output is compact human-readable text.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import os
12
+ import re
13
+ import shutil
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import NamedTuple
17
+
18
+ import yaml
19
+
20
+ from . import discover, help as helptext, store, threadstate, tracker
21
+ from .config import WORKSPACE_ENV, Workspace, WorkspaceError, template_dir
22
+ from .portal import Portal, PortalError, abbrev_tag, parse_marker
23
+
24
+ URL_RE = re.compile(r"mods\.factorio\.com/mod/([^/\s]+)(?:/discussion/([0-9a-f]{24}))?")
25
+
26
+
27
+ def out(data, as_json: bool, text: str | None = None) -> None:
28
+ if as_json:
29
+ print(json.dumps(data, indent=2, ensure_ascii=False))
30
+ elif text is not None:
31
+ print(text)
32
+
33
+
34
+ def parse_target(value: str) -> tuple[str | None, str | None]:
35
+ """Accept a mod name, a thread id, or any mods.factorio.com URL."""
36
+ m = URL_RE.search(value)
37
+ if m:
38
+ return m.group(1), m.group(2)
39
+ if re.fullmatch(r"[0-9a-f]{24}", value):
40
+ return None, value
41
+ return value, None
42
+
43
+
44
+ def with_marker(t: dict) -> dict:
45
+ """Annotate a cached thread summary with its derived title marker."""
46
+ m = parse_marker(t.get("title", ""))
47
+ return {
48
+ **t,
49
+ "marker": m.marker,
50
+ "resolved_on_portal": m.resolved,
51
+ "wrong_on_portal": m.wrong,
52
+ "clean_title": m.clean,
53
+ }
54
+
55
+
56
+ class Detection(NamedTuple):
57
+ mod: str | None
58
+ source: str # how it was determined, for the announcement
59
+ candidates: list # mods below cwd when the answer was ambiguous
60
+
61
+
62
+ def detect_mod_from_cwd(ws: Workspace, start: Path | None = None) -> Detection:
63
+ """Infer which mod the user is working on from the current directory.
64
+
65
+ Three cases, in order of confidence:
66
+
67
+ 1. An info.json in the cwd or an ancestor - that is what defines a mod.
68
+ 2. The cwd sits inside a checkout recorded by `fmi discover`.
69
+ 3. The cwd *contains* exactly one recorded checkout. This is the normal
70
+ Factorio single-mod repository, where the git root wraps a directory
71
+ that must be named after the mod. Ambiguous when the cwd contains
72
+ several - a monorepo root - and then no mod is inferred.
73
+ """
74
+ cwd = (start or Path.cwd()).resolve()
75
+ for d in [cwd, *cwd.parents]:
76
+ info = d / "info.json"
77
+ if info.is_file():
78
+ try:
79
+ name = json.loads(info.read_text()).get("name")
80
+ except (json.JSONDecodeError, UnicodeDecodeError, OSError):
81
+ name = None
82
+ if name:
83
+ return Detection(name, str(info), [])
84
+
85
+ paths = {name: Path(p).resolve() for name, p in ws.load_paths().items() if p}
86
+ for name, path in paths.items():
87
+ if cwd == path or path in cwd.parents:
88
+ return Detection(name, str(path), [])
89
+
90
+ below = sorted(name for name, path in paths.items() if cwd in path.parents)
91
+ if len(below) == 1:
92
+ return Detection(below[0], f"the only mod under {cwd}", below)
93
+ return Detection(None, "", below)
94
+
95
+
96
+ def announce(detection: Detection, stream=None) -> None:
97
+ """Say which scope was chosen, including when nothing was inferred.
98
+
99
+ Silence on the fallback path is how a plausible answer about the wrong
100
+ scope goes unnoticed, so both branches report.
101
+ """
102
+ stream = stream or sys.stderr
103
+ if detection.mod:
104
+ print(f"(using {detection.mod}, detected from {detection.source})", file=stream)
105
+ elif detection.candidates:
106
+ print(f"(cwd contains {len(detection.candidates)} mods - too ambiguous to narrow; "
107
+ "name one explicitly)", file=stream)
108
+
109
+
110
+ def open_ws(args) -> Workspace:
111
+ try:
112
+ return Workspace.open(getattr(args, "workspace", None))
113
+ except WorkspaceError as exc:
114
+ sys.exit(str(exc))
115
+
116
+
117
+ def resolve_mods(ws: Workspace, names: list[str], all_flag: bool, quiet: bool = False) -> list[str]:
118
+ registry = ws.load_mods()["mods"]
119
+ if not names and not all_flag:
120
+ # Working inside a mod checkout is a clear enough signal to act on just
121
+ # that mod. Always say which way it went, so neither the narrow scope
122
+ # nor the fallback to every mod is a surprise.
123
+ detection = detect_mod_from_cwd(ws)
124
+ if not quiet:
125
+ announce(detection)
126
+ if detection.mod:
127
+ return [detection.mod]
128
+ if all_flag or not names:
129
+ chosen = [n for n, m in registry.items() if m.get("on_portal") is not False]
130
+ if not all_flag and not names:
131
+ chosen = [n for n in chosen if registry[n].get("maintained") != "frozen"]
132
+ return chosen
133
+ resolved = []
134
+ for n in names:
135
+ mod, _ = parse_target(n)
136
+ if mod:
137
+ resolved.append(mod)
138
+ return resolved
139
+
140
+
141
+ # ----------------------------------------------------------------------
142
+ # commands
143
+ # ----------------------------------------------------------------------
144
+ def cmd_init(args) -> None:
145
+ path = Path(args.path).expanduser().resolve()
146
+ copied, skipped = [], []
147
+ with template_dir() as template:
148
+ if not template.is_dir():
149
+ sys.exit(f"Bundled template directory is missing or unreadable: {template}")
150
+ for src in sorted(template.rglob("*")):
151
+ if src.is_dir():
152
+ continue
153
+ rel = src.relative_to(template)
154
+ dst = path / rel
155
+ if dst.exists():
156
+ skipped.append(str(rel))
157
+ continue
158
+ dst.parent.mkdir(parents=True, exist_ok=True)
159
+ shutil.copy2(src, dst)
160
+ copied.append(str(rel))
161
+ if not copied and not skipped:
162
+ sys.exit("Bundled template directory is empty - the installation is broken.")
163
+ for sub in ("cache", "tracker", "notes"):
164
+ (path / sub).mkdir(parents=True, exist_ok=True)
165
+
166
+ if args.json:
167
+ out({"workspace": str(path), "copied": copied, "kept": skipped}, True)
168
+ return
169
+ print(f"Workspace ready at {path}")
170
+ for rel in copied:
171
+ print(f" + {rel}")
172
+ for rel in skipped:
173
+ print(f" = {rel} (kept, already present)")
174
+ print("\nNext:")
175
+ print(f" fmw launcher --workspace {path} # one-time: puts `fmw` on your PATH, pinned to this workspace")
176
+ print(f" $EDITOR {path / 'config.yaml'} # set mod_repos")
177
+ print(" fmw discover")
178
+ if not (path / ".git").exists():
179
+ print("\nTip: `git init` inside it to version your mod data separately.")
180
+
181
+
182
+ WINDOWS = os.name == "nt"
183
+
184
+ # Pins a workspace so `fmw` runs from anywhere - typically from inside a mod
185
+ # repository - without setting FMW_WORKSPACE by hand. A value already in the
186
+ # environment still wins, so one-off overrides keep working.
187
+ LAUNCHER_SH = """#!/usr/bin/env sh
188
+ # Generated by `fmw launcher`. Regenerate if either directory moves.
189
+ # POSIX sh, so it works under bash, zsh, dash, ash and friends.
190
+ set -e
191
+
192
+ : "${{FMW_WORKSPACE:={workspace}}}"
193
+ export FMW_WORKSPACE
194
+
195
+ exec {runner} "$@"
196
+ """
197
+
198
+ LAUNCHER_CMD = """@echo off
199
+ rem Generated by `fmw launcher`. Regenerate if either directory moves.
200
+ if not defined FMW_WORKSPACE set "FMW_WORKSPACE={workspace}"
201
+ {runner} %*
202
+ """
203
+
204
+
205
+ def _path_dirs() -> list[Path]:
206
+ return [Path(p).expanduser() for p in os.environ.get("PATH", "").split(os.pathsep) if p]
207
+
208
+
209
+ def _bin_candidates() -> list[Path]:
210
+ """Conventional per-user executable directories for this platform."""
211
+ if WINDOWS:
212
+ # Where `uv tool install` places shims, plus the Unix-style fallback
213
+ # uv also honours.
214
+ appdata = os.environ.get("APPDATA")
215
+ dirs = [Path(appdata) / "uv" / "bin"] if appdata else []
216
+ return dirs + [Path.home() / ".local" / "bin"]
217
+ return [Path.home() / ".local" / "bin", Path.home() / "bin"]
218
+
219
+
220
+ def _default_bin_dir() -> tuple[Path, bool]:
221
+ """Pick a directory to install the launcher into, and whether it is on PATH."""
222
+ on_path = _path_dirs()
223
+ for candidate in _bin_candidates():
224
+ if candidate in on_path:
225
+ return candidate, True
226
+ return _bin_candidates()[0], False
227
+
228
+
229
+ def _path_hint(directory: Path) -> str:
230
+ """How to put `directory` on PATH, in the user's actual shell."""
231
+ if WINDOWS:
232
+ return f' setx PATH "%PATH%;{directory}"'
233
+ shell = Path(os.environ.get("SHELL", "sh")).name
234
+ profile = {
235
+ "zsh": "~/.zshrc",
236
+ "bash": "~/.bashrc",
237
+ "fish": "~/.config/fish/config.fish",
238
+ }.get(shell, "your shell profile")
239
+ if shell == "fish":
240
+ return f" fish_add_path {directory}"
241
+ return f" echo 'export PATH=\"{directory}:$PATH\"' >> {profile}"
242
+
243
+
244
+ def _is_ephemeral_install(path: Path) -> bool:
245
+ """True if the interpreter lives in a throwaway environment.
246
+
247
+ `uvx` builds the package into ~/.cache/uv/archive-v0/<hash>/ and that
248
+ directory is removed by `uv cache prune`, so baking its path into a
249
+ launcher produces a wrapper that works until the next cache cleanup.
250
+ """
251
+ return "archive-v0" in path.parts
252
+
253
+
254
+ def _under_uv_tool_dir(path: Path) -> bool:
255
+ """True if `path` belongs to a `uv tool install` environment."""
256
+ roots = []
257
+ env = os.environ.get("UV_TOOL_DIR")
258
+ if env:
259
+ roots.append(Path(env).expanduser())
260
+ if WINDOWS:
261
+ appdata = os.environ.get("APPDATA")
262
+ if appdata:
263
+ roots.append(Path(appdata) / "uv" / "tools")
264
+ else:
265
+ roots.append(Path.home() / ".local" / "share" / "uv" / "tools")
266
+ for root in roots:
267
+ try:
268
+ path.relative_to(root.resolve())
269
+ return True
270
+ except (ValueError, OSError):
271
+ continue
272
+ return False
273
+
274
+
275
+ def cmd_launcher(args) -> None:
276
+ ws = open_ws(args)
277
+ venv_bin = Path(sys.executable).parent
278
+ if _is_ephemeral_install(venv_bin):
279
+ sys.exit(
280
+ f"Refusing to write a launcher pointing at {venv_bin}.\n"
281
+ "This looks like an ephemeral `uvx` environment, which `uv cache prune` "
282
+ "deletes - the launcher would break silently.\n"
283
+ "For a persistent install use: uv tool install "
284
+ "git+https://github.com/AivanF/factorio-mod-workshop\n"
285
+ f"then set the workspace with: export FMW_WORKSPACE={ws.root}"
286
+ )
287
+ console = venv_bin / ("fmw.exe" if WINDOWS else "fmw")
288
+ runner = f'"{console}"' if console.exists() else f'"{sys.executable}" -m fmw.cli'
289
+
290
+ warn_path = False
291
+ if args.out:
292
+ dest = Path(args.out).expanduser()
293
+ else:
294
+ bin_dir, on_path = _default_bin_dir()
295
+ warn_path = not on_path
296
+ dest = bin_dir / (f"{args.name}.cmd" if WINDOWS else args.name)
297
+
298
+ if dest.exists() and not args.force:
299
+ extra = ""
300
+ if dest.is_symlink() and _under_uv_tool_dir(dest.resolve()):
301
+ extra = (
302
+ "\nThat is the shim from `uv tool install`, so `fmw` already works everywhere; "
303
+ f"you only need the workspace:\n export FMW_WORKSPACE={ws.root}\n"
304
+ "Or install this wrapper under another name with --name."
305
+ )
306
+ sys.exit(
307
+ f"{dest} already exists. Pass --force to overwrite, or --out/--name to "
308
+ f"choose another.{extra}"
309
+ )
310
+
311
+ dest.parent.mkdir(parents=True, exist_ok=True)
312
+ template = LAUNCHER_CMD if WINDOWS else LAUNCHER_SH
313
+ dest.write_text(template.format(workspace=ws.root, runner=runner))
314
+ if not WINDOWS:
315
+ dest.chmod(0o755)
316
+
317
+ if args.json:
318
+ out({"launcher": str(dest), "workspace": str(ws.root), "runner": runner,
319
+ "on_path": not warn_path}, True)
320
+ return
321
+ print(f"Installed {dest}")
322
+ print(f" workspace: {ws.root}")
323
+ print(f" runner: {runner}")
324
+ if warn_path:
325
+ print(f"\n{dest.parent} is not on your PATH. Add it:")
326
+ print(_path_hint(dest.parent))
327
+ else:
328
+ print(f"\n`{dest.name}` is now runnable from any directory.")
329
+
330
+
331
+ # Files `fmw init` copies that stay owned by the tool: they document the current
332
+ # schema, so a workspace created before a schema change keeps teaching the old
333
+ # one. config.yaml is deliberately absent - it becomes yours on first edit.
334
+ TEMPLATE_MANAGED = ("README.md", "tracker/README.md", "config.example.yaml", ".gitignore")
335
+
336
+
337
+ def outdated_template_files(ws) -> list[str]:
338
+ stale = []
339
+ with template_dir() as template:
340
+ if not template.is_dir():
341
+ return []
342
+ for rel in TEMPLATE_MANAGED:
343
+ src, dst = template / rel, ws.root / rel
344
+ if not src.is_file():
345
+ continue
346
+ if not dst.exists() or dst.read_text() != src.read_text():
347
+ stale.append(rel)
348
+ return stale
349
+
350
+
351
+ def cmd_upgrade(args) -> None:
352
+ """Refresh the tool-owned documentation inside a workspace.
353
+
354
+ Only files listed in TEMPLATE_MANAGED are touched; your registry, tickets,
355
+ thread judgements, cache and config.yaml are never rewritten.
356
+ """
357
+ ws = open_ws(args)
358
+ changed = []
359
+ with template_dir() as template:
360
+ if not template.is_dir():
361
+ sys.exit(f"Bundled templates not found: {template}")
362
+ for rel in TEMPLATE_MANAGED:
363
+ src, dst = template / rel, ws.root / rel
364
+ if not src.is_file():
365
+ continue
366
+ new_text = src.read_text()
367
+ if dst.exists() and dst.read_text() == new_text:
368
+ continue
369
+ dst.parent.mkdir(parents=True, exist_ok=True)
370
+ action = "updated" if dst.exists() else "added"
371
+ if not args.dry_run:
372
+ dst.write_text(new_text)
373
+ changed.append(f"{action} {rel}")
374
+ # mods.yaml: bring `maintained` onto the current vocabulary.
375
+ registry = ws.load_mods() # load_mods already normalises on read
376
+ raw = yaml.safe_load(ws.mods_file.read_text()) if ws.mods_file.exists() else None
377
+ if raw:
378
+ for name, entry in (raw.get("mods") or {}).items():
379
+ before = entry.get("maintained")
380
+ after = discover.normalise_maintained(before)
381
+ if before != after:
382
+ changed.append(f"maintained: {name} {before!r} -> {after}")
383
+ if not args.dry_run and any(c.startswith("maintained:") for c in changed):
384
+ ws.save_mods(registry)
385
+
386
+ ticket_changes = migrate_tickets(ws, dry_run=args.dry_run)
387
+ changed += [f"ticket {old} -> {new}" for old, new in ticket_changes]
388
+
389
+ if args.json:
390
+ out({"changed": changed, "dry_run": args.dry_run}, True)
391
+ return
392
+ for c in changed:
393
+ print(f" {c}")
394
+ if not changed:
395
+ print("Workspace is already current.")
396
+ elif args.dry_run:
397
+ print(f"{len(changed)} change(s) pending. Re-run without --dry-run.")
398
+ else:
399
+ print(f"{len(changed)} change(s) applied. Your tickets, judgements and cache "
400
+ "keep their content.")
401
+
402
+
403
+ def cmd_repo(args) -> None:
404
+ """Manage the directories `fmw discover` looks in."""
405
+ ws = open_ws(args)
406
+ if args.repo_cmd == "add":
407
+ path = Path(args.path).expanduser()
408
+ if not path.is_dir():
409
+ sys.exit(f"Not a directory: {path}")
410
+ found, _ = discover.scan_local([path])
411
+ repos = ws.edit_mod_repos(add=args.path)
412
+ if args.json:
413
+ out({"repos": repos, "found": [e["name"] for e in found]}, True)
414
+ return
415
+ print(f"Added {args.path} ({len(found)} mod(s) found)")
416
+ for e in found:
417
+ print(f" {e['name']} {e['local_version']}")
418
+ print("\nRun `fmw discover` to add them to the registry.")
419
+ return
420
+
421
+ if args.repo_cmd == "remove":
422
+ before = ws.mod_repos()
423
+ repos = ws.edit_mod_repos(remove=args.path)
424
+ if args.json:
425
+ out({"repos": repos, "removed": len(before) - len(repos)}, True)
426
+ return
427
+ if len(repos) == len(before):
428
+ print(f"{args.path} was not listed. Current repositories:")
429
+ for r in repos:
430
+ print(f" {r}")
431
+ else:
432
+ print(f"Removed {args.path}. Registry entries are kept - "
433
+ "delete them from mods.yaml if you want them gone.")
434
+ return
435
+
436
+ repos = ws.mod_repos()
437
+ paths = ws.load_paths()
438
+ if args.json:
439
+ out({"repos": [
440
+ {"path": r,
441
+ "mods": sorted(n for n, p in paths.items()
442
+ if str(Path(p)).startswith(str(Path(r).expanduser().resolve())))}
443
+ for r in repos]}, True)
444
+ return
445
+ if not repos:
446
+ print("No repositories configured. Add one with `fmw repo add <path>`.")
447
+ return
448
+ for r in repos:
449
+ root = str(Path(r).expanduser().resolve())
450
+ mods = sorted(n for n, p in paths.items() if str(Path(p)).startswith(root))
451
+ print(f"{r}")
452
+ print(f" {len(mods)} mod(s): {', '.join(mods) if mods else '- run `fmw discover`'}")
453
+
454
+
455
+ def cmd_discover(args) -> None:
456
+ ws = open_ws(args)
457
+ roots = [Path(r) for r in args.scan] if args.scan else []
458
+ if not roots:
459
+ cfg = ws.load_config()
460
+ roots = [Path(r) for r in (cfg.get("mod_repos") or [])]
461
+ if not roots:
462
+ sys.exit("Nothing to scan. Pass --scan DIR or set mod_repos in workspace/config.yaml")
463
+
464
+ entries, warnings = discover.scan_local(roots, max_depth=args.max_depth)
465
+ if not args.no_portal:
466
+ with Portal() as portal:
467
+ warnings += discover.enrich_from_portal(portal, entries)
468
+
469
+ paths = discover.sync_paths(ws, entries)
470
+ registry = discover.build_registry(ws.load_mods(), entries)
471
+ discover.strip_legacy_paths(registry)
472
+ ws.save_mods(registry)
473
+
474
+ if args.json:
475
+ out({"found": len(entries), "warnings": warnings,
476
+ "mods": registry["mods"], "paths": paths}, True)
477
+ return
478
+ print(f"Registered {len(entries)} mods in {ws.mods_file}")
479
+ print(f"Local checkout paths in {ws.paths_file}")
480
+ for w in warnings:
481
+ print(f" ! {w}")
482
+ print("Review `maintained:` (active/frozen/unknown) and `notes:` for each mod.")
483
+
484
+
485
+ def cmd_mods(args) -> None:
486
+ ws = open_ws(args)
487
+ mods = ws.load_mods()["mods"]
488
+ if args.maintained:
489
+ mods = {k: v for k, v in mods.items() if v.get("maintained") == args.maintained}
490
+ if args.json:
491
+ paths = ws.load_paths()
492
+ out({k: {**v, "path": paths.get(k, "")} for k, v in mods.items()}, True)
493
+ return
494
+
495
+ def cell(value) -> str:
496
+ return str(value) if value not in (None, "", 0) else "-"
497
+
498
+ # Single-row header: a two-row one with spanning labels falls apart as soon
499
+ # as the terminal wraps. Abbreviations are expanded in the legend below.
500
+ print(
501
+ f"{'':<2}{'mod':<38} {'maint':<8} {'v.loc':<9} {'v.mp':<9} "
502
+ f"{'F.loc':<6} {'F.mp':<6} {'dl':>9}"
503
+ )
504
+ differing = 0
505
+ for name, m in mods.items():
506
+ loc, mp = m.get("local_version"), m.get("portal_version")
507
+ # Only flag it. Why the two differ is not inferable: unreleased work,
508
+ # an out-of-date checkout, or a deliberate local edit all look alike.
509
+ mark = ""
510
+ if loc and mp and loc != mp:
511
+ mark = "~"
512
+ differing += 1
513
+ print(
514
+ f"{mark:<2}{name:<38} {str(m.get('maintained', '?')):<8} "
515
+ f"{cell(loc):<9} {cell(mp):<9} "
516
+ f"{cell(m.get('factorio_version')):<6} {cell(m.get('portal_factorio_version')):<6} "
517
+ f"{cell(m.get('downloads')):>9}"
518
+ )
519
+ print(f"\n{len(mods)} mods "
520
+ "v = mod version, F = Factorio version, loc = local checkout, "
521
+ "mp = mod portal, dl = downloads")
522
+ if differing:
523
+ print(f"~ = local and published versions differ ({differing} mods). Reasons vary and are "
524
+ "not inferable from the numbers - ask the maintainer before changing such a checkout.")
525
+
526
+ unchecked = [
527
+ n for n, m in mods.items()
528
+ if m.get("on_portal") is not False and not m.get("portal_version")
529
+ ]
530
+ if unchecked:
531
+ print(
532
+ f"{len(unchecked)} mods have no portal data yet (portal columns show '-'). "
533
+ "Run `fmw discover` without --no-portal to fill them in."
534
+ )
535
+ unpublished = [n for n, m in mods.items() if m.get("on_portal") is False]
536
+ if unpublished:
537
+ print(f"Not published on the portal: {', '.join(unpublished)}")
538
+
539
+
540
+ def cmd_sync(args) -> None:
541
+ ws = open_ws(args)
542
+ names = resolve_mods(ws, args.mods, args.all)
543
+ if not names:
544
+ sys.exit("No mods selected. Run `fmw discover` first, or name a mod explicitly.")
545
+
546
+ report: dict = {"mods": {}}
547
+ with Portal() as portal:
548
+ for mod in names:
549
+ try:
550
+ threads, warns = portal.thread_list(mod)
551
+ except PortalError as exc:
552
+ report["mods"][mod] = {"error": str(exc)}
553
+ if not args.json:
554
+ print(f"{mod}: ERROR {exc}")
555
+ continue
556
+
557
+ changes = store.diff_lists(store.load_list(ws, mod), threads)
558
+ store.save_list(ws, mod, threads, warns)
559
+ store.append_changelog(ws, mod, changes)
560
+
561
+ fetched = []
562
+ if not args.no_threads:
563
+ have = store.cached_thread_ids(ws, mod)
564
+ touched = {c["id"] for c in changes}
565
+ todo = [t for t in threads if t.id in touched or t.id not in have]
566
+ for t in todo:
567
+ store.save_thread(ws, portal.thread(mod, t.id))
568
+ fetched.append(t.id)
569
+
570
+ drift = _status_drift(ws, mod, threads)
571
+ report["mods"][mod] = {
572
+ "threads": len(threads),
573
+ "changes": changes,
574
+ "fetched": fetched,
575
+ "warnings": warns,
576
+ "drift": drift,
577
+ }
578
+ if not args.json:
579
+ head = f"{mod}: {len(threads)} threads"
580
+ if changes:
581
+ head += f", {len(changes)} change(s)"
582
+ print(head)
583
+ for c in changes:
584
+ mark = "NEW " if c["kind"] == "new_thread" else "REPLY"
585
+ print(f" {mark} [{c.get('tag','')}] {c['title']} ({c.get('author')}, {c.get('at','')[:16]})")
586
+ print(f" https://mods.factorio.com/mod/{mod}/discussion/{c['id']}")
587
+ for w in warns:
588
+ print(f" ! {w}")
589
+ for d in drift:
590
+ print(f" ~ {d}")
591
+ if args.json:
592
+ out(report, True)
593
+
594
+
595
+ def require_mod(ws: Workspace, value: str | None) -> str:
596
+ """A mod name from the argument, or inferred from the current directory."""
597
+ if value:
598
+ mod, _ = parse_target(value)
599
+ if mod:
600
+ return mod
601
+ detection = detect_mod_from_cwd(ws)
602
+ announce(detection)
603
+ if detection.mod:
604
+ return detection.mod
605
+ sys.exit("No mod given, and the current directory does not identify one.")
606
+
607
+
608
+ def _status_drift(ws, mod: str, threads) -> list[str]:
609
+ """Where the portal title and the ticket disagree about being done.
610
+
611
+ The two are maintained by hand in different places, so they drift; each
612
+ direction means a different piece of unfinished work.
613
+ """
614
+ index = tracker.index_by_thread(tracker.load_all(ws, mod))
615
+ notes: list[str] = []
616
+ for t in threads:
617
+ tickets = index.get(t.id)
618
+ if not tickets:
619
+ continue
620
+ m = parse_marker(t.title)
621
+ open_ones = [i for i in tickets if i.meta.get("status") in tracker.open_statuses()]
622
+ if m.resolved and open_ones:
623
+ notes.append(
624
+ f"thread marked '{m.marker}' but ticket "
625
+ f"{', '.join(i.id for i in open_ones)} is still open: {m.clean[:44]}"
626
+ )
627
+ elif not m.resolved and tickets and not open_ones:
628
+ notes.append(
629
+ f"ticket {', '.join(i.id for i in tickets)} is done but the thread "
630
+ f"is not marked: {m.clean[:44]}"
631
+ )
632
+ return notes
633
+
634
+
635
+ def cmd_threads(args) -> None:
636
+ ws = open_ws(args)
637
+ mod = require_mod(ws, args.mod)
638
+ data = store.load_list(ws, mod)
639
+ if not data:
640
+ sys.exit(f"No cached threads for {mod}. Run `fmw sync {mod}` first.")
641
+ threads = data["threads"]
642
+ if args.tag:
643
+ want = args.tag.lower()
644
+ threads = [t for t in threads
645
+ if t["tag"].lower().startswith(want) or abbrev_tag(t["tag"]).startswith(want)]
646
+ if args.limit:
647
+ threads = threads[: args.limit]
648
+
649
+ index = tracker.index_by_thread(tracker.load_all(ws, mod))
650
+ states = threadstate.load(ws, mod)
651
+
652
+ if args.json:
653
+ out({
654
+ "mod": mod,
655
+ "fetched_at": data["fetched_at"],
656
+ "threads": [
657
+ {**with_marker(t),
658
+ "state": states.get(t["id"], threadstate.ThreadState()).to_dict(),
659
+ "tickets": [{"id": i.id, "kind": i.meta.get("kind", ""),
660
+ "status": i.meta.get("status", ""),
661
+ "title": i.meta.get("title", "")}
662
+ for i in index.get(t["id"], [])]}
663
+ for t in threads
664
+ ],
665
+ }, True)
666
+ return
667
+
668
+ print(f"{mod} - {len(threads)} threads (cached {data['fetched_at'][:16]})")
669
+ print(f"{'tag':<5} {'last-msg':<11} {'re':>2} title")
670
+ for t in threads:
671
+ # The title is printed verbatim: any [FIXED] / (fixed) marker is part
672
+ # of it, so a separate resolved flag would only restate what is there.
673
+ print(f"{abbrev_tag(t['tag']):<5} {t['last_at'][:10]:<11} {t['reply_count']:>2} {t['title']}")
674
+ st = states.get(t["id"], threadstate.ThreadState())
675
+ bits = [b for b in (st.triage if st.triage != "pending" else "", st.cls) if b]
676
+ if st.needs_response:
677
+ bits.append("needs-response")
678
+ if st.duplicate_of:
679
+ bits.append(f"dup-of {st.duplicate_of}")
680
+ suffix = f" [{', '.join(bits)}]" if bits else ""
681
+ print(f"{'':<5} {t['id']} by {t['author']}{suffix}")
682
+ for i in index.get(t["id"], []):
683
+ state = f"{i.meta.get('kind','?')}/{i.meta.get('status','?')}"
684
+ print(f"{'':<5} -> {i.id} {state:<21} {i.meta.get('title','')}")
685
+ print("\nre = replies, last-msg = newest message, -> = ticket citing this thread")
686
+
687
+
688
+ def cmd_thread(args) -> None:
689
+ ws = open_ws(args)
690
+ mod, tid = parse_target(args.target)
691
+ if tid is None:
692
+ sys.exit("Give a thread id or a discussion URL.")
693
+ if mod is None:
694
+ mod = store.find_thread_mod(ws, tid)
695
+ data = None
696
+ if not args.refresh and mod:
697
+ data = store.load_thread(ws, mod, tid)
698
+ if data is None:
699
+ if not mod:
700
+ sys.exit(
701
+ "Unknown thread: not in the cache and no mod given. "
702
+ "Pass the full discussion URL, or run `fmw sync <mod>` first."
703
+ )
704
+ with Portal() as portal:
705
+ thread = portal.thread(mod, tid)
706
+ store.save_thread(ws, thread)
707
+ data = store.load_thread(ws, mod, tid)
708
+
709
+ if args.json:
710
+ out(data, True)
711
+ return
712
+ print(f"# {data['title']}")
713
+ print(f"_{data['mod']} - {data['tag']} - {data['url']}_\n")
714
+ for m in data["messages"]:
715
+ edited = f" (edited {m['edited_at'][:16]})" if m.get("edited_at") else ""
716
+ print(f"## {m['author']} - {m['posted_at'][:16]}{edited}")
717
+ print(m["body"])
718
+ print()
719
+
720
+
721
+ def cmd_changes(args) -> None:
722
+ ws = open_ws(args)
723
+ names = resolve_mods(ws, args.mods, args.all)
724
+ records = []
725
+ for mod in names:
726
+ records.extend(store.read_changelog(ws, mod, args.since))
727
+ records.sort(key=lambda r: r.get("at") or "", reverse=True)
728
+ if args.json:
729
+ out(records, True)
730
+ return
731
+ if not records:
732
+ print("No recorded changes." + (f" (since {args.since})" if args.since else ""))
733
+ return
734
+ print(f"{'seen':<11} {'posted':<11} what")
735
+ for r in records:
736
+ mark = "NEW " if r["kind"] == "new_thread" else "REPLY"
737
+ print(f"{r.get('seen_at','')[:10]:<11} {r.get('at','')[:10]:<11} "
738
+ f"{mark} {r['mod']} [{abbrev_tag(r.get('tag',''))}] {r['title']}")
739
+ print(f"{'':<23}https://mods.factorio.com/mod/{r['mod']}/discussion/{r['id']}")
740
+ print("\nseen = when fmw detected it (what --since filters on), "
741
+ "posted = when the message was written")
742
+
743
+
744
+ def cmd_track_new(args) -> None:
745
+ ws = open_ws(args)
746
+ mod, tid = parse_target(args.target) if args.target else (None, None)
747
+ if tid and not mod:
748
+ mod = store.find_thread_mod(ws, tid)
749
+ if not mod:
750
+ mod = require_mod(ws, None)
751
+
752
+ sources = list(args.source or [])
753
+ extra: dict = {}
754
+ title = args.title
755
+ if tid:
756
+ sources.append(f"portal:{tid}")
757
+ cached = store.load_thread(ws, mod, tid)
758
+ if cached:
759
+ m = parse_marker(cached.get("title", ""))
760
+ # Default the title to the thread's, minus the maintainer's marker,
761
+ # so creating a ticket from a thread needs no retyping.
762
+ title = title or m.clean
763
+ extra["portal_tag"] = cached.get("tag", "")
764
+ if m.marker:
765
+ extra["portal_marker"] = m.marker
766
+ extra["portal_resolved"] = m.resolved
767
+ if not title:
768
+ sys.exit("Give a title, or a thread whose title can be used.")
769
+
770
+ mods = [mod] + [m for m in (args.also or []) if m != mod]
771
+ item = tracker.create(
772
+ ws, mods=mods, title=title, kind=args.kind, status=args.status,
773
+ sources=sources, extra=extra or None,
774
+ )
775
+ if args.json:
776
+ out({**item.meta, "path": str(item.path)}, True)
777
+ return
778
+ print(f"{item.id} {item.path}")
779
+
780
+
781
+ def migrate_tickets(ws, dry_run: bool = False) -> list:
782
+ """Bring tickets written by earlier versions up to the current schema."""
783
+ items = tracker.load_all(ws)
784
+ taken = {i.id for i in items if tracker.ID_RE.match(i.id)}
785
+ changed, warnings = [], []
786
+ for item in items:
787
+ before = dict(item.meta)
788
+ if "mod" in item.meta:
789
+ item.meta = {("mods" if k == "mod" else k):
790
+ ([v] if k == "mod" else v) for k, v in item.meta.items()}
791
+ kind = item.meta.get("kind")
792
+ if kind in tracker.LEGACY_KINDS:
793
+ item.meta["kind"] = tracker.LEGACY_KINDS[kind]
794
+ elif kind and kind not in tracker.KINDS:
795
+ warnings.append(f"{item.id}: kind {kind!r} has no equivalent - set it by hand")
796
+ old_path = item.path
797
+ if not tracker.ID_RE.match(str(item.meta.get("id", ""))):
798
+ ident = tracker.new_id(taken)
799
+ taken.add(ident)
800
+ item.meta["id"] = ident
801
+ item.path = old_path.parent / f"{ident}-{tracker.slugify(item.meta.get('title',''), 40)}.md"
802
+ if item.meta != before or item.path != old_path:
803
+ if not dry_run:
804
+ item.save()
805
+ if item.path != old_path:
806
+ old_path.unlink()
807
+ changed.append((str(old_path.name), str(item.path.name)))
808
+ return changed
809
+
810
+
811
+ def cmd_track_list(args) -> None:
812
+ ws = open_ws(args)
813
+ mod = args.mod
814
+ if not mod:
815
+ detection = detect_mod_from_cwd(ws)
816
+ if not args.json:
817
+ announce(detection)
818
+ mod = detection.mod
819
+ items = tracker.load_all(ws, mod)
820
+ if args.kind:
821
+ items = [i for i in items if i.meta.get("kind") == args.kind]
822
+ if args.status:
823
+ items = [i for i in items if i.meta.get("status") == args.status]
824
+ elif args.open:
825
+ items = [i for i in items if i.meta.get("status") in tracker.open_statuses()]
826
+ if args.json:
827
+ out([{**i.meta, "path": str(i.path)} for i in items], True)
828
+ return
829
+ for i in items:
830
+ mods = "+".join(i.mods)
831
+ print(f"{i.id} [{i.meta.get('kind','?'):<7}] [{i.meta.get('status','?'):<12}] {mods:<30} {i.meta.get('title','')}")
832
+ print(f"\n{len(items)} items")
833
+
834
+
835
+ def cmd_testplan(args) -> None:
836
+ """Collect every awaiting-test fix into one document.
837
+
838
+ Each Factorio run is expensive, so the point is to verify a batch of fixes
839
+ in a single session rather than one per fix.
840
+ """
841
+ ws = open_ws(args)
842
+ mods = resolve_mods(ws, args.mods, args.all, quiet=args.json)
843
+ batch: list[dict] = []
844
+ for mod in mods:
845
+ for item in tracker.load_all(ws, mod):
846
+ if item.meta.get("status") != "needs-testing":
847
+ continue
848
+ batch.append(
849
+ {
850
+ "mod": mod,
851
+ "id": item.id,
852
+ "title": item.meta.get("title", item.id),
853
+ "kind": item.meta.get("kind", ""),
854
+ "severity": item.meta.get("severity", ""),
855
+ "fixed_in": item.meta.get("fixed_in", ""),
856
+ "path": str(item.path),
857
+ "plan": tracker.test_plan(item),
858
+ "threads": sorted(item.portal_threads()),
859
+ }
860
+ )
861
+
862
+ if args.json:
863
+ out({"items": batch, "mods": sorted({b["mod"] for b in batch})}, True)
864
+ return
865
+ if not batch:
866
+ print("Nothing awaiting testing. Set an item's status to `needs-testing` once it is fixed but unverified.")
867
+ return
868
+
869
+ by_mod: dict[str, list[dict]] = {}
870
+ for b in batch:
871
+ by_mod.setdefault(b["mod"], []).append(b)
872
+
873
+ print(f"# Test plan - {len(batch)} fix(es) across {len(by_mod)} mod(s)\n")
874
+ for mod, items in by_mod.items():
875
+ version = ws.load_mods()["mods"].get(mod, {}).get("local_version", "")
876
+ print(f"## {mod}" + (f" (local {version})" if version else ""))
877
+ paths = ws.load_paths()
878
+ if paths.get(mod):
879
+ print(f"_{paths[mod]}_")
880
+ print()
881
+ for i, b in enumerate(items, 1):
882
+ head = f"{i}. {b['title']}"
883
+ tags = [t for t in (b["kind"], b["severity"]) if t]
884
+ if tags:
885
+ head += f" [{', '.join(tags)}]"
886
+ print(head)
887
+ print(b["plan"] and indent(b["plan"]) or " _no test plan written yet_")
888
+ for tid in b["threads"]:
889
+ print(f" thread: https://mods.factorio.com/mod/{mod}/discussion/{tid}")
890
+ print(f" item: {b['path']}")
891
+ print()
892
+ print("After verifying, set each item to `status: fixed`, then `released` once published.")
893
+
894
+
895
+ def indent(text: str, prefix: str = " ") -> str:
896
+ return "\n".join(prefix + line if line.strip() else line for line in text.splitlines())
897
+
898
+
899
+ def _changes_from(args) -> dict:
900
+ changes: dict = {}
901
+ if args.triage:
902
+ changes["triage"] = args.triage
903
+ if args.set_class:
904
+ changes["cls"] = args.set_class
905
+ if args.note is not None:
906
+ changes["note"] = args.note
907
+ if args.needs_response:
908
+ changes["needs_response"] = True
909
+ if args.responded:
910
+ changes["needs_response"] = False
911
+ return changes
912
+
913
+
914
+ def cmd_mark_bulk(args) -> None:
915
+ """Apply one judgement to every pending thread older than a cutoff.
916
+
917
+ Long-dormant threads accumulate faster than they can be reviewed one at a
918
+ time; without a bulk path the inbox never empties and stops being trusted.
919
+ """
920
+ ws = open_ws(args)
921
+ changes = _changes_from(args)
922
+ if not changes:
923
+ sys.exit("Give something to set, e.g. --triage handled.")
924
+ touched: list[dict] = []
925
+ mods = resolve_mods(ws, args.targets, args.all, quiet=args.json)
926
+ if not args.targets and not args.all:
927
+ print(f"Sweeping {len(mods)} mod(s). Name mods explicitly, or --all to be sure.",
928
+ file=sys.stderr)
929
+ for mod in mods:
930
+ data = store.load_list(ws, mod)
931
+ if not data:
932
+ continue
933
+ states = threadstate.load(ws, mod)
934
+ for t in data["threads"]:
935
+ if t["last_at"][:10] >= args.before:
936
+ continue
937
+ st = states.get(t["id"], threadstate.ThreadState())
938
+ if st.triage != "pending" or st.needs_response:
939
+ continue # never silently overwrite a decision already made
940
+ if args.tag and not t["tag"].lower().startswith(args.tag.lower()):
941
+ continue
942
+ for key, value in changes.items():
943
+ setattr(st, key, value)
944
+ states[t["id"]] = st
945
+ touched.append({"mod": mod, "id": t["id"], "title": t["title"]})
946
+ threadstate.save(ws, mod, states)
947
+
948
+ if args.json:
949
+ out({"changed": touched, "count": len(touched)}, True)
950
+ return
951
+ for t in touched[:10]:
952
+ print(f" {t['mod']} {t['id']} {t['title'][:56]}")
953
+ if len(touched) > 10:
954
+ print(f" ... and {len(touched) - 10} more")
955
+ print(f"{len(touched)} thread(s) updated (already-decided threads left alone)")
956
+
957
+
958
+ def cmd_mark(args) -> None:
959
+ """Record your judgement about a thread."""
960
+ ws = open_ws(args)
961
+ if args.before:
962
+ return cmd_mark_bulk(args)
963
+ if len(args.targets) != 1:
964
+ sys.exit("Give exactly one thread id or URL, or --before DATE for a bulk update.")
965
+ mod, tid = parse_target(args.targets[0])
966
+ if not tid:
967
+ sys.exit("Give a thread id or a discussion URL.")
968
+ if not mod:
969
+ mod = store.find_thread_mod(ws, tid)
970
+ if not mod:
971
+ sys.exit(f"Thread {tid} is not in the cache. Run `fmw sync` first.")
972
+
973
+ changes: dict = {}
974
+ if args.triage:
975
+ changes["triage"] = args.triage
976
+ if args.set_class:
977
+ changes["cls"] = args.set_class
978
+ if args.note is not None:
979
+ changes["note"] = args.note
980
+ if args.duplicate_of:
981
+ changes["duplicate_of"] = args.duplicate_of
982
+ changes.setdefault("cls", "duplicate")
983
+ changes.setdefault("triage", "handled")
984
+ if args.needs_response:
985
+ changes["needs_response"] = True
986
+ if args.responded:
987
+ changes["needs_response"] = False
988
+ state = threadstate.get(ws, mod, tid)
989
+ tickets = tracker.index_by_thread(tracker.load_all(ws, mod)).get(tid, [])
990
+ pending = threadstate.pending_announcements(state, tickets)
991
+ if pending:
992
+ changes["announced"] = sorted(set(state.announced) | set(pending))
993
+ if not changes:
994
+ sys.exit("Nothing to change. Pass --triage/--class/--note/--needs-response/--responded.")
995
+
996
+ state = threadstate.update(ws, mod, tid, **changes)
997
+ if args.json:
998
+ out({"mod": mod, "thread": tid, **state.to_dict()}, True)
999
+ return
1000
+ bits = [f"triage={state.triage}"]
1001
+ if state.needs_response:
1002
+ bits.append("needs-response")
1003
+ if state.cls:
1004
+ bits.append(f"class={state.cls}")
1005
+ if state.duplicate_of:
1006
+ bits.append(f"duplicate-of={state.duplicate_of}")
1007
+ print(f"{mod} {tid}: " + ", ".join(bits))
1008
+
1009
+
1010
+ def collect_inbox(ws, mods, all_items, include_resolved: bool = False):
1011
+ """Threads still asking for a decision, plus the count hidden as resolved.
1012
+
1013
+ The single definition of "awaiting triage", shared by `inbox` and `status`
1014
+ so the dashboard number always matches the list you work from. A thread
1015
+ with a ticket stays listed - the ticket tracks the code change, not the
1016
+ conversation, which may still need a reply or a classification.
1017
+ """
1018
+ registry = ws.load_mods()["mods"]
1019
+ ticket_index = tracker.index_by_thread(all_items)
1020
+ out: list[dict] = []
1021
+ hidden_resolved = 0
1022
+ for mod in mods:
1023
+ data = store.load_list(ws, mod)
1024
+ if not data:
1025
+ continue
1026
+ states = threadstate.load(ws, mod)
1027
+ for t in data["threads"]:
1028
+ st = states.get(t["id"], threadstate.ThreadState())
1029
+ m = parse_marker(t["title"])
1030
+ tickets = ticket_index.get(t["id"], [])
1031
+ reason = threadstate.reply_reason(st, tickets)
1032
+ if st.triage == "ignored":
1033
+ continue
1034
+ if st.triage == "handled" and not reason:
1035
+ continue
1036
+ if st.triage == "pending" and m.resolved and not tickets:
1037
+ hidden_resolved += 1
1038
+ if not include_resolved:
1039
+ continue
1040
+ out.append({
1041
+ "mod": mod, "id": t["id"], "title": m.clean or t["title"],
1042
+ "tag": t["tag"], "last_at": t["last_at"], "replies": t["reply_count"],
1043
+ "needs_response": bool(reason), "reply_reason": reason,
1044
+ "triage": st.triage,
1045
+ "class": st.cls, "marker": m.marker, "wrong": m.wrong,
1046
+ "tickets": [i.id for i in tickets],
1047
+ "downloads": registry.get(mod, {}).get("downloads") or 0,
1048
+ })
1049
+ return out, hidden_resolved
1050
+
1051
+
1052
+ def _snippet(text: str, needle: str, width: int = 88) -> str:
1053
+ """The first matching line, trimmed around the match."""
1054
+ for line in text.splitlines():
1055
+ pos = line.lower().find(needle)
1056
+ if pos < 0:
1057
+ continue
1058
+ line = line.strip()
1059
+ pos = line.lower().find(needle)
1060
+ if len(line) <= width:
1061
+ return line
1062
+ start = max(0, pos - width // 3)
1063
+ return ("..." if start else "") + line[start:start + width] + "..."
1064
+ return ""
1065
+
1066
+
1067
+ def cmd_search(args) -> None:
1068
+ """Find threads and tickets mentioning a phrase.
1069
+
1070
+ Grouping duplicate reports is the workflow this tool exists for, and it
1071
+ needs one command that answers "has anyone said this before" with thread
1072
+ ids you can hand straight to `fmw thread`.
1073
+ """
1074
+ ws = open_ws(args)
1075
+ mods = resolve_mods(ws, args.mods, args.all, quiet=args.json)
1076
+ needle = args.query.lower()
1077
+ hits: list[dict] = []
1078
+
1079
+ if args.where in ("threads", "both"):
1080
+ for mod in mods:
1081
+ for path in sorted((ws.mod_cache_dir(mod) / "threads").glob("*.json")):
1082
+ data = json.loads(path.read_text())
1083
+ bodies = [m for m in data["messages"] if needle in m.get("body", "").lower()]
1084
+ in_title = needle in data.get("title", "").lower()
1085
+ if not bodies and not in_title:
1086
+ continue
1087
+ # Prefer a body match for the snippet; echoing the title back
1088
+ # under the title is noise.
1089
+ hits.append({
1090
+ "kind": "thread", "id": data["id"], "mod": mod,
1091
+ "title": data.get("title", ""), "tag": data.get("tag", ""),
1092
+ "snippet": _snippet(bodies[0]["body"], needle) if bodies else "",
1093
+ "matched_authors": sorted({m["author"] for m in bodies}),
1094
+ "matched_title": in_title,
1095
+ })
1096
+
1097
+ if args.where in ("tickets", "both"):
1098
+ for item in tracker.load_all(ws):
1099
+ if mods and not any(m in mods for m in item.mods):
1100
+ continue
1101
+ blob = f"{item.meta.get('title','')}\n{item.body}"
1102
+ if needle not in blob.lower():
1103
+ continue
1104
+ hits.append({
1105
+ "kind": "ticket", "id": item.id, "mod": "+".join(item.mods),
1106
+ "title": item.meta.get("title", ""),
1107
+ "tag": f"{item.meta.get('kind','?')}/{item.meta.get('status','?')}",
1108
+ "snippet": _snippet(blob, needle), "path": str(item.path),
1109
+ })
1110
+
1111
+ if args.json:
1112
+ out({"query": args.query, "hits": hits, "count": len(hits)}, True)
1113
+ return
1114
+ if not hits:
1115
+ print(f"No match for {args.query!r} in {len(mods)} mod(s).")
1116
+ return
1117
+ for h in hits[: args.limit]:
1118
+ label = "ticket" if h["kind"] == "ticket" else "thread"
1119
+ print(f"{label} {h['id']} {h['mod']} [{h['tag'][:18]}]")
1120
+ print(f" {h['title'][:80]}")
1121
+ if h["snippet"]:
1122
+ who = f" ({', '.join(h['matched_authors'])})" if h.get("matched_authors") else ""
1123
+ print(f" > {h['snippet']}{who}")
1124
+ if len(hits) > args.limit:
1125
+ print(f"... {len(hits) - args.limit} more (--limit N)")
1126
+ print(f"\n{len(hits)} match(es). Read one with `fmw thread <id>`.")
1127
+
1128
+
1129
+ def cmd_inbox(args) -> None:
1130
+ """Everything awaiting a decision, threads and tickets in one view."""
1131
+ ws = open_ws(args)
1132
+ mods = resolve_mods(ws, args.mods, args.all, quiet=args.json)
1133
+ all_items = tracker.load_all(ws)
1134
+ threads_out, hidden_resolved = collect_inbox(ws, mods, all_items, args.all_threads)
1135
+
1136
+ open_tickets = [
1137
+ i for i in all_items
1138
+ if i.meta.get("status") in tracker.open_statuses()
1139
+ and (args.all or not mods or any(m in mods for m in i.mods))
1140
+ ]
1141
+
1142
+ if args.json:
1143
+ out({"threads": threads_out, "hidden_resolved": hidden_resolved, "tickets": [
1144
+ {**i.meta, "path": str(i.path)} for i in open_tickets]}, True)
1145
+ return
1146
+
1147
+ needs_reply = [t for t in threads_out if t["needs_response"]]
1148
+ undecided = [t for t in threads_out if not t["needs_response"]]
1149
+ # Bugs before ideas, then a busier mod outranks a quiet one, then recency.
1150
+ undecided.sort(key=lambda t: (t["tag"] != "Bugs", -t["downloads"], t["last_at"]), reverse=False)
1151
+ undecided.sort(key=lambda t: t["last_at"], reverse=True)
1152
+
1153
+ def line(t):
1154
+ flags = "".join([
1155
+ "T" if t["tickets"] else " ",
1156
+ "R" if t["needs_response"] else " ",
1157
+ "!" if t["wrong"] else " ",
1158
+ ])
1159
+ return (f" {flags} [{t['tag'][:5]:<5}] {t['last_at'][:10]} {t['replies']:>2}r "
1160
+ f"{t['title'][:58]}\n {t['id']} {t['mod']}")
1161
+
1162
+ if needs_reply:
1163
+ print(f"== You owe a reply ({len(needs_reply)}) ==")
1164
+ for t in needs_reply:
1165
+ print(line(t))
1166
+ if t["reply_reason"].startswith("released"):
1167
+ print(f" fix shipped, reporter not told - {t['reply_reason']}")
1168
+ print()
1169
+ awaiting = [i for i in open_tickets if i.meta.get("status") == "needs-testing"]
1170
+ if awaiting:
1171
+ print(f"== Awaiting your testing ({len(awaiting)}) - run `fmw testplan` ==")
1172
+ for i in awaiting:
1173
+ print(f" {i.id} {'+'.join(i.mods)}: {i.meta.get('title','')}")
1174
+ print()
1175
+ working = [i for i in open_tickets if i.meta.get("status") != "needs-testing"]
1176
+ if working:
1177
+ print(f"== Open tickets ({len(working)}) ==")
1178
+ for i in working:
1179
+ print(f" {i.id} [{i.meta.get('status','?'):<12}] {'+'.join(i.mods)}: {i.meta.get('title','')}")
1180
+ print()
1181
+ if undecided:
1182
+ extra = f", {hidden_resolved} hidden as resolved on the portal" if hidden_resolved else ""
1183
+ print(f"== Threads awaiting triage ({len(undecided)}{extra}) ==")
1184
+ for t in undecided[: args.limit]:
1185
+ print(line(t))
1186
+ if len(undecided) > args.limit:
1187
+ print(f" ... {len(undecided) - args.limit} more (--limit N)")
1188
+ print()
1189
+ if not (needs_reply or open_tickets or undecided):
1190
+ print("Inbox clear.")
1191
+ return
1192
+ print("T = has a ticket, R = you owe a reply, ! = reporter was mistaken")
1193
+ print("Next: `fmw mark <thread> --triage handled` or `fmw track new <thread>`")
1194
+
1195
+
1196
+ def cmd_help(args) -> None:
1197
+ """Prose help, without needing the repository checked out."""
1198
+ if args.all:
1199
+ print(helptext.OVERVIEW)
1200
+ for name in sorted(helptext.TOPICS):
1201
+ print(f"\n{'=' * 72}\nfmw help {name}\n{'=' * 72}\n")
1202
+ print(helptext.TOPICS[name].rstrip())
1203
+ for name in sorted(helptext.COMMANDS):
1204
+ print(f"\n{'=' * 72}\nfmw {name}\n{'=' * 72}\n")
1205
+ print(helptext.COMMANDS[name].rstrip())
1206
+ return
1207
+ if args.command:
1208
+ if args.command in helptext.TOPICS:
1209
+ print(helptext.TOPICS[args.command].rstrip())
1210
+ return
1211
+ text = helptext.COMMANDS.get(args.command)
1212
+ if not text:
1213
+ known = ", ".join(sorted({*helptext.COMMANDS, *helptext.TOPICS}))
1214
+ sys.exit(f"No such command or topic: {args.command}.\nKnown: {known}")
1215
+ print(f"fmw {args.command}\n")
1216
+ print(text.rstrip())
1217
+ print(f"\nFlags: fmw {args.command} --help")
1218
+ return
1219
+ print(helptext.OVERVIEW)
1220
+
1221
+
1222
+ def cmd_status(args) -> None:
1223
+ ws = open_ws(args)
1224
+ registry = ws.load_mods()["mods"]
1225
+ items = tracker.load_all(ws)
1226
+ open_items = [i for i in items if i.meta.get("status") in tracker.open_statuses()]
1227
+ in_scope = [n for n, m in registry.items()
1228
+ if m.get("on_portal") is not False and m.get("maintained") != "frozen"]
1229
+ threads_out, hidden_resolved = collect_inbox(ws, in_scope, items)
1230
+ replies_owed = sum(1 for t in threads_out if t["needs_response"])
1231
+ summary = {
1232
+ "workspace": str(ws.root),
1233
+ "mods": len(registry),
1234
+ # Two different senses of "maintained" used to share one label here.
1235
+ "mods_marked_active": sum(1 for m in registry.values() if m.get("maintained") == "active"),
1236
+ "mods_in_default_scope": len(in_scope),
1237
+ "tracker_items": len(items),
1238
+ "open_items": len(open_items),
1239
+ "awaiting_test": sum(1 for i in items if i.meta.get("status") == "needs-testing"),
1240
+ "replies_owed": replies_owed,
1241
+ "awaiting_triage": len(threads_out),
1242
+ "hidden_as_resolved": hidden_resolved,
1243
+ }
1244
+ stale = outdated_template_files(ws)
1245
+ summary["stale_workspace_docs"] = stale
1246
+ summary["workspace_under_git"] = (ws.root / ".git").is_dir()
1247
+ if args.json:
1248
+ out(summary, True)
1249
+ return
1250
+ for k, v in summary.items():
1251
+ if k in ("stale_workspace_docs", "workspace_under_git"):
1252
+ continue
1253
+ print(f"{k:<22} {v}")
1254
+ if stale:
1255
+ print(f"\n! {len(stale)} workspace doc(s) describe an older schema "
1256
+ f"({', '.join(stale)}). Run `fmw upgrade`.")
1257
+ if not summary["workspace_under_git"]:
1258
+ print(f"\n! {ws.root} is not a git repository. Your tickets and thread "
1259
+ "judgements are the only irreplaceable data here; `git init` there.")
1260
+
1261
+
1262
+ # ----------------------------------------------------------------------
1263
+ def build_parser() -> argparse.ArgumentParser:
1264
+ p = argparse.ArgumentParser(
1265
+ prog="fmw",
1266
+ description=helptext.OVERVIEW,
1267
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1268
+ )
1269
+ p.add_argument("--workspace", help="workspace directory (overrides $FMW_WORKSPACE; no implicit default)")
1270
+ sub = p.add_subparsers(dest="cmd", required=True)
1271
+
1272
+ def common(sp):
1273
+ """Options accepted both before and after the subcommand.
1274
+
1275
+ argparse only matches a parent-level option before the subcommand, so
1276
+ `fmw --workspace X status` works but `fmw status --workspace X` does
1277
+ not. Repeating it on each subparser fixes that; SUPPRESS is required so
1278
+ an absent flag does not overwrite the value parsed by the parent.
1279
+ """
1280
+ sp.add_argument("--workspace", default=argparse.SUPPRESS,
1281
+ help="workspace directory (overrides $FMW_WORKSPACE)")
1282
+ sp.add_argument("--json", action="store_true", help="machine-readable output")
1283
+ return sp
1284
+
1285
+ def add(name, fn, help_):
1286
+ # The prose lives in help.py and surfaces through --help, so there is
1287
+ # no second copy to fall out of step with the code.
1288
+ sp = sub.add_parser(
1289
+ name, help=help_,
1290
+ description=helptext.COMMANDS.get(name),
1291
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1292
+ )
1293
+ sp.set_defaults(func=fn)
1294
+ return common(sp)
1295
+
1296
+ sp = add("init", cmd_init, "create a workspace directory")
1297
+ sp.add_argument("path", help="where to create it (no default: be explicit)")
1298
+
1299
+ rp = sub.add_parser("repo", help="manage the directories holding your mods",
1300
+ description=helptext.COMMANDS.get("repo"),
1301
+ formatter_class=argparse.RawDescriptionHelpFormatter)
1302
+ rsub = rp.add_subparsers(dest="repo_cmd")
1303
+ rp.set_defaults(func=cmd_repo, repo_cmd="list")
1304
+ ra = rsub.add_parser("add", help="register a directory to scan for mods")
1305
+ ra.set_defaults(func=cmd_repo); ra.add_argument("path"); common(ra)
1306
+ rr = rsub.add_parser("remove", help="stop scanning a directory")
1307
+ rr.set_defaults(func=cmd_repo); rr.add_argument("path"); common(rr)
1308
+ rl = rsub.add_parser("list", help="show configured repositories")
1309
+ rl.set_defaults(func=cmd_repo); common(rl)
1310
+ common(rp)
1311
+
1312
+ sp = add("discover", cmd_discover, "find mods in your repos and read their portal metadata")
1313
+ sp.add_argument("--scan", nargs="*", metavar="DIR",
1314
+ help="scan these directories just this once, without registering them "
1315
+ "(use `fmw repo add` to keep one)")
1316
+ sp.add_argument("--no-portal", action="store_true", help="skip portal API lookups")
1317
+ sp.add_argument("--max-depth", type=int, default=2,
1318
+ help="how deep below each root to look for info.json (default: 2)")
1319
+
1320
+ sp = add("mods", cmd_mods, "list the mod registry")
1321
+ sp.add_argument("--maintained", choices=discover.MAINTAINED,
1322
+ help="filter by maintained value")
1323
+
1324
+ sp = add("sync", cmd_sync, "fetch discussions and report what changed")
1325
+ sp.add_argument("mods", nargs="*", help="mods to sync; inferred from the cwd if omitted")
1326
+ sp.add_argument("--all", action="store_true", help="include mods marked frozen")
1327
+ sp.add_argument("--no-threads", action="store_true", help="only refresh the thread list")
1328
+
1329
+ sp = add("threads", cmd_threads, "list cached threads of a mod")
1330
+ sp.add_argument("mod", nargs="?", help="mod name; inferred from the current directory if omitted")
1331
+ sp.add_argument("--tag", help="filter by tag name or abbreviation, e.g. bug / idea")
1332
+ sp.add_argument("--limit", type=int)
1333
+
1334
+ sp = add("thread", cmd_thread, "print a full discussion thread")
1335
+ sp.add_argument("target", help="thread id or discussion URL")
1336
+ sp.add_argument("--refresh", action="store_true", help="re-fetch instead of using cache")
1337
+
1338
+ sp = add("changes", cmd_changes, "replay recorded portal changes")
1339
+ sp.add_argument("mods", nargs="*")
1340
+ sp.add_argument("--all", action="store_true")
1341
+ sp.add_argument("--since", metavar="ISO",
1342
+ help="only changes detected on or after this time (detection time, not post time)")
1343
+
1344
+ sp = add("search", cmd_search, "find threads and tickets mentioning a phrase")
1345
+ sp.add_argument("query")
1346
+ sp.add_argument("mods", nargs="*", help="mods to search; inferred from the cwd if omitted")
1347
+ sp.add_argument("--all", action="store_true", help="search every mod")
1348
+ sp.add_argument("--where", choices=("threads", "tickets", "both"), default="both",
1349
+ help="what to search (default: both)")
1350
+ sp.add_argument("--limit", type=int, default=20, help="max results to print")
1351
+
1352
+ sp = add("inbox", cmd_inbox, "everything awaiting a decision, threads and tickets")
1353
+ sp.add_argument("mods", nargs="*", help="mods to show; inferred from the cwd if omitted")
1354
+ sp.add_argument("--all", action="store_true", help="every mod, including frozen ones")
1355
+ sp.add_argument("--all-threads", action="store_true",
1356
+ help="include threads the portal title already marks resolved")
1357
+ sp.add_argument("--limit", type=int, default=20, help="max untriaged threads to list")
1358
+
1359
+ sp = add("mark", cmd_mark, "record your judgement about a thread")
1360
+ sp.add_argument("targets", nargs="*",
1361
+ help="a thread id or URL; with --before, the mods to sweep instead")
1362
+ sp.add_argument("--before", metavar="YYYY-MM-DD",
1363
+ help="bulk mode: apply to every still-pending thread last active before this date")
1364
+ sp.add_argument("--tag", help="bulk mode: restrict to a portal tag")
1365
+ sp.add_argument("--all", action="store_true", help="bulk mode: every mod")
1366
+ sp.add_argument("--triage", choices=threadstate.TRIAGE,
1367
+ help="how you have dealt with the conversation")
1368
+ sp.add_argument("--class", dest="set_class", choices=threadstate.CLASSES,
1369
+ help="what the thread really is, regardless of its portal tag")
1370
+ sp.add_argument("--note", help="why you decided that")
1371
+ sp.add_argument("--duplicate-of", help="another thread id this repeats")
1372
+ sp.add_argument("--needs-response", action="store_true", help="you owe the reporter a reply")
1373
+ sp.add_argument("--responded", action="store_true", help="clear the reply flag")
1374
+
1375
+ sp = add("testplan", cmd_testplan, "collect every awaiting-test fix into one document")
1376
+ sp.add_argument("mods", nargs="*", help="mods to include; inferred from the cwd if omitted")
1377
+ sp.add_argument("--all", action="store_true", help="every mod, including frozen ones")
1378
+
1379
+ sp = sub.add_parser("help", help="prose help for a command, or an overview")
1380
+ sp.set_defaults(func=cmd_help)
1381
+ sp.add_argument("command", nargs="?",
1382
+ help="a command, or a topic: workspace, workflow")
1383
+ sp.add_argument("--all", action="store_true",
1384
+ help="print every command's help at once (one call, for agents)")
1385
+ sp.add_argument("--json", action="store_true", help=argparse.SUPPRESS)
1386
+ sp.add_argument("--workspace", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
1387
+
1388
+ sp = add("status", cmd_status, "workspace overview")
1389
+
1390
+ sp = add("upgrade", cmd_upgrade,
1391
+ "bring a workspace to the current schema: docs, mods.yaml, tickets")
1392
+ sp.add_argument("--dry-run", action="store_true", help="report what would change")
1393
+
1394
+ sp = add("launcher", cmd_launcher, "install a PATH wrapper pinning a workspace")
1395
+ sp.add_argument("--name", default="fmw", help="command name to install (default: fmw)")
1396
+ sp.add_argument("--out", help="exact path to write instead of a PATH directory")
1397
+ sp.add_argument("--force", action="store_true", help="overwrite an existing file")
1398
+
1399
+ tp = sub.add_parser("track", help="tickets: code changes, error or feature",
1400
+ description=helptext.COMMANDS.get("track"),
1401
+ formatter_class=argparse.RawDescriptionHelpFormatter)
1402
+ tsub = tp.add_subparsers(dest="track_cmd", required=True)
1403
+
1404
+ tn = tsub.add_parser("new", help="create a tracker item")
1405
+ tn.set_defaults(func=cmd_track_new)
1406
+ tn.add_argument("target", nargs="?", help="thread id/URL, or a mod name; inferred from the current directory if omitted")
1407
+ tn.add_argument("title", nargs="?", help="defaults to the thread's title without its marker")
1408
+ tn.add_argument("--also", action="append", help="another mod this ticket applies to")
1409
+ tn.add_argument("--kind", default="error", choices=tracker.KINDS)
1410
+ tn.add_argument("--status", default="new", choices=tracker.STATUSES)
1411
+ tn.add_argument("--source", action="append", help="extra source, e.g. discord:<url>")
1412
+ common(tn)
1413
+
1414
+ tl = tsub.add_parser("list", help="list tracker items")
1415
+ tl.set_defaults(func=cmd_track_list)
1416
+ tl.add_argument("mod", nargs="?", help="mod name; inferred from the current directory if omitted")
1417
+ tl.add_argument("--kind", choices=tracker.KINDS)
1418
+ tl.add_argument("--status", choices=tracker.STATUSES)
1419
+ tl.add_argument("--open", action="store_true", help="only unresolved items")
1420
+ common(tl)
1421
+
1422
+ return p
1423
+
1424
+
1425
+ def main(argv: list[str] | None = None) -> None:
1426
+ args = build_parser().parse_args(argv)
1427
+ args.func(args)
1428
+
1429
+
1430
+ if __name__ == "__main__":
1431
+ main()