workmap 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.
workmap/cli.py ADDED
@@ -0,0 +1,789 @@
1
+ """Command line entry point: argument routing and the text-mode output.
2
+
3
+ The interactive map lives in tui/. What a user sees when they type
4
+ `workmap --help` is help_text() below, not this docstring.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import difflib
9
+ import sys
10
+
11
+ from . import actions, audit, config, layout, scan, shell, terminal
12
+ from . import demo as demo_mod
13
+ from . import setup as setup_mod
14
+ from .config import roots_advice
15
+ from .model import clean_label, fmt_mb, fmt_mem, held_note, plural
16
+ from .tui.text import cell, disp_width
17
+
18
+ UNNAMED = "unsorted"
19
+
20
+ # One measure for `workmap list`: a project's name and a session's label are
21
+ # indented differently but both end where the RAM column begins, so the sizes
22
+ # line up under the heading whichever row they are on.
23
+ PROJECT_W = 46
24
+ SESSION_W = PROJECT_W - 2 # sessions are indented two further
25
+
26
+ # Every word `main` answers to. Used to tell a mistyped command apart from a
27
+ # project name, which is what a bare word otherwise means.
28
+ COMMANDS = (
29
+ "setup", "demo", "list", "kill", "log", "titles", "title", "paint",
30
+ "organize", "org", "organize-all", "org-all", "projects", "path", "agent",
31
+ "shell-init", "help", "version",
32
+ )
33
+
34
+ # Commands that mean nothing on their own, and what each one is missing. They
35
+ # used to fall through to the branch that reads a bare word as a project name,
36
+ # where `workmap paint` came back as "workmap has no 'paint' command. Did you
37
+ # mean `workmap paint`?", suggesting the thing that had just been typed.
38
+ WANTS_AN_ARGUMENT = {
39
+ "paint": "a project to paint",
40
+ "path": "a project to find",
41
+ "organize": "a project to lay out",
42
+ "org": "a project to lay out",
43
+ "title": "a name for the front tab",
44
+ }
45
+
46
+
47
+ def unsupported_platform() -> str:
48
+ """Why workmap cannot read this machine, if it cannot.
49
+
50
+ macOS is not a packaging accident here, it is three separate dependencies
51
+ and only one of them is behind the driver seam:
52
+
53
+ drivers/apple_terminal.py AppleScript. Pluggable: another emulator is a
54
+ new module and nothing else.
55
+ procs.py `top -l 1` for phys_footprint, `vm_stat`,
56
+ `sysctl vm.swapusage`, `lstart` from BSD ps.
57
+ Not syntax. phys_footprint is a macOS idea,
58
+ and the reason the memory numbers are worth
59
+ reading at all.
60
+ model.is_gui_app ".app bundle" is a macOS concept, and it is
61
+ what stops the sweep force-quitting a running
62
+ application.
63
+
64
+ Without this check the failure was worse than useless: every command
65
+ degraded quietly to an empty desk and advised the reader to go to System
66
+ Settings > Privacy & Security > Automation, which does not exist on their
67
+ computer, and exited 0 as though nothing were wrong.
68
+ """
69
+ if sys.platform == "darwin":
70
+ return ""
71
+ return (
72
+ f"workmap only runs on macOS, and this is {sys.platform}.\n"
73
+ "It reads Terminal.app through AppleScript and measures memory with "
74
+ "tools that are macOS's own,\nso there is nothing useful it can tell "
75
+ "you here. Nothing was read and nothing was changed."
76
+ )
77
+
78
+
79
+ def sample_project(fallback: str = "acme-api") -> str:
80
+ """A project of the user's own to write the examples against.
81
+
82
+ Prefers a repository, then anything that reads as a name and fits the
83
+ column. Both filters earn their place on a real machine: the first
84
+ directory under the roots here is called "1321", so `workmap kill 1321`
85
+ read as a command with a number in it rather than an example to copy, and
86
+ the next was "Music", which is a folder rather than something anyone
87
+ works on. A checkout is the best available guess at the difference.
88
+ Falls back to a made-up name only when there is nothing to point at.
89
+ """
90
+ def fits(name: str) -> bool:
91
+ return any(c.isalpha() for c in name) and 3 <= len(name) <= 11
92
+
93
+ found = [(n, p) for n, p in config.project_dirs() if fits(n)]
94
+ for name, path in found:
95
+ # A directory nobody may traverse answers this with an exception on
96
+ # Python 3.9, and this runs while building `--help`. One project with
97
+ # its permissions changed and the tool could not print its own help.
98
+ try:
99
+ checked_out = (path / ".git").exists()
100
+ except OSError:
101
+ checked_out = False
102
+ if checked_out:
103
+ return name
104
+ if found:
105
+ return found[0][0]
106
+ # The last resort has to fit the column too. It did not, so a machine
107
+ # whose only project was called something long ran the name straight into
108
+ # the description with no gap between them: the examples are laid out with
109
+ # `{sample:<14}` and nothing was checking the sample against it.
110
+ return next((n for n, _ in config.project_dirs() if fits(n)), fallback)
111
+
112
+
113
+ def help_text() -> str:
114
+ """What `workmap --help` prints.
115
+
116
+ The examples name a project you actually have wherever one is needed. A
117
+ placeholder is the thing people type: the setup screen used to end by
118
+ telling you to run `work`, which takes a project name, and the first
119
+ person to read it typed `work` and got a list they had not asked for.
120
+ Costs one directory listing, about 15ms.
121
+ """
122
+ sample = sample_project()
123
+ return f"""\
124
+ workmap: a desk map for the Terminal windows you already have.
125
+
126
+ Run it with no arguments for the interactive map. Everything else below is a
127
+ one-shot command, so you can pipe it or put it in a script.
128
+
129
+ Getting set up
130
+ workmap setup say where your projects are, switch on `work`
131
+ workmap demo walk through setup on a pretend computer first
132
+ workmap demo --keep the same, but leave the pretend computer behind
133
+
134
+ Seeing what you have open
135
+ workmap the interactive map
136
+ workmap list the same thing as text
137
+ workmap log what workmap has quit lately, and what
138
+ happened to each one. `workmap log 100` for more
139
+
140
+ Quitting what outlived its window
141
+ workmap kill -n/--dry-run show what it would quit, and quit nothing
142
+ workmap kill quit every orphan
143
+ workmap kill {sample:<14}quit just that project's orphans
144
+
145
+ Tidying the screen
146
+ workmap {sample:<19}lay its windows out (`organize {sample}`, `org`)
147
+ workmap organize-all give every project its own region (`org-all`)
148
+ workmap titles rename every window to "project · what"
149
+ workmap paint {sample:<13}put that project's colour on the front tab
150
+
151
+ Starting work (the pieces `work` is built from)
152
+ workmap projects every project directory under your roots
153
+ workmap path {sample:<14}where a project lives
154
+ workmap agent which agents you have, and which `work` starts
155
+ workmap agent codex start that one from now on
156
+ workmap agent --pick ask which one, and print the answer
157
+ workmap agent --command x the command line that agent name stands for
158
+ workmap title {sample:<13}name the front tab, without a desk scan
159
+ workmap shell-init zsh print the `work` function, for your shell to
160
+ evaluate. Add --name proj if `work` is taken.
161
+
162
+ Settings live in {config.CONFIG_PATH}.
163
+ Every signal is recorded in {audit.log_path()}."""
164
+
165
+
166
+ def flag_value(args: list[str], flag: str, default: str = "") -> str:
167
+ """The argument after `flag`, or `default` if there isn't one.
168
+
169
+ `args[args.index(flag) + 1]` reads past the end whenever the flag is last,
170
+ which is the ordinary way to find out what a flag wants: type it and press
171
+ enter. That printed a traceback.
172
+ """
173
+ try:
174
+ i = args.index(flag)
175
+ except ValueError:
176
+ return default
177
+ return args[i + 1] if i + 1 < len(args) else default
178
+
179
+
180
+ def without_flag(args: list[str], flag: str) -> list[str]:
181
+ """`args` with `flag` and the word after it removed, by position.
182
+
183
+ Removing them by *value* instead took out any positional that happened to
184
+ equal the flag's value: `workmap shell-init bash --name bash` lost the
185
+ shell and silently generated for zsh, and `shell-init fish --name fish`
186
+ skipped the unsupported-shell check altogether and handed a fish user
187
+ POSIX source their shell cannot parse.
188
+ """
189
+ out: list[str] = []
190
+ skip = False
191
+ for arg in args:
192
+ if skip:
193
+ skip = False
194
+ continue
195
+ if arg == flag:
196
+ skip = True
197
+ continue
198
+ out.append(arg)
199
+ return out
200
+
201
+
202
+ def print_list() -> None:
203
+ """The desk as text, for reading or piping.
204
+
205
+ This used to number the projects, which read like a menu you could pick
206
+ from and was not one, and it left the orphans unmarked. That is the one
207
+ distinction the tool exists to draw: an orphan is what `kill` quits, and
208
+ the snapshot was the one view that would not tell you which lines those
209
+ were.
210
+ """
211
+ snap = scan.snapshot()
212
+ mem = snap["mem"]
213
+ projects = snap["projects"]
214
+ print()
215
+ orphan_mb = sum(s["mb"] for p in projects
216
+ for s in p["sessions"] + p["hidden_sessions"]
217
+ if s["kind"] == "bg")
218
+ # "0M orphaned" is a claim about a machine nobody managed to look at, and
219
+ # it was printed above the line saying Terminal could not be read.
220
+ readable = snap["terminal"]["ok"]
221
+ orphaned = f" {fmt_mb(orphan_mb)} orphaned" if readable else ""
222
+ print(f" workmap {fmt_mb(snap['tracked_mb'])} in "
223
+ f"{plural(len(projects), 'project')}{orphaned}"
224
+ f" swap {fmt_mem(mem['swap_mb'])}")
225
+ print()
226
+
227
+ advice = terminal.unsupported_host() or roots_advice()
228
+ if advice:
229
+ print(f" {advice}")
230
+ print()
231
+
232
+ if not projects:
233
+ status = snap["terminal"]
234
+ if not status["ok"]:
235
+ # The follow-up belongs to the reason, not to "something failed".
236
+ # Only one of the driver's failures is a permission problem, and
237
+ # the Automation instructions were printed for all of them.
238
+ print(f" Can't read Terminal: {status['reason']}.")
239
+ # "Then" only when there is something for it to follow. Half the
240
+ # driver's failures have no instruction that would help, and this
241
+ # printed "Terminal automation failed." then "Then run this
242
+ # again." with nothing between them: a reader assumes they have
243
+ # missed a line and goes looking for it.
244
+ if status.get("fix"):
245
+ print(f" {status['fix']}")
246
+ print(" Then run this again.")
247
+ else:
248
+ print(" Run this again once it is.")
249
+ else:
250
+ print(" Nothing open right now.")
251
+ print(" Open a Terminal window in a project directory and run")
252
+ print(" this again, or start one with `work`.")
253
+ print()
254
+ return
255
+
256
+ print(f" {cell('PROJECT', PROJECT_W)}{'RAM':>6} THEME")
257
+ orphans = 0
258
+ orphan_total = 0
259
+ for p in projects:
260
+ print(f" {cell(p['name'] or UNNAMED, PROJECT_W)}{fmt_mb(p['mb']):>6}"
261
+ f" {p['profile']}")
262
+ for s in p["sessions"]:
263
+ # The tag rides with the label rather than in a column of its own.
264
+ # A separate nine-wide field pushed every session's RAM nine
265
+ # places right of the project's, and of the RAM heading above
266
+ # both, so the one column the eye follows down the page did not
267
+ # line up with itself. Truncate the label, never the tag that
268
+ # qualifies it, which is what the desk does too.
269
+ tag = " · orphaned" if s["kind"] == "bg" else ""
270
+ keep = max(1, SESSION_W - disp_width(tag))
271
+ print(f" {cell(clean_label(s['label']), keep)}{tag}"
272
+ f"{fmt_mb(s['mb']):>6}".rstrip())
273
+ if p["hidden_count"]:
274
+ print(f" +{p['hidden_count']} more")
275
+ for s in p["sessions"] + p["hidden_sessions"]:
276
+ if s["kind"] == "bg":
277
+ orphans += 1
278
+ orphan_total += s["mb"]
279
+ print()
280
+
281
+ print(f" {plural(len(projects), 'project')} open.")
282
+ if orphans:
283
+ print(f" {plural(orphans, 'orphan')} holding "
284
+ f"{fmt_mb(orphan_total)}: dev servers and agents with no")
285
+ print(" Terminal window left to close.")
286
+ print(" See what quitting them would signal: workmap kill -n")
287
+ print(" Quit them: workmap kill")
288
+ else:
289
+ print(" Nothing is running without a window, so there is nothing "
290
+ "to clean up.")
291
+ # Said whether or not any orphan was found, because it changes what both
292
+ # answers mean. An empty list on a machine with a tmux pane holding a dev
293
+ # server is not the same news as an empty list on a quiet one.
294
+ if snap.get("held"):
295
+ print(f" {snap['held']}")
296
+ print()
297
+
298
+
299
+ def print_log(limit: int = 20) -> None:
300
+ """What workmap has signalled, most recent last."""
301
+ records = audit.read_recent(limit * 2)
302
+ intents = {r["id"]: r for r in records if r.get("event") == "intent"}
303
+ outcomes = {r["id"]: r for r in records if r.get("event") == "outcome"}
304
+ if not intents:
305
+ print()
306
+ print(" workmap hasn't quit anything yet.")
307
+ print(f" When it does, it goes here first: {audit.log_path()}")
308
+ print()
309
+ return
310
+ print()
311
+ for event_id, intent in (list(intents.items())[-limit:] if limit > 0
312
+ else []):
313
+ done = outcomes.get(event_id)
314
+ tag = "dry run" if intent.get("dry_run") else "quit"
315
+ print(f" {intent['at']} {tag} {reason_words(intent['reason'])} "
316
+ f"({plural(intent['count'], 'process')})")
317
+ if done is None:
318
+ # The intent is written before anything is signalled precisely so
319
+ # this case can be told apart from a kill that went wrong.
320
+ print(" No outcome recorded, so workmap did not get as far "
321
+ "as signalling these.")
322
+ continue
323
+ by_pid = {t["pid"]: t for t in intent.get("targets", [])}
324
+ for o in done.get("outcomes", []):
325
+ cmd = by_pid.get(o["pid"], {}).get("cmd", "")
326
+ print(f" {o['pid']:>7} {cell(outcome_words(o), OUTCOME_W)}"
327
+ f"{cell(cmd, 43)}".rstrip())
328
+ print()
329
+
330
+
331
+ # What each recorded outcome meant, in words. The stored values are terse
332
+ # because they are a data format; this is the only place they are read by a
333
+ # person, and "skipped-recycled" is not a sentence.
334
+ OUTCOME_W = 21
335
+
336
+ # Each of these is rendered into OUTCOME_W columns, so each has to fit in
337
+ # them. Two did not: "couldn't check it was still the same" and "no start
338
+ # time recorded" arrived on screen as "couldn't check it wa…" and "no start
339
+ # time record…", which is the one place a reader goes to find out what
340
+ # happened to a process, cut off mid-word.
341
+ OUTCOME_WORDS = {
342
+ # `sent` and `dry-run` are not here: what they read as depends on which
343
+ # signal it was, and SIGNAL_WORDS below has both. They were the last two
344
+ # stored tokens this table was still printing raw.
345
+ "already-gone": "already gone",
346
+ "not-permitted": "not ours to signal",
347
+ "skipped-recycled": "something else had it",
348
+ "skipped-unchecked": "couldn't be sure",
349
+ "skipped-protected": "protected",
350
+ "skipped-unrecorded": "no start time known",
351
+ }
352
+
353
+
354
+ # Asking and forcing are not the same thing to anybody, which is why the log
355
+ # records which one it was. Saying "quit" for both would fit the column and
356
+ # lose the distinction the record exists to keep: a process that shut itself
357
+ # down tidily and one that was killed where it stood read alike.
358
+ SIGNAL_WORDS = {
359
+ "SIGTERM": ("asked to quit", "would ask to quit"),
360
+ "SIGKILL": ("forced to quit", "would force to quit"),
361
+ }
362
+
363
+
364
+ def outcome_words(outcome: dict) -> str:
365
+ """What a recorded outcome meant, in words, inside OUTCOME_W columns."""
366
+ result = outcome["result"]
367
+ if result in ("sent", "dry-run"):
368
+ asked, would = SIGNAL_WORDS.get(outcome.get("signal") or "",
369
+ ("quit", "would quit"))
370
+ return would if result == "dry-run" else asked
371
+ return OUTCOME_WORDS.get(result, result)
372
+
373
+
374
+ def reason_words(reason: str) -> str:
375
+ """What a kill was for, in words. The stored token is data.
376
+
377
+ `orphans:acme-web` is a record, not a sentence, and this is the one place a
378
+ person reads it. The log used to print the token itself, which is how the
379
+ word the tool stopped using stayed on the screen the README sells as the
380
+ audit trail.
381
+ """
382
+ kind, _, where = reason.partition(":")
383
+ # `leftovers` is what the token was called before the tool settled on one
384
+ # word, and records written then are still in the file: eleven of the
385
+ # twelve on the machine this was found on. They printed raw, so the word
386
+ # the tool stopped using was on screen anyway, spelled as data.
387
+ if kind not in ("orphans", "leftovers"):
388
+ return reason
389
+ return "orphans everywhere" if where in ("", "all") else f"orphans on {where}"
390
+
391
+
392
+ def pick_agent() -> str:
393
+ """Ask which agent to launch, remembering the answer as the new default.
394
+
395
+ The prompt goes to stderr because the caller is a shell function capturing
396
+ stdout. The only thing on stdout is the answer.
397
+ """
398
+ agents = config.available_agents()
399
+ if not agents:
400
+ return "none"
401
+ keys = list(agents) + ["none"]
402
+ current = config.default_agent()
403
+ print("Agent:", file=sys.stderr)
404
+ for i, key in enumerate(keys, 1):
405
+ mark = " (default)" if key == current else ""
406
+ print(f" {i}) {key}{mark}", file=sys.stderr)
407
+ print(f"Pick a number or name [Enter={current}]: ", end="", file=sys.stderr)
408
+ sys.stderr.flush()
409
+ try:
410
+ choice = input().strip()
411
+ except (EOFError, KeyboardInterrupt):
412
+ print(file=sys.stderr)
413
+ return current
414
+ if not choice:
415
+ choice = current
416
+ elif choice.isdigit() and 1 <= int(choice) <= len(keys):
417
+ choice = keys[int(choice) - 1]
418
+ config.set_default_agent(choice)
419
+ return choice
420
+
421
+
422
+ def print_no_such_project(name: str) -> str:
423
+ """Why that name did not work, and what would."""
424
+ known = [n for n, _ in config.project_dirs()]
425
+ if not known:
426
+ return (f"There's no project called \"{name}\".\n"
427
+ + (config.roots_advice() or
428
+ "In fact there are no projects yet. Check your roots."))
429
+ lower = name.lower()
430
+ # Substring first (typing a fragment is deliberate), then edit distance
431
+ # for actual typos: "acme-wbe" is not a substring of "acme-web".
432
+ close = [n for n in known if lower in n.lower() or n.lower() in lower]
433
+ close += [n for n in difflib.get_close_matches(name, known, n=3, cutoff=0.6)
434
+ if n not in close]
435
+ lines = [f"There's no project called \"{name}\"."]
436
+ if close:
437
+ lines.append("Did you mean: " + ", ".join(close[:5]) + "?")
438
+ else:
439
+ shown = ", ".join(known[:8])
440
+ more = f", and {len(known) - 8} more" if len(known) > 8 else ""
441
+ lines.append(f"You have: {shown}{more}")
442
+ lines.append("Run `work` with no name to pick from a list.")
443
+ return "\n".join(lines)
444
+
445
+
446
+ def print_no_such_agent(name: str) -> str:
447
+ """Why that agent did not work, and what would."""
448
+ known = sorted(config.configured_agents())
449
+ return (f"There's no agent called \"{name}\".\n"
450
+ f"You have: {', '.join(known)}, or \"none\".\n"
451
+ f"To add another, put it under \"agents\" in {config.CONFIG_PATH}.")
452
+
453
+
454
+ def print_agents() -> None:
455
+ agents = config.configured_agents()
456
+ available = config.available_agents()
457
+ current = config.default_agent()
458
+ print()
459
+ print(f" default: {current}")
460
+ for key, command in sorted(agents.items()):
461
+ if key in available:
462
+ state = ""
463
+ elif not (command or "").strip():
464
+ # Setting an agent to "" in the config is how you say no to one,
465
+ # so reporting it as missing blames the machine for a choice the
466
+ # reader made. They are the only two reasons a name is on this
467
+ # list without being offered, and they need different words.
468
+ state = " (turned off)"
469
+ else:
470
+ state = " (not installed)"
471
+ mark = "*" if key == current else " "
472
+ print(f" {mark} {key:<10} {command}{state}")
473
+ print()
474
+ print(f" set with: workmap agent <name> ({config.CONFIG_PATH})")
475
+ print()
476
+
477
+
478
+ def main(argv: list[str] | None = None) -> int:
479
+ """Run one command, then say anything it left to be said.
480
+
481
+ The notice is drained here rather than at each of the commands that can
482
+ raise one, because "did this run rewrite Terminal's settings" is a fact
483
+ about the process and not about which word was typed. Five commands reach
484
+ that rewrite and a sixth will be added by somebody who has not read this.
485
+ """
486
+ code = _run(argv)
487
+ notice = actions.take_title_notice()
488
+ if notice:
489
+ print(notice)
490
+ return code
491
+
492
+
493
+ def _run(argv: list[str] | None = None) -> int:
494
+ args = list(sys.argv[1:] if argv is None else argv)
495
+
496
+ if not args:
497
+ wrong_machine = unsupported_platform()
498
+ if wrong_machine:
499
+ print(wrong_machine, file=sys.stderr)
500
+ return 1
501
+ from .tui.app import NoTerminalToDrawOn, run
502
+ try:
503
+ return run()
504
+ except NoTerminalToDrawOn as why:
505
+ # The snapshot is the whole point of the tool, so still print it.
506
+ print(why, file=sys.stderr)
507
+ print_list()
508
+ return 1
509
+
510
+ cmd = args[0]
511
+
512
+ # Anywhere, not just first. Asking a subcommand what it does is the most
513
+ # ordinary thing a reader can do, and every one of them took the word as
514
+ # an argument instead: `workmap setup --help` ran the real setup,
515
+ # `workmap demo --help` ran the demo, and `workmap kill --help` scanned
516
+ # the whole desk to report that there is no project called "--help".
517
+ if cmd in ("-h", "--help", "help") or any(
518
+ a in ("-h", "--help") for a in args[1:]):
519
+ print(help_text())
520
+ return 0
521
+
522
+ if cmd in ("-V", "--version", "version"):
523
+ from . import __version__
524
+ print(f"workmap {__version__}")
525
+ return 0
526
+
527
+ # Everything below this reads the machine. --help and --version are above
528
+ # it on purpose: they answer for themselves anywhere, and someone who has
529
+ # just installed the wrong thing deserves to be able to ask what it was.
530
+ wrong_machine = unsupported_platform()
531
+ if wrong_machine:
532
+ print(wrong_machine, file=sys.stderr)
533
+ return 1
534
+
535
+ if cmd == "setup":
536
+ return setup_mod.run(args[1:])
537
+
538
+ if cmd == "demo":
539
+ return demo_mod.run(args[1:])
540
+
541
+ if cmd == "list":
542
+ print_list()
543
+ return 0
544
+
545
+ if cmd == "paint" and len(args) >= 2:
546
+ # Painting remembers the colour, so it writes to the settings file.
547
+ # Without this check a typo was stored as though it were a project
548
+ # someone had chosen a colour for, and the file quietly accumulated
549
+ # every name anyone had ever mistyped. Checking costs a directory
550
+ # listing and no desk scan.
551
+ if config.project_path(args[1]) is None:
552
+ print(print_no_such_project(args[1]), file=sys.stderr)
553
+ return 1
554
+ colour = actions.paint_project(args[1])
555
+ print(f"Front tab is now {colour}, {args[1]}'s colour.")
556
+ return 0
557
+
558
+ if cmd == "titles":
559
+ n = actions.retitle_all()
560
+ if not n:
561
+ print("No Terminal windows to rename.")
562
+ return 0
563
+ print(f"Renamed {plural(n, 'Terminal window')} to "
564
+ f"\"project · what\".")
565
+ return 0
566
+
567
+ if cmd == "kill":
568
+ rest = [a for a in args[1:] if a not in ("-n", "--dry-run")]
569
+ dry = len(rest) != len(args) - 1
570
+ name = rest[0] if rest else None
571
+ # One scan, used for both the report and the kill. Fetching a second
572
+ # one to describe what the first is about to signal would mean
573
+ # printing one set of processes and signalling another, which is the
574
+ # same mistake kill_sessions() exists to prevent.
575
+ held: list = []
576
+ desk = scan.build_projects(held=held)
577
+ # A name that is not a project reads exactly like a project with
578
+ # nothing to quit, and `kill` was the one name-taking command that did
579
+ # not say which it was: `workmap kill acme-wbe` answered "nothing to
580
+ # quit" and exited 0, which is a false all-clear and a lie to any
581
+ # script checking the status. A name counts if it is a directory under
582
+ # a root *or* a project on the desk, because a project can be on the
583
+ # desk without a directory of its own.
584
+ if (name is not None and config.project_path(name) is None
585
+ and not any(config.same_name(p.name, name) for p in desk)):
586
+ print(print_no_such_project(name), file=sys.stderr)
587
+ return 1
588
+ found = scan.background_sessions(name, projects=desk)
589
+ where = f"on {name}" if name else "across every project"
590
+ note = held_note(held)
591
+ if not found:
592
+ print(f"Nothing is running without a window {where}, so there is "
593
+ "nothing to quit.")
594
+ # The one place an all clear can be a lie. Something recognised is
595
+ # running and a multiplexer has it, which reads exactly like a
596
+ # quiet machine and is not one.
597
+ if note:
598
+ print(note)
599
+ return 0
600
+ result = actions.kill_background(name, projects=desk, dry_run=dry)
601
+ # What, not how many. A dry run that prints a column of pid numbers
602
+ # answers the question nobody asked: the reason to run it is to find
603
+ # out whether the thing about to be signalled is the thing you meant.
604
+ commands = {}
605
+ for _, session in found:
606
+ commands.update(session.cmds)
607
+ print()
608
+ print("These have no Terminal window left to close:")
609
+ print()
610
+ for outcome in result["outcomes"]:
611
+ pid = outcome["pid"]
612
+ what = commands.get(pid, "")
613
+ print(f" {pid:>7} {cell(what, 68)}".rstrip())
614
+ if outcome["result"] not in ("dry-run", "sent"):
615
+ print(f" {outcome['result']}")
616
+ print()
617
+ verb = "Would quit" if dry else "Quit"
618
+ print(f"{verb} {plural(result['sessions'], 'orphan')} {where}, "
619
+ f"{plural(result['pids'], 'process')}, about "
620
+ f"{fmt_mb(result['mb'])}.")
621
+ if note:
622
+ print(note)
623
+ if dry:
624
+ print("Nothing was quit. Run the same command without -n "
625
+ "to do it.")
626
+ else:
627
+ print(f"Written to {audit.log_path()}; `workmap log` reads it back.")
628
+ return 0
629
+
630
+ if cmd == "log":
631
+ n = int(args[1]) if len(args) >= 2 and args[1].isdigit() else 20
632
+ print_log(n)
633
+ return 0
634
+
635
+ # --- the pieces `work` is built from -------------------------------
636
+ if cmd == "projects":
637
+ found = config.project_dirs()
638
+ if not found:
639
+ advice = config.roots_advice() or (
640
+ "no project directories under: "
641
+ + ", ".join(str(r) for r in config.ROOTS)
642
+ )
643
+ print(advice, file=sys.stderr)
644
+ return 1
645
+ for name, _ in found:
646
+ print(name)
647
+ return 0
648
+
649
+ if cmd == "path" and len(args) >= 2:
650
+ path = config.project_path(args[1])
651
+ if path is None:
652
+ # A dead end is where a CLI feels unfriendly: say what went wrong
653
+ # and what to type instead, in the same breath.
654
+ print(print_no_such_project(args[1]), file=sys.stderr)
655
+ return 1
656
+ print(path)
657
+ return 0
658
+
659
+ if cmd == "agent":
660
+ if "--pick" in args:
661
+ print(pick_agent())
662
+ return 0
663
+ if "--command" in args:
664
+ key = flag_value(args, "--command")
665
+ agents = config.available_agents()
666
+ if key in agents:
667
+ print(agents[key])
668
+ elif key and key != "none":
669
+ # Anything unrecognised is taken literally, so
670
+ # `work acme-web "npm test"` launches that instead of an agent.
671
+ print(key)
672
+ return 0
673
+ if len(args) >= 2:
674
+ # Any word used to be written to the config and echoed back as
675
+ # though it had taken, while default_agent() quietly ignored it
676
+ # and fell back. The setting said one thing and the tool did
677
+ # another. `paint` already guards against the same typo.
678
+ want = args[1]
679
+ if want != "none" and want not in config.configured_agents():
680
+ print(print_no_such_agent(want), file=sys.stderr)
681
+ return 1
682
+ config.set_default_agent(want)
683
+ if want == "none":
684
+ print("`work` now opens the project without starting anything.")
685
+ elif want in config.available_agents():
686
+ print(f"`work` now starts {want}.")
687
+ else:
688
+ print(f"`work` will start {want} once it is installed. "
689
+ f"Until then it starts {config.default_agent()}.")
690
+ return 0
691
+ print_agents()
692
+ return 0
693
+
694
+ if cmd == "shell-init":
695
+ name = flag_value(args, "--name", "work")
696
+ rest = [a for a in without_flag(args[1:], "--name")
697
+ if not a.startswith("-")]
698
+ try:
699
+ print(shell.shell_init(rest[0] if rest else "zsh", name=name))
700
+ except (ValueError, IndexError) as exc:
701
+ print(str(exc), file=sys.stderr)
702
+ return 1
703
+ return 0
704
+
705
+ if cmd in ("organize", "org") and len(args) >= 2:
706
+ # These used to print the result dict. A caller wants to know what
707
+ # moved, not what shape the return value is.
708
+ result = actions.organize_project(args[1])
709
+ if not result.get("ok"):
710
+ print(f"Nothing to lay out for {args[1]}: "
711
+ f"{result.get('reason', 'no Terminal windows')}.",
712
+ file=sys.stderr)
713
+ return 1
714
+ stuck = actions.stuck_words(result)
715
+ if stuck:
716
+ print(f"Laid out {result.get('placed', 0)} of "
717
+ f"{plural(result['windows'], 'window')} for {args[1]}. "
718
+ f"{stuck}")
719
+ else:
720
+ print(f"Laid {plural(result['windows'], 'window')} out on screen "
721
+ f"for {args[1]}.")
722
+ return 0
723
+
724
+ if cmd in ("organize-all", "org-all"):
725
+ result = actions.organize_all()
726
+ if not result.get("ok"):
727
+ print(f"Nothing to lay out: "
728
+ f"{result.get('reason', 'no Terminal windows')}.",
729
+ file=sys.stderr)
730
+ return 1
731
+ stuck = actions.stuck_words(result)
732
+ print(f"Laid out {plural(result['projects'], 'project')}, each in "
733
+ f"its own region of the screen ({result['grid']} grid).")
734
+ if stuck:
735
+ print(stuck)
736
+ return 0
737
+
738
+ if cmd == "title" and len(args) >= 2:
739
+ from . import terminal
740
+ what = args[2] if len(args) >= 3 else "shell"
741
+ # Cheap path for shell wrappers: title the front tab only, no
742
+ # profile rewrite and no desk scan.
743
+ terminal.set_front_title(f"{args[1]} · {what}")
744
+ print(f"Front tab is now \"{args[1]} · {what}\".")
745
+ return 0
746
+
747
+ # A bare word means a project: lay that project's windows out on screen.
748
+ # Before paying a second for a desk scan, rule out the likelier reading:
749
+ # that it was meant to be one of the commands above and got mistyped.
750
+ # A real command with its argument left off. Answering this as a mistyped
751
+ # project name is how `workmap paint` came to suggest `workmap paint`.
752
+ if cmd in WANTS_AN_ARGUMENT:
753
+ print(f"`workmap {cmd}` needs {WANTS_AN_ARGUMENT[cmd]}. Try: "
754
+ f"workmap {cmd} {sample_project()}", file=sys.stderr)
755
+ return 1
756
+
757
+ mistyped = difflib.get_close_matches(cmd, COMMANDS, n=1, cutoff=0.7)
758
+ if mistyped and mistyped[0] == cmd:
759
+ mistyped = [] # never suggest the word that was just typed
760
+ if cmd.startswith("-") or (mistyped and config.project_path(cmd) is None):
761
+ if mistyped:
762
+ print(f"workmap has no {cmd!r} command. Did you mean "
763
+ f"`workmap {mistyped[0]}`?", file=sys.stderr)
764
+ else:
765
+ print(f"workmap has no {cmd!r} option. Run `workmap --help` to "
766
+ "see what it does understand.", file=sys.stderr)
767
+ return 1
768
+
769
+ hit = next((p for p in scan.build_projects() if p.name == cmd), None)
770
+ if not hit or not hit.window_ids:
771
+ if config.project_path(cmd) is None:
772
+ print(print_no_such_project(cmd), file=sys.stderr)
773
+ else:
774
+ print(f"{cmd} has no Terminal windows open, so there is nothing "
775
+ f"to lay out. Start one with `work {cmd}`.", file=sys.stderr)
776
+ return 1
777
+ moved = layout.gather_windows(hit.window_ids)
778
+ stuck = actions.stuck_words({"stuck": len(hit.window_ids) - moved})
779
+ if stuck:
780
+ print(f"Laid out {moved} of {plural(len(hit.window_ids), 'window')} "
781
+ f"for {cmd}. {stuck}")
782
+ else:
783
+ print(f"Laid {plural(len(hit.window_ids), 'window')} out on screen "
784
+ f"for {cmd}.")
785
+ return 0
786
+
787
+
788
+ if __name__ == "__main__":
789
+ raise SystemExit(main())