microwave-method 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.
Files changed (64) hide show
  1. microwave_method/__init__.py +131 -0
  2. microwave_method/_payload/.github/workflows/gates.yml +34 -0
  3. microwave_method/_payload/CODEOWNERS +10 -0
  4. microwave_method/_payload/banner.txt +10 -0
  5. microwave_method/_payload/embodiment/adapters/__init__.py +0 -0
  6. microwave_method/_payload/embodiment/adapters/linux.py +78 -0
  7. microwave_method/_payload/embodiment/adapters/macos.py +109 -0
  8. microwave_method/_payload/embodiment/adapters/windows.py +152 -0
  9. microwave_method/_payload/embodiment/embody.py +188 -0
  10. microwave_method/_payload/embodiment/icons/factory.png +0 -0
  11. microwave_method/_payload/embodiment/icons/librarian.png +0 -0
  12. microwave_method/_payload/flows/adopt.md +69 -0
  13. microwave_method/_payload/flows/amend-rule.md +31 -0
  14. microwave_method/_payload/flows/create-agent.md +49 -0
  15. microwave_method/_payload/flows/create-feature.md +41 -0
  16. microwave_method/_payload/flows/devil-loop.md +46 -0
  17. microwave_method/_payload/flows/devil-review.md +84 -0
  18. microwave_method/_payload/flows/librarian.md +39 -0
  19. microwave_method/_payload/flows/metrics.md +41 -0
  20. microwave_method/_payload/flows/resume.md +35 -0
  21. microwave_method/_payload/flows/save.md +59 -0
  22. microwave_method/_payload/flows/welcome.md +95 -0
  23. microwave_method/_payload/gates/_lib.py +201 -0
  24. microwave_method/_payload/gates/activate.py +87 -0
  25. microwave_method/_payload/gates/docgen.py +130 -0
  26. microwave_method/_payload/gates/gate_antidup.py +62 -0
  27. microwave_method/_payload/gates/gate_brief.py +45 -0
  28. microwave_method/_payload/gates/gate_docs.py +31 -0
  29. microwave_method/_payload/gates/gate_embodiment.py +68 -0
  30. microwave_method/_payload/gates/gate_schema.py +107 -0
  31. microwave_method/_payload/gates/gate_slop.py +106 -0
  32. microwave_method/_payload/gates/gate_testable.py +52 -0
  33. microwave_method/_payload/gates/gate_wiki.py +119 -0
  34. microwave_method/_payload/gates/metrics.py +118 -0
  35. microwave_method/_payload/gates/run_gates.py +37 -0
  36. microwave_method/_payload/gates/trace.py +136 -0
  37. microwave_method/_payload/harness/claude-settings.example.json +21 -0
  38. microwave_method/_payload/hooks/install-hooks.ps1 +22 -0
  39. microwave_method/_payload/hooks/install-hooks.sh +23 -0
  40. microwave_method/_payload/hooks/pre-commit +41 -0
  41. microwave_method/_payload/slop/slop-rules.csv +16 -0
  42. microwave_method/_payload/techniques/README.md +39 -0
  43. microwave_method/_payload/techniques/brainstorming-methods.csv +109 -0
  44. microwave_method/_payload/techniques/design-methods.csv +31 -0
  45. microwave_method/_payload/techniques/elicitation-methods.csv +72 -0
  46. microwave_method/_payload/techniques/innovation-frameworks.csv +31 -0
  47. microwave_method/_payload/techniques/solving-methods.csv +31 -0
  48. microwave_method/_payload/techniques/story-types.csv +26 -0
  49. microwave_method/_payload/templates/adr.md +27 -0
  50. microwave_method/_payload/templates/agent-card.md +52 -0
  51. microwave_method/_payload/templates/brief.md +19 -0
  52. microwave_method/_payload/templates/bug.md +28 -0
  53. microwave_method/_payload/templates/devil-report.md +32 -0
  54. microwave_method/_payload/templates/inventory-entry.md +25 -0
  55. microwave_method/_payload/templates/learning.md +24 -0
  56. microwave_method/_payload/templates/project-seed.md +24 -0
  57. microwave_method/_payload/templates/session-save.md +40 -0
  58. microwave_method/_payload/templates/story.md +24 -0
  59. microwave_method-0.1.0.dist-info/METADATA +228 -0
  60. microwave_method-0.1.0.dist-info/RECORD +64 -0
  61. microwave_method-0.1.0.dist-info/WHEEL +4 -0
  62. microwave_method-0.1.0.dist-info/entry_points.txt +2 -0
  63. microwave_method-0.1.0.dist-info/licenses/LICENSE +21 -0
  64. microwave_method-0.1.0.dist-info/licenses/NOTICE.md +35 -0
