skit-cli 0.3.0__tar.gz → 0.4.0__tar.gz

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 (79) hide show
  1. {skit_cli-0.3.0 → skit_cli-0.4.0}/PKG-INFO +7 -6
  2. {skit_cli-0.3.0 → skit_cli-0.4.0}/README.md +5 -4
  3. {skit_cli-0.3.0 → skit_cli-0.4.0}/pyproject.toml +2 -2
  4. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/analysis.py +106 -12
  5. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/argstate.py +31 -7
  6. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/cli.py +49 -7
  7. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/flows.py +102 -10
  8. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/javascript/cli_reader.py +161 -15
  9. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/python/argspec.py +112 -22
  10. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/shell/analyzer.py +4 -0
  11. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/locales/skit.pot +579 -563
  12. skit_cli-0.4.0/src/skit/locales/zh_CN/LC_MESSAGES/skit.mo +0 -0
  13. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/locales/zh_CN/LC_MESSAGES/skit.po +578 -562
  14. skit_cli-0.4.0/src/skit/locales/zh_TW/LC_MESSAGES/skit.mo +0 -0
  15. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/locales/zh_TW/LC_MESSAGES/skit.po +578 -562
  16. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/promptform.py +4 -0
  17. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/skills/skit/SKILL.md +3 -0
  18. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/tui.py +11 -1
  19. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/tui_form.py +139 -18
  20. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/tui_settings.py +27 -14
  21. skit_cli-0.3.0/src/skit/locales/zh_CN/LC_MESSAGES/skit.mo +0 -0
  22. skit_cli-0.3.0/src/skit/locales/zh_TW/LC_MESSAGES/skit.mo +0 -0
  23. {skit_cli-0.3.0 → skit_cli-0.4.0}/LICENSE +0 -0
  24. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/__init__.py +0 -0
  25. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/agentskill.py +0 -0
  26. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/argv_text.py +0 -0
  27. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/atomic.py +0 -0
  28. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/callmatch.py +0 -0
  29. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/config.py +0 -0
  30. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/editor.py +0 -0
  31. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/healthcheck.py +0 -0
  32. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/i18n.py +0 -0
  33. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/inlineform.py +0 -0
  34. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/kindnames.py +0 -0
  35. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/__init__.py +0 -0
  36. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/base.py +0 -0
  37. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/fish/__init__.py +0 -0
  38. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/fish/analyzer.py +0 -0
  39. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/fish/cli_reader.py +0 -0
  40. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/javascript/__init__.py +0 -0
  41. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/javascript/analyzer.py +0 -0
  42. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/javascript/deps.py +0 -0
  43. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/javascript/inject.py +0 -0
  44. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/javascript/io.py +0 -0
  45. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/launch.py +0 -0
  46. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/powershell/__init__.py +0 -0
  47. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/powershell/cli_reader.py +0 -0
  48. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/prompt/__init__.py +0 -0
  49. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/prompt/analyzer.py +0 -0
  50. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/prompt/render.py +0 -0
  51. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/prompt/text.py +0 -0
  52. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/python/__init__.py +0 -0
  53. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/python/analyzer.py +0 -0
  54. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/python/metawriter.py +0 -0
  55. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/python/reconcile.py +0 -0
  56. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/python/shim.py +0 -0
  57. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/registry.py +0 -0
  58. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/shell/__init__.py +0 -0
  59. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/shell/cli_reader.py +0 -0
  60. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/shell/inject.py +0 -0
  61. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/langs/shell/normalize.py +0 -0
  62. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/launcher.py +0 -0
  63. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/models.py +0 -0
  64. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/params.py +0 -0
  65. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/paths.py +0 -0
  66. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/pep723.py +0 -0
  67. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/rewrite.py +0 -0
  68. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/store.py +0 -0
  69. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/theme.py +0 -0
  70. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/tokens.py +0 -0
  71. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/tui_add.py +0 -0
  72. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/tui_footer.py +0 -0
  73. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/tui_health.py +0 -0
  74. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/tui_layout.py +0 -0
  75. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/tui_pathpick.py +0 -0
  76. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/tui_prefs.py +0 -0
  77. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/tui_prompt.py +0 -0
  78. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/tui_runner.py +0 -0
  79. {skit_cli-0.3.0 → skit_cli-0.4.0}/src/skit/uvman.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skit-cli
