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/cli.py ADDED
@@ -0,0 +1,1621 @@
1
+ """CLI entry point (Typer): the v2 command surface.
2
+
3
+ Running `skit` with no subcommand opens the TUI workbench. The commands here are the
4
+ automation/SSH/muscle-memory shortcuts; every interactive flow goes through the shared
5
+ form layer (flows) so CLI and TUI behave identically.
6
+
7
+ Command-surface contracts:
8
+ - Exit codes (docker convention): `run` passes the script's exit code through PURE;
9
+ skit's own failures are 125, a target that exists but isn't executable is 126, a
10
+ missing target/name is 127, usage errors are 2. Other commands: 0/1/2.
11
+ - Every output has a --json twin where output exists.
12
+ - Lists are repeatable flags (--dep), never comma-joined (PEP 508 specifiers contain
13
+ commas).
14
+ - Non-interactive contract: on a pipe/CI/--no-input, never prompt, never guess, never
15
+ silently assemble a broken command.
16
+
17
+ Every user-visible string goes through i18n.gettext()/ngettext(). Help strings resolve at
18
+ import time, so i18n initializes lazily on module import (see i18n.py).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ import typer
29
+ from rich.console import Console
30
+ from rich.markup import escape
31
+ from rich.prompt import Confirm, Prompt
32
+
33
+ from . import (
34
+ __version__,
35
+ analyzer,
36
+ argstate,
37
+ config,
38
+ editor,
39
+ flows,
40
+ i18n,
41
+ launcher,
42
+ metawriter,
43
+ models,
44
+ pep723,
45
+ promptform,
46
+ reconcile,
47
+ store,
48
+ )
49
+ from .i18n import gettext, ngettext
50
+
51
+ app = typer.Typer(
52
+ name="skit",
53
+ help=gettext(
54
+ "skit — a script launcher and parameter manager. Run it without a subcommand to open the main menu."
55
+ ),
56
+ add_completion=True,
57
+ no_args_is_help=False,
58
+ )
59
+ console = Console()
60
+ err_console = Console(stderr=True)
61
+
62
+ # Exit-code contract for `skit run` (docker convention; the script's own code passes
63
+ # through untouched, so these must stay out of the 0-124 range scripts commonly use).
64
+ EXIT_USAGE = 2
65
+ EXIT_SKIT = 125
66
+ EXIT_NOT_EXECUTABLE = 126
67
+ EXIT_NOT_FOUND = 127
68
+ EXIT_CANCELLED = 130 # user cancelled the form (128+SIGINT convention) — not a skit failure
69
+
70
+ # Rich closing tags are case-insensitive, so a mutated-case variant is behaviorally identical.
71
+ _DIM_CLOSE = "[/dim]" # pragma: no mutate
72
+ _RED_CLOSE = "[/red]" # pragma: no mutate
73
+
74
+
75
+ def _fail(message: str, code: int) -> typer.Exit:
76
+ err_console.print(f"[red]{escape(message)}[/red]")
77
+ return typer.Exit(code)
78
+
79
+
80
+ def _is_interactive() -> bool:
81
+ return sys.stdin.isatty() and sys.stdout.isatty()
82
+
83
+
84
+ # --------------------------------------------------------------------------
85
+ # dynamic completion (north star: nothing to memorize — not even your own names)
86
+ # --------------------------------------------------------------------------
87
+
88
+
89
+ def _complete_script(incomplete: str) -> list[str]:
90
+ try:
91
+ entries = store.list_entries()
92
+ except Exception: # completion must never crash the shell
93
+ return []
94
+ out = {e.meta.name for e in entries} | {e.slug for e in entries}
95
+ return sorted(c for c in out if c.startswith(incomplete))
96
+
97
+
98
+ def _complete_preset(ctx: typer.Context, incomplete: str) -> list[str]:
99
+ name = ctx.params.get("name")
100
+ if not name:
101
+ return []
102
+ try:
103
+ entry = store.resolve(name)
104
+ presets = argstate.load_state(entry.slug)["presets"]
105
+ except Exception: # completion must never crash the shell
106
+ return []
107
+ return sorted(p for p in presets if p.startswith(incomplete))
108
+
109
+
110
+ _SCRIPT_ARG = typer.Argument(
111
+ ..., help=gettext("Script name or slug"), autocompletion=_complete_script
112
+ )
113
+
114
+
115
+ @app.callback(invoke_without_command=True)
116
+ def main(
117
+ ctx: typer.Context,
118
+ version: bool = typer.Option(False, "--version", "-V", help=gettext("Show version")),
119
+ ) -> None:
120
+ if version:
121
+ console.print(f"skit {__version__}")
122
+ raise typer.Exit()
123
+ if ctx.invoked_subcommand is None:
124
+ _maybe_first_run_setup()
125
+ from .tui import run_menu
126
+
127
+ raise typer.Exit(run_menu())
128
+
129
+
130
+ # --------------------------------------------------------------------------
131
+ # add
132
+ # --------------------------------------------------------------------------
133
+
134
+
135
+ def _resolve_python_metadata(
136
+ text: str, deps_opt: list[str] | None, python_opt: str | None, no_input: bool
137
+ ) -> tuple[list[str], str]:
138
+ """Decide the (dependencies, requires_python) to fill in.
139
+
140
+ - Script already has a PEP 723 block: don't ask, don't fill (the block is the source of truth).
141
+ - Explicit --dep / --python: use them directly, no prompting.
142
+ - Interactive: only ask when the AST reveals likely third-party imports; ask nothing when
143
+ there are no dependencies at all.
144
+ """
145
+ if pep723.has_block(text):
146
+ meta = pep723.parse_block(text) or {}
147
+ deps = meta.get("dependencies")
148
+ if deps:
149
+ console.print(
150
+ gettext("The script declares its own dependencies (PEP 723): %(deps)s")
151
+ % {"deps": ", ".join(escape(d) for d in deps)}
152
+ )
153
+ return [], ""
154
+ if deps_opt is not None or python_opt is not None:
155
+ return list(deps_opt or []), python_opt or ""
156
+ suggested = pep723.suggest_dependencies(text)
157
+ if not suggested:
158
+ return [], "" # No dependencies: nothing to ask
159
+ if no_input or not sys.stdin.isatty():
160
+ return suggested, "" # Non-interactive: accept the suggestions as-is
161
+ answer = Prompt.ask(
162
+ gettext("Dependencies to install (Enter to accept, edit the list, or '-' for none)"),
163
+ default=", ".join(suggested),
164
+ console=console,
165
+ )
166
+ if answer.strip().lower() in ("-", "none"):
167
+ deps_list: list[str] = []
168
+ else:
169
+ deps_list = pep723.split_requirements(answer)
170
+ py = Prompt.ask(
171
+ gettext("Python version (leave empty for automatic)"), default="", console=console
172
+ )
173
+ return deps_list, py.strip()
174
+
175
+
176
+ def _prompt_identity(
177
+ p: Path, text: str, name: str | None, description: str | None, no_input: bool
178
+ ) -> tuple[str | None, str | None]:
179
+ """Interactive name + description prompts for `add`. `None` means "let the store derive it"."""
180
+ if no_input or not sys.stdin.isatty():
181
+ return name, description
182
+ if name is None:
183
+ name = Prompt.ask(gettext("Name in skit"), default=p.stem, console=console).strip() or None
184
+ if description is None:
185
+ description = Prompt.ask(
186
+ gettext("Description (optional)"),
187
+ default=store.suggest_description(text),
188
+ console=console,
189
+ ).strip()
190
+ return name, description
191
+
192
+
193
+ def _require_py_file(resolved: Path) -> None:
194
+ if not resolved.is_file():
195
+ raise store.StoreError(gettext("File not found: %(path)s") % {"path": str(resolved)})
196
+
197
+
198
+ def _parse_selection(answer: str, count: int) -> list[int]:
199
+ """Parse an onboarding selection: 'all' / 'none' (or empty) / '1,3,5'."""
200
+ answer = answer.strip().lower()
201
+ if answer == "all":
202
+ return list(range(count))
203
+ picked: list[int] = []
204
+ for raw_part in answer.split(","):
205
+ part = raw_part.strip()
206
+ # isdecimal() is the predicate whose truth guarantees int() succeeds (isdigit()
207
+ # also accepts superscripts/circled digits that int() rejects).
208
+ if part.isdecimal() and 1 <= int(part) <= count and (int(part) - 1) not in picked:
209
+ picked.append(int(part) - 1)
210
+ return picked
211
+
212
+
213
+ def _default_selection(candidates: list[analyzer.Candidate]) -> str:
214
+ """Signal-driven default (UX spec §0): clean candidates in, demoted candidates out."""
215
+ clean = [i for i, c in enumerate(candidates, start=1) if not c.demoted]
216
+ if len(clean) == len(candidates):
217
+ return "all"
218
+ if not clean:
219
+ return "none"
220
+ return ",".join(str(i) for i in clean)
221
+
222
+
223
+ def _print_candidate(i: int, c: analyzer.Candidate) -> None:
224
+ mark = gettext(" (secret)") if c.secret else ""
225
+ if c.kind == "const":
226
+ console.print(
227
+ " "
228
+ + gettext("%(num)s. %(name)s (%(type)s) = %(value)s%(secret)s")
229
+ % {
230
+ "num": i,
231
+ "name": escape(c.name),
232
+ "type": c.type,
233
+ "value": escape(repr(c.default)),
234
+ "secret": mark,
235
+ }
236
+ )
237
+ else:
238
+ console.print(
239
+ " "
240
+ + gettext("%(num)s. input() #%(ordinal)s: %(prompt)s%(secret)s")
241
+ % {"num": i, "ordinal": c.order + 1, "prompt": escape(repr(c.prompt)), "secret": mark}
242
+ )
243
+ if c.demoted:
244
+ console.print(
245
+ f" [yellow]{gettext('⚠ looks like a loop accumulator — probably not a parameter')}[/yellow]"
246
+ )
247
+
248
+
249
+ def _print_add_hints(result: analyzer.Analysis, script_name: str) -> None:
250
+ """The honest, rule-backed hints (UX spec §0): argv passthrough, extractable filenames."""
251
+ if result.uses_argv:
252
+ console.print(
253
+ "[dim]"
254
+ + gettext(
255
+ "This script reads command-line arguments; the run form has an extra-arguments field for them."
256
+ )
257
+ + _DIM_CLOSE
258
+ )
259
+ if result.filename_literals:
260
+ names = ", ".join(escape(repr(s)) for s in result.filename_literals)
261
+ console.print(
262
+ "[dim]"
263
+ + gettext(
264
+ "💡 %(names)s are written directly inside the code, so skit can't turn them into form fields. To manage one, first give it a name at the top of the script, e.g. OUTPUT = '…' (skit edit %(script)s)."
265
+ )
266
+ % {"names": names, "script": escape(script_name)}
267
+ + _DIM_CLOSE
268
+ )
269
+
270
+
271
+ def _onboard_params(text: str, script_name: str, no_input: bool) -> list[metawriter.ParamSpec]:
272
+ """Parameter onboarding at add time (A4: which constant counts as a parameter is a UX call).
273
+
274
+ - argparse detected: nothing to manage — the run form is read statically from the
275
+ script's own argument declarations (the unified form model).
276
+ - Non-interactive: don't guess, don't select, return empty (honesty beats clever).
277
+ """
278
+ result = analyzer.analyze(text)
279
+ if result.uses_cli_framework:
280
+ from . import argspec
281
+
282
+ spec = argspec.read_cli(text)
283
+ if spec is not None and spec.ok and spec.fields:
284
+ console.print(
285
+ gettext(
286
+ "✓ skit read this script's own arguments (%(count)s fields). Running it opens a form — nothing to memorize."
287
+ )
288
+ % {"count": len(spec.fields)}
289
+ )
290
+ else:
291
+ console.print(
292
+ "[dim]"
293
+ + gettext(
294
+ "This script parses its own arguments (%(names)s); skit couldn't model them statically, so the run form offers a passthrough-arguments field."
295
+ )
296
+ % {"names": ", ".join(result.frameworks)}
297
+ + _DIM_CLOSE
298
+ )
299
+ return []
300
+ _print_add_hints(result, script_name)
301
+ if not result.candidates or no_input or not sys.stdin.isatty():
302
+ return []
303
+ console.print(
304
+ ngettext(
305
+ "Found %(count)s parameter candidate (constants / input() calls):",
306
+ "Found %(count)s parameter candidates (constants / input() calls):",
307
+ len(result.candidates),
308
+ )
309
+ % {"count": len(result.candidates)}
310
+ )
311
+ for i, c in enumerate(result.candidates, start=1):
312
+ _print_candidate(i, c)
313
+ answer = Prompt.ask(
314
+ gettext("Which ones should skit manage? (e.g. 1,3 / all / none)"),
315
+ default=_default_selection(result.candidates),
316
+ console=console,
317
+ )
318
+ picked = _parse_selection(answer, len(result.candidates))
319
+ return [metawriter.ParamSpec.from_candidate(result.candidates[i]) for i in picked]
320
+
321
+
322
+ # A brand-new script starts from just a shebang; if the editor is closed with nothing more
323
+ # than this (or empty), we treat it as "cancelled" and add nothing.
324
+ _STARTER_SCRIPT = "#!/usr/bin/env python3\n"
325
+
326
+
327
+ def _onboard_python(
328
+ p: Path,
329
+ text: str,
330
+ *,
331
+ name: str | None,
332
+ description: str | None = None,
333
+ ref: bool = False,
334
+ deps_opt: list[str] | None = None,
335
+ python_opt: str | None = None,
336
+ no_input: bool = False,
337
+ ) -> tuple[store.Entry, list[str], list[str], list[str]]:
338
+ """Shared add/create pipeline: identity -> dependencies -> store add -> parameter
339
+ onboarding. Returns (entry, deps, managed_names, secret_names) for the summary."""
340
+ name, description = _prompt_identity(p, text, name, description, no_input)
341
+ final_deps, final_py = _resolve_python_metadata(text, deps_opt, python_opt, no_input)
342
+ entry = store.add_python(
343
+ p,
344
+ name=name,
345
+ mode="reference" if ref else "copy",
346
+ description=description,
347
+ dependencies=final_deps or None,
348
+ requires_python=final_py,
349
+ )
350
+ managed: list[str] = []
351
+ secrets: list[str] = []
352
+ if entry.meta.mode == "reference":
353
+ console.print(
354
+ f"[dim]{gettext('Reference mode never touches the original file, so parameter setup was skipped.')}[/dim]"
355
+ )
356
+ else:
357
+ params_specs = _onboard_params(text, entry.meta.name, no_input)
358
+ if params_specs:
359
+ copy_path = entry.dir / "script.py" # pragma: no mutate — fs-case
360
+ current = copy_path.read_text(encoding="utf-8") # pragma: no mutate — utf-8 equivalence
361
+ new_text = metawriter.write_params(current, params_specs)
362
+ copy_path.write_text(new_text, encoding="utf-8") # pragma: no mutate
363
+ managed = [s.name for s in params_specs]
364
+ secrets = [s.name for s in params_specs if s.secret]
365
+ return entry, final_deps, managed, secrets
366
+
367
+
368
+ def _create_python_in_editor(name: str | None) -> None:
369
+ """Write a starter script to a temp file, open the user's editor, then ingest whatever
370
+ they saved."""
371
+ import tempfile
372
+
373
+ if not _is_interactive():
374
+ err_console.print(
375
+ f"[red]{gettext('Writing a new script in an editor needs an interactive terminal.')}[/red]"
376
+ )
377
+ raise typer.Exit(EXIT_USAGE)
378
+ if not name:
379
+ name = Prompt.ask(gettext("Name in skit"), console=console).strip()
380
+ if not name:
381
+ err_console.print(f"[red]{gettext('A name is required.')}[/red]")
382
+ raise typer.Exit(EXIT_USAGE)
383
+ fd, tmp_name = tempfile.mkstemp(suffix=".py", prefix="skit-new-") # pragma: no mutate
384
+ os.close(fd)
385
+ tmp = Path(tmp_name)
386
+ tmp.write_text(_STARTER_SCRIPT, encoding="utf-8") # pragma: no mutate
387
+ try:
388
+ console.print(f"[dim]{gettext('Opening your editor…')}[/dim]")
389
+ editor.open_in_editor(tmp)
390
+ text = tmp.read_text(encoding="utf-8", errors="replace") # pragma: no mutate — utf-8 equiv
391
+ if text.strip() in ("", _STARTER_SCRIPT.strip()):
392
+ console.print(gettext("Nothing was written, so no script was added."))
393
+ return
394
+ entry, deps, managed, secrets = _onboard_python(tmp, text, name=name)
395
+ except (editor.EditorError, store.StoreError) as exc:
396
+ raise _fail(str(exc), 1) from exc
397
+ finally:
398
+ tmp.unlink(missing_ok=True) # pragma: no mutate — the temp file always exists here
399
+ _print_add_summary(entry, deps, managed, secrets)
400
+
401
+
402
+ def _add_from_stdin(name: str | None, description: str | None) -> None:
403
+ """`skit add -`: ingest a script from stdin (e.g. `pbpaste | skit add - -n clip`).
404
+ stdin is the script, so there is nobody to prompt: the non-interactive contract
405
+ applies, and a name is required up front."""
406
+ import tempfile
407
+
408
+ if not name:
409
+ err_console.print(
410
+ f"[red]{gettext('Reading the script from stdin needs an explicit --name.')}[/red]"
411
+ )
412
+ raise typer.Exit(EXIT_USAGE)
413
+ text = sys.stdin.read()
414
+ if not text.strip():
415
+ err_console.print(
416
+ f"[red]{gettext('Nothing arrived on stdin, so there is nothing to add.')}[/red]"
417
+ )
418
+ raise typer.Exit(1)
419
+ fd, tmp_name = tempfile.mkstemp(suffix=".py", prefix="skit-stdin-") # pragma: no mutate
420
+ os.close(fd)
421
+ tmp = Path(tmp_name)
422
+ tmp.write_text(text, encoding="utf-8") # pragma: no mutate
423
+ try:
424
+ entry, deps, managed, secrets = _onboard_python(
425
+ tmp, text, name=name, description=description, no_input=True
426
+ )
427
+ except store.StoreError as exc:
428
+ raise _fail(str(exc), 1) from exc
429
+ finally:
430
+ tmp.unlink(missing_ok=True) # pragma: no mutate
431
+ _print_add_summary(entry, deps, managed, secrets)
432
+
433
+
434
+ def _infer_add_kind(resolved: Path, exe_flag: bool) -> str:
435
+ """Type inference (v2) — delegated to store.infer_kind so the CLI and the TUI add
436
+ panel share one rule and can't drift apart."""
437
+ return store.infer_kind(resolved, force_exe=exe_flag)
438
+
439
+
440
+ @app.command(
441
+ help=gettext("Add a script, executable, or command to skit."),
442
+ epilog=gettext(
443
+ "Examples: skit add tools/resize.py · skit add --cmd 'ffmpeg -i {input}' -n convert · pbpaste | skit add - -n clip"
444
+ ),
445
+ )
446
+ def add(
447
+ path: str = typer.Argument(
448
+ None, help=gettext("Path to a script or executable, or '-' to read a script from stdin")
449
+ ),
450
+ name: str = typer.Option(
451
+ None, "--name", "-n", help=gettext("Name / alias (defaults to the file name)")
452
+ ),
453
+ description: str = typer.Option(
454
+ None,
455
+ "--description",
456
+ "-d",
457
+ help=gettext("Description (defaults to the first line of the docstring)"),
458
+ ),
459
+ edit_new: bool = typer.Option(
460
+ False, "--edit", "-e", help=gettext("Write a brand-new script in your editor, then add it")
461
+ ),
462
+ ref: bool = typer.Option(
463
+ False,
464
+ "--ref",
465
+ help=gettext("Reference mode: link to the original file instead of copying it"),
466
+ ),
467
+ exe: bool = typer.Option(
468
+ False,
469
+ "--exe",
470
+ help=gettext("Force the executable kind (normally inferred from the file itself)"),
471
+ ),
472
+ cmd: str = typer.Option(
473
+ None, "--cmd", help=gettext("Register a command template, e.g. --cmd 'ffmpeg -i {input}'")
474
+ ),
475
+ dep: list[str] = typer.Option(
476
+ None,
477
+ "--dep",
478
+ help=gettext("A dependency (repeat for more; skips the interactive question)"),
479
+ ),
480
+ python: str = typer.Option(
481
+ None, "--python", help=gettext('Python version constraint, e.g. ">=3.11"')
482
+ ),
483
+ no_input: bool = typer.Option(
484
+ False, "--no-input", help=gettext("Never prompt; accept the detected suggestions")
485
+ ),
486
+ ) -> None:
487
+ """Add a script / executable / command to skit."""
488
+ if edit_new:
489
+ if path:
490
+ err_console.print(
491
+ f"[red]{gettext('Use --edit to write a new script, or pass a path to add an existing one — not both.')}[/red]"
492
+ )
493
+ raise typer.Exit(EXIT_USAGE)
494
+ _create_python_in_editor(name)
495
+ return
496
+ if path == "-":
497
+ _add_from_stdin(name, description)
498
+ return
499
+ summary_deps: list[str] = []
500
+ summary_managed: list[str] = []
501
+ summary_secrets: list[str] = []
502
+ try:
503
+ if cmd is not None:
504
+ if not name:
505
+ err_console.print(f"[red]{gettext('A --cmd entry needs a --name')}[/red]")
506
+ raise typer.Exit(EXIT_USAGE)
507
+ entry = store.add_command(cmd, name=name, description=description or "")
508
+ if entry.meta.params:
509
+ console.print(
510
+ gettext(
511
+ "Detected parameters: %(names)s (the run form asks for them; your last values are remembered)"
512
+ )
513
+ % {"names": ", ".join(escape(p) for p in entry.meta.params)}
514
+ )
515
+ else:
516
+ if not path:
517
+ err_console.print(
518
+ f"[red]{gettext('Provide a script path, or use --cmd to register a command template')}[/red]"
519
+ )
520
+ raise typer.Exit(EXIT_USAGE)
521
+ resolved = Path(path).expanduser().resolve()
522
+ kind = _infer_add_kind(resolved, exe)
523
+ if kind == "exe":
524
+ entry = store.add_exe(Path(path), name=name, description=description or "")
525
+ elif kind == "unknown":
526
+ err_console.print(
527
+ f"[red]{gettext("%(file)s isn't a .py file or an executable — pass --exe for a program, or --cmd for a command template.") % {'file': escape(resolved.name)}}[/red]"
528
+ )
529
+ raise typer.Exit(EXIT_USAGE)
530
+ else:
531
+ _require_py_file(resolved)
532
+ try:
533
+ text = resolved.read_text(encoding="utf-8", errors="replace")
534
+ except OSError as exc:
535
+ raise store.StoreError(
536
+ gettext("Can't read %(path)s: %(error)s")
537
+ % {"path": str(resolved), "error": exc.strerror or str(exc)}
538
+ ) from exc
539
+ entry, summary_deps, summary_managed, summary_secrets = _onboard_python(
540
+ Path(path),
541
+ text,
542
+ name=name,
543
+ description=description,
544
+ ref=ref,
545
+ deps_opt=dep,
546
+ python_opt=python,
547
+ no_input=no_input,
548
+ )
549
+ except store.StoreError as exc:
550
+ raise _fail(str(exc), 1) from exc
551
+ _print_add_summary(entry, summary_deps, summary_managed, summary_secrets)
552
+
553
+
554
+ def _print_add_summary(
555
+ entry: store.Entry, deps: list[str], managed: list[str], secrets: list[str]
556
+ ) -> None:
557
+ """One consolidated block after a successful add."""
558
+ mode_note = (
559
+ gettext("(%(mode)s mode)") % {"mode": entry.meta.mode}
560
+ if entry.meta.kind == "python"
561
+ else ""
562
+ )
563
+ console.print(
564
+ f"[green]{gettext('Added: %(name)s') % {'name': escape(entry.meta.name)}}[/green] {mode_note}"
565
+ )
566
+ if entry.meta.description:
567
+ console.print(
568
+ f" {gettext('Description: %(desc)s') % {'desc': escape(entry.meta.description)}}"
569
+ )
570
+ if deps:
571
+ console.print(
572
+ f" {gettext('Dependencies: %(deps)s') % {'deps': ', '.join(escape(d) for d in deps)}}"
573
+ )
574
+ if managed:
575
+ console.print(
576
+ f" {gettext('Managed parameters: %(names)s') % {'names': ', '.join(escape(n) for n in managed)}}"
577
+ )
578
+ console.print(f" {gettext('Run it: skit run %(name)s') % {'name': escape(entry.meta.name)}}")
579
+ if secrets:
580
+ console.print(
581
+ f"[dim]{gettext('Secret parameter values are never saved to disk: %(names)s') % {'names': ', '.join(escape(n) for n in secrets)}}[/dim]"
582
+ )
583
+
584
+
585
+ # --------------------------------------------------------------------------
586
+ # list / remove / edit
587
+ # --------------------------------------------------------------------------
588
+
589
+
590
+ @app.command("list", help=gettext("List every registered script."))
591
+ def list_cmd(
592
+ as_json: bool = typer.Option(False, "--json", help=gettext("Output as JSON")),
593
+ ) -> None:
594
+ """List every registered script."""
595
+ entries = store.list_entries()
596
+ if as_json:
597
+ rows = []
598
+ for e in entries:
599
+ last = argstate.load_state(e.slug)["last_run"]
600
+ rows.append(
601
+ {
602
+ "name": e.meta.name,
603
+ "slug": e.slug,
604
+ "kind": e.meta.kind,
605
+ "mode": e.meta.mode,
606
+ "description": e.meta.description,
607
+ "missing": launcher.target_missing(e),
608
+ "last_run_at": last.get("at"),
609
+ "last_exit": last.get("exit"),
610
+ }
611
+ )
612
+ console.print_json(json.dumps(rows, ensure_ascii=False))
613
+ return
614
+ if not entries:
615
+ console.print(gettext("No scripts yet. Add one with: skit add <path>"))
616
+ return
617
+ from rich.table import Table
618
+
619
+ table = Table(show_header=True, header_style="bold")
620
+ table.add_column(gettext("Name"))
621
+ table.add_column(gettext("Kind"))
622
+ table.add_column(gettext("Description"))
623
+ for e in entries:
624
+ table.add_row(escape(e.meta.name), e.meta.kind, _list_description(e))
625
+ console.print(table)
626
+
627
+
628
+ def _list_description(e: store.Entry) -> str:
629
+ desc = escape(e.meta.description) if e.meta.description else "—"
630
+ marker = launcher.missing_marker(e)
631
+ if marker is None:
632
+ return desc
633
+ marker = f"[dim]{escape(marker)}[/dim]"
634
+ return marker if desc == "—" else f"{desc} {marker}"
635
+
636
+
637
+ @app.command(help=gettext("Remove a registered script (the original file is left untouched)."))
638
+ def remove(
639
+ name: str = _SCRIPT_ARG,
640
+ yes: bool = typer.Option(False, "--yes", "-y", help=gettext("Skip confirmation")),
641
+ ) -> None:
642
+ """Remove a script (copy mode deletes the copy in the store; the original is untouched)."""
643
+ try:
644
+ entry = store.resolve(name)
645
+ except store.NotFoundError as exc:
646
+ raise _fail(str(exc), 1) from exc
647
+ if not yes:
648
+ if entry.meta.kind == "command":
649
+ question = gettext('Remove "%(name)s"?') % {"name": entry.meta.name}
650
+ else:
651
+ question = gettext('Remove "%(name)s"? Your original file will not be deleted.') % {
652
+ "name": entry.meta.name
653
+ }
654
+ typer.confirm(question, abort=True)
655
+ removed = store.remove(name)
656
+ console.print(f"[green]{gettext('Removed: %(name)s') % {'name': escape(removed)}}[/green]")
657
+
658
+
659
+ def _offer_create_in_editor(name: str) -> None:
660
+ """`skit edit <unknown>`: offer to create a brand-new script under that name."""
661
+ if not _is_interactive():
662
+ err_console.print(
663
+ f"[red]{gettext('No script named %(name)s.') % {'name': escape(name)}}[/red]"
664
+ )
665
+ raise typer.Exit(1)
666
+ if not Confirm.ask(
667
+ gettext('No script named "%(name)s". Create it now?') % {"name": escape(name)},
668
+ default=True,
669
+ console=console,
670
+ ):
671
+ raise typer.Exit(0) # pragma: no mutate — Exit(0)/Exit(None) both mean a clean exit
672
+ _create_python_in_editor(name)
673
+
674
+
675
+ @app.command(
676
+ help=gettext("Open a script's source in your editor (offers to create it if the name is new)."),
677
+ epilog=gettext("Example: skit edit resize"),
678
+ )
679
+ def edit(name: str = _SCRIPT_ARG) -> None:
680
+ """Open a registered script's source in your editor."""
681
+ try:
682
+ entry = store.resolve(name)
683
+ except store.NotFoundError:
684
+ _offer_create_in_editor(name)
685
+ return
686
+ if entry.meta.kind != "python":
687
+ raise _fail(
688
+ gettext("%(name)s isn't a Python script, so it has no source to edit.")
689
+ % {"name": entry.meta.name},
690
+ 1,
691
+ )
692
+ if entry.meta.mode == "reference":
693
+ source = Path(entry.meta.source)
694
+ if not source.exists():
695
+ raise _fail(
696
+ gettext("%(name)s: the referenced source file is gone: %(path)s")
697
+ % {"name": entry.meta.name, "path": str(source)},
698
+ 1,
699
+ )
700
+ console.print(
701
+ f"[dim]{gettext('Editing the original file (reference mode): %(path)s') % {'path': escape(str(source))}}[/dim]"
702
+ )
703
+ target = source
704
+ else:
705
+ target = entry.dir / "script.py"
706
+ if not target.exists():
707
+ raise _fail(
708
+ gettext("%(name)s has no stored copy to edit.") % {"name": entry.meta.name}, 1
709
+ )
710
+ try:
711
+ editor.open_in_editor(target)
712
+ except editor.EditorError as exc:
713
+ raise _fail(str(exc), 1) from exc
714
+ console.print(
715
+ f"[green]{gettext('Saved %(name)s.') % {'name': escape(entry.meta.name)}}[/green]"
716
+ )
717
+ console.print(
718
+ f"[dim]{gettext('skit reconciles parameter drift at run time; review managed parameters with: skit params %(name)s') % {'name': escape(entry.meta.name)}}[/dim]"
719
+ )
720
+
721
+
722
+ # --------------------------------------------------------------------------
723
+ # run
724
+ # --------------------------------------------------------------------------
725
+
726
+
727
+ def _validate_preset(entry: store.Entry, preset: str | None) -> None:
728
+ if not preset:
729
+ return
730
+ presets = argstate.load_state(entry.slug)["presets"]
731
+ if preset not in presets:
732
+ err_console.print(
733
+ "[red]"
734
+ + gettext('Unknown preset "%(preset)s". Available: %(presets)s')
735
+ % {
736
+ "preset": escape(preset),
737
+ "presets": ", ".join(escape(p) for p in sorted(presets)) or "—",
738
+ }
739
+ + _RED_CLOSE
740
+ )
741
+ raise typer.Exit(EXIT_USAGE)
742
+
743
+
744
+ def _print_drift(plan: flows.FormPlan) -> None:
745
+ for line in plan.drift_lines:
746
+ err_console.print(f"[yellow]{escape(line)}[/yellow]")
747
+
748
+
749
+ def _collect_values(
750
+ entry: store.Entry, plan: flows.FormPlan, prefill: dict[str, str], *, plain: bool
751
+ ) -> dict[str, str]:
752
+ """Interactive collection through the configured renderer. The inline mini-form is
753
+ the default; "plain" (--plain / form=plain / TERM=dumb) is the line-prompt fallback."""
754
+ style = "plain" if plain or os.environ.get("TERM") == "dumb" else config.load_form()
755
+ if style == "tui":
756
+ import importlib
757
+
758
+ try: # the inline renderer ships with the TUI layer; degrade to plain without it
759
+ inlineform = importlib.import_module("skit.inlineform")
760
+ except ImportError: # pragma: no cover — transitional
761
+ pass
762
+ else:
763
+ values = inlineform.collect(entry, plan, prefill)
764
+ if values is None:
765
+ raise typer.Exit(EXIT_CANCELLED) # cancelling is not a skit failure
766
+ return values
767
+ console.print(
768
+ gettext("Parameters for %(name)s (press Enter to keep the value shown):")
769
+ % {"name": escape(entry.meta.name)}
770
+ )
771
+ return promptform.collect(plan, prefill, console=console)
772
+
773
+
774
+ # How a flows.RunOutcome failure maps to skit's exit-code contract (docker convention).
775
+ _FAILURE_EXIT = {
776
+ flows.FAIL_BAD_VALUE: EXIT_SKIT,
777
+ flows.FAIL_DRIFT: EXIT_SKIT,
778
+ flows.FAIL_LAUNCH: EXIT_SKIT,
779
+ flows.FAIL_NOT_EXECUTABLE: EXIT_NOT_EXECUTABLE,
780
+ flows.FAIL_MISSING: EXIT_NOT_FOUND,
781
+ }
782
+
783
+
784
+ @app.command(
785
+ help=gettext("Run a registered script or command in the terminal."),
786
+ epilog=gettext(
787
+ "Examples: skit run stitch · skit run stitch -p web -- extra.png · skit run stitch --dry-run"
788
+ ),
789
+ )
790
+ def run(
791
+ name: str = _SCRIPT_ARG,
792
+ args: list[str] = typer.Argument(
793
+ None, help=gettext("Arguments passed through to the script (after --)")
794
+ ),
795
+ no_input: bool = typer.Option(
796
+ False, "--no-input", help=gettext("Never prompt; reuse last values and defaults")
797
+ ),
798
+ preset: str = typer.Option(
799
+ None,
800
+ "--preset",
801
+ "-p",
802
+ help=gettext("Named preset of parameter values to prefill the form with"),
803
+ autocompletion=_complete_preset,
804
+ ),
805
+ save_preset: str = typer.Option(
806
+ None, "--save-preset", help=gettext("Save this run's values as a named preset")
807
+ ),
808
+ plain: bool = typer.Option(
809
+ False, "--plain", help=gettext("Line-by-line prompts instead of the inline form")
810
+ ),
811
+ raw: bool = typer.Option(
812
+ False,
813
+ "--raw",
814
+ help=gettext(
815
+ "Skip the parameter form and injection and run the script as-is (escape hatch)"
816
+ ),
817
+ ),
818
+ dry_run: bool = typer.Option(
819
+ False,
820
+ "--dry-run",
821
+ help=gettext(
822
+ "Print the exact command that would run (tokens and globs expanded), then exit"
823
+ ),
824
+ ),
825
+ ) -> None:
826
+ """Run a script (straight through the terminal). skit's own failures exit 125/126/127;
827
+ the script's exit code passes through untouched."""
828
+ try:
829
+ entry = store.resolve(name)
830
+ except store.NotFoundError as exc:
831
+ raise _fail(str(exc), EXIT_NOT_FOUND) from exc
832
+ _validate_preset(entry, preset)
833
+ if raw and entry.meta.kind == "python":
834
+ console.print(
835
+ f"[dim]{gettext('Raw mode: skipping the parameter form and injection.')}[/dim]"
836
+ )
837
+ plan = flows.FormPlan(source="none")
838
+ else:
839
+ plan = flows.plan_for_entry(entry)
840
+ _print_drift(plan)
841
+ if plan.degraded_reason:
842
+ console.print(
843
+ f"[dim]{gettext("skit could not model this script's own arguments; pass them after -- instead.")}[/dim]"
844
+ )
845
+ prefilled = flows.prefill(plan, entry.slug, preset)
846
+ extra = list(args or [])
847
+ # Both ends must be a terminal: `skit run x | tee log` has a tty stdin but would
848
+ # pump the inline form's ANSI straight into the pipe.
849
+ interactive = not no_input and _is_interactive()
850
+ if interactive and plan.fields:
851
+ values = _collect_values(entry, plan, prefilled, plain=plain)
852
+ else:
853
+ values = prefilled
854
+ errors = flows.validate(plan, values)
855
+ # Passthrough args are the legitimate manual escape (skit run x -- <args>):
856
+ # when the user supplies them, the script's own parser is in charge and an
857
+ # unfilled required *field* is not a hole to refuse over.
858
+ if errors and not extra:
859
+ for msg in errors.values():
860
+ err_console.print(f"[red]{escape(msg)}[/red]")
861
+ raise typer.Exit(EXIT_SKIT)
862
+ if not extra and entry.meta.kind in ("python", "exe"):
863
+ last_extra = argstate.load_state(entry.slug)["extra_args"]
864
+ if last_extra:
865
+ extra = last_extra
866
+ console.print(
867
+ f"[dim]{gettext('Reusing your last arguments: %(args)s') % {'args': ' '.join(escape(a) for a in extra)}}[/dim]"
868
+ )
869
+ try:
870
+ asm = flows.assemble(plan, values, extra, cwd=Path.cwd(), expand_extra=False)
871
+ except flows.FormError as exc:
872
+ raise _fail(str(exc), EXIT_SKIT) from exc
873
+ if save_preset:
874
+ argstate.save_preset(
875
+ entry.slug,
876
+ save_preset,
877
+ {k: v for k, v in values.items() if v},
878
+ secret_names=plan.secret_names,
879
+ )
880
+ console.print(
881
+ f"[green]{gettext('Preset "%(preset)s" saved for %(name)s.') % {'preset': escape(save_preset), 'name': escape(entry.meta.name)}}[/green]"
882
+ )
883
+ if dry_run:
884
+ # No temp copy is written for a dry run, so the command line shows the original
885
+ # script path — the shape, not a doomed-to-be-deleted temp file.
886
+ for line in flows.transparency_lines(entry, asm, None):
887
+ console.print(f"[dim]{escape(line)}[/dim]")
888
+ raise typer.Exit(0)
889
+ outcome = flows.execute(
890
+ entry, plan, asm, emit=lambda line: console.print(f"[dim]{escape(line)}[/dim]")
891
+ )
892
+ code = outcome.code
893
+ if code is None:
894
+ raise _fail(outcome.message, _FAILURE_EXIT[outcome.failure])
895
+ flows.save_after_run(entry.slug, plan, values, extra, code, at=models.now_iso())
896
+ if code != 0:
897
+ err_console.print(
898
+ f"[yellow]{gettext('Script exited with code %(code)s') % {'code': code}}[/yellow]"
899
+ )
900
+ raise typer.Exit(code)
901
+
902
+
903
+ # --------------------------------------------------------------------------
904
+ # preset
905
+ # --------------------------------------------------------------------------
906
+
907
+ preset_app = typer.Typer(
908
+ help=gettext("Manage named parameter presets for a script."), no_args_is_help=True
909
+ )
910
+ app.add_typer(preset_app, name="preset")
911
+
912
+
913
+ @preset_app.command("save", help=gettext("Save a set of parameter values as a named preset."))
914
+ def preset_save(
915
+ name: str = _SCRIPT_ARG,
916
+ preset_name: str = typer.Argument(..., help=gettext("Preset name")),
917
+ from_last: bool = typer.Option(
918
+ False,
919
+ "--from-last",
920
+ help=gettext("Save the last run's values without asking (automation-friendly)"),
921
+ ),
922
+ ) -> None:
923
+ """Save a named preset: interactively, or straight from the last run with --from-last.
924
+ Secret values are never persisted (C3)."""
925
+ try:
926
+ entry = store.resolve(name)
927
+ except store.NotFoundError as exc:
928
+ raise _fail(str(exc), 1) from exc
929
+ plan = flows.plan_for_entry(entry)
930
+ if not plan.fields:
931
+ raise _fail(
932
+ gettext("%(name)s has no form fields, so there's nothing to save.")
933
+ % {"name": entry.meta.name},
934
+ 1,
935
+ )
936
+ if from_last:
937
+ last = argstate.load_state(entry.slug)["values"]
938
+ keys = {f.key for f in plan.fields}
939
+ values = {k: v for k, v in last.items() if k in keys}
940
+ if not values:
941
+ raise _fail(
942
+ gettext("%(name)s has no remembered values yet — run it once first.")
943
+ % {"name": entry.meta.name},
944
+ 1,
945
+ )
946
+ elif sys.stdin.isatty():
947
+ values = promptform.collect(plan, flows.prefill(plan, entry.slug), console=console)
948
+ else:
949
+ # Non-interactive contract: don't prompt — save what the prefill already knows.
950
+ values = flows.prefill(plan, entry.slug)
951
+ secret_overlap = plan.secret_names & values.keys()
952
+ if secret_overlap:
953
+ console.print(
954
+ "[dim]"
955
+ + gettext("Secret values are never stored in presets; skipped: %(names)s")
956
+ % {"names": ", ".join(escape(n) for n in sorted(secret_overlap))}
957
+ + _DIM_CLOSE
958
+ )
959
+ argstate.save_preset(entry.slug, preset_name, values, secret_names=plan.secret_names)
960
+ console.print(
961
+ f"[green]{gettext('Preset "%(preset)s" saved for %(name)s.') % {'preset': escape(preset_name), 'name': escape(entry.meta.name)}}[/green]"
962
+ )
963
+
964
+
965
+ @preset_app.command("list", help=gettext("List a script's saved presets."))
966
+ def preset_list(
967
+ name: str = _SCRIPT_ARG,
968
+ as_json: bool = typer.Option(False, "--json", help=gettext("Output as JSON")),
969
+ ) -> None:
970
+ """List a script's named presets."""
971
+ try:
972
+ entry = store.resolve(name)
973
+ except store.NotFoundError as exc:
974
+ raise _fail(str(exc), 1) from exc
975
+ presets = argstate.load_state(entry.slug)["presets"]
976
+ if as_json:
977
+ console.print_json(json.dumps(presets, ensure_ascii=False))
978
+ return
979
+ if not presets:
980
+ console.print(
981
+ gettext(
982
+ "No presets for %(name)s yet. Create one with: skit run %(name)s --save-preset <preset>"
983
+ )
984
+ % {"name": escape(entry.meta.name)}
985
+ )
986
+ return
987
+ for pname, vals in sorted(presets.items()):
988
+ pairs = ", ".join(f"{escape(k)}={escape(v)}" for k, v in vals.items())
989
+ console.print(f" [bold]{escape(pname)}[/bold]: {pairs}")
990
+
991
+
992
+ @preset_app.command("delete", help=gettext("Delete a named preset from a script."))
993
+ def preset_delete(
994
+ name: str = _SCRIPT_ARG,
995
+ preset_name: str = typer.Argument(..., help=gettext("Preset name")),
996
+ ) -> None:
997
+ """Delete a named preset."""
998
+ try:
999
+ entry = store.resolve(name)
1000
+ except store.NotFoundError as exc:
1001
+ raise _fail(str(exc), 1) from exc
1002
+ if argstate.delete_preset(entry.slug, preset_name):
1003
+ console.print(
1004
+ gettext('Preset "%(preset)s" deleted from %(name)s.')
1005
+ % {"preset": escape(preset_name), "name": escape(entry.meta.name)}
1006
+ )
1007
+ else:
1008
+ err_console.print(
1009
+ "[red]"
1010
+ + gettext('Unknown preset "%(preset)s". Available: %(presets)s')
1011
+ % {
1012
+ "preset": escape(preset_name),
1013
+ "presets": ", ".join(
1014
+ escape(p) for p in sorted(argstate.load_state(entry.slug)["presets"])
1015
+ )
1016
+ or "—",
1017
+ }
1018
+ + _RED_CLOSE
1019
+ )
1020
+ raise typer.Exit(1)
1021
+
1022
+
1023
+ # --------------------------------------------------------------------------
1024
+ # params
1025
+ # --------------------------------------------------------------------------
1026
+
1027
+
1028
+ def _secret_cell(s: metawriter.ParamSpec) -> str:
1029
+ """The Secret column: "—", "yes", or "yes ← $ENVVAR" when an env source is set."""
1030
+ if not s.secret:
1031
+ return "—"
1032
+ if s.env_source:
1033
+ return gettext("yes") + f" ← ${escape(s.env_source)}"
1034
+ return gettext("yes")
1035
+
1036
+
1037
+ def _show_params(entry: store.Entry, as_json: bool) -> None:
1038
+ """Read view: managed parameters + last values + detected-but-unmanaged candidates."""
1039
+ last = argstate.load_state(entry.slug)["values"]
1040
+ specs: list[metawriter.ParamSpec] = []
1041
+ text = ""
1042
+ if entry.meta.kind == "python" and entry.script_path.exists():
1043
+ text = entry.script_path.read_text(encoding="utf-8", errors="replace") # pragma: no mutate
1044
+ specs = metawriter.read_params(text)
1045
+ unmanaged: list[str] = []
1046
+ if entry.meta.kind == "python" and entry.meta.mode == "copy" and text:
1047
+ report = reconcile.reconcile(text, specs)
1048
+ unmanaged = [c.name for c in report.new]
1049
+ if as_json:
1050
+ payload = {
1051
+ "params": [s.to_dict() for s in specs],
1052
+ "unmanaged": unmanaged,
1053
+ "placeholders": entry.meta.params or [],
1054
+ }
1055
+ console.print_json(json.dumps(payload, ensure_ascii=False))
1056
+ return
1057
+ if entry.meta.kind == "command":
1058
+ placeholders = entry.meta.params or []
1059
+ if not placeholders:
1060
+ console.print(
1061
+ escape(gettext("%(name)s has no managed parameters.") % {"name": entry.meta.name})
1062
+ )
1063
+ return
1064
+ console.print(gettext("Command template placeholders (the run form asks for them):"))
1065
+ for p in placeholders:
1066
+ shown = last.get(p, "—")
1067
+ console.print(f" {escape(p)} = {escape(shown)}")
1068
+ return
1069
+ if not specs:
1070
+ console.print(
1071
+ escape(
1072
+ gettext(
1073
+ "%(name)s has no managed parameters. Use --manage to bring a detected candidate under management."
1074
+ )
1075
+ % {"name": entry.meta.name}
1076
+ )
1077
+ )
1078
+ else:
1079
+ from rich.table import Table
1080
+
1081
+ table = Table(show_header=True, header_style="bold") # pragma: no mutate — cosmetic
1082
+ table.add_column(gettext("Parameter"))
1083
+ table.add_column(gettext("Kind"))
1084
+ table.add_column(gettext("Type"))
1085
+ table.add_column(gettext("Default"))
1086
+ table.add_column(gettext("Secret"))
1087
+ table.add_column(gettext("Last value"))
1088
+ for s in specs:
1089
+ if s.secret:
1090
+ last_shown = gettext("•••") if s.name in last else "—"
1091
+ else:
1092
+ last_shown = last.get(s.name, "—")
1093
+ default_shown = (
1094
+ gettext("•••")
1095
+ if s.secret and s.default is not None
1096
+ else ("—" if s.default is None else str(s.default))
1097
+ )
1098
+ table.add_row(
1099
+ escape(s.name),
1100
+ escape(s.kind),
1101
+ escape(s.type),
1102
+ escape(default_shown),
1103
+ _secret_cell(s),
1104
+ escape(str(last_shown)),
1105
+ )
1106
+ console.print(table)
1107
+ if unmanaged:
1108
+ console.print(
1109
+ gettext("Detected but not yet managed: %(names)s (use --manage to manage them)")
1110
+ % {"names": ", ".join(escape(n) for n in unmanaged)}
1111
+ )
1112
+
1113
+
1114
+ def _parse_kv_opts(raw: list[str], flag: str) -> tuple[dict[str, str], list[str]]:
1115
+ """Parse NAME=value pairs; malformed entries are collected for a warning."""
1116
+ pairs: dict[str, str] = {}
1117
+ bad: list[str] = []
1118
+ for item in raw:
1119
+ if "=" in item:
1120
+ key, _, value = item.partition("=")
1121
+ if key.strip():
1122
+ pairs[key.strip()] = value
1123
+ continue
1124
+ bad.append(f"{flag}: {item}")
1125
+ return pairs, bad
1126
+
1127
+
1128
+ @app.command(
1129
+ help=gettext("Show or edit a script's managed parameters."),
1130
+ epilog=gettext(
1131
+ "Examples: skit params resize --manage WIDTH · skit params api --secret KEY --env-source KEY=OPENAI_API_KEY"
1132
+ ),
1133
+ )
1134
+ def params(
1135
+ name: str = _SCRIPT_ARG,
1136
+ resync: bool = typer.Option(
1137
+ False,
1138
+ "--resync",
1139
+ help=gettext("Prune definitions that no longer match the script and refresh changed types"),
1140
+ ),
1141
+ manage: list[str] = typer.Option(
1142
+ None,
1143
+ "--manage",
1144
+ help=gettext("Bring a currently detected candidate under management (repeatable)"),
1145
+ ),
1146
+ unmanage: list[str] = typer.Option(
1147
+ None, "--unmanage", help=gettext("Drop a managed parameter (repeatable)")
1148
+ ),
1149
+ secret: list[str] = typer.Option(
1150
+ None, "--secret", help=gettext("Mark a managed parameter as secret (repeatable)")
1151
+ ),
1152
+ no_secret: list[str] = typer.Option(
1153
+ None,
1154
+ "--no-secret",
1155
+ help=gettext("Remove the secret mark from a managed parameter (repeatable)"),
1156
+ ),
1157
+ prompt: list[str] = typer.Option(
1158
+ None, "--prompt", help=gettext("Set a parameter's form prompt, as NAME=text (repeatable)")
1159
+ ),
1160
+ env_source: list[str] = typer.Option(
1161
+ None,
1162
+ "--env-source",
1163
+ help=gettext(
1164
+ "Read a secret parameter from an environment variable at run time, as NAME=ENVVAR (empty ENVVAR clears it; repeatable)"
1165
+ ),
1166
+ ),
1167
+ as_json: bool = typer.Option(False, "--json", help=gettext("Output the read view as JSON")),
1168
+ ) -> None:
1169
+ """Show a script's managed parameters, or edit their definitions when any change flag
1170
+ is given. Definitions travel with the file; values live in central state; secret
1171
+ values are never shown or persisted."""
1172
+ try:
1173
+ entry = store.resolve(name)
1174
+ except store.NotFoundError as exc:
1175
+ raise _fail(str(exc), 1) from exc
1176
+ prompts, bad_prompts = _parse_kv_opts(prompt or [], "--prompt")
1177
+ env_sources, bad_env = _parse_kv_opts(env_source or [], "--env-source")
1178
+ if (
1179
+ resync
1180
+ or manage
1181
+ or unmanage
1182
+ or secret
1183
+ or no_secret
1184
+ or prompts
1185
+ or env_sources
1186
+ or bad_prompts
1187
+ or bad_env
1188
+ ):
1189
+ _edit_params(
1190
+ entry,
1191
+ resync=resync,
1192
+ manage=manage or [],
1193
+ unmanage=unmanage or [],
1194
+ secret=secret or [],
1195
+ no_secret=no_secret or [],
1196
+ prompts=prompts,
1197
+ env_sources=env_sources,
1198
+ malformed=bad_prompts + bad_env,
1199
+ )
1200
+ else:
1201
+ _show_params(entry, as_json)
1202
+
1203
+
1204
+ def _apply_env_sources(specs: list[metawriter.ParamSpec], env_sources: dict[str, str]) -> list[str]:
1205
+ """Set/clear env_source on secret specs; returns warnings for unusable requests."""
1206
+ warnings: list[str] = []
1207
+ by_name = {s.name: s for s in specs}
1208
+ for pname, envvar in env_sources.items():
1209
+ spec = by_name.get(pname)
1210
+ if spec is None:
1211
+ warnings.append(
1212
+ gettext("%(name)s isn't a managed parameter; --env-source skipped.")
1213
+ % {"name": pname}
1214
+ )
1215
+ continue
1216
+ if not spec.secret:
1217
+ warnings.append(
1218
+ gettext(
1219
+ "%(name)s isn't secret; --env-source only applies to secret parameters (mark it with --secret first)."
1220
+ )
1221
+ % {"name": pname}
1222
+ )
1223
+ continue
1224
+ spec.env_source = envvar.strip()
1225
+ return warnings
1226
+
1227
+
1228
+ def _edit_params(
1229
+ entry: store.Entry,
1230
+ *,
1231
+ resync: bool,
1232
+ manage: list[str],
1233
+ unmanage: list[str],
1234
+ secret: list[str],
1235
+ no_secret: list[str],
1236
+ prompts: dict[str, str],
1237
+ env_sources: dict[str, str],
1238
+ malformed: list[str],
1239
+ ) -> None:
1240
+ """Apply parameter-definition changes to a copy-mode Python entry (rewrites [tool.skit])."""
1241
+ if entry.meta.kind != "python":
1242
+ raise _fail(
1243
+ gettext("%(name)s isn't a Python script; only Python entries have managed parameters.")
1244
+ % {"name": entry.meta.name},
1245
+ 1,
1246
+ )
1247
+ if entry.meta.mode == "reference":
1248
+ raise _fail(
1249
+ gettext(
1250
+ "%(name)s is in reference mode, and skit never writes the original file. "
1251
+ "Edit the [tool.skit] block in the source directly."
1252
+ )
1253
+ % {"name": entry.meta.name},
1254
+ 1,
1255
+ )
1256
+ copy_path = entry.dir / "script.py" # pragma: no mutate — fs-case
1257
+ if not copy_path.exists():
1258
+ raise _fail(gettext("%(name)s has no stored copy to edit.") % {"name": entry.meta.name}, 1)
1259
+ text = copy_path.read_text(encoding="utf-8", errors="replace") # pragma: no mutate
1260
+ current = metawriter.read_params(text)
1261
+ for item in malformed:
1262
+ err_console.print(
1263
+ f"[yellow]{escape(gettext('Ignored a malformed value: %(item)s (expected NAME=text).') % {'item': item})}[/yellow]"
1264
+ )
1265
+ result = reconcile.edit_specs(
1266
+ text,
1267
+ current,
1268
+ resync=resync,
1269
+ add=manage,
1270
+ remove=unmanage,
1271
+ secret=secret,
1272
+ no_secret=no_secret,
1273
+ prompts=prompts,
1274
+ )
1275
+ for w in result.warnings:
1276
+ err_console.print(f"[yellow]{escape(reconcile.render_warning(w))}[/yellow]")
1277
+ for w in _apply_env_sources(result.specs, env_sources):
1278
+ err_console.print(f"[yellow]{escape(w)}[/yellow]")
1279
+ new_text = metawriter.write_params(text, result.specs)
1280
+ copy_path.write_text(new_text, encoding="utf-8") # pragma: no mutate — utf-8 equivalence
1281
+ secret_now = {s.name for s in result.specs if s.secret}
1282
+ purged = argstate.purge_secret(entry.slug, secret_now)
1283
+ if purged:
1284
+ console.print(
1285
+ "[dim]"
1286
+ + gettext(
1287
+ "Removed previously stored plaintext value(s) for now-secret parameter(s): %(names)s"
1288
+ )
1289
+ % {"names": ", ".join(escape(n) for n in sorted(purged))}
1290
+ + _DIM_CLOSE
1291
+ )
1292
+ remaining = ", ".join(escape(s.name) for s in result.specs) or "—"
1293
+ console.print(
1294
+ f"[green]{gettext('Updated %(name)s. Managed parameters: %(names)s') % {'name': escape(entry.meta.name), 'names': remaining}}[/green]"
1295
+ )
1296
+
1297
+
1298
+ # --------------------------------------------------------------------------
1299
+ # deps
1300
+ # --------------------------------------------------------------------------
1301
+
1302
+
1303
+ @app.command(
1304
+ help=gettext("View or update a script's dependencies and Python constraint."),
1305
+ epilog=gettext(
1306
+ 'Examples: skit deps tool --dep "requests>=2,<3" --dep rich · skit deps tool --clear'
1307
+ ),
1308
+ )
1309
+ def deps(
1310
+ name: str = _SCRIPT_ARG,
1311
+ dep: list[str] = typer.Option(
1312
+ None, "--dep", help=gettext("A dependency (repeat for more; replaces the whole list)")
1313
+ ),
1314
+ clear: bool = typer.Option(False, "--clear", help=gettext("Remove every dependency")),
1315
+ python: str = typer.Option(
1316
+ None, "--python", help=gettext('Python version constraint, e.g. ">=3.11"')
1317
+ ),
1318
+ as_json: bool = typer.Option(False, "--json", help=gettext("Output as JSON")),
1319
+ ) -> None:
1320
+ """View or update a script's recorded dependencies."""
1321
+ try:
1322
+ entry = store.resolve(name)
1323
+ except store.NotFoundError as exc:
1324
+ raise _fail(str(exc), 1) from exc
1325
+ if entry.meta.kind != "python":
1326
+ raise _fail(
1327
+ gettext("%(name)s is not a Python script entry.") % {"name": entry.meta.name}, 1
1328
+ )
1329
+ if dep and clear:
1330
+ err_console.print(
1331
+ f"[red]{gettext('Use --dep to set the list or --clear to empty it — not both.')}[/red]"
1332
+ )
1333
+ raise typer.Exit(EXIT_USAGE)
1334
+ if dep is None and not clear and python is None:
1335
+ current = entry.meta.dependencies or []
1336
+ if as_json:
1337
+ console.print_json(
1338
+ json.dumps(
1339
+ {"dependencies": current, "requires_python": entry.meta.requires_python},
1340
+ ensure_ascii=False,
1341
+ )
1342
+ )
1343
+ return
1344
+ console.print(
1345
+ gettext("Dependencies of %(name)s: %(deps)s")
1346
+ % {
1347
+ "name": escape(entry.meta.name),
1348
+ "deps": ", ".join(escape(d) for d in current) or "—",
1349
+ }
1350
+ )
1351
+ if entry.meta.requires_python:
1352
+ console.print(
1353
+ gettext("Python constraint: %(python)s")
1354
+ % {"python": escape(entry.meta.requires_python)}
1355
+ )
1356
+ return
1357
+ if clear:
1358
+ new_deps: list[str] = []
1359
+ elif dep is not None:
1360
+ new_deps = list(dep)
1361
+ else:
1362
+ new_deps = list(entry.meta.dependencies or [])
1363
+ try:
1364
+ entry = store.update_dependencies(entry.slug, new_deps, requires_python=python)
1365
+ except store.StoreError as exc:
1366
+ raise _fail(str(exc), 1) from exc
1367
+ console.print(
1368
+ f"[green]{gettext('Dependencies of %(name)s updated: %(deps)s') % {'name': escape(entry.meta.name), 'deps': ', '.join(escape(d) for d in new_deps) or '—'}}[/green]"
1369
+ )
1370
+
1371
+
1372
+ # --------------------------------------------------------------------------
1373
+ # doctor
1374
+ # --------------------------------------------------------------------------
1375
+
1376
+
1377
+ def _drifted_entries(entries: list[store.Entry]) -> list[str]:
1378
+ """The health check is the one place that batch-reconciles the whole library."""
1379
+ out: list[str] = []
1380
+ for e in entries:
1381
+ if e.meta.kind != "python" or not e.script_path.exists():
1382
+ continue
1383
+ text = e.script_path.read_text(encoding="utf-8", errors="replace") # pragma: no mutate
1384
+ specs = metawriter.read_params(text)
1385
+ if specs and reconcile.reconcile(text, specs).has_drift:
1386
+ out.append(e.meta.name)
1387
+ return out
1388
+
1389
+
1390
+ @app.command(help=gettext("Check that uv is available and the script store is intact."))
1391
+ def doctor(
1392
+ rebuild: bool = typer.Option(
1393
+ False, "--rebuild", help=gettext("Rebuild the index from each script's meta.toml")
1394
+ ),
1395
+ as_json: bool = typer.Option(False, "--json", help=gettext("Output as JSON")),
1396
+ ) -> None:
1397
+ """Environment self-check (the CLI face of the TUI health-check screen)."""
1398
+ from .paths import scripts_dir
1399
+
1400
+ uv = launcher.find_uv()
1401
+ if rebuild:
1402
+ count, problems = store.doctor_rebuild()
1403
+ console.print(
1404
+ f"[green]{ngettext('Index rebuilt: %(count)s entry', 'Index rebuilt: %(count)s entries', count) % {'count': count}}[/green]"
1405
+ )
1406
+ for p in problems:
1407
+ console.print(f" [yellow]{escape(p)}[/yellow]")
1408
+ entries = store.list_entries()
1409
+ missing = [e.meta.name for e in entries if launcher.target_missing(e)]
1410
+ drifted = _drifted_entries(entries)
1411
+ mirror = config.load_mirror()
1412
+ location = scripts_dir()
1413
+ size = store.dir_size(location)
1414
+ if as_json:
1415
+ console.print_json(
1416
+ json.dumps(
1417
+ {
1418
+ "uv": uv,
1419
+ "entries": len(entries),
1420
+ "missing": missing,
1421
+ "drift": drifted,
1422
+ "mirror": mirror.pypi if mirror.enabled else "off",
1423
+ "location": str(location),
1424
+ "size_bytes": size,
1425
+ },
1426
+ ensure_ascii=False,
1427
+ )
1428
+ )
1429
+ raise typer.Exit(0 if uv else 1)
1430
+ if uv:
1431
+ console.print(f"[green]✓ {gettext('uv: %(path)s') % {'path': escape(uv)}}[/green]")
1432
+ else:
1433
+ console.print(
1434
+ f"[red]✗ {gettext('uv: not found. Install it from https://docs.astral.sh/uv/getting-started/installation/')}[/red]"
1435
+ )
1436
+ console.print(
1437
+ "✓ "
1438
+ + ngettext("%(count)s script registered", "%(count)s scripts registered", len(entries))
1439
+ % {"count": len(entries)}
1440
+ )
1441
+ for m in missing:
1442
+ console.print(
1443
+ f" [yellow]⚠ {gettext('%(name)s: the launch target is gone from disk') % {'name': escape(m)}}[/yellow]"
1444
+ )
1445
+ for d in drifted:
1446
+ console.print(
1447
+ f" [yellow]⚠ {gettext('%(name)s: form definitions are out of sync — run: skit params %(name)s --resync') % {'name': escape(d)}}[/yellow]"
1448
+ )
1449
+ if mirror.enabled:
1450
+ console.print("✓ " + gettext("Mirror: on (%(pypi)s)") % {"pypi": escape(mirror.pypi)})
1451
+ else:
1452
+ console.print("✓ " + gettext("Mirror: off"))
1453
+ console.print(
1454
+ gettext("Library: %(path)s (%(count)s · %(size)s)")
1455
+ % {"path": escape(str(location)), "count": len(entries), "size": store.human_size(size)}
1456
+ )
1457
+ raise typer.Exit(0 if uv else 1)
1458
+
1459
+
1460
+ # --------------------------------------------------------------------------
1461
+ # config (git-config grammar: bare = list, KEY = read, KEY VALUE = write)
1462
+ # --------------------------------------------------------------------------
1463
+
1464
+ _CONFIG_KEYS = ("lang", "editor", "mirror", "form")
1465
+
1466
+
1467
+ def _config_value(key: str) -> str:
1468
+ if key == "lang":
1469
+ override = config.load_config().get("language", "")
1470
+ if isinstance(override, str) and override:
1471
+ return override
1472
+ return gettext("auto (%(locale)s)") % {"locale": i18n.current_locale()}
1473
+ if key == "editor":
1474
+ return config.load_editor() or gettext("default ($VISUAL / $EDITOR)")
1475
+ if key == "mirror":
1476
+ m = config.load_mirror()
1477
+ return m.pypi if m.enabled else "off"
1478
+ return config.load_form() # "form" — _CONFIG_KEYS guards the key set
1479
+
1480
+
1481
+ def _config_set(key: str, value: str) -> None:
1482
+ if key == "lang":
1483
+ if value.lower() != "auto" and not i18n.is_supported(value):
1484
+ err_console.print(
1485
+ f"[red]{gettext('Unknown language: %(tag)s. Available: %(locales)s') % {'tag': escape(value), 'locales': ', '.join(i18n.available_locales())}}[/red]"
1486
+ )
1487
+ raise typer.Exit(EXIT_USAGE)
1488
+ i18n.set_language("" if value.lower() == "auto" else value)
1489
+ elif key == "editor":
1490
+ config.save_editor(value)
1491
+ elif key == "mirror":
1492
+ if value == "off":
1493
+ config.disable()
1494
+ elif value in config.PYPI_PRESETS:
1495
+ config.save_mirror(config.preset(value))
1496
+ else:
1497
+ choices = ", ".join([*config.PYPI_PRESETS, "off"])
1498
+ err_console.print(
1499
+ f"[red]{gettext('Unknown mirror: %(name)s. Choose from: %(names)s') % {'name': escape(value), 'names': choices}}[/red]"
1500
+ )
1501
+ raise typer.Exit(EXIT_USAGE)
1502
+ else: # form
1503
+ if value not in config.FORM_STYLES:
1504
+ err_console.print(
1505
+ f"[red]{gettext('Unknown form style: %(value)s. Choose from: tui, plain') % {'value': escape(value)}}[/red]"
1506
+ )
1507
+ raise typer.Exit(EXIT_USAGE)
1508
+ config.save_form(value)
1509
+
1510
+
1511
+ @app.command(
1512
+ "config",
1513
+ help=gettext("Read or set skit's settings (language, editor, mirror, form style)."),
1514
+ epilog=gettext("Examples: skit config · skit config lang zh-TW · skit config form plain"),
1515
+ )
1516
+ def config_cmd(
1517
+ key: str = typer.Argument(None, help=gettext("Setting name: lang / editor / mirror / form")),
1518
+ value: str = typer.Argument(
1519
+ None, help=gettext('New value (omit to read; lang also accepts "auto")')
1520
+ ),
1521
+ as_json: bool = typer.Option(False, "--json", help=gettext("Output as JSON")),
1522
+ ) -> None:
1523
+ """git-config grammar: bare `skit config` lists everything; `config KEY` reads one;
1524
+ `config KEY VALUE` writes one. The guided experience lives in the TUI (press ,)."""
1525
+ if key is None:
1526
+ if as_json:
1527
+ console.print_json(
1528
+ json.dumps({k: _config_value(k) for k in _CONFIG_KEYS}, ensure_ascii=False)
1529
+ )
1530
+ return
1531
+ for k in _CONFIG_KEYS:
1532
+ console.print(f" {k:<8}{escape(_config_value(k))}")
1533
+ return
1534
+ if key not in _CONFIG_KEYS:
1535
+ err_console.print(
1536
+ f"[red]{gettext('Unknown setting: %(key)s. Available: %(keys)s') % {'key': escape(key), 'keys': ', '.join(_CONFIG_KEYS)}}[/red]"
1537
+ )
1538
+ raise typer.Exit(EXIT_USAGE)
1539
+ if value is None:
1540
+ console.print(escape(_config_value(key)))
1541
+ return
1542
+ _config_set(key, value)
1543
+ console.print(f"[green]{key} = {escape(_config_value(key))}[/green]")
1544
+
1545
+
1546
+ # --------------------------------------------------------------------------
1547
+ # first run (mirror offer for blocked networks; interactive TTY only)
1548
+ # --------------------------------------------------------------------------
1549
+
1550
+
1551
+ def _prompt_uv_binary(default: str) -> str:
1552
+ """Prompt for the uv-binary mirror URL, insisting on https:// (the binary is
1553
+ downloaded, chmod +x'd, and executed — an http:// mirror is a MITM->RCE vector)."""
1554
+ while True:
1555
+ value = Prompt.ask(gettext("uv binary mirror URL"), default=default, console=console)
1556
+ if value.startswith("https://"):
1557
+ return value
1558
+ err_console.print(
1559
+ "[red]"
1560
+ + gettext(
1561
+ "The uv binary is downloaded and executed, so its mirror URL must use https:// (got: %(url)s)."
1562
+ )
1563
+ % {"url": escape(value)}
1564
+ + _RED_CLOSE
1565
+ )
1566
+
1567
+
1568
+ def _mirror_wizard() -> None:
1569
+ m = config.load_mirror()
1570
+ if not m.enabled:
1571
+ default = "off"
1572
+ else:
1573
+ default = next((k for k, v in config.PYPI_PRESETS.items() if v == m.pypi), "custom")
1574
+ choice = Prompt.ask(
1575
+ gettext("Mirror for faster installs in mainland China"),
1576
+ choices=[*config.PYPI_PRESETS, "custom", "off"],
1577
+ default=default,
1578
+ console=console,
1579
+ )
1580
+ if choice == "off":
1581
+ config.disable()
1582
+ elif choice == "custom":
1583
+ config.save_mirror(
1584
+ config.MirrorConfig(
1585
+ enabled=True,
1586
+ pypi=Prompt.ask(
1587
+ gettext("PyPI index URL"),
1588
+ default=m.pypi or config.PYPI_PRESETS["tsinghua"],
1589
+ console=console,
1590
+ ),
1591
+ python_install=Prompt.ask(
1592
+ gettext("Python-install mirror URL"),
1593
+ default=m.python_install or config.PYTHON_INSTALL_MIRROR,
1594
+ console=console,
1595
+ ),
1596
+ uv_binary=_prompt_uv_binary(m.uv_binary or config.UV_BINARY_MIRROR),
1597
+ )
1598
+ )
1599
+ else:
1600
+ config.save_mirror(config.preset(choice))
1601
+
1602
+
1603
+ def _maybe_first_run_setup() -> None:
1604
+ """On the first bare `skit` run, offer mirror setup if the network to PyPI/GitHub looks
1605
+ blocked. Interactive TTY only; runs once (a [mirror] section marks the offer as done)."""
1606
+ if config.mirror_configured() or not _is_interactive():
1607
+ return
1608
+ if config.looks_blocked():
1609
+ console.print(gettext("Network to PyPI / GitHub looks slow or blocked."))
1610
+ if Confirm.ask(
1611
+ gettext("Configure mirrors for faster installs (mainland China)?"),
1612
+ default=True,
1613
+ console=console,
1614
+ ):
1615
+ _mirror_wizard()
1616
+ if not config.mirror_configured():
1617
+ config.save_mirror(config.load_mirror()) # persist a marker so we don't probe every run
1618
+
1619
+
1620
+ if __name__ == "__main__":
1621
+ sys.exit(app())