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/config.py
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
"""Where workmap keeps its settings, what counts as a project, and which
|
|
2
|
+
colour each one gets.
|
|
3
|
+
|
|
4
|
+
Project identity lives here rather than in model.py because it is a question
|
|
5
|
+
about a disk and a setting, not about a process: which directories the user
|
|
6
|
+
calls project directories, which of the things inside them are projects, and
|
|
7
|
+
what a name has to be for `work` to be able to take it. model.py answers the
|
|
8
|
+
other half, what a *process* is, and having the two in one file is what let
|
|
9
|
+
"the first path component under a root" spread into three functions that
|
|
10
|
+
disagreed. See ProjectIndex."""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import unicodedata
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from .themes import ASSIGN_ORDER, PROFILE_SWATCHES
|
|
22
|
+
|
|
23
|
+
CONFIG_DIR = Path.home() / ".config" / "workmap"
|
|
24
|
+
CONFIG_PATH = CONFIG_DIR / "projects.json"
|
|
25
|
+
SESSION_VISIBLE = 3
|
|
26
|
+
|
|
27
|
+
# Below this, an orphaned tree is not worth a line on screen.
|
|
28
|
+
#
|
|
29
|
+
# The number only means something alongside how memory is measured. It was 8,
|
|
30
|
+
# chosen against `ps` RSS, where it sat *above* most real orphans: eleven
|
|
31
|
+
# orphaned astro servers holding 1.6 GB reported 1-2 MB of RSS each and were
|
|
32
|
+
# silently filtered out. The tool was blind to exactly the leak it exists to
|
|
33
|
+
# find. Against phys_footprint the smallest genuine dev server on a real
|
|
34
|
+
# machine is about 10 MB (a bare esbuild service), so this is set below that
|
|
35
|
+
# on purpose. It excludes processes that are already effectively gone and
|
|
36
|
+
# nothing else.
|
|
37
|
+
#
|
|
38
|
+
# Erring low is the right direction: an orphan is worth quitting because it
|
|
39
|
+
# holds a port and a file watcher, not only because it holds memory.
|
|
40
|
+
ORPHAN_FLOOR_MB = 4
|
|
41
|
+
# The smallest real dev-server footprint observed, which the floor must stay
|
|
42
|
+
# under. Tested, so raising the floor past it fails loudly.
|
|
43
|
+
SMALLEST_REAL_DEV_SERVER_MB = 10
|
|
44
|
+
|
|
45
|
+
# Directories that commonly hold a developer's projects, tried in order when
|
|
46
|
+
# nothing has been configured. Only used to guess; `roots` in the config file
|
|
47
|
+
# is what actually decides.
|
|
48
|
+
ROOT_GUESSES = ("dev", "code", "src", "projects", "work", "Developer")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_config() -> dict:
|
|
52
|
+
"""The settings, or the empty default. Reading must never raise.
|
|
53
|
+
|
|
54
|
+
It used to make the directory first, and that call sat outside the try.
|
|
55
|
+
ROOTS is computed at import, so on a machine where ~/.config cannot hold a
|
|
56
|
+
directory (it exists as a file, the home is read-only) importing workmap
|
|
57
|
+
at all ended in a traceback: `workmap --help`, `workmap --version` and
|
|
58
|
+
`import workmap` in someone else's program all died before reaching the
|
|
59
|
+
line that would have explained anything.
|
|
60
|
+
|
|
61
|
+
Making the directory belongs to writing, which is where it now happens.
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
return json.loads(CONFIG_PATH.read_text())
|
|
65
|
+
except Exception:
|
|
66
|
+
return {"profiles": {}}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Where an unreadable settings file is kept rather than thrown away.
|
|
70
|
+
KEPT_SUFFIX = ".unreadable"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def kept_path() -> Path:
|
|
74
|
+
return CONFIG_PATH.with_name(CONFIG_PATH.name + KEPT_SUFFIX)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _keep_anything_unreadable() -> None:
|
|
78
|
+
"""Move a settings file we cannot parse aside instead of over-writing it.
|
|
79
|
+
|
|
80
|
+
load_config() turns any parse failure into an empty default and says
|
|
81
|
+
nothing, and every write path here is a read-modify-write built on it. So
|
|
82
|
+
one stray comma in projects.json cost the user their roots, their agents
|
|
83
|
+
and every colour they had chosen, permanently and silently, at the next
|
|
84
|
+
thing that happened to write: `workmap paint`, `workmap agent`, or the
|
|
85
|
+
`work` function, which writes twice per invocation.
|
|
86
|
+
|
|
87
|
+
Failing to parse is not the same as having nothing to say. Keep it.
|
|
88
|
+
"""
|
|
89
|
+
if not CONFIG_PATH.exists():
|
|
90
|
+
return
|
|
91
|
+
try:
|
|
92
|
+
json.loads(CONFIG_PATH.read_text())
|
|
93
|
+
return
|
|
94
|
+
except (OSError, ValueError):
|
|
95
|
+
pass
|
|
96
|
+
try:
|
|
97
|
+
os.replace(CONFIG_PATH, kept_path())
|
|
98
|
+
except OSError:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def save_config(cfg: dict) -> None:
|
|
103
|
+
"""Write the settings file in one step, keeping anything unreadable.
|
|
104
|
+
|
|
105
|
+
write_text() truncates and then writes, so the file is empty for the gap
|
|
106
|
+
between the two and a crash in it leaves nothing. A reader that catches
|
|
107
|
+
that moment sees no settings and, being a read-modify-write itself, makes
|
|
108
|
+
it permanent. Writing beside it and renaming is atomic: a reader gets the
|
|
109
|
+
old file or the new one, never half of either.
|
|
110
|
+
"""
|
|
111
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
_keep_anything_unreadable()
|
|
113
|
+
tmp = CONFIG_PATH.with_name(CONFIG_PATH.name + ".tmp")
|
|
114
|
+
tmp.write_text(json.dumps(cfg, indent=2) + "\n")
|
|
115
|
+
os.replace(tmp, CONFIG_PATH)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def detect_roots() -> list[Path]:
|
|
119
|
+
home = Path.home()
|
|
120
|
+
return [home / name for name in ROOT_GUESSES if (home / name).is_dir()]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def configured_roots() -> list[Path]:
|
|
124
|
+
"""Where projects live, in order of precedence.
|
|
125
|
+
|
|
126
|
+
A project is the first directory *under* a root, so the root has to be the
|
|
127
|
+
directory that contains your projects, not its parent. Pointing at ~/dev
|
|
128
|
+
when your work is in ~/dev/Company would name every project "Company".
|
|
129
|
+
That's why this is configuration and not a guess baked into the source.
|
|
130
|
+
"""
|
|
131
|
+
env = os.environ.get("WORKMAP_ROOTS") or os.environ.get("DEVSTACK_ROOTS", "")
|
|
132
|
+
if env:
|
|
133
|
+
return [Path(p).expanduser() for p in env.split(":") if p]
|
|
134
|
+
configured = load_config().get("roots") or []
|
|
135
|
+
if configured:
|
|
136
|
+
return [Path(p).expanduser() for p in configured]
|
|
137
|
+
return detect_roots()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
ROOTS = configured_roots()
|
|
141
|
+
|
|
142
|
+
NO_ROOTS_ADVICE = (
|
|
143
|
+
"workmap doesn't know where you keep your projects yet. Run: workmap setup"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def roots_advice() -> str:
|
|
148
|
+
"""What to tell the user when nothing can be a project.
|
|
149
|
+
|
|
150
|
+
Without roots the scanner cannot say which directories are projects, so it
|
|
151
|
+
declines to call anything an orphan rather than treating every directory
|
|
152
|
+
on the machine as one. That is the safe answer but a silent one: a fresh
|
|
153
|
+
install would just never find anything and never explain why.
|
|
154
|
+
|
|
155
|
+
A root that is set but is not there is the same silence with a harder
|
|
156
|
+
cause to guess: a typo in the config file, a disk that is not mounted, a
|
|
157
|
+
directory that has been renamed. project_dirs() steps over an unreadable
|
|
158
|
+
root without a word, so `workmap projects` printed nothing and `workmap`
|
|
159
|
+
drew an empty desk, both of which look exactly like having no work open.
|
|
160
|
+
|
|
161
|
+
Asked as "can it be listed", not "is it a directory". Those are different
|
|
162
|
+
questions and only the first is the one the tool depends on: a folder
|
|
163
|
+
whose permissions nobody may read answers is_dir() with True and iterdir()
|
|
164
|
+
with an error, so it was reported as fine while every project in it and
|
|
165
|
+
every orphan under it silently vanished.
|
|
166
|
+
"""
|
|
167
|
+
if not ROOTS:
|
|
168
|
+
return NO_ROOTS_ADVICE
|
|
169
|
+
missing = [str(r) for r in ROOTS if not _can_be_listed(r)]
|
|
170
|
+
if not missing:
|
|
171
|
+
return ""
|
|
172
|
+
named = ", ".join(missing)
|
|
173
|
+
if len(missing) == len(ROOTS):
|
|
174
|
+
return (f"workmap can't read where you keep your projects: {named}. "
|
|
175
|
+
f"Run: workmap setup")
|
|
176
|
+
return f"one of your project folders can't be read: {named}"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _can_be_listed(root: Path) -> bool:
|
|
180
|
+
try:
|
|
181
|
+
next(iter(root.iterdir()), None)
|
|
182
|
+
return True
|
|
183
|
+
except OSError:
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def default_profile(name: str) -> str:
|
|
188
|
+
"""The colour a project gets when the user has not chosen one.
|
|
189
|
+
|
|
190
|
+
Derived from the name rather than handed out in first-seen order, so it is
|
|
191
|
+
the same on every scan, every machine, and whatever else is open at the
|
|
192
|
+
time. The order-based version had to be written to disk to be stable,
|
|
193
|
+
which meant a scan, something that happens on every keypress, rewrote
|
|
194
|
+
the config file, and every synthetic bucket name the scanner produced
|
|
195
|
+
("other", "home", and once an empty string) was saved as if it were a
|
|
196
|
+
project the user cared about.
|
|
197
|
+
|
|
198
|
+
Two projects can land on the same colour. That is a much smaller problem
|
|
199
|
+
than colours moving around between refreshes.
|
|
200
|
+
"""
|
|
201
|
+
if not name:
|
|
202
|
+
return "Basic"
|
|
203
|
+
# A stable hash: Python's is randomised per process, which would defeat
|
|
204
|
+
# the entire point.
|
|
205
|
+
digest = hashlib.sha256(name.encode("utf-8")).digest()
|
|
206
|
+
return ASSIGN_ORDER[digest[0] % len(ASSIGN_ORDER)]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def profile_for(name: str, cfg: dict) -> str:
|
|
210
|
+
"""The colour to draw this project in. Reads only; never writes.
|
|
211
|
+
|
|
212
|
+
An explicit choice always wins; everything else is derived. This is what
|
|
213
|
+
the scanner calls, so scanning cannot touch the disk.
|
|
214
|
+
"""
|
|
215
|
+
chosen = cfg.get("profiles", {}).get(name)
|
|
216
|
+
return chosen if chosen else default_profile(name)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# Cc control, Cf format (bidi overrides, zero widths, the byte order mark),
|
|
220
|
+
# Cs lone surrogate (what an undecodable filename arrives as, and what raises
|
|
221
|
+
# on the way back out), Zl and Zp line and paragraph separator.
|
|
222
|
+
_UNADDRESSABLE = frozenset({"Cc", "Cf", "Cs", "Zl", "Zp"})
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def addressable(name: str) -> bool:
|
|
226
|
+
"""Can this directory name be used as a project name?
|
|
227
|
+
|
|
228
|
+
Everything a project name touches is line-oriented or goes straight to a
|
|
229
|
+
terminal. `workmap projects` prints one per line and the `work` function
|
|
230
|
+
picks by line number, so a name containing a newline is two entries in the
|
|
231
|
+
list and neither of them is a directory. A name containing an escape
|
|
232
|
+
character is worse than useless: it is printed unquoted into the picker and
|
|
233
|
+
into the tab title, where it can move the cursor and repaint the screen.
|
|
234
|
+
|
|
235
|
+
Refusing is better than rewriting. A sanitised name no longer matches the
|
|
236
|
+
directory it came from, so `work` would land somewhere else or nowhere;
|
|
237
|
+
a name that cannot be typed safely is one the tool should not offer.
|
|
238
|
+
|
|
239
|
+
"Newline" is not only `\\n`. `str.splitlines()` breaks on ten characters,
|
|
240
|
+
and the ASCII check this used to be admitted three of them: U+0085, U+2028
|
|
241
|
+
and U+2029. A name with one in it was one entry in `workmap projects` and
|
|
242
|
+
two lines on screen, which is the exact failure the rule was written for.
|
|
243
|
+
|
|
244
|
+
The rest of what is refused here is invisible or reverses what follows it.
|
|
245
|
+
A zero-width space makes two different projects show the same name, and a
|
|
246
|
+
right-to-left override makes a name read as something other than the
|
|
247
|
+
directory it opens. Both survive every sanitiser downstream, and a project
|
|
248
|
+
name is printed into a tab title and into the prompt that asks whether to
|
|
249
|
+
signal a list of processes, which is the last place to be showing somebody
|
|
250
|
+
text that does not say what it is.
|
|
251
|
+
|
|
252
|
+
Categories rather than a list of codepoints, because the list keeps
|
|
253
|
+
growing. Measured identical on Unicode 13 and 16, so the CI floor and the
|
|
254
|
+
newest interpreter agree: emoji and the wider spaces stay allowed, and an
|
|
255
|
+
unassigned codepoint stays allowed too, since which ones those are is the
|
|
256
|
+
one thing that does change between versions.
|
|
257
|
+
"""
|
|
258
|
+
if not name:
|
|
259
|
+
return False
|
|
260
|
+
return all(unicodedata.category(ch) not in _UNADDRESSABLE for ch in name)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _resolved(path: Path) -> Path:
|
|
264
|
+
try:
|
|
265
|
+
return path.resolve()
|
|
266
|
+
except OSError:
|
|
267
|
+
return path
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@dataclass(frozen=True)
|
|
271
|
+
class ProjectId:
|
|
272
|
+
"""One project, under both the names it answers to.
|
|
273
|
+
|
|
274
|
+
A project is reached one way and reported another. `path` is how the user
|
|
275
|
+
got to it, `<root>/offsite`, which is where `work offsite` has to land.
|
|
276
|
+
`real` is what the kernel says, `/elsewhere/offsite`, because a process's
|
|
277
|
+
working directory comes back already resolved and there is no call that
|
|
278
|
+
returns the link it was reached through. Keeping both is the whole of what
|
|
279
|
+
it takes to support a project that is a symlink; keeping one was the whole
|
|
280
|
+
of why it did not work.
|
|
281
|
+
"""
|
|
282
|
+
name: str
|
|
283
|
+
path: Path
|
|
284
|
+
real: Path
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class ProjectIndex:
|
|
288
|
+
"""Which project a path belongs to. One question, one answer.
|
|
289
|
+
|
|
290
|
+
This used to be three: `scan.under_a_root()` asked whether a path was in a
|
|
291
|
+
project, `model.project_from_path()` asked which one, and
|
|
292
|
+
`config.project_dirs()` decided which directories were offered as
|
|
293
|
+
projects. All three answered by taking the first path component under a
|
|
294
|
+
configured root, and all three disagreed. Measured, with one root set:
|
|
295
|
+
|
|
296
|
+
<root> under a root, and a project named after the root
|
|
297
|
+
<root>/.hidden under a root, and a project named ".hidden"
|
|
298
|
+
<root>/nosuchdir under a root, and a project named "nosuchdir"
|
|
299
|
+
|
|
300
|
+
while project_dirs() refused all three, so `workmap kill` could name
|
|
301
|
+
something `work` could not reach and `workmap projects` did not list. A
|
|
302
|
+
prefix rule invents projects, because a prefix is a shape and a project is
|
|
303
|
+
a directory.
|
|
304
|
+
|
|
305
|
+
So identity stops being derived and becomes a lookup. This is built once
|
|
306
|
+
per scan from the directories that are actually there, and a path belongs
|
|
307
|
+
to the project whose own path is the longest one above it. That single
|
|
308
|
+
rule replaces the two it came from: nested roots need no precedence order,
|
|
309
|
+
because the inner project's path is simply the longer match.
|
|
310
|
+
|
|
311
|
+
Nothing here touches the filesystem. build_project_index() does the
|
|
312
|
+
listing and hands the answers over, which is what lets everything that
|
|
313
|
+
reads it stay a total function of its arguments.
|
|
314
|
+
"""
|
|
315
|
+
|
|
316
|
+
def __init__(self, projects, home: "Path | None" = None, unreadable=()):
|
|
317
|
+
self.projects = list(projects)
|
|
318
|
+
self.home = home
|
|
319
|
+
# Roots that are there but would not list. Carried so the desk can say
|
|
320
|
+
# so: without it, one unreadable root means every project under it and
|
|
321
|
+
# every orphan in them disappear, and the desk that results is
|
|
322
|
+
# indistinguishable from having nothing open. roots_advice() reports
|
|
323
|
+
# it, and it cannot be found by asking is_dir(), which a directory
|
|
324
|
+
# nobody may read still answers True.
|
|
325
|
+
self.unreadable = list(unreadable)
|
|
326
|
+
keyed: dict[Path, ProjectId] = {}
|
|
327
|
+
# Two projects can resolve to one directory: `<root>/api` and
|
|
328
|
+
# `<root>/api-link -> api`. The one reached directly wins, and then the
|
|
329
|
+
# shorter path, so which name an orphan there is filed under is a
|
|
330
|
+
# property of the disk rather than of the order a listing came back in.
|
|
331
|
+
for p in sorted(self.projects,
|
|
332
|
+
key=lambda p: (p.path != p.real, len(str(p.path)))):
|
|
333
|
+
# Both spellings, so a working directory (always resolved) and a
|
|
334
|
+
# command line (whatever was typed) reach the same project without
|
|
335
|
+
# either reader having to resolve anything.
|
|
336
|
+
keyed.setdefault(p.real, p)
|
|
337
|
+
keyed.setdefault(p.path, p)
|
|
338
|
+
self._keys = sorted(keyed.items(),
|
|
339
|
+
key=lambda kv: len(str(kv[0])), reverse=True)
|
|
340
|
+
|
|
341
|
+
def of_path(self, path: "Path | None") -> "ProjectId | None":
|
|
342
|
+
"""The project this path is in, or None if it is in none.
|
|
343
|
+
|
|
344
|
+
None is the honest answer for a path under a root that is not a
|
|
345
|
+
project: the root itself, a dot directory, a directory that has been
|
|
346
|
+
deleted since. Every one of those used to become a project named after
|
|
347
|
+
a path component.
|
|
348
|
+
"""
|
|
349
|
+
if path is None:
|
|
350
|
+
return None
|
|
351
|
+
for key, project in self._keys:
|
|
352
|
+
if path == key or key in path.parents:
|
|
353
|
+
return project
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
def named(self, name: str) -> "ProjectId | None":
|
|
357
|
+
for project in self.projects:
|
|
358
|
+
if same_name(project.name, name):
|
|
359
|
+
return project
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
def in_cmd(self, cmd: str) -> "ProjectId | None":
|
|
363
|
+
"""The project a command line names, if it names one.
|
|
364
|
+
|
|
365
|
+
Only a fallback, for a process whose working directory cannot be read.
|
|
366
|
+
Where a process is running is a better answer than what is written in
|
|
367
|
+
its arguments, and scan.build_projects() asks in that order. Reading a
|
|
368
|
+
project out of an argument is the mistake this codebase keeps finding,
|
|
369
|
+
so this is the one rule allowed to look at the whole command line, and
|
|
370
|
+
it is bounded on the other side instead: it can only ever answer with
|
|
371
|
+
a project that is on the disk right now. It used to be able to invent
|
|
372
|
+
one out of any word that followed a root.
|
|
373
|
+
|
|
374
|
+
Longest path first, so `<root>/my project` wins over `<root>/my`, and
|
|
375
|
+
the character after the match has to end the name, so `<root>/myapp`
|
|
376
|
+
is not `<root>/my`.
|
|
377
|
+
"""
|
|
378
|
+
for key, project in self._keys:
|
|
379
|
+
text = str(key)
|
|
380
|
+
start = 0
|
|
381
|
+
while True:
|
|
382
|
+
idx = cmd.find(text, start)
|
|
383
|
+
if idx < 0:
|
|
384
|
+
break
|
|
385
|
+
after = cmd[idx + len(text): idx + len(text) + 1]
|
|
386
|
+
if after in ("", "/", " "):
|
|
387
|
+
return project
|
|
388
|
+
start = idx + 1
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
def bucket_for(self, path: "Path | None") -> "tuple[str, Path | None]":
|
|
392
|
+
"""Where to file a Terminal window whose directory is not a project.
|
|
393
|
+
|
|
394
|
+
Windows are not orphans and belong on the desk wherever they are, so
|
|
395
|
+
they need somewhere to go when of_path() says None. Orphans do not get
|
|
396
|
+
this: a process with no project is not offered for killing, which is
|
|
397
|
+
the safe direction and the reason the two questions are separate.
|
|
398
|
+
|
|
399
|
+
Compared resolved on both sides. It once compared a resolved path
|
|
400
|
+
against a raw Path.home(), so on any machine whose home is reached
|
|
401
|
+
through a symlink the home bucket never matched and every directory
|
|
402
|
+
beside it became a project named after itself.
|
|
403
|
+
"""
|
|
404
|
+
if path is None:
|
|
405
|
+
return "other", None
|
|
406
|
+
if self.home is not None and self.home in (path, path.parent):
|
|
407
|
+
return "home", self.home
|
|
408
|
+
return path.name, path
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def project_map() -> tuple[list[ProjectId], list[Path]]:
|
|
412
|
+
"""Every project on disk, and every root that would not list.
|
|
413
|
+
|
|
414
|
+
build_projects() only knows about projects with live sessions, which is
|
|
415
|
+
the wrong list for starting work: the project you want next is usually the
|
|
416
|
+
one with nothing open. This is the one definition of what a project is,
|
|
417
|
+
and it is a directory rather than a path prefix.
|
|
418
|
+
|
|
419
|
+
Two projects may share a name. Nothing is dropped here, because dropping
|
|
420
|
+
is what the name-keyed version did: with two roots each holding an `api`,
|
|
421
|
+
only the first was kept, so every orphan under the second was invisible
|
|
422
|
+
for good. Which one `work api` lands in is a question about a typed name,
|
|
423
|
+
and it is answered in project_dirs(), where dropping one is the point.
|
|
424
|
+
|
|
425
|
+
Measured on this machine: 1.5ms for 44 projects, plus 0.5ms to resolve
|
|
426
|
+
them all, against a scan that costs about a second.
|
|
427
|
+
"""
|
|
428
|
+
# A directory that is itself a configured root holds projects, it is not
|
|
429
|
+
# one. With ~/dev and ~/dev/Company both set, "Company" was offered as a
|
|
430
|
+
# project and `work Company` landed in the folder holding the real ones.
|
|
431
|
+
root_paths = {_resolved(r) for r in ROOTS}
|
|
432
|
+
home = _resolved(Path.home())
|
|
433
|
+
found: list[ProjectId] = []
|
|
434
|
+
unreadable: list[Path] = []
|
|
435
|
+
for root in ROOTS:
|
|
436
|
+
try:
|
|
437
|
+
entries = sorted(root.iterdir())
|
|
438
|
+
except OSError:
|
|
439
|
+
unreadable.append(root)
|
|
440
|
+
continue
|
|
441
|
+
for entry in entries:
|
|
442
|
+
if entry.name.startswith(".") or not addressable(entry.name):
|
|
443
|
+
continue
|
|
444
|
+
try:
|
|
445
|
+
if not entry.is_dir():
|
|
446
|
+
continue
|
|
447
|
+
except OSError:
|
|
448
|
+
continue
|
|
449
|
+
real = _resolved(entry)
|
|
450
|
+
if real in root_paths or _holds_projects(real, root_paths, home):
|
|
451
|
+
continue
|
|
452
|
+
found.append(ProjectId(name=entry.name, path=entry, real=real))
|
|
453
|
+
return found, unreadable
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _holds_projects(real: Path, root_paths: set, home: Path) -> bool:
|
|
457
|
+
"""Is this directory somewhere projects live, rather than a project?
|
|
458
|
+
|
|
459
|
+
The same rule as "a root is not a project", followed through a link. A
|
|
460
|
+
symlink is allowed to leave its root, which is the whole point of
|
|
461
|
+
supporting one, so `<root>/offsite -> /Volumes/ext/offsite` is a project.
|
|
462
|
+
`<root>/dotfiles -> ~` is not, and neither is `<root>/current -> ..`: both
|
|
463
|
+
resolve to a directory holding the projects rather than to one of them.
|
|
464
|
+
|
|
465
|
+
Without this, following the link made every directory in the home folder
|
|
466
|
+
part of one project. A dev server in ~/scratch would be listed as an
|
|
467
|
+
orphan of "dotfiles" and `workmap kill dotfiles` would signal it, which is
|
|
468
|
+
a great deal more than anyone asked for by making a symlink.
|
|
469
|
+
"""
|
|
470
|
+
if real == home or real in home.parents:
|
|
471
|
+
return True
|
|
472
|
+
return any(root == real or real in root.parents for root in root_paths)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def build_project_index() -> ProjectIndex:
|
|
476
|
+
"""The index, read off the disk. The one call here that does any listing."""
|
|
477
|
+
projects, unreadable = project_map()
|
|
478
|
+
return ProjectIndex(projects, home=_resolved(Path.home()),
|
|
479
|
+
unreadable=unreadable)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def project_dirs() -> list[tuple[str, Path]]:
|
|
483
|
+
"""Every project, as (name, where to go). What `work` and `projects` read.
|
|
484
|
+
|
|
485
|
+
One entry per name, first root first, because this answers a typed name
|
|
486
|
+
and a typed name can only go one place.
|
|
487
|
+
"""
|
|
488
|
+
seen: dict[str, Path] = {}
|
|
489
|
+
for project in project_map()[0]:
|
|
490
|
+
seen.setdefault(project.name, project.path)
|
|
491
|
+
return sorted(seen.items())
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def same_name(a: str, b: str) -> bool:
|
|
495
|
+
"""Do these name the same project?
|
|
496
|
+
|
|
497
|
+
Compared with accents composed on both sides. macOS filesystems disagree
|
|
498
|
+
about whether "é" is one character or two: APFS keeps what you gave it,
|
|
499
|
+
HFS+ decomposes, and a name typed at a keyboard is composed. So a project
|
|
500
|
+
read off one volume did not match itself typed at a prompt, and the tool
|
|
501
|
+
answered `There's no project called "café-api". Did you mean: café-api?`
|
|
502
|
+
with the two spellings looking identical on screen.
|
|
503
|
+
"""
|
|
504
|
+
return (unicodedata.normalize("NFC", a) == unicodedata.normalize("NFC", b))
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def project_path(name: str) -> Path | None:
|
|
508
|
+
"""Where a project lives, by name. None if there is no such directory."""
|
|
509
|
+
for candidate, path in project_dirs():
|
|
510
|
+
if same_name(candidate, name):
|
|
511
|
+
return path
|
|
512
|
+
return None
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
# The agents worth offering, and how to launch each one. A key is only shown
|
|
516
|
+
# if its binary is actually on PATH, so nobody is offered a tool they do not
|
|
517
|
+
# have. Override any of these under "agents" in the config file.
|
|
518
|
+
AGENT_DEFAULTS = {
|
|
519
|
+
"claude": "claude",
|
|
520
|
+
"codex": "codex",
|
|
521
|
+
"cursor": "cursor-agent",
|
|
522
|
+
"aider": "aider",
|
|
523
|
+
"gemini": "gemini",
|
|
524
|
+
"opencode": "opencode",
|
|
525
|
+
"goose": "goose",
|
|
526
|
+
"amp": "amp",
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def configured_agents(cfg: dict | None = None) -> dict:
|
|
531
|
+
"""The defaults, with anything in the config layered on top.
|
|
532
|
+
|
|
533
|
+
Merging rather than replacing means installing workmap is enough to get
|
|
534
|
+
the agents you already have offered to you. To take one off the list,
|
|
535
|
+
set it to "" or null. Otherwise there would be no way to say no.
|
|
536
|
+
"""
|
|
537
|
+
cfg = load_config() if cfg is None else cfg
|
|
538
|
+
agents = dict(AGENT_DEFAULTS)
|
|
539
|
+
agents.update(cfg.get("agents") or {})
|
|
540
|
+
return agents
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def available_agents(cfg: dict | None = None) -> dict:
|
|
544
|
+
"""Configured agents whose command exists on this machine."""
|
|
545
|
+
out = {}
|
|
546
|
+
for key, command in configured_agents(cfg).items():
|
|
547
|
+
words = (command or "").split()
|
|
548
|
+
if words and shutil.which(words[0]):
|
|
549
|
+
out[key] = command
|
|
550
|
+
return out
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def default_agent(cfg: dict | None = None) -> str:
|
|
554
|
+
cfg = load_config() if cfg is None else cfg
|
|
555
|
+
chosen = cfg.get("default_agent")
|
|
556
|
+
available = available_agents(cfg)
|
|
557
|
+
if chosen in available or chosen == "none":
|
|
558
|
+
return chosen
|
|
559
|
+
return next(iter(available), "none")
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def set_default_agent(name: str) -> str:
|
|
563
|
+
cfg = load_config()
|
|
564
|
+
cfg["default_agent"] = name
|
|
565
|
+
save_config(cfg)
|
|
566
|
+
return name
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def remembered_profile(name: str, cfg: dict, available: set) -> str:
|
|
570
|
+
"""profile_for(), but remember the answer, and only pick a real colour.
|
|
571
|
+
|
|
572
|
+
Only for paths where the user has expressed intent about this project:
|
|
573
|
+
painting it, organizing it, choosing its colour. Writing is the point
|
|
574
|
+
here, so unlike profile_for() this one is not safe to call from a scan.
|
|
575
|
+
|
|
576
|
+
`available` is passed in rather than looked up. Which colours exist is a
|
|
577
|
+
question for the terminal driver, and this module sits above the driver:
|
|
578
|
+
it used to import terminal to ask, which put the settings file underneath
|
|
579
|
+
the thing that reads it and made the layer order in the README false.
|
|
580
|
+
"""
|
|
581
|
+
profiles = cfg.setdefault("profiles", {})
|
|
582
|
+
if name in profiles:
|
|
583
|
+
return profiles[name]
|
|
584
|
+
pick = default_profile(name)
|
|
585
|
+
if pick not in available:
|
|
586
|
+
pick = "Basic" if "Basic" in available else (next(iter(available), "Basic"))
|
|
587
|
+
profiles[name] = pick
|
|
588
|
+
save_config(cfg)
|
|
589
|
+
return pick
|
workmap/demo.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""A throwaway machine to try the first run on.
|
|
2
|
+
|
|
3
|
+
The first thing a new user sees is the one screen its author can never see
|
|
4
|
+
again, because their machine is already configured. Reproducing it normally
|
|
5
|
+
means deleting your own config, which is a bad enough trade that the screen
|
|
6
|
+
just goes untested and undesigned.
|
|
7
|
+
|
|
8
|
+
So: build a complete fake home in a temp directory, projects, config, state,
|
|
9
|
+
shell rc, then run the real setup inside it as a child process with HOME
|
|
10
|
+
pointed there. Nothing is stubbed and no code path is special-cased for demo
|
|
11
|
+
mode, which is what makes what you see the actual first run.
|
|
12
|
+
|
|
13
|
+
PATH is deliberately inherited, so the agents it detects are your real ones.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
# Enough projects that the picker has to sort and number something.
|
|
25
|
+
SAMPLE_PROJECTS = ("acme-api", "acme-web", "marketing-site", "sandbox")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_home(base: Path, *, projects=SAMPLE_PROJECTS) -> Path:
|
|
29
|
+
"""A believable empty machine: a projects directory and nothing else."""
|
|
30
|
+
home = base / "home"
|
|
31
|
+
for name in projects:
|
|
32
|
+
(home / "dev" / name).mkdir(parents=True, exist_ok=True)
|
|
33
|
+
(home / ".zshrc").write_text("# a shell rc that was already here\n"
|
|
34
|
+
"export EDITOR=vim\n", encoding="utf-8")
|
|
35
|
+
return home
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def child_env(home: Path) -> dict:
|
|
39
|
+
"""The environment the demo runs in.
|
|
40
|
+
|
|
41
|
+
HOME does all the work: config, state, rc file and root detection are all
|
|
42
|
+
derived from it, so one variable moves the entire footprint into the
|
|
43
|
+
sandbox without workmap needing to know it is being demoed.
|
|
44
|
+
"""
|
|
45
|
+
env = dict(os.environ)
|
|
46
|
+
env["HOME"] = str(home)
|
|
47
|
+
env["SHELL"] = env.get("SHELL", "/bin/zsh")
|
|
48
|
+
# Roots must be discovered, not inherited: these are what make it a *first*
|
|
49
|
+
# run rather than this machine's run.
|
|
50
|
+
env.pop("WORKMAP_ROOTS", None)
|
|
51
|
+
env.pop("WORKMAP_STATE_DIR", None)
|
|
52
|
+
env.pop("DEVSTACK_ROOTS", None)
|
|
53
|
+
return env
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def run(argv: list[str] | None = None) -> int:
|
|
57
|
+
argv = list(argv or [])
|
|
58
|
+
keep = "--keep" in argv
|
|
59
|
+
base = Path(tempfile.mkdtemp(prefix="workmap-demo-"))
|
|
60
|
+
home = build_home(base)
|
|
61
|
+
|
|
62
|
+
print()
|
|
63
|
+
print(" This is a practice run on a pretend computer.")
|
|
64
|
+
print(" Answer anything you like. Your real settings, shell files")
|
|
65
|
+
print(" and projects are not touched.")
|
|
66
|
+
print()
|
|
67
|
+
# The child writes straight to this terminal; flush first or the header
|
|
68
|
+
# lands after the screens it introduces.
|
|
69
|
+
sys.stdout.flush()
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
proc = subprocess.run(
|
|
73
|
+
[sys.executable, "-m", "workmap.cli", "setup"],
|
|
74
|
+
env=child_env(home),
|
|
75
|
+
)
|
|
76
|
+
except OSError as exc:
|
|
77
|
+
print(f" could not start the demo: {exc}", file=sys.stderr)
|
|
78
|
+
return 1
|
|
79
|
+
finally:
|
|
80
|
+
if keep:
|
|
81
|
+
print()
|
|
82
|
+
print(f" The pretend computer is still here: {home}")
|
|
83
|
+
# HOME alone is not the environment the demo ran in: roots are
|
|
84
|
+
# read from WORKMAP_ROOTS and DEVSTACK_ROOTS first, so this
|
|
85
|
+
# command used to show the real machine's projects.
|
|
86
|
+
print(" Look around it with:")
|
|
87
|
+
print(f" env -u WORKMAP_ROOTS -u DEVSTACK_ROOTS "
|
|
88
|
+
f"-u WORKMAP_STATE_DIR HOME={home} workmap projects")
|
|
89
|
+
else:
|
|
90
|
+
shutil.rmtree(base, ignore_errors=True)
|
|
91
|
+
print()
|
|
92
|
+
print(" That was a practice run, so nothing was saved.")
|
|
93
|
+
print(" Run `workmap setup` when you want to do it for real.")
|
|
94
|
+
return proc.returncode
|