3
- Version: 0.3.0
4
- Summary: Launcher and parameter manager for scripts, prompts, programs, and commands: one library, one run form, nothing to memorize.
3
+ Version: 0.4.0
4
+ Summary: A terminal script manager and launcher for humans and AI agents: put any script, prompt, or program in one library; skit turns its inputs and flags into a run form and remembers past parameters. Ships an Agent Skill so agents reuse the scripts they wrote.
5
5
  Keywords: script,launcher,uv,tui,pep723,script-manager,script-runner,automation,ai-agent,ai-prompt,agent-skills
6
6
  Author: Yi-Ting Chiu
7
7
  Author-email: Yi-Ting Chiu <mytim710@gmail.com>
@@ -48,7 +48,8 @@ Description-Content-Type: text/markdown
48
48
 
49
49
  **skit is a launcher and a home for your scripts.**
50
50
 
51
- skit stores your scripts in one place and makes them painless to launch.
51
+ skit stores your scripts in one place and makes them painless to launch — Python, shell, and
52
+ JS/TS go deepest, a dozen kinds in all.
52
53
 
53
54
  **AI writes the scripts. skit gives them a home.**
54
55
 
@@ -56,14 +57,14 @@ And it's a home you share with your agent: the library you drive from a menu, AI
56
57
  drive through a deterministic CLI — checking it before writing yet another one-off
57
58
  script, and (with your OK) saving the good ones back, so they outlive the chat.
58
59
 
59
- <video src="https://github.com/user-attachments/assets/d27121fe-5855-4270-91b0-b0ee9e5d11ee" controls></video>
60
+ <video src="https://github.com/user-attachments/assets/8a1f27cd-f2f2-42db-977a-b4f8ea207340" controls></video>
60
61
 
61
62
  ## What it does
62
63
 
63
64
  - **One home for your scripts and prompts.** `skit add` collects scattered scripts and prompts into a searchable library — keep a copy in the library, or reference the original file. Run it bare and it asks what you're adding; hand it a file it can't classify and it asks which kind, instead of erroring out.
64
65
  - **Parameters without the pain.** Flags, `input()` calls, and the constants you tick become form fields (choices → pickers, booleans → checkboxes, paths → completion and a file browser, types enforced).
65
- - **Paths you don't have to type.** Path fields complete as you type — press `→` to accept the suggestion and `Ctrl+T` → *File or folder…* opens a browsable picker, so you never hand-type a directory again.
66
- - **It remembers.** Last-used values come back automatically; save favorites as named presets. Parameters marked secret never touch disk. Tokens like `{cwd}` and `{today}` keep presets portable.
66
+ - **Paths you don't have to type.** Every text field carries a `📁 browse` link that opens a file picker, and path fields complete as you type — press `→` to accept the suggestion. You never hand-type a directory again. (`Ctrl+T` → *File or folder…* is the keyboard route to the same picker.)
67
+ - **It remembers.** Script defaults arrive prefilled and track the source when it changes; last-used values come back automatically, and `↺ default` (Ctrl+O) puts the script's own value back. Save favorites as named presets. Parameters marked secret never touch disk. Tokens like `{cwd}` and `{today}` keep presets portable.
67
68
  - **No environment mess.** Python scripts declare their dependencies inline (PEP 723) and run via uv in isolated environments; JS/TS scripts get a per-script `node_modules`, installed from their declared packages on first run. Nothing global either way. Other languages use the tools already on your machine — skit checks that declared external commands are on your `PATH` before running.
68
69
  - **Mouse or keyboard.** Plain `skit` opens the full TUI; every key hint on screen is also a clickable button.
69
70
  - **Automation-ready.** Every TUI action is also a CLI command with `--json` output and meaningful exit codes — for shell scripts, CI, and AI agents.
@@ -13,7 +13,8 @@
13
13
 
14
14
  **skit is a launcher and a home for your scripts.**
15
15
 
16
- skit stores your scripts in one place and makes them painless to launch.
16
+ skit stores your scripts in one place and makes them painless to launch — Python, shell, and
17
+ JS/TS go deepest, a dozen kinds in all.
17
18
 
