sepia-cli 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.
- sepia/__init__.py +3 -0
- sepia/__main__.py +3 -0
- sepia/ascii_art.py +368 -0
- sepia/cli.py +58 -0
- sepia/config.py +105 -0
- sepia/director.py +162 -0
- sepia/ipc.py +169 -0
- sepia/launcher.py +141 -0
- sepia/painter.py +326 -0
- sepia/prompts/director.md +17 -0
- sepia/prompts/request.md +2 -0
- sepia/py.typed +0 -0
- sepia/raster.py +33 -0
- sepia/repl.py +340 -0
- sepia/scene.py +54 -0
- sepia/ui.py +220 -0
- sepia/viewer.py +167 -0
- sepia_cli-0.1.0.dist-info/METADATA +204 -0
- sepia_cli-0.1.0.dist-info/RECORD +22 -0
- sepia_cli-0.1.0.dist-info/WHEEL +4 -0
- sepia_cli-0.1.0.dist-info/entry_points.txt +3 -0
- sepia_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
sepia/director.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Ask Claude to turn a short request into a prompt for the local image model."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from functools import cache
|
|
6
|
+
from importlib.resources import files
|
|
7
|
+
from string import Template
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import anthropic
|
|
11
|
+
from anthropic.types.beta import BetaOutputConfigParam
|
|
12
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
13
|
+
|
|
14
|
+
from sepia.config import Effort
|
|
15
|
+
from sepia.scene import Scene
|
|
16
|
+
|
|
17
|
+
MAX_TOKENS = 8_000
|
|
18
|
+
"""Room for thinking plus a short JSON reply."""
|
|
19
|
+
MAX_RETRIES = 3
|
|
20
|
+
"""The SDK retries connection errors, 429 and 5xx with exponential backoff."""
|
|
21
|
+
FALLBACK_BETA = "server-side-fallback-2026-07-01"
|
|
22
|
+
"""Lets the API re-run a declined request on Anthropic's recommended fallback model."""
|
|
23
|
+
FALLBACK_MODELS = frozenset(
|
|
24
|
+
{"claude-fable-5-1", "claude-opus-5-5", "claude-opus-5", "claude-sonnet-5-5"}
|
|
25
|
+
)
|
|
26
|
+
"""Models that accept `fallbacks: "default"`; Haiku has no server-side fallback."""
|
|
27
|
+
|
|
28
|
+
REPLY_SCHEMA: dict[str, Any] = {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"properties": {
|
|
31
|
+
"message": {
|
|
32
|
+
"type": "string",
|
|
33
|
+
"description": "One short sentence in the language of the latest request.",
|
|
34
|
+
},
|
|
35
|
+
"prompt": {"type": "string", "description": "The English image prompt."},
|
|
36
|
+
"edit": {
|
|
37
|
+
"type": "boolean",
|
|
38
|
+
"description": "True to repaint the previous picture, false for a new one.",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
"required": ["message", "prompt", "edit"],
|
|
42
|
+
"additionalProperties": False,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Failure(StrEnum):
|
|
47
|
+
"""Why a request failed, so the prompt can explain it in the user's language."""
|
|
48
|
+
|
|
49
|
+
AUTH = "auth"
|
|
50
|
+
RATE_LIMIT = "rate_limit"
|
|
51
|
+
SERVER = "server"
|
|
52
|
+
CONNECTION = "connection"
|
|
53
|
+
REJECTED = "rejected"
|
|
54
|
+
REFUSED = "refused"
|
|
55
|
+
TRUNCATED = "truncated"
|
|
56
|
+
INVALID = "invalid"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class DirectorError(Exception):
|
|
60
|
+
"""The request failed. The scene is untouched, so the user can simply retry."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, failure: Failure) -> None:
|
|
63
|
+
super().__init__(failure.value)
|
|
64
|
+
self.failure = failure
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _Reply(BaseModel):
|
|
68
|
+
message: str = Field(min_length=1)
|
|
69
|
+
prompt: str = Field(min_length=1)
|
|
70
|
+
edit: bool
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class Direction:
|
|
75
|
+
message: str
|
|
76
|
+
prompt: str
|
|
77
|
+
edit: bool
|
|
78
|
+
input_tokens: int
|
|
79
|
+
output_tokens: int
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def create_client(api_key: str) -> anthropic.Anthropic:
|
|
83
|
+
return anthropic.Anthropic(api_key=api_key, max_retries=MAX_RETRIES)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Director:
|
|
87
|
+
def __init__(self, client: anthropic.Anthropic, model: str, effort: Effort) -> None:
|
|
88
|
+
self._client = client
|
|
89
|
+
self._model = model
|
|
90
|
+
self._effort = effort
|
|
91
|
+
|
|
92
|
+
def direct(self, request: str, scene: Scene) -> Direction:
|
|
93
|
+
"""Turn `request`, in the context of `scene`, into an image prompt."""
|
|
94
|
+
output_config: BetaOutputConfigParam = {
|
|
95
|
+
"effort": self._effort,
|
|
96
|
+
"format": {"type": "json_schema", "schema": REPLY_SCHEMA},
|
|
97
|
+
}
|
|
98
|
+
fallback = self._model in FALLBACK_MODELS
|
|
99
|
+
try:
|
|
100
|
+
with self._client.beta.messages.stream(
|
|
101
|
+
model=self._model,
|
|
102
|
+
max_tokens=MAX_TOKENS,
|
|
103
|
+
betas=[FALLBACK_BETA] if fallback else anthropic.omit,
|
|
104
|
+
fallbacks="default" if fallback else anthropic.omit,
|
|
105
|
+
system=system_prompt(),
|
|
106
|
+
output_config=output_config,
|
|
107
|
+
messages=[{"role": "user", "content": build_request(request, scene)}],
|
|
108
|
+
) as stream:
|
|
109
|
+
message = stream.get_final_message()
|
|
110
|
+
except (anthropic.AuthenticationError, anthropic.PermissionDeniedError) as exc:
|
|
111
|
+
raise DirectorError(Failure.AUTH) from exc
|
|
112
|
+
except anthropic.RateLimitError as exc:
|
|
113
|
+
raise DirectorError(Failure.RATE_LIMIT) from exc
|
|
114
|
+
except anthropic.BadRequestError as exc:
|
|
115
|
+
raise DirectorError(Failure.REJECTED) from exc
|
|
116
|
+
except anthropic.APIStatusError as exc:
|
|
117
|
+
raise DirectorError(Failure.SERVER) from exc
|
|
118
|
+
except anthropic.APIConnectionError as exc:
|
|
119
|
+
raise DirectorError(Failure.CONNECTION) from exc
|
|
120
|
+
|
|
121
|
+
if message.stop_reason == "refusal":
|
|
122
|
+
raise DirectorError(Failure.REFUSED)
|
|
123
|
+
if message.stop_reason == "max_tokens":
|
|
124
|
+
raise DirectorError(Failure.TRUNCATED)
|
|
125
|
+
text = "".join(block.text for block in message.content if block.type == "text")
|
|
126
|
+
try:
|
|
127
|
+
reply = _Reply.model_validate_json(text)
|
|
128
|
+
except ValidationError as exc:
|
|
129
|
+
raise DirectorError(Failure.INVALID) from exc
|
|
130
|
+
return Direction(
|
|
131
|
+
message=reply.message.strip(),
|
|
132
|
+
prompt=reply.prompt.strip(),
|
|
133
|
+
edit=reply.edit and not scene.is_empty,
|
|
134
|
+
input_tokens=message.usage.input_tokens,
|
|
135
|
+
output_tokens=message.usage.output_tokens,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@cache
|
|
140
|
+
def system_prompt() -> str:
|
|
141
|
+
return _prompt("director.md")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def build_request(request: str, scene: Scene) -> str:
|
|
145
|
+
if scene.is_empty:
|
|
146
|
+
history = (
|
|
147
|
+
"<previous_requests>none: this is the first picture</previous_requests>"
|
|
148
|
+
)
|
|
149
|
+
else:
|
|
150
|
+
earlier = "\n".join(f"{i}. {text}" for i, text in enumerate(scene.requests, 1))
|
|
151
|
+
history = (
|
|
152
|
+
f"<previous_requests>\n{earlier}\n</previous_requests>\n"
|
|
153
|
+
f"<previous_prompt>{scene.prompts[-1]}</previous_prompt>"
|
|
154
|
+
)
|
|
155
|
+
return Template(_prompt("request.md")).substitute(
|
|
156
|
+
history=history, request=request.strip()
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@cache
|
|
161
|
+
def _prompt(name: str) -> str:
|
|
162
|
+
return files("sepia").joinpath("prompts", name).read_text(encoding="utf-8")
|
sepia/ipc.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Authenticated JSON messages between the prompt window and the viewer window.
|
|
2
|
+
|
|
3
|
+
The prompt listens on a random loopback port; the viewer connects with a one-time key
|
|
4
|
+
read from a session file only the current user can read. `multiprocessing.connection`
|
|
5
|
+
does the HMAC handshake. Messages travel as JSON bytes and are never unpickled, so a
|
|
6
|
+
peer can send data but never code.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import json
|
|
11
|
+
import secrets
|
|
12
|
+
import stat
|
|
13
|
+
import threading
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from multiprocessing import AuthenticationError
|
|
17
|
+
from multiprocessing.connection import Client, Listener
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Protocol
|
|
20
|
+
|
|
21
|
+
HOST = "127.0.0.1"
|
|
22
|
+
|
|
23
|
+
Message = dict[str, Any]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _Connection(Protocol):
|
|
27
|
+
"""The part of `multiprocessing.connection.Connection` sepia uses."""
|
|
28
|
+
|
|
29
|
+
def send_bytes(self, buf: bytes) -> None: ...
|
|
30
|
+
def recv_bytes(self) -> bytes: ...
|
|
31
|
+
def poll(self, timeout: float | None = ...) -> bool: ...
|
|
32
|
+
def close(self) -> None: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Session:
|
|
37
|
+
"""Where the viewer finds the prompt and how it proves who it is."""
|
|
38
|
+
|
|
39
|
+
port: int
|
|
40
|
+
authkey: bytes
|
|
41
|
+
|
|
42
|
+
def save(self, path: Path) -> None:
|
|
43
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
data = {"port": self.port, "authkey": self.authkey.hex()}
|
|
45
|
+
path.write_text(json.dumps(data), encoding="utf-8")
|
|
46
|
+
with contextlib.suppress(OSError): # Windows ignores POSIX modes
|
|
47
|
+
path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def load(cls, path: Path) -> "Session":
|
|
51
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
52
|
+
return cls(port=int(data["port"]), authkey=bytes.fromhex(data["authkey"]))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Channel:
|
|
56
|
+
"""One end of an open link."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, connection: _Connection) -> None:
|
|
59
|
+
self._connection = connection
|
|
60
|
+
self._send_lock = threading.Lock()
|
|
61
|
+
|
|
62
|
+
def send(self, message: Message) -> None:
|
|
63
|
+
"""Send `message`; raises `OSError` or `EOFError` once the peer is gone."""
|
|
64
|
+
data = json.dumps(message).encode("utf-8")
|
|
65
|
+
with self._send_lock:
|
|
66
|
+
self._connection.send_bytes(data)
|
|
67
|
+
|
|
68
|
+
def poll(self, timeout: float = 0.0) -> bool:
|
|
69
|
+
return self._connection.poll(timeout)
|
|
70
|
+
|
|
71
|
+
def receive(self) -> Message:
|
|
72
|
+
"""Next message; raises `EOFError` or `OSError` once the peer is gone."""
|
|
73
|
+
message = json.loads(self._connection.recv_bytes().decode("utf-8"))
|
|
74
|
+
if not isinstance(message, dict):
|
|
75
|
+
raise ValueError("expected a JSON object")
|
|
76
|
+
return message
|
|
77
|
+
|
|
78
|
+
def close(self) -> None:
|
|
79
|
+
with contextlib.suppress(OSError):
|
|
80
|
+
self._connection.close()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def connect(session: Session) -> Channel:
|
|
84
|
+
"""Viewer side: open the link described by `session`."""
|
|
85
|
+
return Channel(Client((HOST, session.port), authkey=session.authkey))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Host:
|
|
89
|
+
"""Prompt side: waits for one viewer at a time and relays what it reports.
|
|
90
|
+
|
|
91
|
+
`on_message` runs on a background thread for every message the viewer sends.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(self, on_message: Callable[[Message], None]) -> None:
|
|
95
|
+
self._on_message = on_message
|
|
96
|
+
authkey = secrets.token_bytes(32)
|
|
97
|
+
self._listener = Listener((HOST, 0), authkey=authkey)
|
|
98
|
+
self.session = Session(port=int(self._listener.address[1]), authkey=authkey)
|
|
99
|
+
self._channel: Channel | None = None
|
|
100
|
+
self._connected = threading.Event()
|
|
101
|
+
self._lock = threading.Lock()
|
|
102
|
+
self._accepting = False
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def connected(self) -> bool:
|
|
106
|
+
return self._connected.is_set()
|
|
107
|
+
|
|
108
|
+
def accept_in_background(self) -> None:
|
|
109
|
+
"""Start waiting for a viewer unless one is connected or already awaited."""
|
|
110
|
+
with self._lock:
|
|
111
|
+
if self._accepting or self.connected:
|
|
112
|
+
return
|
|
113
|
+
self._accepting = True
|
|
114
|
+
threading.Thread(target=self._accept, name="sepia-accept", daemon=True).start()
|
|
115
|
+
|
|
116
|
+
def wait_connected(self, timeout: float) -> bool:
|
|
117
|
+
return self._connected.wait(timeout)
|
|
118
|
+
|
|
119
|
+
def send(self, message: Message) -> bool:
|
|
120
|
+
"""Deliver `message` to the viewer; False when no viewer is connected."""
|
|
121
|
+
channel = self._channel
|
|
122
|
+
if channel is None:
|
|
123
|
+
return False
|
|
124
|
+
try:
|
|
125
|
+
channel.send(message)
|
|
126
|
+
except (OSError, EOFError):
|
|
127
|
+
self._drop(channel)
|
|
128
|
+
return False
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
def close(self) -> None:
|
|
132
|
+
if self._channel is not None:
|
|
133
|
+
self._channel.close()
|
|
134
|
+
with contextlib.suppress(OSError):
|
|
135
|
+
self._listener.close()
|
|
136
|
+
|
|
137
|
+
def _accept(self) -> None:
|
|
138
|
+
while True:
|
|
139
|
+
try:
|
|
140
|
+
channel = Channel(self._listener.accept())
|
|
141
|
+
break
|
|
142
|
+
except (AuthenticationError, EOFError):
|
|
143
|
+
continue # a peer without the key; keep waiting for the real viewer
|
|
144
|
+
except OSError: # the listener was closed
|
|
145
|
+
with self._lock:
|
|
146
|
+
self._accepting = False
|
|
147
|
+
return
|
|
148
|
+
with self._lock:
|
|
149
|
+
self._channel = channel
|
|
150
|
+
self._accepting = False
|
|
151
|
+
self._connected.set()
|
|
152
|
+
self._read(channel)
|
|
153
|
+
|
|
154
|
+
def _read(self, channel: Channel) -> None:
|
|
155
|
+
while True:
|
|
156
|
+
try:
|
|
157
|
+
message = channel.receive()
|
|
158
|
+
except (OSError, EOFError, ValueError):
|
|
159
|
+
self._drop(channel)
|
|
160
|
+
return
|
|
161
|
+
self._on_message(message)
|
|
162
|
+
|
|
163
|
+
def _drop(self, channel: Channel) -> None:
|
|
164
|
+
with self._lock:
|
|
165
|
+
if self._channel is not channel:
|
|
166
|
+
return
|
|
167
|
+
self._channel = None
|
|
168
|
+
self._connected.clear()
|
|
169
|
+
channel.close()
|
sepia/launcher.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Open the picture window in a new terminal window."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
VIEWER_COLS = 110
|
|
13
|
+
VIEWER_ROWS = 36
|
|
14
|
+
"""Window size in cells of the default font; the small profile font fits about
|
|
15
|
+
1.8x as many in each direction."""
|
|
16
|
+
VIEWER_FONT_SIZE = 6
|
|
17
|
+
"""Points. More, smaller cells give the picture more detail."""
|
|
18
|
+
LAUNCH_TIMEOUT_SECONDS = 15
|
|
19
|
+
PROFILE_NAME = "sepia"
|
|
20
|
+
PROFILE_GUID = "{6f1c2b9e-3a57-4d0e-9c84-5e2f7a1d3b60}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LaunchError(RuntimeError):
|
|
24
|
+
"""The picture window could not be opened."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def viewer_command(session_file: Path) -> list[str]:
|
|
28
|
+
"""The command the new window runs; same interpreter, so it works in any venv."""
|
|
29
|
+
return [sys.executable, "-m", "sepia", "--viewer", str(session_file)]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def open_viewer(session_file: Path) -> None:
|
|
33
|
+
command = viewer_command(session_file)
|
|
34
|
+
try:
|
|
35
|
+
if sys.platform == "win32":
|
|
36
|
+
_open_windows(command)
|
|
37
|
+
elif sys.platform == "darwin":
|
|
38
|
+
_open_macos(command)
|
|
39
|
+
else:
|
|
40
|
+
raise LaunchError("the picture window opens on Windows and macOS only")
|
|
41
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
42
|
+
raise LaunchError(f"could not open the picture window: {exc}") from exc
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def windows_terminal_command(wt: str, command: list[str]) -> list[str]:
|
|
46
|
+
"""A new Windows Terminal window in focus mode: no tab bar and no title bar."""
|
|
47
|
+
return [
|
|
48
|
+
wt,
|
|
49
|
+
"--window",
|
|
50
|
+
"new",
|
|
51
|
+
"--focus",
|
|
52
|
+
"--size",
|
|
53
|
+
f"{VIEWER_COLS},{VIEWER_ROWS}",
|
|
54
|
+
"new-tab",
|
|
55
|
+
"--profile",
|
|
56
|
+
PROFILE_NAME,
|
|
57
|
+
"--title",
|
|
58
|
+
"sepia",
|
|
59
|
+
"--suppressApplicationTitle",
|
|
60
|
+
"--",
|
|
61
|
+
*command,
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def macos_script(command: list[str]) -> str:
|
|
66
|
+
"""AppleScript that runs `command` in a new Terminal.app window.
|
|
67
|
+
|
|
68
|
+
`exec` replaces the shell, so the window closes when the viewer exits.
|
|
69
|
+
"""
|
|
70
|
+
shell_line = "exec " + shlex.join(command)
|
|
71
|
+
quoted = shell_line.replace("\\", "\\\\").replace('"', '\\"')
|
|
72
|
+
return f'tell application "Terminal"\n do script "{quoted}"\n activate\nend tell'
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def windows_profile() -> dict[str, object]:
|
|
76
|
+
"""A Windows Terminal profile for the picture: tiny font, black, no padding."""
|
|
77
|
+
return {
|
|
78
|
+
"guid": PROFILE_GUID,
|
|
79
|
+
"name": PROFILE_NAME,
|
|
80
|
+
"commandline": "cmd.exe",
|
|
81
|
+
"font": {"face": "Cascadia Mono", "size": VIEWER_FONT_SIZE},
|
|
82
|
+
"padding": "0",
|
|
83
|
+
"background": "#000000",
|
|
84
|
+
"scrollbarState": "hidden",
|
|
85
|
+
"closeOnExit": "always",
|
|
86
|
+
"bellStyle": "none",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def install_windows_profile(local_app_data: Path | None = None) -> Path | None:
|
|
91
|
+
"""Write sepia's profile as a Windows Terminal fragment; None when not possible.
|
|
92
|
+
|
|
93
|
+
Fragments live in their own folder, so the user's settings.json is never touched,
|
|
94
|
+
and deleting the folder removes the profile again.
|
|
95
|
+
"""
|
|
96
|
+
base = local_app_data or Path(os.environ.get("LOCALAPPDATA", ""))
|
|
97
|
+
if not base.is_dir():
|
|
98
|
+
return None
|
|
99
|
+
target = (
|
|
100
|
+
base / "Microsoft" / "Windows Terminal" / "Fragments" / "sepia" / "sepia.json"
|
|
101
|
+
)
|
|
102
|
+
content = json.dumps({"profiles": [windows_profile()]}, indent=2)
|
|
103
|
+
with contextlib.suppress(OSError):
|
|
104
|
+
if not target.exists() or target.read_text(encoding="utf-8") != content:
|
|
105
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
target.write_text(content, encoding="utf-8")
|
|
107
|
+
return target
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def find_windows_terminal(local_app_data: Path | None = None) -> str | None:
|
|
112
|
+
"""wt.exe from PATH, or from its app alias folder, which PATH may not include."""
|
|
113
|
+
found = shutil.which("wt")
|
|
114
|
+
if found is not None:
|
|
115
|
+
return found
|
|
116
|
+
base = local_app_data or Path(os.environ.get("LOCALAPPDATA", ""))
|
|
117
|
+
alias = base / "Microsoft" / "WindowsApps" / "wt.exe"
|
|
118
|
+
return str(alias) if alias.is_file() else None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _open_windows(command: list[str]) -> None:
|
|
122
|
+
wt = find_windows_terminal()
|
|
123
|
+
if wt is not None:
|
|
124
|
+
install_windows_profile()
|
|
125
|
+
subprocess.run(
|
|
126
|
+
windows_terminal_command(wt, command),
|
|
127
|
+
check=True,
|
|
128
|
+
timeout=LAUNCH_TIMEOUT_SECONDS,
|
|
129
|
+
)
|
|
130
|
+
return
|
|
131
|
+
if sys.platform == "win32": # narrows the type for the console flag
|
|
132
|
+
subprocess.Popen(command, creationflags=subprocess.CREATE_NEW_CONSOLE)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _open_macos(command: list[str]) -> None:
|
|
136
|
+
subprocess.run(
|
|
137
|
+
["osascript", "-e", macos_script(command)],
|
|
138
|
+
check=True,
|
|
139
|
+
capture_output=True,
|
|
140
|
+
timeout=LAUNCH_TIMEOUT_SECONDS,
|
|
141
|
+
)
|