skit-cli 0.0.1__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.
skit/launcher.py ADDED
@@ -0,0 +1,326 @@
1
+ """Launcher: assemble the run command and execute it straight through the terminal (C2/C5/C6).
2
+
3
+ - python entries: always `uv run --no-project --script <path>` (C2). `--script` alone does NOT
4
+ isolate a block-less script — uv attaches it to any enclosing project's environment (verified
5
+ empirically), so --no-project is unconditional; PEP 723 blocks and --with deps still resolve.
6
+ - exe entries: run directly.
7
+ - command entries: template + placeholder fill-in, executed through the shell.
8
+ - The terminal is handed entirely to the child process (stdin/stdout/stderr pass through); the TUI
9
+ caller is responsible for suspend/resume.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import re
16
+ import shlex
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from . import config
23
+ from .i18n import gettext
24
+ from .models import Entry
25
+ from .paths import private_bin_dir
26
+
27
+
28
+ class LaunchError(Exception):
29
+ pass
30
+
31
+
32
+ class TargetMissingError(LaunchError):
33
+ """The launch target (script file / executable) is gone from disk.
34
+
35
+ A distinct type so `skit run` can map it to exit 127 (command not found, docker
36
+ convention) while other skit-side failures map to 125 — scripts that themselves
37
+ exit 1 stay distinguishable from skit failing to launch them at all."""
38
+
39
+
40
+ class NotExecutableError(LaunchError):
41
+ """The exe target exists but has no execute permission (exit 126, docker convention)."""
42
+
43
+
44
+ def find_uv() -> str | None:
45
+ """Detection order: PATH -> skit's private bin (A9/§5.6)."""
46
+ found = shutil.which("uv")
47
+ if found:
48
+ return found
49
+ private = private_bin_dir() / "uv"
50
+ if private.exists():
51
+ return str(private)
52
+ private_exe = private_bin_dir() / "uv.exe"
53
+ if private_exe.exists():
54
+ return str(private_exe)
55
+ return None
56
+
57
+
58
+ def ensure_uv() -> str:
59
+ """Find uv or auto-download a managed copy (first-run experience: zero user action). Raises
60
+ LaunchError on failure."""
61
+ found = find_uv()
62
+ if found:
63
+ return found
64
+ from . import uvman
65
+
66
+ try:
67
+ return uvman.ensure_uv_downloaded()
68
+ except uvman.UvDownloadError as exc:
69
+ raise LaunchError(
70
+ f"{gettext('uv not found. Install it (https://docs.astral.sh/uv/) or run skit doctor for guidance.')} ({exc})"
71
+ ) from exc
72
+
73
+
74
+ def _resolve_workdir(entry: Entry, invoke_cwd: Path) -> Path:
75
+ policy = entry.meta.workdir
76
+ if policy == "origin":
77
+ src = entry.meta.source
78
+ origin_dir = Path(src).parent if src else invoke_cwd
79
+ if entry.meta.mode == "copy" and not origin_dir.is_dir():
80
+ # Copy mode exists to decouple the entry from its original location, so a vanished
81
+ # origin must not block a run when the store copy is intact — this also recovers
82
+ # entries persisted with workdir="origin" before store.add_python's copy-mode default
83
+ # changed to "invoke". Reference-mode entries are not decoupled from their origin (the
84
+ # script check already fails first with a clearer message if it's gone), so they keep
85
+ # resolving to the origin dir unconditionally.
86
+ return invoke_cwd
87
+ return origin_dir
88
+ if policy == "store":
89
+ return entry.dir
90
+ if policy == "invoke":
91
+ return invoke_cwd
92
+ return Path(policy) # absolute path
93
+
94
+
95
+ def _check_script_exists(script: Path) -> None:
96
+ if not script.exists():
97
+ raise TargetMissingError(
98
+ gettext("The script file doesn't exist: %(path)s") % {"path": str(script)}
99
+ )
100
+
101
+
102
+ def _check_exe_exists(source: str) -> None:
103
+ path = Path(source)
104
+ if not path.exists():
105
+ raise TargetMissingError(
106
+ gettext("The executable doesn't exist: %(path)s") % {"path": source}
107
+ )
108
+ if sys.platform != "win32" and path.is_file() and not os.access(path, os.X_OK):
109
+ raise NotExecutableError(
110
+ gettext("%(path)s exists but isn't executable (chmod +x it?).") % {"path": source}
111
+ )
112
+
113
+
114
+ def _build_python(entry: Entry, extra: list[str], script_override: Path | None) -> list[str]:
115
+ # Check the cheap, local condition (does the script exist?) before the potentially-network-
116
+ # bound one (is uv installed, or does it need downloading?) — mirrors preflight's ordering, and
117
+ # spares a user with a missing script a pointless uv download/error first.
118
+ script = script_override or entry.script_path
119
+ _check_script_exists(script)
120
+ uv = ensure_uv()
121
+ # C2: unconditional isolation. Without --no-project, `uv run --script` attaches a
122
+ # block-less script to whatever uv project encloses the cwd (empirically verified) —
123
+ # and copy-mode entries default to workdir="invoke", so "run it from inside any
124
+ # project directory" was a live hijack path. Scripts with a PEP 723 block and
125
+ # reference-mode --with deps are unaffected by the flag.
126
+ cmd = [uv, "run", "--no-project"]
127
+ # In reference mode, dependencies are recorded in meta (the original file can't take a PEP 723
128
+ # block), so pass them via --with/--python.
129
+ if entry.meta.requires_python:
130
+ cmd += ["--python", entry.meta.requires_python]
131
+ for dep in entry.meta.dependencies or []:
132
+ cmd += ["--with", dep]
133
+ return [*cmd, "--script", str(script), *extra]
134
+
135
+
136
+ def _build_exe(entry: Entry, extra: list[str]) -> list[str]:
137
+ exe = entry.meta.source
138
+ _check_exe_exists(exe)
139
+ return [exe, *extra]
140
+
141
+
142
+ def _quote_for_shell(value: str) -> str:
143
+ """Quote a single substituted value for the platform shell _build_shell executes under,
144
+ mirroring how `extra` args are already quoted below (shlex on POSIX, list2cmdline on Windows) —
145
+ otherwise a value with spaces or shell metacharacters reshapes the command's argument
146
+ structure or, worse, injects extra shell syntax."""
147
+ if sys.platform == "win32":
148
+ return subprocess.list2cmdline([value])
149
+ return shlex.quote(value)
150
+
151
+
152
+ # Matches, left to right: a `{{` escape, a `}}` escape, or a `{name}` placeholder (the same
153
+ # identifier rule as store.extract_placeholders). Substitution and escape-restoration run together
154
+ # in ONE pass over the ORIGINAL template via this pattern so replacement text is never re-scanned —
155
+ # doing it as two sequential passes (substitute placeholders, then str.replace "{{"/"}}") would
156
+ # corrupt any substituted value that itself contains "{{" or "}}".
157
+ _TEMPLATE_TOKEN_RE = re.compile(r"\{\{|\}\}|(?<!\{)\{([a-zA-Z_][a-zA-Z0-9_]*)\}(?!\})")
158
+
159
+
160
+ def _build_shell(entry: Entry, extra: list[str], values: dict[str, str] | None) -> str:
161
+ template = entry.meta.template
162
+ vals = values or {}
163
+ if entry.meta.params:
164
+ missing = [p for p in entry.meta.params if p not in vals]
165
+ if missing:
166
+ raise LaunchError(
167
+ gettext("Missing parameter values: %(names)s") % {"names": ", ".join(missing)}
168
+ )
169
+
170
+ def repl(m: re.Match[str]) -> str:
171
+ matched = m.group(0)
172
+ if matched == "{{":
173
+ return "{"
174
+ if matched == "}}":
175
+ return "}"
176
+ name = m.group(1)
177
+ if name is None or name not in vals:
178
+ return matched
179
+ return _quote_for_shell(vals[name])
180
+
181
+ cmd = _TEMPLATE_TOKEN_RE.sub(repl, template)
182
+ if extra:
183
+ # shell=True execution: quoting must follow that platform's shell (POSIX uses shlex, Windows
184
+ # cmd uses list2cmdline), or arguments containing $ or backticks would be expanded.
185
+ if sys.platform == "win32":
186
+ cmd = cmd + " " + subprocess.list2cmdline(extra)
187
+ else:
188
+ cmd = cmd + " " + shlex.join(extra)
189
+ return cmd
190
+
191
+
192
+ def build_command(
193
+ entry: Entry,
194
+ extra_args: list[str] | None = None,
195
+ values: dict[str, str] | None = None,
196
+ *,
197
+ script_override: Path | None = None,
198
+ ) -> list[str] | str:
199
+ """Return an argv list (python/exe) or a shell string (command).
200
+
201
+ values: fill-ins for the named placeholders of a command template (missing values raise
202
+ LaunchError).
203
+ script_override: the temporary script path after shim injection (python entries only; A5 leaves
204
+ the original copy untouched).
205
+ """
206
+ extra = extra_args or []
207
+ kind = entry.meta.kind
208
+ if kind == "python":
209
+ return _build_python(entry, extra, script_override)
210
+ if kind == "exe":
211
+ return _build_exe(entry, extra)
212
+ if kind == "command":
213
+ return _build_shell(entry, extra, values)
214
+ raise LaunchError(gettext("Unknown entry kind: %(kind)s") % {"kind": kind})
215
+
216
+
217
+ def describe_command(
218
+ entry: Entry,
219
+ extra_args: list[str] | None = None,
220
+ values: dict[str, str] | None = None,
221
+ *,
222
+ script_override: Path | None = None,
223
+ ) -> str:
224
+ """A purely descriptive command line for transparency output and --dry-run: no uv
225
+ lookup or download, no existence checks, no side effects. Mirrors build_command's
226
+ shape; when uv isn't installed yet the literal "uv" stands in."""
227
+ extra = extra_args or []
228
+ kind = entry.meta.kind
229
+ if kind == "python":
230
+ uv = find_uv() or "uv"
231
+ cmd = [uv, "run", "--no-project"] # mirrors _build_python's unconditional C2 isolation
232
+ if entry.meta.requires_python:
233
+ cmd += ["--python", entry.meta.requires_python]
234
+ for dep in entry.meta.dependencies or []:
235
+ cmd += ["--with", dep]
236
+ script = script_override or entry.script_path
237
+ return _join_for_display([*cmd, "--script", str(script), *extra])
238
+ if kind == "exe":
239
+ return _join_for_display([entry.meta.source, *extra])
240
+ try:
241
+ built = _build_shell(entry, extra, values)
242
+ except LaunchError:
243
+ built = entry.meta.template
244
+ return built
245
+
246
+
247
+ def _join_for_display(argv: list[str]) -> str:
248
+ if sys.platform == "win32":
249
+ return subprocess.list2cmdline(argv)
250
+ return shlex.join(argv)
251
+
252
+
253
+ def target_missing(entry: Entry) -> bool:
254
+ """Whether entry's launch target is already known to be gone from disk: the source path for
255
+ exe/reference entries, the stored copy for copy-mode python. Command entries have no file
256
+ target and never report missing."""
257
+ if entry.meta.kind == "exe":
258
+ return not Path(entry.meta.source).exists()
259
+ if entry.meta.kind == "python":
260
+ return not entry.script_path.exists()
261
+ return False # command entries have no file target
262
+
263
+
264
+ def missing_marker(entry: Entry) -> str | None:
265
+ """A human-readable "target is missing" message for entry, or None when it's healthy or has no
266
+ file target (command entries). Callers decide how to style/render it (TUI table, CLI list).
267
+ exe entries are always reference-mode, so script_path is exactly their source path."""
268
+ if not target_missing(entry):
269
+ return None
270
+ return gettext("⚠ missing: %(path)s") % {"path": str(entry.script_path)}
271
+
272
+
273
+ def _check_workdir(cwd: Path) -> None:
274
+ if not cwd.is_dir():
275
+ raise LaunchError(
276
+ gettext("The working directory doesn't exist: %(path)s") % {"path": str(cwd)}
277
+ )
278
+
279
+
280
+ def preflight(entry: Entry, invoke_cwd: Path | None = None) -> None:
281
+ """Validate what can be checked before any values/params are collected: the launch target
282
+ (script/exe) and the working directory. Raises LaunchError with the same messages the actual
283
+ build/run would eventually raise, but does none of the actual work (no uv lookup/download, no
284
+ process spawn) — so the TUI can call this before suspending the terminal."""
285
+ if entry.meta.kind == "python":
286
+ _check_script_exists(entry.script_path)
287
+ elif entry.meta.kind == "exe":
288
+ _check_exe_exists(entry.meta.source)
289
+ _check_workdir(_resolve_workdir(entry, invoke_cwd or Path.cwd()))
290
+
291
+
292
+ def run_entry(
293
+ entry: Entry,
294
+ extra_args: list[str] | None = None,
295
+ *,
296
+ values: dict[str, str] | None = None,
297
+ invoke_cwd: Path | None = None,
298
+ script_override: Path | None = None,
299
+ ) -> int:
300
+ """Run straight through the terminal and return the exit code.
301
+
302
+ The TUI must be suspended before calling this.
303
+ """
304
+ cmd = build_command(entry, extra_args, values, script_override=script_override)
305
+ cwd = _resolve_workdir(entry, invoke_cwd or Path.cwd())
306
+ _check_workdir(cwd)
307
+ # Overlay skit's mirror settings onto uv's environment — a no-op unless the user enabled them,
308
+ # and never clobbering a variable the user set themselves (see config.mirror_env).
309
+ env = {**os.environ, **config.mirror_env(os.environ)}
310
+ if isinstance(cmd, str):
311
+ # A command entry is by definition "a shell command the user registered"; shell=True is a
312
+ # feature, not a hole. The template was written by the user via `skit add`, so the trust
313
+ # boundary is the same as the user's own shell history.
314
+ proc = subprocess.run(cmd, shell=True, cwd=cwd, check=False, env=env) # noqa: S602 # pragma: no mutate — check=None is falsy-equivalent to False; omitting it matches subprocess.run's own default
315
+ else:
316
+ proc = subprocess.run(cmd, cwd=cwd, check=False, env=env) # noqa: S603 — argv from a user entry # pragma: no mutate — check=None is falsy-equivalent to False; omitting it matches subprocess.run's own default
317
+ return _normalize_exit_code(proc.returncode)
318
+
319
+
320
+ def _normalize_exit_code(returncode: int) -> int:
321
+ """Map subprocess.run's signal-death reporting (a negative returncode -N for "killed by signal
322
+ N") onto the conventional shell exit status 128+N, matching what a user would see running the
323
+ same command directly in a POSIX shell. Left as a raw negative number, it would be silently
324
+ mangled by sys.exit (which reduces any status to a byte via `& 0xFF`, e.g. -11 -> 245) while
325
+ also being printed to the user as a confusing negative code."""
326
+ return returncode if returncode >= 0 else 128 - returncode
Binary file
Binary file
skit/metawriter.py ADDED
@@ -0,0 +1,267 @@
1
+ """MetaWriter: write parameter definitions into the `[tool.skit]` table of a PEP 723 block (A5/A6).
2
+
3
+ Strict discipline:
4
+ - **Plain-text operations, never through the AST** — never reorder or reformat a single line of the
5
+ user's code.
6
+ - Only touch the `# /// script … # ///` comment block; create one (with empty dependencies) if none
7
+ exists.
8
+ - Inside the block, only the `[tool.skit]` section is replaced; everything else (dependencies, other
9
+ tool tables) is preserved verbatim.
10
+ - Definitions travel with the file (portable, hand-editable); values and presets live in central
11
+ state (argstate).
12
+
13
+ Shape of `[tool.skit]` (the TOML once comment prefixes are stripped):
14
+
15
+ [tool.skit]
16
+ schema = 1
17
+
18
+ [[tool.skit.params]]
19
+ name = "API_KEY"
20
+ kind = "const" # const | input
21
+ type = "str" # str | int | float | bool
22
+ default = "xxx" # const: the source value (absent for input)
23
+ prompt = "API key: " # input: the original prompt; const may set a custom form prompt
24
+ order = 0 # input: the call-order key (B1); omitted for const
25
+ secret = true # C3: the value never lands in a state file
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import re
31
+ from dataclasses import dataclass, field
32
+ from typing import TYPE_CHECKING, Any
33
+
34
+ from . import pep723
35
+
36
+ if TYPE_CHECKING:
37
+ from .analyzer import Candidate
38
+
39
+ SCT_SCHEMA = 1
40
+
41
+ _BLOCK_RE = re.compile(
42
+ # See the identical comment on pep723._BLOCK_RE: the closer's trailing whitespace is restricted
43
+ # to horizontal whitespace so a greedy `\s*$` can't cross a line boundary and swallow blank lines
44
+ # that follow the block, deleting them on rewrite.
45
+ r"(?m)^# /// script\s*$\n(?P<body>(?:^#(?:| .*)$\n)*?)^# ///[^\S\n]*$\n?",
46
+ )
47
+
48
+
49
+ @dataclass
50
+ class ParamSpec:
51
+ """One `[[tool.skit.params]]`. Field-aligned with analyzer.Candidate and inter-convertible."""
52
+
53
+ name: str
54
+ kind: str = "const" # "const" | "input"
55
+ type: str = "str"
56
+ default: str | int | float | bool | None = None
57
+ prompt: str = ""
58
+ order: int = -1
59
+ secret: bool = False
60
+ # Secret-value source: the name of an environment variable to read at run time instead of
61
+ # asking on every run. Stores WHERE to find the value, never the value itself — the reason
62
+ # this may live in a plain-text file at all (C3: values never land on disk; a variable
63
+ # *name* is not a secret).
64
+ env_source: str = ""
65
+
66
+ @classmethod
67
+ def from_candidate(cls, c: Candidate) -> ParamSpec:
68
+ """Build a managed-parameter spec from an analyzer.Candidate (the two are
69
+ field-aligned by design — A2). The one place this conversion lives, so the CLI,
70
+ TUI add panel, TUI settings, and reconcile can't drift on which fields carry over."""
71
+ return cls(
72
+ name=c.name,
73
+ kind=c.kind,
74
+ type=c.type,
75
+ default=c.default,
76
+ prompt=c.prompt,
77
+ order=c.order,
78
+ secret=c.secret,
79
+ )
80
+
81
+ def to_dict(self) -> dict[str, str | int | float | bool]:
82
+ d: dict[str, str | int | float | bool] = {
83
+ "name": self.name,
84
+ "kind": self.kind,
85
+ "type": self.type,
86
+ }
87
+ if self.default is not None:
88
+ d["default"] = self.default
89
+ if self.prompt:
90
+ d["prompt"] = self.prompt
91
+ if self.order >= 0:
92
+ d["order"] = self.order
93
+ if self.secret:
94
+ d["secret"] = True
95
+ if self.env_source:
96
+ d["env_source"] = self.env_source
97
+ return d
98
+
99
+ @classmethod
100
+ def from_dict(cls, d: dict[str, Any]) -> ParamSpec:
101
+ # read_params is contracted to be total: a hand-edited `order` can be any valid TOML scalar
102
+ # (e.g. a non-numeric string), so an uncoercible value must fall back to "no explicit order"
103
+ # rather than raising and crashing every caller (TUI load, `skit params`/`run`/`edit`).
104
+ try:
105
+ order = int(d.get("order", -1))
106
+ except (TypeError, ValueError):
107
+ order = -1
108
+ return cls(
109
+ name=str(d.get("name", "")),
110
+ kind=str(d.get("kind", "const")),
111
+ type=str(d.get("type", "str")),
112
+ default=d.get("default"),
113
+ prompt=str(d.get("prompt", "")),
114
+ order=order,
115
+ secret=bool(d.get("secret", False)),
116
+ env_source=str(d.get("env_source", "")),
117
+ )
118
+
119
+
120
+ @dataclass
121
+ class SctMeta:
122
+ params: list[ParamSpec] = field(default_factory=list)
123
+
124
+
125
+ def _toml_str(value: str) -> str:
126
+ r"""A TOML basic string. Control characters must be escaped: if a prompt contains something like
127
+ \n and is emitted verbatim, it splits the string across comment lines, the rewritten block fails
128
+ to parse, and all definitions are lost."""
129
+ out = value.replace("\\", "\\\\").replace('"', '\\"')
130
+ out = out.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
131
+
132
+ # A TOML basic string forbids unescaped U+0000..U+001F and U+007F; escape those.
133
+ # ALSO escape U+0085/U+2028/U+2029: TOML permits them literally, but _commentify splits
134
+ # the rewritten block with str.splitlines(), which breaks on those three too — an
135
+ # unescaped one would shred the comment body and drop every managed param definition.
136
+ def _escape(ch: str) -> str:
137
+ if ch < " " or ch in ("\x7f", "\x85", "\u2028", "\u2029"): # pragma: no mutate
138
+ return f"\\u{ord(ch):04X}"
139
+ return ch
140
+
141
+ out = "".join(_escape(ch) for ch in out)
142
+ return '"' + out + '"'
143
+
144
+
145
+ def _toml_value(value: str | int | float | bool) -> str:
146
+ if isinstance(value, bool):
147
+ return "true" if value else "false"
148
+ if isinstance(value, (int, float)):
149
+ return repr(value)
150
+ return _toml_str(value)
151
+
152
+
153
+ def render_skit_toml(params: list[ParamSpec]) -> str:
154
+ """Generate the comment-stripped [tool.skit] TOML text (without the comment prefix)."""
155
+ lines = ["[tool.skit]", f"schema = {SCT_SCHEMA}"]
156
+ for p in params:
157
+ lines.append("")
158
+ lines.append("[[tool.skit.params]]")
159
+ for key, val in p.to_dict().items():
160
+ lines.append(f"{key} = {_toml_value(val)}")
161
+ return "\n".join(lines) + "\n"
162
+
163
+
164
+ def _commentify(toml_text: str) -> list[str]:
165
+ return [("# " + ln).rstrip() for ln in toml_text.splitlines()]
166
+
167
+
168
+ def _strip_comment_prefix(line: str) -> str:
169
+ if line.startswith("# "): # pragma: no mutate — prefix length is unobservable, re-stripped next
170
+ return line[2:]
171
+ if line.startswith("#"):
172
+ return line[1:]
173
+ return line
174
+
175
+
176
+ def _strip_skit_section(body_lines: list[str]) -> list[str]:
177
+ """Remove any existing [tool.skit] section (and its [[tool.skit.params]]) from the block body,
178
+ keeping the rest."""
179
+ out: list[str] = []
180
+ skipping = False # pragma: no mutate — only read via truthiness (`if not skipping`)
181
+ for line in body_lines:
182
+ stripped = _strip_comment_prefix(line).strip()
183
+ if stripped.startswith("["):
184
+ in_skit = stripped.startswith("[tool.skit]") or stripped.startswith("[[tool.skit.")
185
+ skipping = in_skit
186
+ if not skipping:
187
+ out.append(line)
188
+ # Drop trailing empty comment lines.
189
+ while out and out[-1].strip() in ("#", ""):
190
+ out.pop()
191
+ return out
192
+
193
+
194
+ def _drop_synthetic_separator(base: str, original: str) -> str:
195
+ """Undo inject_block()'s optional blank-line separator when write_params() is about to fill the
196
+ freshly-created block with params in the same operation.
197
+
198
+ inject_block() inserts a blank line between the closer and the following code purely for
199
+ readability when it is used on its own (e.g. `skit deps` adding a bare dependencies-only
200
+ block). But when write_params() creates the block itself (this is the "no block yet, non-empty
201
+ params" path), that blank line would be the ONLY thing ever added outside the
202
+ "# /// … # ///" block — breaking the comment-only-edits contract (A5) and the golden-corpus
203
+ byte-fidelity invariant. Detected structurally (not by re-deriving inject_block's shebang/coding
204
+ insertion logic): strip exactly one blank line, and only when it truly is synthetic (i.e.
205
+ `original`'s own content did not already start with a blank line at that point — inject_block
206
+ itself skips the separator in that case, to avoid a double blank line).
207
+ """
208
+ block_only = pep723.build_block([])
209
+ idx = base.index(block_only)
210
+ prefix = base[:idx]
211
+ rest = base[idx + len(block_only) :]
212
+ suffix = original[len(prefix) :]
213
+ if rest == "\n" + suffix:
214
+ return prefix + block_only + suffix
215
+ return base
216
+
217
+
218
+ def write_params(text: str, params: list[ParamSpec]) -> str:
219
+ """Write (or replace) the script's [tool.skit] with the parameter definitions. Empty params
220
+ means removing the section.
221
+
222
+ - Existing PEP 723 block: replace/append [tool.skit] inside the block, other lines verbatim.
223
+ - No block and non-empty params: create a block with empty dependencies (same insertion point as
224
+ pep723.inject_block).
225
+ """
226
+ m = _BLOCK_RE.search(text)
227
+ if m is None:
228
+ if not params:
229
+ return text
230
+ base = pep723.inject_block(
231
+ text, []
232
+ ) # pragma: no mutate — build_block only checks truthiness of dependencies
233
+ # inject_block has inserted the block; recurse once through the "block exists" path.
234
+ if _BLOCK_RE.search(base) is None: # pragma: no cover — inject_block guarantees a block
235
+ raise RuntimeError("inject_block failed to create a PEP 723 block")
236
+ base = _drop_synthetic_separator(base, text)
237
+ return write_params(base, params)
238
+ body_lines = m.group("body").splitlines()
239
+ kept = _strip_skit_section(body_lines)
240
+ new_body_lines = list(kept)
241
+ if params:
242
+ if new_body_lines:
243
+ new_body_lines.append("#")
244
+ new_body_lines.extend(_commentify(render_skit_toml(params)))
245
+ new_block = "# /// script\n"
246
+ if new_body_lines:
247
+ new_block += "\n".join(new_body_lines) + "\n"
248
+ new_block += "# ///\n"
249
+ return text[: m.start()] + new_block + text[m.end() :]
250
+
251
+
252
+ def read_params(text: str) -> list[ParamSpec]:
253
+ """Read the script's [tool.skit] parameter definitions; empty list if no block/section/parse."""
254
+ meta = pep723.parse_block(text)
255
+ if not meta:
256
+ return []
257
+ # All valid TOML, all defended: `tool`, `tool.skit`, and `tool.skit.params` could each be a
258
+ # scalar (e.g. `tool = 5`) rather than the table/array shape this module writes. read_params is
259
+ # contracted to be total ("empty list if no block/section/parse"), so a malformed shape must
260
+ # fall back to [] rather than raising AttributeError/TypeError out of the `.get` chain or the
261
+ # iteration below.
262
+ tool = meta.get("tool")
263
+ skit = tool.get("skit") if isinstance(tool, dict) else None
264
+ raw = skit.get("params") if isinstance(skit, dict) else None
265
+ if not isinstance(raw, list):
266
+ return []
267
+ return [ParamSpec.from_dict(d) for d in raw if isinstance(d, dict) and d.get("name")]