18
19
  **AI writes the scripts. skit gives them a home.**
19
20
 
@@ -21,14 +22,14 @@ And it's a home you share with your agent: the library you drive from a menu, AI
21
22
  drive through a deterministic CLI — checking it before writing yet another one-off
22
23
  script, and (with your OK) saving the good ones back, so they outlive the chat.
23
24
 
24
- <video src="https://github.com/user-attachments/assets/d27121fe-5855-4270-91b0-b0ee9e5d11ee" controls></video>
25
+ <video src="https://github.com/user-attachments/assets/8a1f27cd-f2f2-42db-977a-b4f8ea207340" controls></video>
25
26
 
26
27
  ## What it does
27
28
 
28
29
  - **One home for your scripts and prompts.** `skit add` collects scattered scripts and prompts into a searchable library — keep a copy in the library, or reference the original file. Run it bare and it asks what you're adding; hand it a file it can't classify and it asks which kind, instead of erroring out.
29
30
  - **Parameters without the pain.** Flags, `input()` calls, and the constants you tick become form fields (choices → pickers, booleans → checkboxes, paths → completion and a file browser, types enforced).
30
- - **Paths you don't have to type.** Path fields complete as you type — press `→` to accept the suggestion and `Ctrl+T` → *File or folder…* opens a browsable picker, so you never hand-type a directory again.
31
- - **It remembers.** Last-used values come back automatically; save favorites as named presets. Parameters marked secret never touch disk. Tokens like `{cwd}` and `{today}` keep presets portable.
31
+ - **Paths you don't have to type.** Every text field carries a `📁 browse` link that opens a file picker, and path fields complete as you type — press `→` to accept the suggestion. You never hand-type a directory again. (`Ctrl+T` → *File or folder…* is the keyboard route to the same picker.)
32
+ - **It remembers.** Script defaults arrive prefilled and track the source when it changes; last-used values come back automatically, and `↺ default` (Ctrl+O) puts the script's own value back. Save favorites as named presets. Parameters marked secret never touch disk. Tokens like `{cwd}` and `{today}` keep presets portable.
32
33
  - **No environment mess.** Python scripts declare their dependencies inline (PEP 723) and run via uv in isolated environments; JS/TS scripts get a per-script `node_modules`, installed from their declared packages on first run. Nothing global either way. Other languages use the tools already on your machine — skit checks that declared external commands are on your `PATH` before running.
33
34
  - **Mouse or keyboard.** Plain `skit` opens the full TUI; every key hint on screen is also a clickable button.
34
35
  - **Automation-ready.** Every TUI action is also a CLI command with `--json` output and meaningful exit codes — for shell scripts, CI, and AI agents.
@@ -2,8 +2,8 @@
2
2
  # Distribution name only ("skit" is admin-prohibited on PyPI); the import package,
3
3
  # CLI command, and config dirs all stay "skit".
4
4
  name = "skit-cli"
5
- version = "0.3.0"
6
- description = "Launcher and parameter manager for scripts, prompts, programs, and commands: one library, one run form, nothing to memorize."
5
+ version = "0.4.0"
6
+ description = "A terminal script manager and launcher for humans and AI agents: put any script, prompt, or program in one library; skit turns its inputs and flags into a run form and remembers past parameters. Ships an Agent Skill so agents reuse the scripts they wrote."
7
7
  readme = "README.md"
8
8
  requires-python = ">=3.12"
9
9
  license = "MIT"
@@ -25,10 +25,10 @@ from dataclasses import dataclass, field, replace
25
25
  from typing import TYPE_CHECKING
26
26
 
27
27
  from .callmatch import match_calls
28
- from .params import ParamDecl
28
+ from .params import ParamDecl, coerce_default
29
29
 
30
30
  if TYPE_CHECKING:
31
- from collections.abc import Callable
31
+ from collections.abc import Callable, Mapping
32
32
 
33
33
 
34
34
  @dataclass
@@ -55,6 +55,11 @@ class Candidate:
55
55
  # envdefault only: the ${NAME} variable the value is read from at run time (== name; kept
