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/__init__.py +30 -0
- workmap/__main__.py +15 -0
- workmap/actions.py +350 -0
- workmap/audit.py +153 -0
- workmap/cli.py +789 -0
- workmap/config.py +589 -0
- workmap/demo.py +94 -0
- workmap/drivers/__init__.py +93 -0
- workmap/drivers/apple_terminal.py +578 -0
- workmap/layout.py +64 -0
- workmap/model.py +719 -0
- workmap/multiplexer.py +201 -0
- workmap/procs.py +504 -0
- workmap/scan.py +413 -0
- workmap/setup.py +400 -0
- workmap/shell.py +117 -0
- workmap/terminal.py +50 -0
- workmap/themes.py +45 -0
- workmap/tui/__init__.py +6 -0
- workmap/tui/app.py +1090 -0
- workmap/tui/onboarding.py +266 -0
- workmap/tui/text.py +156 -0
- workmap/tui/widgets.py +189 -0
- workmap-0.1.0.dist-info/METADATA +258 -0
- workmap-0.1.0.dist-info/RECORD +28 -0
- workmap-0.1.0.dist-info/WHEEL +4 -0
- workmap-0.1.0.dist-info/entry_points.txt +2 -0
- workmap-0.1.0.dist-info/licenses/LICENSE +21 -0
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())
|