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.
Files changed (167) hide show
  1. induscode/__init__.py +56 -0
  2. induscode/addons/__init__.py +176 -0
  3. induscode/addons/contract.py +923 -0
  4. induscode/addons/dispatch/__init__.py +43 -0
  5. induscode/addons/dispatch/event_dispatcher.py +348 -0
  6. induscode/addons/dispatch/tool_interceptor.py +349 -0
  7. induscode/addons/host.py +469 -0
  8. induscode/addons/loader.py +314 -0
  9. induscode/addons/manifest.py +232 -0
  10. induscode/addons/surface.py +199 -0
  11. induscode/boot/__init__.py +108 -0
  12. induscode/boot/auth_vault.py +323 -0
  13. induscode/boot/boot.py +210 -0
  14. induscode/boot/contract.py +223 -0
  15. induscode/boot/invocation.py +117 -0
  16. induscode/boot/runners/__init__.py +42 -0
  17. induscode/boot/runners/link_runner.py +82 -0
  18. induscode/boot/runners/oneshot_runner.py +85 -0
  19. induscode/boot/runners/registry.py +46 -0
  20. induscode/boot/runners/repl_runner.py +340 -0
  21. induscode/boot/runners/session.py +549 -0
  22. induscode/boot/stages.py +198 -0
  23. induscode/boot/upgrade/__init__.py +36 -0
  24. induscode/boot/upgrade/apply.py +125 -0
  25. induscode/boot/upgrade/upgrades.py +136 -0
  26. induscode/briefing/__init__.py +115 -0
  27. induscode/briefing/compose.py +414 -0
  28. induscode/briefing/contract.py +528 -0
  29. induscode/briefing/macros.py +721 -0
  30. induscode/briefing/skills.py +417 -0
  31. induscode/capability_deck/__init__.py +233 -0
  32. induscode/capability_deck/bridge_ledger/__init__.py +66 -0
  33. induscode/capability_deck/bridge_ledger/key.py +181 -0
  34. induscode/capability_deck/bridge_ledger/ledger.py +276 -0
  35. induscode/capability_deck/bridge_ledger/network.py +336 -0
  36. induscode/capability_deck/builtin_bridge.py +358 -0
  37. induscode/capability_deck/cards/__init__.py +116 -0
  38. induscode/capability_deck/cards/bg_process.py +482 -0
  39. induscode/capability_deck/cards/memory.py +226 -0
  40. induscode/capability_deck/cards/saas.py +280 -0
  41. induscode/capability_deck/cards/task.py +256 -0
  42. induscode/capability_deck/cards/todo.py +312 -0
  43. induscode/capability_deck/contract.py +450 -0
  44. induscode/capability_deck/manifest.py +126 -0
  45. induscode/capability_deck/provision.py +217 -0
  46. induscode/channels/__init__.py +146 -0
  47. induscode/channels/contract.py +585 -0
  48. induscode/channels/framer.py +132 -0
  49. induscode/channels/link/__init__.py +50 -0
  50. induscode/channels/link/dialog.py +246 -0
  51. induscode/channels/link/driver.py +308 -0
  52. induscode/channels/link/server.py +217 -0
  53. induscode/channels/oneshot.py +178 -0
  54. induscode/channels/ops.py +140 -0
  55. induscode/channels/session_ops.py +172 -0
  56. induscode/conductor/__init__.py +240 -0
  57. induscode/conductor/catalog.py +309 -0
  58. induscode/conductor/conductor.py +1084 -0
  59. induscode/conductor/contract.py +1035 -0
  60. induscode/conductor/matcher.py +291 -0
  61. induscode/conductor/serialize.py +575 -0
  62. induscode/conductor/signal_hub.py +382 -0
  63. induscode/conductor/skill_parse.py +294 -0
  64. induscode/conductor/transcript_store.py +449 -0
  65. induscode/console/__init__.py +236 -0
  66. induscode/console/app.py +1677 -0
  67. induscode/console/components/__init__.py +62 -0
  68. induscode/console/components/banner.py +499 -0
  69. induscode/console/components/banner_sweep.py +188 -0
  70. induscode/console/components/emblem.py +181 -0
  71. induscode/console/components/status_bar.py +102 -0
  72. induscode/console/contract.py +836 -0
  73. induscode/console/input/__init__.py +107 -0
  74. induscode/console/input/chord.py +197 -0
  75. induscode/console/input/dir_reader.py +113 -0
  76. induscode/console/input/intents.py +258 -0
  77. induscode/console/input/providers.py +469 -0
  78. induscode/console/mount.py +137 -0
  79. induscode/console/overlays/__init__.py +94 -0
  80. induscode/console/overlays/auth.py +503 -0
  81. induscode/console/overlays/pickers.py +526 -0
  82. induscode/console/overlays/router.py +129 -0
  83. induscode/console/overlays/sessions.py +232 -0
  84. induscode/console/reducer.py +145 -0
  85. induscode/console/resume_picker.py +156 -0
  86. induscode/console/slash_commands/__init__.py +78 -0
  87. induscode/console/slash_commands/builtins.py +254 -0
  88. induscode/console/slash_commands/dynamic.py +217 -0
  89. induscode/console/slash_commands/integrations.py +949 -0
  90. induscode/console/slash_commands/transcript.py +404 -0
  91. induscode/console/slash_commands/workbench.py +430 -0
  92. induscode/console/startup.py +434 -0
  93. induscode/console/theme/__init__.py +44 -0
  94. induscode/console/theme/adapter.py +168 -0
  95. induscode/console/theme/palette.py +128 -0
  96. induscode/console/theme/resolve.py +123 -0
  97. induscode/console/theme/tokens.py +185 -0
  98. induscode/console_slash/__init__.py +111 -0
  99. induscode/console_slash/contract.py +185 -0
  100. induscode/console_slash/registry.py +140 -0
  101. induscode/console_slash/resolve.py +194 -0
  102. induscode/console_slash/shared.py +172 -0
  103. induscode/entry.py +108 -0
  104. induscode/insight/__init__.py +153 -0
  105. induscode/insight/collector.py +73 -0
  106. induscode/insight/replay.py +305 -0
  107. induscode/insight/wrapper.py +1115 -0
  108. induscode/kit/__init__.py +82 -0
  109. induscode/kit/clipboard_image.py +215 -0
  110. induscode/kit/external_editor.py +120 -0
  111. induscode/kit/image.py +188 -0
  112. induscode/kit/shell.py +89 -0
  113. induscode/kit/tool_fetch.py +288 -0
  114. induscode/launch/__init__.py +224 -0
  115. induscode/launch/catalog.py +310 -0
  116. induscode/launch/contract.py +569 -0
  117. induscode/launch/credentials.py +852 -0
  118. induscode/launch/invocation/__init__.py +39 -0
  119. induscode/launch/invocation/attachments.py +281 -0
  120. induscode/launch/invocation/flags.py +210 -0
  121. induscode/launch/invocation/read.py +369 -0
  122. induscode/launch/invocation/usage.py +110 -0
  123. induscode/launch/oauth.py +808 -0
  124. induscode/launch/packages.py +299 -0
  125. induscode/launch/pickers.py +291 -0
  126. induscode/py.typed +0 -0
  127. induscode/runtime_bridge/__init__.py +166 -0
  128. induscode/runtime_bridge/bridges/__init__.py +66 -0
  129. induscode/runtime_bridge/bridges/_drive.py +268 -0
  130. induscode/runtime_bridge/bridges/builtins.py +177 -0
  131. induscode/runtime_bridge/bridges/claude_cli.py +198 -0
  132. induscode/runtime_bridge/bridges/codex_cli.py +203 -0
  133. induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
  134. induscode/runtime_bridge/broker.py +397 -0
  135. induscode/runtime_bridge/contract.py +734 -0
  136. induscode/runtime_bridge/sink.py +351 -0
  137. induscode/sessions/__init__.py +25 -0
  138. induscode/sessions/contract.py +119 -0
  139. induscode/sessions/library.py +350 -0
  140. induscode/settings/__init__.py +47 -0
  141. induscode/settings/contract.py +313 -0
  142. induscode/settings/manager.py +268 -0
  143. induscode/transcript_export/__init__.py +109 -0
  144. induscode/transcript_export/contract.py +522 -0
  145. induscode/transcript_export/publish.py +455 -0
  146. induscode/transcript_export/sgr.py +566 -0
  147. induscode/transcript_export/template.py +319 -0
  148. induscode/transcript_export/theme_bridge.py +325 -0
  149. induscode/window_budget/__init__.py +76 -0
  150. induscode/window_budget/budget/__init__.py +26 -0
  151. induscode/window_budget/budget/estimate.py +273 -0
  152. induscode/window_budget/budget/gate.py +60 -0
  153. induscode/window_budget/budget/slice.py +145 -0
  154. induscode/window_budget/condenser.py +170 -0
  155. induscode/window_budget/contract.py +329 -0
  156. induscode/window_budget/summarize/__init__.py +33 -0
  157. induscode/window_budget/summarize/condense.py +212 -0
  158. induscode/window_budget/summarize/prompt.py +241 -0
  159. induscode/workspace/__init__.py +30 -0
  160. induscode/workspace/brand.py +96 -0
  161. induscode/workspace/locator.py +269 -0
  162. induscode-0.1.0.dist-info/METADATA +97 -0
  163. induscode-0.1.0.dist-info/RECORD +167 -0
  164. induscode-0.1.0.dist-info/WHEEL +4 -0
  165. induscode-0.1.0.dist-info/entry_points.txt +3 -0
  166. induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
  167. 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)