@@ -0,0 +1,131 @@
1
+ """microwave-method: `uvx microwave-method` drops Microwave into your repo.
2
+
3
+ Stdlib only (ADR-007). This is the single install command. It copies the
4
+ framework files into the current repo (additive, never overwrites), seeds the
5
+ wiki, wires the pre-commit hook, then hands you to your coding agent: the
6
+ guided welcome itself is played by the agent (flows/welcome.md), because
7
+ Microwave is a method, not a runtime.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import importlib.resources as resources
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+ from pathlib import Path
16
+
17
+ PAYLOAD_DIRS = ["flows", "templates", "techniques", "slop", "gates",
18
+ "embodiment", "hooks", "harness"]
19
+ WIKI_SPACES = ["agents", "adr", "projects", "_staging", "_archive"]
20
+ WIKI_INDEX = (
21
+ "# Registry index\n\n"
22
+ "One line per artifact: `- [type] id: one-line summary -> path`\n\n"
23
+ "## Agents\n\n## ADR (meta)\n\n## Projects\n"
24
+ )
25
+ BANNER = "install/banner.txt"
26
+
27
+
28
+ def _payload() -> Path:
29
+ return Path(str(resources.files("microwave_method"))) / "_payload"
30
+
31
+
32
+ def _copy_tree(src: Path, dst: Path) -> int:
33
+ copied = 0
34
+ if not src.is_dir():
35
+ return 0
36
+ for root, _dirs, files in os.walk(src):
37
+ rel = Path(root).relative_to(src)
38
+ if "icons" in rel.parts:
39
+ continue
40
+ for name in files:
41
+ out = dst / rel / name
42
+ if not out.exists():
43
+ out.parent.mkdir(parents=True, exist_ok=True)
44
+ shutil.copy2(Path(root) / name, out)
45
+ copied += 1
46
+ return copied
47
+
48
+
49
+ def _is_git_repo(target: Path) -> bool:
50
+ try:
51
+ r = subprocess.run(["git", "-C", str(target), "rev-parse",
52
+ "--is-inside-work-tree"],
53
+ capture_output=True, text=True)
54
+ return r.returncode == 0
55
+ except FileNotFoundError:
56
+ return False
57
+
58
+
59
+ def main() -> None:
60
+ target = Path(os.environ.get("MICROWAVE_TARGET", os.getcwd())).resolve()
61
+ payload = _payload()
62
+
63
+ banner = payload / "banner.txt"
64
+ if banner.is_file():
65
+ print(banner.read_text(encoding="utf-8"))
66
+
67
+ print(f"Installing Microwave into {target}")
68
+ is_repo = _is_git_repo(target)
69
+ if not is_repo:
70
+ init = subprocess.run(["git", "-C", str(target), "init"],
71
+ capture_output=True, text=True)
72
+ is_repo = init.returncode == 0
73
+ print(" initialized a git repo here (the gates need it)." if is_repo
74
+ else " could not run git init; install git so the gates can guard it.")
75
+
76
+ copied = sum(_copy_tree(payload / d, target / d) for d in PAYLOAD_DIRS)
77
+
78
+ gh = target / ".github" / "workflows"
79
+ gh.mkdir(parents=True, exist_ok=True)
80
+ ci = gh / "gates.yml"
81
+ if not ci.exists():
82
+ shutil.copy2(payload / ".github" / "workflows" / "gates.yml", ci)
83
+
84
+ co = target / "CODEOWNERS"
85
+ if not co.exists():
86
+ text = (payload / "CODEOWNERS").read_text(encoding="utf-8")
87
+ co.write_text(text.replace("@microphage-create", "@your-gatekeeper"),
88
+ encoding="utf-8", newline="\n")
89
+
90
+ wiki = target / "wiki"
91
+ for space in WIKI_SPACES:
92
+ (wiki / space).mkdir(parents=True, exist_ok=True)
93
+ index = wiki / "INDEX.md"
94
+ if not index.exists():
95
+ index.write_text(WIKI_INDEX, encoding="utf-8", newline="\n")
96
+
97
+ if is_repo:
98
+ hooks_dir = target / ".git" / "hooks"
99
+ src_hook = target / "hooks" / "pre-commit"
100
+ if hooks_dir.is_dir() and src_hook.is_file():
101
+ dest = hooks_dir / "pre-commit"
102
+ if not dest.exists():
103
+ shutil.copy2(src_hook, dest)
104
+ try:
105
+ os.chmod(dest, 0o755)
106
+ except OSError:
107
+ pass
108
+
109
+ print(f"\nDone. {copied} files installed.")
110
+
111
+ # True one-command experience: hand straight off to the coding agent on the
112
+ # welcome flow. Launch the detected agent (claude); fall back to printing
113
+ # the line when none is found or MICROWAVE_NO_LAUNCH=1 (tests, CI).
114
+ prompt = "run the Microwave welcome flow"
115
+ agent = shutil.which("claude")
116
+ if agent and os.environ.get("MICROWAVE_NO_LAUNCH") != "1":
117
+ print("\nStarting your agent on the welcome flow...\n")
118
+ try:
119
+ subprocess.run([agent, prompt], cwd=str(target))
120
+ except OSError as exc:
121
+ print(f"(could not launch the agent: {exc})")
122
+ print(f"Open your coding agent here and say: {prompt}")
123
+ else:
124
+ print("\nOne line to start: open your coding agent here and say\n")
125
+ print(f" {prompt}\n")
126
+ print("It adapts to you, scans what you already have, and brings in your")
127
+ print("first agent. Nothing changes until you say so.")
128
+
129
+
130
+ if __name__ == "__main__":
131
+ main()
@@ -0,0 +1,34 @@
1
+ name: gates
2
+ on:
3
+ pull_request:
4
+ push:
5
+ branches: [main]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ gates:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-python@v5
16
+ with:
17
+ python-version: "3.12"
18
+ - name: Wiki integrity
19
+ run: python gates/gate_wiki.py
20
+ - name: Anti-slop sweep
21
+ run: python gates/gate_slop.py
22
+ - name: Generated docs are fresh
23
+ run: python gates/gate_docs.py
24
+ - name: All agent cards (active and staged)
25
+ run: |
26
+ set -e
27
+ found=0
28
+ for card in wiki/agents/*.md wiki/_staging/*.md; do
29
+ [ -e "$card" ] || continue
30
+ case "$card" in *README.md) continue;; esac
31
+ found=1
32
+ python gates/run_gates.py "$card"
33
+ done
34
+ [ "$found" = "1" ] || echo "no agent cards yet"
@@ -0,0 +1,10 @@
1
+ # The gatekeeper owns the protected space: no change to the rules, the flows,
2
+ # or the meta wiki merges without their review (pair with branch protection,
3
+ # see docs/governance.md "Repo layer").
4
+ /gates/ @microphage-create
5
+ /flows/ @microphage-create
6
+ /hooks/ @microphage-create
7
+ /harness/ @microphage-create
8
+ /wiki/adr/ @microphage-create
9
+ /.github/ @microphage-create
10
+ /CODEOWNERS @microphage-create
@@ -0,0 +1,10 @@
1
+ +------------------------+
2
+ | +------------------+ |
3
+ | | | | M I C R O W A V E
4
+ | | ~ ~ ~ ~ | |
5
+ | | ~ ~ ~ | | an agent factory
6
+ | | ~ ~ ~ ~ | | with a governed memory
7
+ | | | |
8
+ | +------------------+ | context cooked once,
9
+ | (o) | reheated at cache price
10
+ +------------------------+
@@ -0,0 +1,78 @@
1
+ """Linux adapter: freedesktop .desktop entry + icon, terminal auto-detected.
2
+
3
+ Supported terminals: kitty, wezterm, gnome-terminal; generic fallback via
4
+ $TERMINAL. The PNG icon is used natively (no conversion needed).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import shutil
9
+ from pathlib import Path
10
+
11
+ APPS = Path.home() / ".local/share/applications"
12
+ ICONS = Path.home() / ".local/share/icons"
13
+
14
+
15
+ def _terminal_cmd(ident) -> str:
16
+ """Exec= lines get no shell expansion (freedesktop spec), so only
17
+ concrete binaries found on PATH are usable; $TERMINAL would be run
18
+ literally and can never work."""
19
+ launch = ident.launch or "exec $SHELL"
20
+ inner = f"cd '{ident.repo}' && {launch}"
21
+ if shutil.which("kitty"):
22
+ return f"kitty --title '{ident.name}' bash -lc \"{inner}\""
23
+ if shutil.which("wezterm"):
24
+ return f"wezterm start -- bash -lc \"{inner}\""
25
+ if shutil.which("gnome-terminal"):
26
+ return f"gnome-terminal --title='{ident.name}' -- bash -lc \"{inner}\""
27
+ for candidate in ("x-terminal-emulator", "xdg-terminal-exec", "konsole", "xterm"):
28
+ if shutil.which(candidate):
29
+ return f"{candidate} -e bash -lc \"{inner}\""
30
+ raise RuntimeError(
31
+ "no known terminal found (kitty, wezterm, gnome-terminal, "
32
+ "x-terminal-emulator, xdg-terminal-exec, konsole, xterm): "
33
+ "install one or open an issue naming yours")
34
+
35
+
36
+ def apply(ident, dry_run: bool = False) -> None:
37
+ ident.png_bytes() # same icon contract as the other adapters
38
+ desktop_file = APPS / f"microwave-{ident.slug}.desktop"
39
+ icon_dst = ICONS / f"microwave-{ident.slug}.png"
40
+ entry = (
41
+ "[Desktop Entry]\n"
42
+ "Type=Application\n"
43
+ f"Name={ident.name}\n"
44
+ f"Comment=Microwave agent: {ident.slug}\n"
45
+ f"Exec={_terminal_cmd(ident)}\n"
46
+ f"Icon={icon_dst}\n"
47
+ "Terminal=false\n"
48
+ "Categories=Development;\n"
49
+ )
50
+ if dry_run:
51
+ print(f"[linux] would write {desktop_file} and {icon_dst}")
52
+ return
53
+ APPS.mkdir(parents=True, exist_ok=True)
54
+ ICONS.mkdir(parents=True, exist_ok=True)
55
+ shutil.copyfile(ident.icon_src, icon_dst)
56
+ desktop_file.write_text(entry, encoding="utf-8")
57
+ desktop_file.chmod(0o755)
58
+ desktop_dir = Path.home() / "Desktop"
59
+ if desktop_dir.is_dir():
60
+ desktop_copy = desktop_dir / desktop_file.name
61
+ shutil.copyfile(desktop_file, desktop_copy)
62
+ desktop_copy.chmod(0o755)
63
+ print(f"[linux] desktop entry: {desktop_file}")
64
+
65
+
66
+ def remove(ident, dry_run: bool = False) -> None:
67
+ targets = [
68
+ APPS / f"microwave-{ident.slug}.desktop",
69
+ ICONS / f"microwave-{ident.slug}.png",
70
+ Path.home() / "Desktop" / f"microwave-{ident.slug}.desktop",
71
+ ]
72
+ if dry_run:
73
+ print("[linux] would remove: " + ", ".join(str(t) for t in targets))
74
+ return
75
+ for t in targets:
76
+ if t.exists():
77
+ t.unlink()
78
+ print("[linux] removed")
@@ -0,0 +1,109 @@
1
+ """macOS adapter: iTerm2 Dynamic Profile (additive, zero-risk) + minimal
2
+ .app bundle launcher with .icns, so the agent exists in Dock/Spotlight.
3
+
4
+ Falls back to Terminal.app in the launcher script when iTerm2 is absent.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import plistlib
10
+ import shlex
11
+ import shutil
12
+ import stat
13
+ from pathlib import Path
14
+
15
+ DYNAMIC = Path.home() / "Library/Application Support/iTerm2/DynamicProfiles"
16
+ APPS = Path.home() / "Applications"
17
+
18
+
19
+ def _hexpair(c: str) -> dict:
20
+ r, g, b = (int(c.lstrip("#")[i:i + 2], 16) / 255 for i in (0, 2, 4))
21
+ return {"Red Component": r, "Green Component": g, "Blue Component": b}
22
+
23
+
24
+ def _dynamic_profile(ident) -> dict:
25
+ launch = ident.launch or ""
26
+ cmd = f"cd '{ident.repo}'" + (f" && {launch}" if launch else "; exec $SHELL")
27
+ return {"Profiles": [{
28
+ "Name": ident.name,
29
+ "Guid": ident.guid,
30
+ "Custom Command": "Yes",
31
+ "Command": f"/bin/zsh -lc \"{cmd}\"",
32
+ "Background Color": _hexpair(ident.bg),
33
+ "Foreground Color": _hexpair(ident.fg),
34
+ "Cursor Color": _hexpair(ident.accent),
35
+ "Badge Text": ident.name,
36
+ }]}
37
+
38
+
39
+ def _bundle(ident, icns: Path) -> Path:
40
+ app = APPS / f"{ident.name}.app"
41
+ macos_dir = app / "Contents/MacOS"
42
+ res_dir = app / "Contents/Resources"
43
+ macos_dir.mkdir(parents=True, exist_ok=True)
44
+ res_dir.mkdir(parents=True, exist_ok=True)
45
+ plistlib.dump({
46
+ "CFBundleName": ident.name,
47
+ "CFBundleIdentifier": f"dev.microwave.{ident.slug}",
48
+ "CFBundleExecutable": "run",
49
+ "CFBundleIconFile": "icon.icns",
50
+ "CFBundlePackageType": "APPL",
51
+ }, (app / "Contents/Info.plist").open("wb"))
52
+ shutil.copyfile(icns, res_dir / "icon.icns")
53
+
54
+ # Fallback launch script for Terminal.app: a plain .command file, so no
55
+ # nested AppleScript quoting is ever needed.
56
+ launch = ident.launch or "exec $SHELL"
57
+ command_file = res_dir / "launch.command"
58
+ command_file.write_text(f"#!/bin/zsh\ncd {shlex.quote(str(ident.repo))}\n{launch}\n",
59
+ encoding="utf-8")
60
+ command_file.chmod(command_file.stat().st_mode | stat.S_IEXEC)
61
+
62
+ runner = macos_dir / "run"
63
+ runner.write_text(app_script(ident, command_file), encoding="utf-8")
64
+ runner.chmod(runner.stat().st_mode | stat.S_IEXEC)
65
+ return app
66
+
67
+
68
+ def app_script(ident, command_file: Path) -> str:
69
+ # ident.name is constrained by gate_schema (letters, digits, space, _.-):
70
+ # safe inside AppleScript double quotes. The Terminal fallback opens the
71
+ # .command file instead of inlining any shell string.
72
+ return f"""#!/bin/zsh
73
+ # Launcher for agent '{ident.name}': open its themed terminal profile.
74
+ if [ -d "/Applications/iTerm.app" ]; then
75
+ osascript -e 'tell application "iTerm" to create window with profile "{ident.name}"' \\
76
+ -e 'tell application "iTerm" to activate'
77
+ else
78
+ open -b com.apple.Terminal {shlex.quote(str(command_file))}
79
+ fi
80
+ """
81
+
82
+
83
+ def apply(ident, dry_run: bool = False) -> None:
84
+ profile_path = DYNAMIC / f"microwave-{ident.slug}.json"
85
+ if dry_run:
86
+ ident.png_bytes() # validate the icon source, write nothing
87
+ print(f"[macos] would write dynamic profile {profile_path}")
88
+ print(f"[macos] would create {APPS / (ident.name + '.app')}")
89
+ return
90
+ icns = ident.write_icns()
91
+ DYNAMIC.mkdir(parents=True, exist_ok=True)
92
+ profile_path.write_text(json.dumps(_dynamic_profile(ident), indent=2),
93
+ encoding="utf-8")
94
+ print(f"[macos] iTerm2 dynamic profile: {profile_path}")
95
+ app = _bundle(ident, icns)
96
+ print(f"[macos] launcher bundle: {app}")
97
+
98
+
99
+ def remove(ident, dry_run: bool = False) -> None:
100
+ profile_path = DYNAMIC / f"microwave-{ident.slug}.json"
101
+ app = APPS / f"{ident.name}.app"
102
+ if dry_run:
103
+ print(f"[macos] would remove {profile_path} and {app}")
104
+ return
105
+ if profile_path.exists():
106
+ profile_path.unlink()
107
+ if app.exists():
108
+ shutil.rmtree(app)
109
+ print("[macos] removed")
@@ -0,0 +1,152 @@
1
+ """Windows adapter: Windows Terminal profile + color scheme + desktop .lnk.
2
+
3
+ Additive and idempotent. Timestamped backup of settings.json before any edit.
4
+ Override the settings path with the MICROWAVE_WT_SETTINGS env var if needed.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ import time
14
+ from pathlib import Path
15
+
16
+ WT_SETTINGS = Path(os.environ.get(
17
+ "MICROWAVE_WT_SETTINGS",
18
+ Path(os.environ.get("LOCALAPPDATA", "")) /
19
+ "Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState/settings.json",
20
+ ))
21
+
22
+
23
+ def _strip_jsonc(text: str) -> str:
24
+ """Windows Terminal ships settings.json as JSONC: strip // and /* */
25
+ comments (outside strings) and trailing commas so json can parse it.
26
+ Comments are NOT preserved on rewrite; the timestamped backup keeps the
27
+ original."""
28
+ out, i, in_str, esc = [], 0, False, False
29
+ while i < len(text):
30
+ ch = text[i]
31
+ if in_str:
32
+ out.append(ch)
33
+ if esc:
34
+ esc = False
35
+ elif ch == "\\":
36
+ esc = True
37
+ elif ch == '"':
38
+ in_str = False
39
+ i += 1
40
+ continue
41
+ if ch == '"':
42
+ in_str, esc = True, False
43
+ out.append(ch)
44
+ i += 1
45
+ elif text.startswith("//", i):
46
+ i = text.find("\n", i)
47
+ i = len(text) if i == -1 else i
48
+ elif text.startswith("/*", i):
49
+ j = text.find("*/", i + 2)
50
+ i = len(text) if j == -1 else j + 2
51
+ else:
52
+ out.append(ch)
53
+ i += 1
54
+ return re.sub(r",(\s*[}\]])", r"\1", "".join(out))
55
+
56
+
57
+ def _load() -> dict:
58
+ if not WT_SETTINGS.exists():
59
+ raise RuntimeError(f"Windows Terminal settings not found: {WT_SETTINGS} "
60
+ f"(set MICROWAVE_WT_SETTINGS to override)")
61
+ raw = WT_SETTINGS.read_text(encoding="utf-8-sig")
62
+ try:
63
+ data = json.loads(_strip_jsonc(raw))
64
+ except json.JSONDecodeError as e:
65
+ raise RuntimeError(f"cannot parse {WT_SETTINGS}: {e}. "
66
+ f"Fix the file or point MICROWAVE_WT_SETTINGS elsewhere.")
67
+ profiles = data.get("profiles")
68
+ if isinstance(profiles, list): # legacy schema: profiles was a bare list
69
+ data["profiles"] = {"list": profiles}
70
+ return data
71
+
72
+
73
+ def _save(data: dict) -> Path:
74
+ bak = WT_SETTINGS.with_name(WT_SETTINGS.name + ".bak." + str(int(time.time())))
75
+ shutil.copyfile(WT_SETTINGS, bak)
76
+ WT_SETTINGS.write_text(json.dumps(data, indent=4, ensure_ascii=False),
77
+ encoding="utf-8")
78
+ return bak
79
+
80
+
81
+ def _psq(s: object) -> str:
82
+ """Escape for a PowerShell single-quoted string ('' = literal ')."""
83
+ return str(s).replace("'", "''")
84
+
85
+
86
+ def _profile(ident, ico: Path) -> dict:
87
+ launch = ident.launch or ""
88
+ inner = f"cd '{_psq(ident.repo)}'" + (f"; {launch}" if launch else "")
89
+ return {
90
+ "name": ident.name,
91
+ "guid": "{" + ident.guid + "}",
92
+ "colorScheme": ident.name,
93
+ "icon": str(ico),
94
+ "cursorShape": "bar",
95
+ "commandline": f"pwsh.exe -NoExit -Command \"{inner}\"",
96
+ }
97
+
98
+
99
+ def _desktop_lnk(ident, ico: Path) -> Path:
100
+ # ident.name is revalidated by Identity (no quotes); everything else is
101
+ # escaped anyway: defense in depth.
102
+ lnk = Path.home() / "Desktop" / f"{ident.name}.lnk"
103
+ ps = (
104
+ f"$s=(New-Object -ComObject WScript.Shell).CreateShortcut('{_psq(lnk)}');"
105
+ f"$s.TargetPath='wt.exe';"
106
+ f"$s.Arguments='-p \"{_psq(ident.name)}\"';"
107
+ f"$s.IconLocation='{_psq(ico)}';"
108
+ f"$s.Save()"
109
+ )
110
+ subprocess.run(["powershell", "-NoProfile", "-Command", ps], check=True)
111
+ return lnk
112
+
113
+
114
+ def apply(ident, dry_run: bool = False) -> None:
115
+ if dry_run:
116
+ ident.png_bytes() # validate the icon source, write nothing
117
+ print(f"[windows] would write {ident.build_dir / (ident.slug + '.ico')}")
118
+ print(f"[windows] would add scheme+profile '{ident.name}' to {WT_SETTINGS}")
119
+ print(f"[windows] would create desktop shortcut '{ident.name}.lnk'")
120
+ return
121
+
122
+ ico = ident.write_ico()
123
+ scheme = ident.scheme()
124
+ profile = _profile(ident, ico)
125
+
126
+ data = _load()
127
+ schemes = data.setdefault("schemes", [])
128
+ schemes[:] = [s for s in schemes if s.get("name") != scheme["name"]]
129
+ schemes.append(scheme)
130
+ plist = data.setdefault("profiles", {}).setdefault("list", [])
131
+ plist[:] = [p for p in plist if p.get("guid") != profile["guid"]]
132
+ plist.append(profile)
133
+ bak = _save(data)
134
+ print(f"[windows] scheme+profile '{ident.name}' written (backup: {bak.name})")
135
+
136
+ lnk = _desktop_lnk(ident, ico)
137
+ print(f"[windows] desktop shortcut: {lnk}")
138
+
139
+
140
+ def remove(ident, dry_run: bool = False) -> None:
141
+ if dry_run:
142
+ print(f"[windows] would remove scheme/profile/shortcut '{ident.name}'")
143
+ return
144
+ data = _load()
145
+ data["schemes"] = [s for s in data.get("schemes", []) if s.get("name") != ident.name]
146
+ plist = data.get("profiles", {}).get("list", [])
147
+ plist[:] = [p for p in plist if p.get("guid") != "{" + ident.guid + "}"]
148
+ bak = _save(data)
149
+ lnk = Path.home() / "Desktop" / f"{ident.name}.lnk"
150
+ if lnk.exists():
151
+ lnk.unlink()
152
+ print(f"[windows] removed (backup: {bak.name})")