56
56
  # explicit so an env-delivery caller never has to re-derive it). "" for const/input.
57
57
  env_name: str = ""
58
+ # envdefault only: an explicitly empty environment value still activates the
59
+ # source's fallback. Shell's colon operators (:- and :=) have this behavior;
60
+ # the non-colon operators and fish's set-query idiom do not. Kept as a semantic
61
+ # boolean so the language-neutral form layer never needs to know shell syntax.
62
+ empty_uses_default: bool = False
58
63
 
59
64
 
60
65
  @dataclass
@@ -92,6 +97,20 @@ class Report:
92
97
  rebind: list[tuple[ParamDecl, Candidate]] = field(default_factory=list)
93
98
  new: list[Candidate] = field(default_factory=list)
94
99
  syntax_error: bool = False
100
+ # The SOURCE's current default for every ok-matched const/envdefault spec, keyed by
101
+ # name. The stored block default is a cache written at manage time; the script is the
102
+ # truth, and a user who edits `X = "hello"` to `X = "bonjour"` must see (and prefill,
103
+ # and reset to) "bonjour" — the stale cache used to be silently re-injected OVER that
104
+ # edit. A value change is deliberately NOT drift: tracking the source is the expected
105
+ # behavior, not a warning. Two exclusions, both in _records_default/_type_matches: a
106
+ # SECRET's literal never travels (C3), and a type-mismatched default never overlays a
107
+ # spec (a const carries a drift warning and keeps its old prefill until resync; an env
108
+ # param stays ok but its default must still fit the declared type).
109
+ current_defaults: dict[str, str | int | float | bool] = field(default_factory=dict)
110
+ # Managed envdefault names for which an empty environment value still selects
111
+ # the source fallback. Like current_defaults, this is live source semantics that
112
+ # is projected onto the run form without changing the frozen [tool.skit] block.
113
+ empty_uses_default: set[str] = field(default_factory=set)
95
114
 
96
115
  @property
97
116
  def has_drift(self) -> bool:
@@ -107,6 +126,18 @@ class Report:
107
126
  return self.ok + [spec for spec, _ in self.changed] + [spec for spec, _ in self.rebind]
108
127
 
109
128
 
129
+ def effective_default(
130
+ spec: ParamDecl, current: Mapping[str, str | int | float | bool]
131
+ ) -> str | int | float | bool | None:
132
+ """The default a READ surface should show for a managed spec: the source's current
133
+ literal when reconcile published one (Report.current_defaults), else the stored
134
+ block value. THE one place that rule lives — `skit params`, `params --json` and the
135
+ Entry-settings pane all show the same record, and the comments on each of them
136
+ promise they cannot disagree; two hand-rolled copies of a fallback is exactly how
137
+ that promise rots."""
138
+ return current.get(spec.name, spec.default)
139
+
140
+
110
141
  def drift_lines(report: Report, name: str) -> list[str]:
111
142
  """The display lines for a drift report (shared by CLI/TUI). The only copy exit point in this
112
143
  module: plain-text old/new comparison; rich markup/color is wrapped by the caller."""
