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.
- microwave_method/__init__.py +131 -0
- microwave_method/_payload/.github/workflows/gates.yml +34 -0
- microwave_method/_payload/CODEOWNERS +10 -0
- microwave_method/_payload/banner.txt +10 -0
- microwave_method/_payload/embodiment/adapters/__init__.py +0 -0
- microwave_method/_payload/embodiment/adapters/linux.py +78 -0
- microwave_method/_payload/embodiment/adapters/macos.py +109 -0
- microwave_method/_payload/embodiment/adapters/windows.py +152 -0
- microwave_method/_payload/embodiment/embody.py +188 -0
- microwave_method/_payload/embodiment/icons/factory.png +0 -0
- microwave_method/_payload/embodiment/icons/librarian.png +0 -0
- microwave_method/_payload/flows/adopt.md +69 -0
- microwave_method/_payload/flows/amend-rule.md +31 -0
- microwave_method/_payload/flows/create-agent.md +49 -0
- microwave_method/_payload/flows/create-feature.md +41 -0
- microwave_method/_payload/flows/devil-loop.md +46 -0
- microwave_method/_payload/flows/devil-review.md +84 -0
- microwave_method/_payload/flows/librarian.md +39 -0
- microwave_method/_payload/flows/metrics.md +41 -0
- microwave_method/_payload/flows/resume.md +35 -0
- microwave_method/_payload/flows/save.md +59 -0
- microwave_method/_payload/flows/welcome.md +95 -0
- microwave_method/_payload/gates/_lib.py +201 -0
- microwave_method/_payload/gates/activate.py +87 -0
- microwave_method/_payload/gates/docgen.py +130 -0
- microwave_method/_payload/gates/gate_antidup.py +62 -0
- microwave_method/_payload/gates/gate_brief.py +45 -0
- microwave_method/_payload/gates/gate_docs.py +31 -0
- microwave_method/_payload/gates/gate_embodiment.py +68 -0
- microwave_method/_payload/gates/gate_schema.py +107 -0
- microwave_method/_payload/gates/gate_slop.py +106 -0
- microwave_method/_payload/gates/gate_testable.py +52 -0
- microwave_method/_payload/gates/gate_wiki.py +119 -0
- microwave_method/_payload/gates/metrics.py +118 -0
- microwave_method/_payload/gates/run_gates.py +37 -0
- microwave_method/_payload/gates/trace.py +136 -0
- microwave_method/_payload/harness/claude-settings.example.json +21 -0
- microwave_method/_payload/hooks/install-hooks.ps1 +22 -0
- microwave_method/_payload/hooks/install-hooks.sh +23 -0
- microwave_method/_payload/hooks/pre-commit +41 -0
- microwave_method/_payload/slop/slop-rules.csv +16 -0
- microwave_method/_payload/techniques/README.md +39 -0
- microwave_method/_payload/techniques/brainstorming-methods.csv +109 -0
- microwave_method/_payload/techniques/design-methods.csv +31 -0
- microwave_method/_payload/techniques/elicitation-methods.csv +72 -0
- microwave_method/_payload/techniques/innovation-frameworks.csv +31 -0
- microwave_method/_payload/techniques/solving-methods.csv +31 -0
- microwave_method/_payload/techniques/story-types.csv +26 -0
- microwave_method/_payload/templates/adr.md +27 -0
- microwave_method/_payload/templates/agent-card.md +52 -0
- microwave_method/_payload/templates/brief.md +19 -0
- microwave_method/_payload/templates/bug.md +28 -0
- microwave_method/_payload/templates/devil-report.md +32 -0
- microwave_method/_payload/templates/inventory-entry.md +25 -0
- microwave_method/_payload/templates/learning.md +24 -0
- microwave_method/_payload/templates/project-seed.md +24 -0
- microwave_method/_payload/templates/session-save.md +40 -0
- microwave_method/_payload/templates/story.md +24 -0
- microwave_method-0.1.0.dist-info/METADATA +228 -0
- microwave_method-0.1.0.dist-info/RECORD +64 -0
- microwave_method-0.1.0.dist-info/WHEEL +4 -0
- microwave_method-0.1.0.dist-info/entry_points.txt +2 -0
- microwave_method-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
+------------------------+
|
|
File without changes
|
|
@@ -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})")
|