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/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