induscode 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.
- induscode/__init__.py +56 -0
- induscode/addons/__init__.py +176 -0
- induscode/addons/contract.py +923 -0
- induscode/addons/dispatch/__init__.py +43 -0
- induscode/addons/dispatch/event_dispatcher.py +348 -0
- induscode/addons/dispatch/tool_interceptor.py +349 -0
- induscode/addons/host.py +469 -0
- induscode/addons/loader.py +314 -0
- induscode/addons/manifest.py +232 -0
- induscode/addons/surface.py +199 -0
- induscode/boot/__init__.py +108 -0
- induscode/boot/auth_vault.py +323 -0
- induscode/boot/boot.py +210 -0
- induscode/boot/contract.py +223 -0
- induscode/boot/invocation.py +117 -0
- induscode/boot/runners/__init__.py +42 -0
- induscode/boot/runners/link_runner.py +82 -0
- induscode/boot/runners/oneshot_runner.py +85 -0
- induscode/boot/runners/registry.py +46 -0
- induscode/boot/runners/repl_runner.py +340 -0
- induscode/boot/runners/session.py +549 -0
- induscode/boot/stages.py +198 -0
- induscode/boot/upgrade/__init__.py +36 -0
- induscode/boot/upgrade/apply.py +125 -0
- induscode/boot/upgrade/upgrades.py +136 -0
- induscode/briefing/__init__.py +115 -0
- induscode/briefing/compose.py +414 -0
- induscode/briefing/contract.py +528 -0
- induscode/briefing/macros.py +721 -0
- induscode/briefing/skills.py +417 -0
- induscode/capability_deck/__init__.py +233 -0
- induscode/capability_deck/bridge_ledger/__init__.py +66 -0
- induscode/capability_deck/bridge_ledger/key.py +181 -0
- induscode/capability_deck/bridge_ledger/ledger.py +276 -0
- induscode/capability_deck/bridge_ledger/network.py +336 -0
- induscode/capability_deck/builtin_bridge.py +358 -0
- induscode/capability_deck/cards/__init__.py +116 -0
- induscode/capability_deck/cards/bg_process.py +482 -0
- induscode/capability_deck/cards/memory.py +226 -0
- induscode/capability_deck/cards/saas.py +280 -0
- induscode/capability_deck/cards/task.py +256 -0
- induscode/capability_deck/cards/todo.py +312 -0
- induscode/capability_deck/contract.py +450 -0
- induscode/capability_deck/manifest.py +126 -0
- induscode/capability_deck/provision.py +217 -0
- induscode/channels/__init__.py +146 -0
- induscode/channels/contract.py +585 -0
- induscode/channels/framer.py +132 -0
- induscode/channels/link/__init__.py +50 -0
- induscode/channels/link/dialog.py +246 -0
- induscode/channels/link/driver.py +308 -0
- induscode/channels/link/server.py +217 -0
- induscode/channels/oneshot.py +178 -0
- induscode/channels/ops.py +140 -0
- induscode/channels/session_ops.py +172 -0
- induscode/conductor/__init__.py +240 -0
- induscode/conductor/catalog.py +309 -0
- induscode/conductor/conductor.py +1084 -0
- induscode/conductor/contract.py +1035 -0
- induscode/conductor/matcher.py +291 -0
- induscode/conductor/serialize.py +575 -0
- induscode/conductor/signal_hub.py +382 -0
- induscode/conductor/skill_parse.py +294 -0
- induscode/conductor/transcript_store.py +449 -0
- induscode/console/__init__.py +236 -0
- induscode/console/app.py +1677 -0
- induscode/console/components/__init__.py +62 -0
- induscode/console/components/banner.py +499 -0
- induscode/console/components/banner_sweep.py +188 -0
- induscode/console/components/emblem.py +181 -0
- induscode/console/components/status_bar.py +102 -0
- induscode/console/contract.py +836 -0
- induscode/console/input/__init__.py +107 -0
- induscode/console/input/chord.py +197 -0
- induscode/console/input/dir_reader.py +113 -0
- induscode/console/input/intents.py +258 -0
- induscode/console/input/providers.py +469 -0
- induscode/console/mount.py +137 -0
- induscode/console/overlays/__init__.py +94 -0
- induscode/console/overlays/auth.py +503 -0
- induscode/console/overlays/pickers.py +526 -0
- induscode/console/overlays/router.py +129 -0
- induscode/console/overlays/sessions.py +232 -0
- induscode/console/reducer.py +145 -0
- induscode/console/resume_picker.py +156 -0
- induscode/console/slash_commands/__init__.py +78 -0
- induscode/console/slash_commands/builtins.py +254 -0
- induscode/console/slash_commands/dynamic.py +217 -0
- induscode/console/slash_commands/integrations.py +949 -0
- induscode/console/slash_commands/transcript.py +404 -0
- induscode/console/slash_commands/workbench.py +430 -0
- induscode/console/startup.py +434 -0
- induscode/console/theme/__init__.py +44 -0
- induscode/console/theme/adapter.py +168 -0
- induscode/console/theme/palette.py +128 -0
- induscode/console/theme/resolve.py +123 -0
- induscode/console/theme/tokens.py +185 -0
- induscode/console_slash/__init__.py +111 -0
- induscode/console_slash/contract.py +185 -0
- induscode/console_slash/registry.py +140 -0
- induscode/console_slash/resolve.py +194 -0
- induscode/console_slash/shared.py +172 -0
- induscode/entry.py +108 -0
- induscode/insight/__init__.py +153 -0
- induscode/insight/collector.py +73 -0
- induscode/insight/replay.py +305 -0
- induscode/insight/wrapper.py +1115 -0
- induscode/kit/__init__.py +82 -0
- induscode/kit/clipboard_image.py +215 -0
- induscode/kit/external_editor.py +120 -0
- induscode/kit/image.py +188 -0
- induscode/kit/shell.py +89 -0
- induscode/kit/tool_fetch.py +288 -0
- induscode/launch/__init__.py +224 -0
- induscode/launch/catalog.py +310 -0
- induscode/launch/contract.py +569 -0
- induscode/launch/credentials.py +852 -0
- induscode/launch/invocation/__init__.py +39 -0
- induscode/launch/invocation/attachments.py +281 -0
- induscode/launch/invocation/flags.py +210 -0
- induscode/launch/invocation/read.py +369 -0
- induscode/launch/invocation/usage.py +110 -0
- induscode/launch/oauth.py +808 -0
- induscode/launch/packages.py +299 -0
- induscode/launch/pickers.py +291 -0
- induscode/py.typed +0 -0
- induscode/runtime_bridge/__init__.py +166 -0
- induscode/runtime_bridge/bridges/__init__.py +66 -0
- induscode/runtime_bridge/bridges/_drive.py +268 -0
- induscode/runtime_bridge/bridges/builtins.py +177 -0
- induscode/runtime_bridge/bridges/claude_cli.py +198 -0
- induscode/runtime_bridge/bridges/codex_cli.py +203 -0
- induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
- induscode/runtime_bridge/broker.py +397 -0
- induscode/runtime_bridge/contract.py +734 -0
- induscode/runtime_bridge/sink.py +351 -0
- induscode/sessions/__init__.py +25 -0
- induscode/sessions/contract.py +119 -0
- induscode/sessions/library.py +350 -0
- induscode/settings/__init__.py +47 -0
- induscode/settings/contract.py +313 -0
- induscode/settings/manager.py +268 -0
- induscode/transcript_export/__init__.py +109 -0
- induscode/transcript_export/contract.py +522 -0
- induscode/transcript_export/publish.py +455 -0
- induscode/transcript_export/sgr.py +566 -0
- induscode/transcript_export/template.py +319 -0
- induscode/transcript_export/theme_bridge.py +325 -0
- induscode/window_budget/__init__.py +76 -0
- induscode/window_budget/budget/__init__.py +26 -0
- induscode/window_budget/budget/estimate.py +273 -0
- induscode/window_budget/budget/gate.py +60 -0
- induscode/window_budget/budget/slice.py +145 -0
- induscode/window_budget/condenser.py +170 -0
- induscode/window_budget/contract.py +329 -0
- induscode/window_budget/summarize/__init__.py +33 -0
- induscode/window_budget/summarize/condense.py +212 -0
- induscode/window_budget/summarize/prompt.py +241 -0
- induscode/workspace/__init__.py +30 -0
- induscode/workspace/brand.py +96 -0
- induscode/workspace/locator.py +269 -0
- induscode-0.1.0.dist-info/METADATA +97 -0
- induscode-0.1.0.dist-info/RECORD +167 -0
- induscode-0.1.0.dist-info/WHEEL +4 -0
- induscode-0.1.0.dist-info/entry_points.txt +3 -0
- induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
- induscode-0.1.0.dist-info/licenses/NOTICE +7 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Kit subsystem — public barrel.
|
|
2
|
+
|
|
3
|
+
``kit/`` holds the agent's framework-agnostic leaf helpers: small, pure,
|
|
4
|
+
stdlib-only utilities with no dependency on the framework or on any sibling
|
|
5
|
+
subsystem. It re-exports five groups (the TS ``src/kit/index.ts``):
|
|
6
|
+
|
|
7
|
+
- **tool_fetch** — the managed-binary provisioner stub (fd / rg): managed
|
|
8
|
+
path resolution, regex-driven release-asset selection, and a download-
|
|
9
|
+
request builder stamped with this kit's own :data:`KIT_USER_AGENT`. The
|
|
10
|
+
network is injected, never imported.
|
|
11
|
+
- **image** — PNG / JPEG magic-byte sniffing (interface-dictated signatures)
|
|
12
|
+
and the ``{token}`` asset-name renderer.
|
|
13
|
+
- **shell** — POSIX shell-argument quoting and command assembly.
|
|
14
|
+
- **clipboard_image** — clipboard → temp PNG staging (best-effort, ``None``
|
|
15
|
+
on any miss).
|
|
16
|
+
- **external_editor** — ``$VISUAL`` / ``$EDITOR`` / ``vi`` buffer hand-off.
|
|
17
|
+
|
|
18
|
+
Consumers import from :mod:`induscode.kit` rather than reaching into
|
|
19
|
+
individual modules. The upstream filler libraries (generic array / string /
|
|
20
|
+
date / json / etc. stdlib clones) are intentionally omitted: they had zero
|
|
21
|
+
consumers, so they are not rebuilt here.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from .clipboard_image import ClipboardImageOptions, read_clipboard_image
|
|
25
|
+
from .external_editor import ExternalEditorOptions, open_in_external_editor
|
|
26
|
+
from .image import (
|
|
27
|
+
IMAGE_SNIFF_BYTES,
|
|
28
|
+
AssetNameContext,
|
|
29
|
+
ByteSource,
|
|
30
|
+
ImageFormat,
|
|
31
|
+
ImageMediaType,
|
|
32
|
+
detect_image_media_type,
|
|
33
|
+
is_supported_image,
|
|
34
|
+
media_type_for_image_format,
|
|
35
|
+
resolve_asset_name,
|
|
36
|
+
sniff_image_format,
|
|
37
|
+
)
|
|
38
|
+
from .shell import build_shell_command, needs_quoting, quote_arg
|
|
39
|
+
from .tool_fetch import (
|
|
40
|
+
KIT_USER_AGENT,
|
|
41
|
+
DownloadRequest,
|
|
42
|
+
ProvisionPlan,
|
|
43
|
+
ReleaseAsset,
|
|
44
|
+
ReleaseInfo,
|
|
45
|
+
ReleaseLookup,
|
|
46
|
+
ToolDescriptor,
|
|
47
|
+
build_download_request,
|
|
48
|
+
pick_release_asset,
|
|
49
|
+
plan_provision,
|
|
50
|
+
resolve_managed_binary_path,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"AssetNameContext",
|
|
55
|
+
"ByteSource",
|
|
56
|
+
"ClipboardImageOptions",
|
|
57
|
+
"DownloadRequest",
|
|
58
|
+
"ExternalEditorOptions",
|
|
59
|
+
"IMAGE_SNIFF_BYTES",
|
|
60
|
+
"ImageFormat",
|
|
61
|
+
"ImageMediaType",
|
|
62
|
+
"KIT_USER_AGENT",
|
|
63
|
+
"ProvisionPlan",
|
|
64
|
+
"ReleaseAsset",
|
|
65
|
+
"ReleaseInfo",
|
|
66
|
+
"ReleaseLookup",
|
|
67
|
+
"ToolDescriptor",
|
|
68
|
+
"build_download_request",
|
|
69
|
+
"build_shell_command",
|
|
70
|
+
"detect_image_media_type",
|
|
71
|
+
"is_supported_image",
|
|
72
|
+
"media_type_for_image_format",
|
|
73
|
+
"needs_quoting",
|
|
74
|
+
"open_in_external_editor",
|
|
75
|
+
"pick_release_asset",
|
|
76
|
+
"plan_provision",
|
|
77
|
+
"quote_arg",
|
|
78
|
+
"read_clipboard_image",
|
|
79
|
+
"resolve_asset_name",
|
|
80
|
+
"resolve_managed_binary_path",
|
|
81
|
+
"sniff_image_format",
|
|
82
|
+
]
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Clipboard-image kit — pull a raster image off the OS clipboard and stage it
|
|
2
|
+
on disk as a temp PNG, returning the path the composer can splice into a
|
|
3
|
+
prompt.
|
|
4
|
+
|
|
5
|
+
The terminal can only carry text, so an image on the clipboard cannot ride a
|
|
6
|
+
normal paste. Instead this helper shells out to the platform's clipboard
|
|
7
|
+
tool, captures the raw bytes, writes them to a temp file, and hands back the
|
|
8
|
+
path — the agent then attaches the file by reference.
|
|
9
|
+
|
|
10
|
+
Platform strategy (each is the conventional tool for its OS):
|
|
11
|
+
|
|
12
|
+
- **macOS** — ``pngpaste -`` dumps a clipboard image to stdout; when it is not
|
|
13
|
+
installed we fall back to an ``osascript`` one-liner that asks the clipboard
|
|
14
|
+
for its «class PNGf» data and stages it through a temp file.
|
|
15
|
+
- **Linux/Wayland** — ``wl-paste --type image/png`` writes the image to stdout.
|
|
16
|
+
- **Linux/X11** — ``xclip -selection clipboard -t image/png -o`` does the same.
|
|
17
|
+
|
|
18
|
+
Every spawn is best-effort: a missing tool, a non-zero exit, a timeout, or an
|
|
19
|
+
empty clipboard all collapse to ``None`` so the caller can warn rather than
|
|
20
|
+
throw. The :mod:`subprocess` / :mod:`os` / :mod:`tempfile` stdlib modules are
|
|
21
|
+
the only dependencies; nothing here touches the framework or a sibling
|
|
22
|
+
subsystem.
|
|
23
|
+
|
|
24
|
+
Port of TS ``src/kit/clipboard-image.ts``.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import os
|
|
30
|
+
import random
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
import tempfile
|
|
34
|
+
import time
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Final, Mapping, Sequence
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"ClipboardImageOptions",
|
|
41
|
+
"read_clipboard_image",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Tunables
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
#: How long a clipboard tool may run before we give up on it (seconds; the TS
|
|
49
|
+
#: kit's 3000 ms).
|
|
50
|
+
_READ_TIMEOUT_SECONDS: Final[float] = 3.0
|
|
51
|
+
|
|
52
|
+
#: Ceiling on the captured image size; a larger blob is treated as a miss
|
|
53
|
+
#: (50 MiB). Node's ``maxBuffer`` kills the child at the limit; Python has no
|
|
54
|
+
#: equivalent knob, so the cap is enforced on the captured bytes instead —
|
|
55
|
+
#: same outcome (an oversized capture is a ``None`` miss).
|
|
56
|
+
_MAX_BUFFER_BYTES: Final[int] = 50 * 1024 * 1024
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Spawning
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _capture_stdout(command: str, args: Sequence[str]) -> bytes | None:
|
|
64
|
+
"""Run a clipboard tool and return its stdout bytes, or ``None`` on any
|
|
65
|
+
failure.
|
|
66
|
+
|
|
67
|
+
A spawn error (binary absent), a timeout, a non-zero exit, an oversized
|
|
68
|
+
capture, or empty output all yield ``None`` — the helper never raises, so
|
|
69
|
+
a probe for an absent tool is cheap.
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
result = subprocess.run(
|
|
73
|
+
[command, *args],
|
|
74
|
+
capture_output=True,
|
|
75
|
+
timeout=_READ_TIMEOUT_SECONDS,
|
|
76
|
+
)
|
|
77
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
78
|
+
return None
|
|
79
|
+
if result.returncode != 0:
|
|
80
|
+
return None
|
|
81
|
+
out = result.stdout
|
|
82
|
+
if not out or len(out) > _MAX_BUFFER_BYTES:
|
|
83
|
+
return None
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _capture_via_osascript() -> bytes | None:
|
|
88
|
+
"""macOS fallback when ``pngpaste`` is absent: drive ``osascript`` to write
|
|
89
|
+
the clipboard's PNG payload to a temp file and echo its path, then read it
|
|
90
|
+
back.
|
|
91
|
+
|
|
92
|
+
Returns the raw bytes or ``None`` when the clipboard holds no PNG data,
|
|
93
|
+
the script errored, or the staged file came back empty.
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
result = subprocess.run(
|
|
97
|
+
[
|
|
98
|
+
"osascript",
|
|
99
|
+
"-e",
|
|
100
|
+
"try",
|
|
101
|
+
"-e",
|
|
102
|
+
"set png to (the clipboard as «class PNGf»)",
|
|
103
|
+
"-e",
|
|
104
|
+
'set fp to (path to temporary items as string) & "indus-clip.png"',
|
|
105
|
+
"-e",
|
|
106
|
+
"set fh to open for access file fp with write permission",
|
|
107
|
+
"-e",
|
|
108
|
+
"set eof fh to 0",
|
|
109
|
+
"-e",
|
|
110
|
+
"write png to fh",
|
|
111
|
+
"-e",
|
|
112
|
+
"close access fh",
|
|
113
|
+
"-e",
|
|
114
|
+
"POSIX path of fp",
|
|
115
|
+
"-e",
|
|
116
|
+
"on error",
|
|
117
|
+
"-e",
|
|
118
|
+
'return ""',
|
|
119
|
+
"-e",
|
|
120
|
+
"end try",
|
|
121
|
+
],
|
|
122
|
+
capture_output=True,
|
|
123
|
+
timeout=_READ_TIMEOUT_SECONDS,
|
|
124
|
+
encoding="utf-8",
|
|
125
|
+
)
|
|
126
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
127
|
+
return None
|
|
128
|
+
if result.returncode != 0:
|
|
129
|
+
return None
|
|
130
|
+
path = (result.stdout or "").strip()
|
|
131
|
+
if len(path) == 0:
|
|
132
|
+
return None
|
|
133
|
+
try:
|
|
134
|
+
data = Path(path).read_bytes()
|
|
135
|
+
except OSError:
|
|
136
|
+
return None
|
|
137
|
+
return data if len(data) > 0 else None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _read_clipboard_bytes(platform: str, env: Mapping[str, str]) -> bytes | None:
|
|
141
|
+
"""Read the clipboard image bytes for the host platform, trying the most
|
|
142
|
+
likely tool first and falling back where a platform offers more than one
|
|
143
|
+
route.
|
|
144
|
+
"""
|
|
145
|
+
if platform == "darwin":
|
|
146
|
+
return _capture_stdout("pngpaste", ["-"]) or _capture_via_osascript()
|
|
147
|
+
if platform == "linux":
|
|
148
|
+
wayland = bool(env.get("WAYLAND_DISPLAY")) or env.get("XDG_SESSION_TYPE") == "wayland"
|
|
149
|
+
if wayland:
|
|
150
|
+
from_wayland = _capture_stdout("wl-paste", ["--type", "image/png", "--no-newline"])
|
|
151
|
+
if from_wayland:
|
|
152
|
+
return from_wayland
|
|
153
|
+
return _capture_stdout("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"])
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
# Public surface
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True, slots=True)
|
|
163
|
+
class ClipboardImageOptions:
|
|
164
|
+
"""Options for :func:`read_clipboard_image` — injectable so tests stay
|
|
165
|
+
hermetic.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
# Override the host platform (defaults to :data:`sys.platform`).
|
|
169
|
+
platform: str | None = None
|
|
170
|
+
# Override the environment consulted for the Wayland probe.
|
|
171
|
+
env: Mapping[str, str] | None = None
|
|
172
|
+
# Override the temp directory the captured PNG is written to.
|
|
173
|
+
dir: str | os.PathLike[str] | None = None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
#: The digit alphabet used by the temp-name stamp (JS ``toString(36)``).
|
|
177
|
+
_BASE36_DIGITS: Final[str] = "0123456789abcdefghijklmnopqrstuvwxyz"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _to_base36(value: int) -> str:
|
|
181
|
+
"""Render a non-negative integer in base 36 (the JS ``toString(36)``)."""
|
|
182
|
+
if value == 0:
|
|
183
|
+
return "0"
|
|
184
|
+
digits: list[str] = []
|
|
185
|
+
while value:
|
|
186
|
+
value, remainder = divmod(value, 36)
|
|
187
|
+
digits.append(_BASE36_DIGITS[remainder])
|
|
188
|
+
return "".join(reversed(digits))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def read_clipboard_image(options: ClipboardImageOptions | None = None) -> str | None:
|
|
192
|
+
"""Pull an image off the OS clipboard, write it to a temp PNG, and return
|
|
193
|
+
the file path — or ``None`` when no image is on the clipboard, the
|
|
194
|
+
platform's tool is missing, or the write fails.
|
|
195
|
+
|
|
196
|
+
The returned path is unique per call (millisecond + random suffix) so two
|
|
197
|
+
pastes never collide. The caller owns the temp file thereafter.
|
|
198
|
+
"""
|
|
199
|
+
resolved = options if options is not None else ClipboardImageOptions()
|
|
200
|
+
platform = resolved.platform if resolved.platform is not None else sys.platform
|
|
201
|
+
env: Mapping[str, str] = resolved.env if resolved.env is not None else os.environ
|
|
202
|
+
data = _read_clipboard_bytes(platform, env)
|
|
203
|
+
if data is None:
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
directory = resolved.dir if resolved.dir is not None else tempfile.gettempdir()
|
|
207
|
+
stamp = _to_base36(int(time.time() * 1000))
|
|
208
|
+
suffix = "".join(random.choices(_BASE36_DIGITS, k=6))
|
|
209
|
+
name = f"indus-clipboard-{stamp}-{suffix}.png"
|
|
210
|
+
path = os.path.join(directory, name)
|
|
211
|
+
try:
|
|
212
|
+
Path(path).write_bytes(data)
|
|
213
|
+
return path
|
|
214
|
+
except OSError:
|
|
215
|
+
return None
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""External-editor kit — hand the composer buffer off to the user's
|
|
2
|
+
``$EDITOR``, wait for them to close it, and read the edited text back.
|
|
3
|
+
|
|
4
|
+
Composing a long prompt at a single-line terminal caret is painful; this
|
|
5
|
+
helper stages the current buffer in a temp file, launches the configured
|
|
6
|
+
editor against it with the terminal shared (so vi/nano/etc. take over the
|
|
7
|
+
screen), blocks until the editor exits, then reads the file back as the new
|
|
8
|
+
buffer.
|
|
9
|
+
|
|
10
|
+
The editor is resolved from ``$VISUAL``, then ``$EDITOR``, then a ``vi``
|
|
11
|
+
fallback — the conventional precedence. The spawn is synchronous and inherits
|
|
12
|
+
this process's stdio so the editor owns the real terminal; on return the temp
|
|
13
|
+
file is removed. Any failure (no editor that runs, a non-zero exit, an
|
|
14
|
+
unreadable file) yields ``None`` and the caller keeps the original buffer.
|
|
15
|
+
|
|
16
|
+
Only :mod:`subprocess` / :mod:`os` / :mod:`tempfile` stdlib modules are used;
|
|
17
|
+
nothing here depends on the framework or a sibling subsystem.
|
|
18
|
+
|
|
19
|
+
Port of TS ``src/kit/external-editor.ts``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import random
|
|
26
|
+
import re
|
|
27
|
+
import subprocess
|
|
28
|
+
import tempfile
|
|
29
|
+
import time
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Final, Mapping
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"ExternalEditorOptions",
|
|
36
|
+
"open_in_external_editor",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
#: The editor used when neither ``$VISUAL`` nor ``$EDITOR`` is set.
|
|
40
|
+
_FALLBACK_EDITOR: Final[str] = "vi"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class ExternalEditorOptions:
|
|
45
|
+
"""Options for :func:`open_in_external_editor` — injectable so tests stay
|
|
46
|
+
hermetic.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
# Override the environment consulted for ``$VISUAL`` / ``$EDITOR``.
|
|
50
|
+
env: Mapping[str, str] | None = None
|
|
51
|
+
# Override the temp directory the scratch file is written to.
|
|
52
|
+
dir: str | os.PathLike[str] | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _resolve_editor(env: Mapping[str, str]) -> list[str]:
|
|
56
|
+
"""Resolve the editor command line, splitting it into an argv so an editor
|
|
57
|
+
set with flags (e.g. ``"code --wait"``) is honored. Falls back to ``vi``.
|
|
58
|
+
|
|
59
|
+
``$VISUAL`` then ``$EDITOR`` with *truthy* (TS ``||``) semantics: an empty
|
|
60
|
+
or unset value falls through.
|
|
61
|
+
"""
|
|
62
|
+
configured = env.get("VISUAL") or env.get("EDITOR") or _FALLBACK_EDITOR
|
|
63
|
+
parts = [part for part in re.split(r"\s+", configured) if len(part) > 0]
|
|
64
|
+
return parts if len(parts) > 0 else [_FALLBACK_EDITOR]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
#: The digit alphabet used by the temp-name stamp (JS ``toString(36)``).
|
|
68
|
+
_BASE36_DIGITS: Final[str] = "0123456789abcdefghijklmnopqrstuvwxyz"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _to_base36(value: int) -> str:
|
|
72
|
+
"""Render a non-negative integer in base 36 (the JS ``toString(36)``)."""
|
|
73
|
+
if value == 0:
|
|
74
|
+
return "0"
|
|
75
|
+
digits: list[str] = []
|
|
76
|
+
while value:
|
|
77
|
+
value, remainder = divmod(value, 36)
|
|
78
|
+
digits.append(_BASE36_DIGITS[remainder])
|
|
79
|
+
return "".join(reversed(digits))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def open_in_external_editor(
|
|
83
|
+
buffer: str,
|
|
84
|
+
options: ExternalEditorOptions | None = None,
|
|
85
|
+
) -> str | None:
|
|
86
|
+
"""Stage ``buffer`` in a temp file, open it in the user's editor sharing
|
|
87
|
+
this terminal, block until the editor exits, and return the edited text —
|
|
88
|
+
or ``None`` when the editor could not run or exited non-zero.
|
|
89
|
+
|
|
90
|
+
A single trailing newline (most editors add one on save) is stripped so
|
|
91
|
+
the round-trip does not silently grow the buffer. The temp file is always
|
|
92
|
+
removed.
|
|
93
|
+
"""
|
|
94
|
+
resolved = options if options is not None else ExternalEditorOptions()
|
|
95
|
+
env: Mapping[str, str] = resolved.env if resolved.env is not None else os.environ
|
|
96
|
+
directory = resolved.dir if resolved.dir is not None else tempfile.gettempdir()
|
|
97
|
+
stamp = _to_base36(int(time.time() * 1000))
|
|
98
|
+
suffix = "".join(random.choices(_BASE36_DIGITS, k=6))
|
|
99
|
+
name = f"indus-editor-{stamp}-{suffix}.md"
|
|
100
|
+
path = Path(os.path.join(directory, name))
|
|
101
|
+
|
|
102
|
+
editor, *editor_args = _resolve_editor(env)
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
path.write_text(buffer, encoding="utf-8")
|
|
106
|
+
# No capture: the child inherits this process's stdio (the TS
|
|
107
|
+
# `stdio: "inherit"`), so a full-screen editor owns the terminal.
|
|
108
|
+
result = subprocess.run([editor, *editor_args, str(path)])
|
|
109
|
+
if result.returncode != 0:
|
|
110
|
+
return None
|
|
111
|
+
text = path.read_text(encoding="utf-8")
|
|
112
|
+
# Strip exactly one trailing "\n" (the TS `.replace(/\n$/, "")`).
|
|
113
|
+
return text[:-1] if text.endswith("\n") else text
|
|
114
|
+
except OSError:
|
|
115
|
+
return None
|
|
116
|
+
finally:
|
|
117
|
+
try:
|
|
118
|
+
path.unlink()
|
|
119
|
+
except OSError:
|
|
120
|
+
pass # best-effort cleanup
|
induscode/kit/image.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Image kit — magic-byte sniffing for the two raster formats the agent ships
|
|
2
|
+
support for, plus a tiny asset-naming helper.
|
|
3
|
+
|
|
4
|
+
This is a leaf utility: framework-agnostic, stdlib-only, and pure apart from
|
|
5
|
+
the byte reads it is handed. Two unrelated jobs live here because both are
|
|
6
|
+
small and both are about turning opaque bytes / platform tuples into a stable
|
|
7
|
+
name:
|
|
8
|
+
|
|
9
|
+
- :func:`sniff_image_format` / :func:`detect_image_media_type` classify a byte
|
|
10
|
+
buffer as PNG or JPEG by inspecting its leading signature bytes. The
|
|
11
|
+
signatures are an interface-dictated constant — they are fixed by the PNG
|
|
12
|
+
and JFIF/EXIF container specifications, not a choice we make.
|
|
13
|
+
- :func:`resolve_asset_name` renders a release-asset file name from a small
|
|
14
|
+
template by substituting platform / architecture / version tokens, so a
|
|
15
|
+
provisioner can locate the right download for the host without a per-tool
|
|
16
|
+
switch.
|
|
17
|
+
|
|
18
|
+
Nothing here performs I/O: callers read the bytes (a file head, a clipboard
|
|
19
|
+
blob, a fetched body) and pass them in. That keeps the module trivially
|
|
20
|
+
testable and usable from any host.
|
|
21
|
+
|
|
22
|
+
Port of TS ``src/kit/image.ts``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import Final, Literal, Sequence, TypeAlias
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"AssetNameContext",
|
|
32
|
+
"ByteSource",
|
|
33
|
+
"IMAGE_SNIFF_BYTES",
|
|
34
|
+
"ImageFormat",
|
|
35
|
+
"ImageMediaType",
|
|
36
|
+
"detect_image_media_type",
|
|
37
|
+
"is_supported_image",
|
|
38
|
+
"media_type_for_image_format",
|
|
39
|
+
"resolve_asset_name",
|
|
40
|
+
"sniff_image_format",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Image format detection
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
#: The raster image formats this kit can recognise from their leading bytes.
|
|
48
|
+
#:
|
|
49
|
+
#: Deliberately tiny — the agent only ever needs to tell PNG and JPEG apart
|
|
50
|
+
#: for its attachment / clipboard paths; anything else is reported as
|
|
51
|
+
#: unrecognised (``None``) rather than guessed.
|
|
52
|
+
ImageFormat: TypeAlias = Literal["png", "jpeg"]
|
|
53
|
+
|
|
54
|
+
#: The IANA media types :data:`ImageFormat` values map to.
|
|
55
|
+
#:
|
|
56
|
+
#: Used when a recognised format must be handed to the framework attachment
|
|
57
|
+
#: model, which speaks media-type strings.
|
|
58
|
+
ImageMediaType: TypeAlias = Literal["image/png", "image/jpeg"]
|
|
59
|
+
|
|
60
|
+
#: The PNG file signature: the eight fixed bytes every PNG stream opens with.
|
|
61
|
+
#:
|
|
62
|
+
#: ``89 50 4E 47 0D 0A 1A 0A`` — interface-dictated by the PNG container
|
|
63
|
+
#: format (the high bit set on the first byte, the ASCII tag, then CR LF SUB
|
|
64
|
+
#: LF to catch transfer corruption). Not a value we are free to change.
|
|
65
|
+
_PNG_SIGNATURE: Final[tuple[int, ...]] = (0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)
|
|
66
|
+
|
|
67
|
+
#: The JPEG Start-Of-Image marker: the first three bytes of every JFIF / EXIF
|
|
68
|
+
#: stream.
|
|
69
|
+
#:
|
|
70
|
+
#: ``FF D8 FF`` — interface-dictated by the JPEG/JFIF container; the third
|
|
71
|
+
#: byte begins the first marker segment. Three bytes is enough to distinguish
|
|
72
|
+
#: JPEG from the other formats the agent handles.
|
|
73
|
+
_JPEG_SIGNATURE: Final[tuple[int, ...]] = (0xFF, 0xD8, 0xFF)
|
|
74
|
+
|
|
75
|
+
#: The widest signature checked, so a caller knows the minimum head to read.
|
|
76
|
+
IMAGE_SNIFF_BYTES: Final[int] = len(_PNG_SIGNATURE)
|
|
77
|
+
|
|
78
|
+
#: A byte source this module can read a leading window from.
|
|
79
|
+
#:
|
|
80
|
+
#: ``bytes`` / ``bytearray`` and a plain list of ints all satisfy this, so a
|
|
81
|
+
#: caller never has to convert before sniffing (the Python analogue of the TS
|
|
82
|
+
#: ``ArrayLike<number>``).
|
|
83
|
+
ByteSource: TypeAlias = Sequence[int]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _starts_with(data: ByteSource, signature: tuple[int, ...]) -> bool:
|
|
87
|
+
"""Whether ``data`` opens with the given fixed signature.
|
|
88
|
+
|
|
89
|
+
Compares byte-for-byte from offset zero; a buffer shorter than the
|
|
90
|
+
signature can never match (and short-circuits without reading past its
|
|
91
|
+
end).
|
|
92
|
+
"""
|
|
93
|
+
if len(data) < len(signature):
|
|
94
|
+
return False
|
|
95
|
+
for index, expected in enumerate(signature):
|
|
96
|
+
if data[index] != expected:
|
|
97
|
+
return False
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def sniff_image_format(data: ByteSource) -> ImageFormat | None:
|
|
102
|
+
"""Classify a byte buffer as PNG or JPEG by its leading signature.
|
|
103
|
+
|
|
104
|
+
Reads at most :data:`IMAGE_SNIFF_BYTES` bytes from the front of ``data``
|
|
105
|
+
and returns the matching :data:`ImageFormat`, or ``None`` when neither
|
|
106
|
+
signature matches (an empty buffer, a truncated head, or any other
|
|
107
|
+
format). Pure: it inspects the buffer and allocates nothing.
|
|
108
|
+
|
|
109
|
+
:param data: the leading bytes of a candidate image (a file head is enough)
|
|
110
|
+
"""
|
|
111
|
+
if _starts_with(data, _PNG_SIGNATURE):
|
|
112
|
+
return "png"
|
|
113
|
+
if _starts_with(data, _JPEG_SIGNATURE):
|
|
114
|
+
return "jpeg"
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def media_type_for_image_format(format: ImageFormat) -> ImageMediaType:
|
|
119
|
+
"""Map a recognised :data:`ImageFormat` to its IANA media type."""
|
|
120
|
+
return "image/png" if format == "png" else "image/jpeg"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def detect_image_media_type(data: ByteSource) -> ImageMediaType | None:
|
|
124
|
+
"""Detect the media type of a byte buffer, or ``None`` when unrecognised.
|
|
125
|
+
|
|
126
|
+
A convenience over :func:`sniff_image_format` for callers that speak
|
|
127
|
+
media-type strings (e.g. the framework attachment model) rather than the
|
|
128
|
+
internal :data:`ImageFormat` tag.
|
|
129
|
+
|
|
130
|
+
:param data: the leading bytes of a candidate image
|
|
131
|
+
"""
|
|
132
|
+
format = sniff_image_format(data)
|
|
133
|
+
return None if format is None else media_type_for_image_format(format)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def is_supported_image(data: ByteSource) -> bool:
|
|
137
|
+
"""Whether ``data`` is a buffer this kit recognises as a supported image."""
|
|
138
|
+
return sniff_image_format(data) is not None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# Asset-name resolution
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True, slots=True)
|
|
147
|
+
class AssetNameContext:
|
|
148
|
+
"""The platform / architecture / version facts a :func:`resolve_asset_name`
|
|
149
|
+
template can interpolate.
|
|
150
|
+
|
|
151
|
+
Field names mirror the substitution tokens; every member is optional so a
|
|
152
|
+
template that only references some of them can be rendered from a partial
|
|
153
|
+
context (a missing token resolves to an empty string).
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
# The host operating system, e.g. ``"darwin"`` / ``"linux"`` / ``"win32"``.
|
|
157
|
+
platform: str | None = None
|
|
158
|
+
# The host CPU architecture, e.g. ``"arm64"`` / ``"x64"``.
|
|
159
|
+
arch: str | None = None
|
|
160
|
+
# The release version, e.g. ``"1.2.3"`` (with or without a leading ``v``).
|
|
161
|
+
version: str | None = None
|
|
162
|
+
# The bare tool / binary name, e.g. ``"fd"`` / ``"rg"``.
|
|
163
|
+
name: str | None = None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
#: The substitution tokens :func:`resolve_asset_name` understands inside a
|
|
167
|
+
#: template, each written as ``{token}``.
|
|
168
|
+
_ASSET_TOKENS: Final[tuple[str, ...]] = ("platform", "arch", "version", "name")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def resolve_asset_name(template: str, context: AssetNameContext) -> str:
|
|
172
|
+
"""Render a release-asset file name from a ``{token}`` template.
|
|
173
|
+
|
|
174
|
+
Replaces each ``{platform}`` / ``{arch}`` / ``{version}`` / ``{name}``
|
|
175
|
+
occurrence with the matching :class:`AssetNameContext` field (an absent
|
|
176
|
+
field becomes the empty string), leaving every other character verbatim.
|
|
177
|
+
This lets a provisioner declare one template per tool — e.g.
|
|
178
|
+
``"{name}-{version}-{arch}-{platform}.tar.gz"`` — instead of branching on
|
|
179
|
+
the host. Pure string work, no I/O.
|
|
180
|
+
|
|
181
|
+
:param template: the asset-name pattern with ``{token}`` placeholders
|
|
182
|
+
:param context: the platform / arch / version / name facts to substitute
|
|
183
|
+
"""
|
|
184
|
+
rendered = template
|
|
185
|
+
for token in _ASSET_TOKENS:
|
|
186
|
+
value: str | None = getattr(context, token)
|
|
187
|
+
rendered = rendered.replace(f"{{{token}}}", value if value is not None else "")
|
|
188
|
+
return rendered
|
induscode/kit/shell.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Shell kit — POSIX shell-argument quoting and command assembly.
|
|
2
|
+
|
|
3
|
+
A leaf utility for safely turning argument strings into a single shell line.
|
|
4
|
+
The agent shells out to provisioned binaries (fd / rg) and to user commands;
|
|
5
|
+
any argument carrying a space, a glob, a quote, or a metacharacter must be
|
|
6
|
+
quoted so the shell passes it through as one literal token rather than
|
|
7
|
+
re-parsing it.
|
|
8
|
+
|
|
9
|
+
The strategy is the conventional POSIX one: wrap the argument in single
|
|
10
|
+
quotes and escape any embedded single quote by closing the quote, emitting an
|
|
11
|
+
escaped quote, and reopening — ``it's`` becomes ``'it'\\''s'``. Inside single
|
|
12
|
+
quotes the shell treats every other character literally, so this is robust
|
|
13
|
+
against spaces, ``$``, backticks, ``*``, ``;``, newlines, and the rest.
|
|
14
|
+
Arguments that contain only safe characters are passed through unquoted to
|
|
15
|
+
keep the rendered line readable.
|
|
16
|
+
|
|
17
|
+
Pure and framework-agnostic: no process spawn, no environment, no I/O.
|
|
18
|
+
|
|
19
|
+
Port of TS ``src/kit/shell.ts``. Deliberately *not* :func:`shlex.quote` —
|
|
20
|
+
the pass-through readability rule (its safe-character class) is this module's
|
|
21
|
+
own, kept verbatim from the TS source.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
from typing import Final, Sequence
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"build_shell_command",
|
|
31
|
+
"needs_quoting",
|
|
32
|
+
"quote_arg",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Quoting
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
#: The characters that are safe to leave unquoted on a POSIX shell line.
|
|
40
|
+
#:
|
|
41
|
+
#: Letters, digits, and a small set of punctuation the shell never treats
|
|
42
|
+
#: specially in argument position. Anything outside this class forces quoting.
|
|
43
|
+
#: Applied via ``fullmatch`` rather than ``^...$`` anchors: Python's ``$``
|
|
44
|
+
#: tolerates a trailing newline, which would wrongly classify ``"foo\n"`` as
|
|
45
|
+
#: safe — ``fullmatch`` restores the strict whole-string semantics of the TS
|
|
46
|
+
#: ``/^[A-Za-z0-9_@%+=:,./-]+$/``.
|
|
47
|
+
_SHELL_SAFE: Final[re.Pattern[str]] = re.compile(r"[A-Za-z0-9_@%+=:,./-]+")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def quote_arg(arg: str) -> str:
|
|
51
|
+
"""Quote a single argument for a POSIX shell.
|
|
52
|
+
|
|
53
|
+
Returns the argument unchanged when it is non-empty and made up entirely
|
|
54
|
+
of shell-safe characters; otherwise wraps it in single quotes, escaping
|
|
55
|
+
embedded single quotes with the ``'\\''`` idiom. An empty string becomes
|
|
56
|
+
``''`` so it survives as a distinct (empty) argument rather than
|
|
57
|
+
vanishing.
|
|
58
|
+
|
|
59
|
+
:param arg: the raw argument value to quote
|
|
60
|
+
"""
|
|
61
|
+
if len(arg) == 0:
|
|
62
|
+
return "''"
|
|
63
|
+
if _SHELL_SAFE.fullmatch(arg):
|
|
64
|
+
return arg
|
|
65
|
+
return "'" + arg.replace("'", "'\\''") + "'"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def needs_quoting(arg: str) -> bool:
|
|
69
|
+
"""Whether an argument would need quoting on a POSIX shell line.
|
|
70
|
+
|
|
71
|
+
``True`` when the value is empty or contains any character outside the
|
|
72
|
+
shell-safe class. Useful for diagnostics or for deciding whether to render
|
|
73
|
+
a command differently; :func:`quote_arg` applies the same test internally.
|
|
74
|
+
|
|
75
|
+
:param arg: the raw argument value to test
|
|
76
|
+
"""
|
|
77
|
+
return len(arg) == 0 or _SHELL_SAFE.fullmatch(arg) is None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build_shell_command(args: Sequence[str]) -> str:
|
|
81
|
+
"""Assemble a list of arguments into a single space-separated shell line.
|
|
82
|
+
|
|
83
|
+
Each element is run through :func:`quote_arg`, so the result is safe to
|
|
84
|
+
hand to a shell as one command string. The argument order is preserved; an
|
|
85
|
+
empty list yields an empty string.
|
|
86
|
+
|
|
87
|
+
:param args: the command and its arguments, in invocation order
|
|
88
|
+
"""
|
|
89
|
+
return " ".join(quote_arg(arg) for arg in args)
|