@@ -231,8 +262,8 @@ def edit_specs(
231
262
  # insertion order, so this only changes anything when specs already has duplicate names).
232
263
  order: list[str] = list(by_name) # keep original order; new ones appended at the end
233
264
 
234
- # 1) resync: prune missing and update changed types per the current script (keeping custom
235
- # secret/prompt/default).
265
+ # 1) resync: prune missing and update changed types/defaults per the current script
266
+ # (keeping custom secret/prompt).
236
267
  if resync:
237
268
  _apply_resync(text, specs, by_name, order, warnings, analyze=analyze)
238
269
 
@@ -275,20 +306,39 @@ def _apply_resync(
275
306
  warnings.append("resync-skipped")
276
307
  return
277
308
  missing_names = {s.name for s in report.missing}
278
- changed_types = {spec.name: cand.type for spec, cand in report.changed}
309
+ changed_pairs = {spec.name: cand for spec, cand in report.changed}
279
310
  rebind_targets = {spec.name: cand for spec, cand in report.rebind}
280
311
  for name in list(order):
281
312
  if name in missing_names:
282
313
  warnings.append(f"resync-dropped:{name}")
283
314
  del by_name[name]
284
315
  order.remove(name)
285
- elif name in changed_types:
286
- # changed_types[name] is a Candidate.type (a plain str); ParamDecl.type is the
287
- # closed ParamType literal, so re-anchor the type through dataclasses.replace
288
- # (whose kwargs are untyped) rather than a direct attribute write. by_name[name]
289
- # is already a private shallow copy (see the replace() at the top of edit_specs),
290
- # so swapping in a fresh copy is the same visible effect as mutating in place.
291
- by_name[name] = replace(by_name[name], type=changed_types[name])
316
+ elif name in changed_pairs:
317
+ # cand.type is a plain str while ParamDecl.type is the closed ParamType
318
+ # literal, so re-anchor through dataclasses.replace (whose kwargs are
319
+ # untyped) rather than a direct attribute write. by_name[name] is already a
320
+ # private shallow copy (see the replace() at the top of edit_specs), so
321
+ # swapping in a fresh copy is the same visible effect as mutating in place.
322
+ # The default rides along with the type: once the spec is retyped to the
323
+ # source's current shape, keeping the old-typed default would leave stale
324
+ # junk (an int 3 on a now-str constant) in the refreshed record. It rides
325
+ # through the SAME secret gate the ok lane uses (_recordable_default): a
326
+ # secret's literal must not be written into the block, from where
327
+ # to_block_dict would publish it to `params --json` / `show --json`. A
328
+ # retyped secret therefore comes out with no default at all — honest, and
329
+ # the only leak-free answer once its old default no longer fits the type.
330
+ cand = changed_pairs[name]
331
+ by_name[name] = replace(
332
+ by_name[name],
333
+ type=cand.type,
334
+ default=_recordable_default(by_name[name], cand),
335
+ )
336
+ elif name in report.current_defaults:
337
+ # An ok-matched const/envdefault whose SOURCE default moved on: resync's job
338
+ # is to make the stored record match the script again, and the cached default
339
+ # is part of that record (the run form already tracks the source either way —
340
+ # see Report.current_defaults).
341
+ by_name[name] = replace(by_name[name], default=report.current_defaults[name])
292
342
  elif name in rebind_targets:
293
343
  # The prompt no longer uniquely resolves; re-anchor to whichever call site position
294
344
  # currently supplied it, so the *next* run's plain reconcile() (no --resync) sees an
@@ -338,6 +388,12 @@ def _apply_tweaks(
338
388
  by_name[name].env_source = "" # an env source only means anything on a secret
339
389
  else:
340
390
  warnings.append(f"not-managed:{name}")
391
+ # Apply the final-state rule after both lists: `--secret X --no-secret X` ends
392
+ # public and must keep its public default, while every explicit final transition
393
+ # to secret removes the cached literal before the block/JSON surfaces can expose it.
394
+ for name in secret:
395
+ if name in by_name and by_name[name].secret:
396
+ by_name[name].default = None
341
397
  for name, prompt in prompts.items():
342
398
  if name in by_name:
343
399
  by_name[name].prompt = prompt
@@ -345,6 +401,40 @@ def _apply_tweaks(
345
401
  warnings.append(f"not-managed:{name}")
346
402
 
347
403
 
404
+ def _record_default(report: Report, spec: ParamDecl, cand: Candidate) -> None:
405
+ """Publish the source's current literal as this spec's default — the one rule both
406
+ matched lanes share. It travels only when it MAY (see _recordable_default) and when
407
+ the DECLARED type can actually hold it.
408
+
409
+ Fitness is coercibility, not type equality: the analyzers type a literal by its
410
+ shape, so a `str` param defaulting to `8080` reads back as an int candidate and must
411
+ still refresh (its value is text either way). What must not pass is a default the
412
+ declared type cannot represent — an env param stays ok through a type change (the
413
+ value arrives by environment regardless), so `PORT=${PORT:-$FALLBACK}` on an int
414
+ param would otherwise prefill text that fails the form's own validation and hand
415
+ resync that text to write onto an int spec."""
416
+ default = _recordable_default(spec, cand)
417
+ if default is None:
418
+ return
419
+ try:
420
+ coerce_default(str(default), spec.type)
421
+ except ValueError:
422
+ return
423
+ report.current_defaults[spec.name] = default
424
+
425
+
426
+ def _recordable_default(spec: ParamDecl, cand: Candidate) -> str | int | float | bool | None:
427
+ """The source literal this match may publish as the spec's current default, or None
428
+ when it may not.
429
+
430
+ A SECRET is the one hard exclusion (C3): its literal is exactly the thing that must
431
+ not travel. current_defaults feeds `params --json`, `show --json` and the settings
432
+ pane — machine-facing surfaces with no masking of their own — so a hardcoded
433
+ `TOKEN = "sk-live-…"` would leave the script's own text for the first time. The
434
+ secret's stored block default (if any) still describes it, masked, exactly as before."""
435
+ return None if spec.secret else cand.default
436
+
437
+
348
438
  def _type_matches(spec: ParamDecl, cand: Candidate) -> bool:
349
439
  """Whether a stored const type still matches what the source derives. A declared
350
440
  ``path`` over a derived ``str`` is a user refinement — a string constant is exactly
@@ -402,6 +492,9 @@ def reconcile( # noqa: PLR0912 — one branch per binding and drift category; a
402
492
  else:
403
493
  covered_envs.add(spec.name)
404
494
  report.ok.append(spec) # default-text/type changes stay ok (env delivery)
495
+ _record_default(report, spec, cand)
496
+ if cand.empty_uses_default:
497
+ report.empty_uses_default.add(spec.name)
405
498
  continue
406
499
  cand = consts.get(spec.name)
407
500
  if cand is None:
@@ -412,6 +505,7 @@ def reconcile( # noqa: PLR0912 — one branch per binding and drift category; a
412
505
  else:
413
506
  covered_consts.add(spec.name)
414
507
  report.ok.append(spec)
508
+ _record_default(report, spec, cand)
415
509
 
416
510
  for cand in analysis.candidates:
417
511
  if (
@@ -4,11 +4,12 @@
4
4
  - File shape: [values] (last-used), extra_args, [presets.<name>] (named presets).
5
5
  - **C3 is enforced structurally here**: every write entry point requires secret_names, and any key
6
6
  in that list is stripped before it hits disk, so a secret value can never appear in a state file
7
- (there are tests for this). This holds for *new* writes; it says nothing about a value that was
8
- written while the parameter was still public. purge_secret() retroactively scrubs that plaintext
9
- once a parameter transitions to secret, and save_last() also drops any now-secret key left over
10
- from before, even on calls that carry no new value for it so nothing written while a parameter
11
- was public can outlive it becoming secret.
7
+ (there are tests for this). This includes the complete values snapshot nested under last_run,
8
+ which powers `preset save --from-last`. This holds for *new* writes; it says nothing about a value
9
+ that was written while the parameter was still public. purge_secret() retroactively scrubs that
10
+ plaintext once a parameter transitions to secret, and save_last() also drops any now-secret key
11
+ left over from before, even on calls that carry no new value for it so nothing written while a
12
+ parameter was public can outlive it becoming secret.
12
13
  - Value resolution (this run's input > preset > last-used > definition default) lives in
13
14
  flows.prefill; this module only stores and strips.
14
15
  """
@@ -150,6 +151,16 @@ def purge_secret(slug: str, names: Iterable[str]) -> set[str]:
150
151
  new_presets[name] = cleaned
151
152
  doc["presets"] = new_presets
152
153
 
154
+ # The exact last-run snapshot is another value-bearing surface. A parameter that
155
+ # becomes secret after it ran publicly must be scrubbed here too, or --from-last
156
+ # could copy the old plaintext back into a preset.
157
+ last_run = dict(doc.get("last_run", {}))
158
+ if "values" in last_run:
159
+ last_values = dict(last_run.get("values", {}))
160
+ removed |= banned & last_values.keys()
161
+ last_run["values"] = _strip_secrets(last_values, banned)
162
+ doc["last_run"] = last_run
163
+
153
164
  _save_doc(slug, doc)
154
165
  return removed
155
166
 
@@ -177,12 +188,25 @@ def save_last_runner(name: str) -> None:
177
188
  atomic_write_toml(state_dir() / "prompt.toml", {"last_runner": name})
178
189
 
179
190
 
180
- def record_run(slug: str, exit_code: int, *, at: str) -> None:
191
+ def record_run(
192
+ slug: str,
193
+ exit_code: int,
194
+ *,
195
+ at: str,
196
+ values: dict[str, str] | None = None,
197
+ secret_names: Iterable[str] = (),
198
+ ) -> None:
181
199
  """Remember when the entry last ran and how it exited (Library sort order, detail pane,
182
200
  and the r-rerun context key all read this). Stored as a table — a bare `last_exit = 0`
183
201
  top-level key would be dropped by _save_doc's empty-section pruning (0 is falsy)."""
184
202
  doc = _load_doc(slug)
185
- doc["last_run"] = {"at": at, "exit": exit_code}
203
+ last_run: dict[str, Any] = {"at": at, "exit": exit_code}
204
+ if values is not None:
205
+ # Unlike last-used [values], this is the exact accepted invocation: values
206
+ # equal to defaults and delivered empty strings stay so --from-last can pin
207
+ # what actually ran instead of reconstructing it from a later source version.
208
+ last_run["values"] = _strip_secrets(values, secret_names)
209
+ doc["last_run"] = last_run
186
210
  _save_doc(slug, doc)
187
211
 
188
212
 
@@ -2334,6 +2334,10 @@ def _field_to_dict(f: flows.FormField) -> dict[str, object]:
2334
2334
  "flag": f.flag,
2335
2335
  "action": f.action,
2336
2336
  "env_source": f.env_source,
2337
+ # True = clearing this field (or --set NAME=) delivers an EMPTY STRING; false =
2338
+ # an empty value means "unset — the script's own default applies". Additive key
2339
+ # so an agent can tell the two apart before assembling a run.
2340
+ "delivers_empty": f.delivers_empty,
2337
2341
  }
2338
2342
 
2339
2343
 
@@ -3132,7 +3136,10 @@ def run(
3132
3136
  argstate.save_preset(
3133
3137
  entry.slug,
3134
3138
  save_preset,
3135
- {k: v for k, v in values.items() if v},
3139
+ # A preset is the exact snapshot of this run — a cleared field included, so
3140
+ # it pins "stays cleared" over whatever last-used remembers (secrets are
3141
+ # stripped structurally by argstate).
3142
+ values,
3136
3143
  secret_names=plan.secret_names,
3137
3144
  )
3138
3145
  # stderr, like every run-adjacent skit line ("Reusing your last arguments",
@@ -3536,15 +3543,34 @@ def preset_save(
3536
3543
  EXIT_USAGE,
3537
3544
  )
3538
3545
  if from_last:
3539
- last = argstate.load_state(entry.slug)["values"]
3546
+ state = argstate.load_state(entry.slug)
3547
+ if not state["last_run"] and not state["values"]:
3548
+ raise _fail(
3549
+ gettext("%(name)s has no remembered values yet — run it once first.")
3550
+ % {"name": entry.meta.name},
3551
+ 1,
3552
+ )
3540
3553
  keys = {f.key for f in plan.fields}
3541
- values = {k: v for k, v in last.items() if k in keys}
3542
- if not values:
3554
+ last_snapshot = state["last_run"].get("values")
3555
+ if isinstance(last_snapshot, dict):
3556
+ # The exact accepted invocation, filtered only for fields removed since
3557
+ # that run. Never overlay today's defaults: a changed/new definition did
3558
+ # not participate in the historical run this command promises to save.
3559
+ values = {k: str(v) for k, v in last_snapshot.items() if k in keys}
3560
+ elif state["last_run"]:
3561
+ # Legacy state with a run stamp but no exact snapshot cannot be rebuilt:
3562
+ # remembered values deliberately omitted accepted defaults. Asking for a
3563
+ # fresh run is honest; current defaults would fabricate history.
3543
3564
  raise _fail(
3544
3565
  gettext("%(name)s has no remembered values yet — run it once first.")
3545
3566
  % {"name": entry.meta.name},
3546
3567
  1,
3547
3568
  )
3569
+ else:
3570
+ # Older state from before run stamps existed may still have explicit
3571
+ # last-used values. Preserve that narrow compatibility lane without
3572
+ # inventing missing defaults or newly-added fields.
3573
+ values = {k: v for k, v in state["values"].items() if k in keys}
3548
3574
  elif _is_interactive():
3549
3575
  values = promptform.collect(plan, flows.prefill(plan, entry.slug), console=console)
3550
3576
  else:
@@ -3558,7 +3584,14 @@ def preset_save(
3558
3584
  % {"names": ", ".join(escape(n) for n in sorted(secret_overlap))}
3559
3585
  + _DIM_CLOSE
3560
3586
  )
3561
- argstate.save_preset(entry.slug, preset_name, values, secret_names=plan.secret_names)
3587
+ argstate.save_preset(
3588
+ entry.slug,
3589
+ preset_name,
3590
+ # The exact snapshot, cleared fields included — one rule for every preset
3591
+ # writer (the run form's Ctrl+S and `run --save-preset` store the same way).
3592
+ values,
3593
+ secret_names=plan.secret_names,
3594
+ )
3562
3595
  console.print(
3563
3596
  f"[green]{gettext('Preset "%(preset)s" saved for %(name)s.') % {'preset': escape(preset_name), 'name': escape(entry.meta.name)}}[/green]"
3564
3597
  )
@@ -3787,6 +3820,7 @@ def _show_params(entry: store.Entry, as_json: bool) -> None:
3787
3820
  # vanished analyzable script must not invent either signal (test_cli_mut_part06 pins it).
3788
3821
  self_locating, reader_driven = False, False
3789
3822
  ref_mode = entry.meta.mode == "reference"
3823
+ current_defaults: dict[str, str | int | float | bool] = {}
3790
3824
  if entry_spec is not None and entry_spec.analyzer is not None: # noqa: SIM102
3791
3825
  # Keep the text gate nested instead of pragma-suppressing a three-way BooleanOperation:
3792
3826
  # changing ``and text`` to ``or text`` is equivalent under the registry invariant, but a
@@ -3797,6 +3831,9 @@ def _show_params(entry: store.Entry, as_json: bool) -> None:
3797
3831
  # only the WRITE ops differ, and the advice below switches voice on that).
3798
3832
  report = entry_spec.analyzer.reconcile(text, specs)
3799
3833
  an = entry_spec.analyzer.analyze(text)
3834
+ # The Default column shows the SOURCE's current value, exactly like the run
3835
+ # form's prefill — two surfaces reading one record must not disagree.
3836
+ current_defaults = report.current_defaults
3800
3837
  # A MODELED reader form (flows.reader_fields — the one trap predicate every
3801
3838
  # surface shares) is the entry's real interface: plan_for_entry prefers managed
3802
3839
  # params, so a --manage advice here would sell REPLACING that form. Self-parsing
@@ -3822,6 +3859,10 @@ def _show_params(entry: store.Entry, as_json: bool) -> None:
3822
3859
  if as_json:
3823
3860
  payload = {
3824
3861
  "params": [s.to_block_dict() for s in specs],
3862
+ # The SOURCE's current default per managed name (additive key). "params"
3863
+ # stays the verbatim stored record — an agent that wants the value a run
3864
+ # would actually prefill reads it here, same truth as the human table.
3865
+ "current_defaults": current_defaults,
3825
3866
  "unmanaged": unmanaged,
3826
3867
  "placeholders": entry.meta.params or [],
3827
3868
  "declared": [d.to_meta_dict() for d in declared],
@@ -3887,10 +3928,11 @@ def _show_params(entry: store.Entry, as_json: bool) -> None:
3887
3928
  last_shown = gettext("•••") if s.name in last else "—"
3888
3929
  else:
3889
3930
  last_shown = last.get(s.name, "—")
3931
+ shown_default = analysis.effective_default(s, current_defaults)
3890
3932
  default_shown = (
3891
3933
  gettext("•••")
3892
- if s.secret and s.default is not None
3893
- else ("—" if s.default is None else str(s.default))
3934
+ if s.secret and shown_default is not None
3935
+ else ("—" if shown_default is None else str(shown_default))
3894
3936
  )
3895
3937
  table.add_row(
3896
3938
  escape(s.name),