dotslot 0.2.1__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.
dotslot-0.2.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ao3575911
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
dotslot-0.2.1/PKG-INFO ADDED
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: dotslot
3
+ Version: 0.2.1
4
+ Summary: Let agents fill the blanks in a file, and prove they touched nothing else.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/ao3575911/dotslot
7
+ Project-URL: Changelog, https://github.com/ao3575911/dotslot/blob/main/CHANGELOG.md
8
+ Keywords: templates,fixed-width,ai-agents,mcp,pre-commit,lint
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Environment :: Console
11
+ Classifier: Topic :: Software Development :: Quality Assurance
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: wcwidth>=0.2.6
16
+ Provides-Extra: test
17
+ Requires-Dist: pytest>=8; extra == "test"
18
+ Dynamic: license-file
19
+
20
+ [![M8ven Score](https://m8ven.ai/badge/mcp/ao3575911-dotslot-ia87ds?v=c3035f8aeacd04053e6285ac74d5eccb)](https://m8ven.ai/mcp/ao3575911-dotslot-ia87ds?s=readme)
21
+
22
+ ```
23
+ dotslot: let agents fill the blanks, and prove they touched nothing else.
24
+
25
+ Owner: `<./owner:____________>` Status: `<./status~:____________>`
26
+
27
+ $ dotslot fill release.md --set owner=Adam --set status="ready for tag" -i
28
+ $ dotslot check release.md --git-base HEAD
29
+ release.md: fill-safe (2 slot(s) changed)
30
+
31
+ CLI · pre-commit · GitHub Action · MCP server (Claude Code, Cursor, aider)
32
+ ```
33
+
34
+ [![ci](https://github.com/ao3575911/dotslot/actions/workflows/ci.yml/badge.svg)](https://github.com/ao3575911/dotslot/actions/workflows/ci.yml) · MIT · v0.2.1 · [Spec](SPEC.md) · [Experiments](EXPERIMENTS.md) · [Changelog](CHANGELOG.md)
35
+
36
+ ## What it is
37
+
38
+ Mark the parts of a file that may change with `<./________>`. An agent or script fills those blanks and nothing else. `dotslot check` proves the diff touched only the blanks, and that each blank kept its width, so columns and line wraps stay put. It works in docs, configs and code comments.
39
+
40
+ A file with slots is **fill-only**. That turns "don't touch the rest" into a rule a machine can check.
41
+
42
+ ## Install
43
+
44
+ Install from PyPI:
45
+
46
+ ```bash
47
+ pip install dotslot
48
+ dotslot --help
49
+ ```
50
+
51
+ Requires Python 3.10+ and `wcwidth`.
52
+
53
+ ## Use
54
+
55
+ ```bash
56
+ dotslot new notes --width 26 --lines 3 --comment '# ' # generate a slot or block
57
+ dotslot scan FILE # list slots, widths, capacity
58
+ dotslot fill FILE --set name=value -i # fill in place, width kept
59
+ dotslot check FILE --git-base HEAD [--allow a,b] # prove only slots changed
60
+ dotslot repad FILE --base OLD -i # restore padding after a hand edit
61
+ dotslot fit FILE --targets 80,100,120 # where will lines wrap?
62
+ ```
63
+
64
+ Slot forms: `<./______>` (unnamed), `<./owner:____>` (named), `<./status~:____>` (ellipsis on overflow). In Markdown, put slots in backticks. Full grammar: [SPEC.md](SPEC.md).
65
+
66
+ ## Integrations
67
+
68
+ | tool | files | what it does |
69
+ |---|---|---|
70
+ | MCP server | `dotslot-mcp --root .` | `dotslot_scan`, `dotslot_fill`, `dotslot_check` over stdio; fill verifies before writing; paths confined to root |
71
+ | Claude Code | [`integrations/claude-code/`](integrations/claude-code) | `.mcp.json`, a `PostToolUse` hook that exits 2 on a violation, and a skill |
72
+ | Cursor | [`integrations/cursor/.cursor/`](integrations/cursor/.cursor) | `mcp.json` and a fill-only rule |
73
+ | aider | [`integrations/aider/`](integrations/aider) | `lint-cmd: dotslot check --git-base HEAD` with auto-lint |
74
+ | any agent | [`integrations/AGENTS.md`](integrations/AGENTS.md) | snippet to paste into your `AGENTS.md` |
75
+ | pre-commit | [`.pre-commit-hooks.yaml`](.pre-commit-hooks.yaml) | `dotslot-check`, `dotslot-lint` |
76
+ | GitHub Action | [`action.yml`](action.yml) | checks PR files against the base SHA, writes a JSON report to the step summary |
77
+
78
+ pre-commit:
79
+
80
+ ```yaml
81
+ - repo: https://github.com/ao3575911/dotslot
82
+ rev: v0.2.1
83
+ hooks: [{ id: dotslot-check }]
84
+ ```
85
+
86
+ GitHub Action:
87
+
88
+ ```yaml
89
+ - uses: actions/checkout@v4
90
+ with: { fetch-depth: 0 }
91
+ - uses: ao3575911/dotslot@v0.2.1
92
+ with: { files: "docs/**/*.md config/*.yaml", allow: "status,owner" }
93
+ ```
94
+
95
+ ## Limits
96
+
97
+ dotslot is a check, not a sandbox. The comment finder is a heuristic until tree-sitter lands; formatters such as gofmt can move columns, so run dotslot after them; trailing whitespace counts as a change. See [CHANGELOG.md](CHANGELOG.md) for open items and [PLAN.md](PLAN.md) for the roadmap.
98
+
99
+ ## Repository layout
100
+
101
+ - `dotslot/`: the v0.2 package (CLI, MCP server).
102
+ - `slot/`, `bin/slot`: the v0.1 reference, kept only so `experiments/run.sh` reproduces findings (a)–(g). Not installed.
103
+ - `tests/`: 45 tests (12 v0.1, 33 v0.2). Run `pip install -e ".[test]" && pytest -q`.
104
+ - `experiments/`: `run.sh` (a–g) and `run_v02.sh` (v02, h, i), with recorded output in `out/`.
105
+
106
+ ## Licence
107
+
108
+ MIT. See [LICENSE](LICENSE). Releases are GPG-signed in CI and carry SHA256SUMS and build provenance; see [RELEASING.md](RELEASING.md).
@@ -0,0 +1,89 @@
1
+ [![M8ven Score](https://m8ven.ai/badge/mcp/ao3575911-dotslot-ia87ds?v=c3035f8aeacd04053e6285ac74d5eccb)](https://m8ven.ai/mcp/ao3575911-dotslot-ia87ds?s=readme)
2
+
3
+ ```
4
+ dotslot: let agents fill the blanks, and prove they touched nothing else.
5
+
6
+ Owner: `<./owner:____________>` Status: `<./status~:____________>`
7
+
8
+ $ dotslot fill release.md --set owner=Adam --set status="ready for tag" -i
9
+ $ dotslot check release.md --git-base HEAD
10
+ release.md: fill-safe (2 slot(s) changed)
11
+
12
+ CLI · pre-commit · GitHub Action · MCP server (Claude Code, Cursor, aider)
13
+ ```
14
+
15
+ [![ci](https://github.com/ao3575911/dotslot/actions/workflows/ci.yml/badge.svg)](https://github.com/ao3575911/dotslot/actions/workflows/ci.yml) · MIT · v0.2.1 · [Spec](SPEC.md) · [Experiments](EXPERIMENTS.md) · [Changelog](CHANGELOG.md)
16
+
17
+ ## What it is
18
+
19
+ Mark the parts of a file that may change with `<./________>`. An agent or script fills those blanks and nothing else. `dotslot check` proves the diff touched only the blanks, and that each blank kept its width, so columns and line wraps stay put. It works in docs, configs and code comments.
20
+
21
+ A file with slots is **fill-only**. That turns "don't touch the rest" into a rule a machine can check.
22
+
23
+ ## Install
24
+
25
+ Install from PyPI:
26
+
27
+ ```bash
28
+ pip install dotslot
29
+ dotslot --help
30
+ ```
31
+
32
+ Requires Python 3.10+ and `wcwidth`.
33
+
34
+ ## Use
35
+
36
+ ```bash
37
+ dotslot new notes --width 26 --lines 3 --comment '# ' # generate a slot or block
38
+ dotslot scan FILE # list slots, widths, capacity
39
+ dotslot fill FILE --set name=value -i # fill in place, width kept
40
+ dotslot check FILE --git-base HEAD [--allow a,b] # prove only slots changed
41
+ dotslot repad FILE --base OLD -i # restore padding after a hand edit
42
+ dotslot fit FILE --targets 80,100,120 # where will lines wrap?
43
+ ```
44
+
45
+ Slot forms: `<./______>` (unnamed), `<./owner:____>` (named), `<./status~:____>` (ellipsis on overflow). In Markdown, put slots in backticks. Full grammar: [SPEC.md](SPEC.md).
46
+
47
+ ## Integrations
48
+
49
+ | tool | files | what it does |
50
+ |---|---|---|
51
+ | MCP server | `dotslot-mcp --root .` | `dotslot_scan`, `dotslot_fill`, `dotslot_check` over stdio; fill verifies before writing; paths confined to root |
52
+ | Claude Code | [`integrations/claude-code/`](integrations/claude-code) | `.mcp.json`, a `PostToolUse` hook that exits 2 on a violation, and a skill |
53
+ | Cursor | [`integrations/cursor/.cursor/`](integrations/cursor/.cursor) | `mcp.json` and a fill-only rule |
54
+ | aider | [`integrations/aider/`](integrations/aider) | `lint-cmd: dotslot check --git-base HEAD` with auto-lint |
55
+ | any agent | [`integrations/AGENTS.md`](integrations/AGENTS.md) | snippet to paste into your `AGENTS.md` |
56
+ | pre-commit | [`.pre-commit-hooks.yaml`](.pre-commit-hooks.yaml) | `dotslot-check`, `dotslot-lint` |
57
+ | GitHub Action | [`action.yml`](action.yml) | checks PR files against the base SHA, writes a JSON report to the step summary |
58
+
59
+ pre-commit:
60
+
61
+ ```yaml
62
+ - repo: https://github.com/ao3575911/dotslot
63
+ rev: v0.2.1
64
+ hooks: [{ id: dotslot-check }]
65
+ ```
66
+
67
+ GitHub Action:
68
+
69
+ ```yaml
70
+ - uses: actions/checkout@v4
71
+ with: { fetch-depth: 0 }
72
+ - uses: ao3575911/dotslot@v0.2.1
73
+ with: { files: "docs/**/*.md config/*.yaml", allow: "status,owner" }
74
+ ```
75
+
76
+ ## Limits
77
+
78
+ dotslot is a check, not a sandbox. The comment finder is a heuristic until tree-sitter lands; formatters such as gofmt can move columns, so run dotslot after them; trailing whitespace counts as a change. See [CHANGELOG.md](CHANGELOG.md) for open items and [PLAN.md](PLAN.md) for the roadmap.
79
+
80
+ ## Repository layout
81
+
82
+ - `dotslot/`: the v0.2 package (CLI, MCP server).
83
+ - `slot/`, `bin/slot`: the v0.1 reference, kept only so `experiments/run.sh` reproduces findings (a)–(g). Not installed.
84
+ - `tests/`: 45 tests (12 v0.1, 33 v0.2). Run `pip install -e ".[test]" && pytest -q`.
85
+ - `experiments/`: `run.sh` (a–g) and `run_v02.sh` (v02, h, i), with recorded output in `out/`.
86
+
87
+ ## Licence
88
+
89
+ MIT. See [LICENSE](LICENSE). Releases are GPG-signed in CI and carry SHA256SUMS and build provenance; see [RELEASING.md](RELEASING.md).
@@ -0,0 +1,3 @@
1
+ from .core import scan_text, fill_text, SlotError # noqa
2
+ from .verify import verify # noqa
3
+ __version__ = "0.2.1"
@@ -0,0 +1,3 @@
1
+ import sys
2
+ from .cli import main
3
+ sys.exit(main())
@@ -0,0 +1,204 @@
1
+ """dotslot scan | fill | check | fit"""
2
+ from __future__ import annotations
3
+ import argparse, json, subprocess, sys
4
+ from .core import scan_text, fill_text, SlotError
5
+ from .verify import verify
6
+ from .width import layout, preview, legacy_ends, display_width
7
+
8
+ def read(p): return sys.stdin.read() if p == '-' else open(p, encoding='utf-8').read()
9
+
10
+ def parse_sets(items):
11
+ out = {}
12
+ for it in items or []:
13
+ k, sep, v = it.partition('=')
14
+ if not sep: raise SystemExit(f'--set needs name=value, got {it!r}')
15
+ out[k] = v
16
+ return out
17
+
18
+ def cmd_scan(a):
19
+ rows = []
20
+ for p in a.files:
21
+ slots, issues = scan_text(read(p), p, a.mode)
22
+ for s in slots:
23
+ rows.append(dict(file=p, line=s.line + 1, col=s.col, index=s.index, name=s.name, role=s.role,
24
+ width=s.width, lines=1 + len(s.conts), capacity=s.capacity + sum(c.capacity for c in s.conts),
25
+ filled=s.filled, value=(s.block_value() if s.conts else s.value) if s.filled else None,
26
+ policy=s.policy, in_comment=s.in_comment))
27
+ for sev, l, c, m in issues: print(f'{p}:{l+1}:{c}: {sev}: {m}', file=sys.stderr)
28
+ if a.json: print(json.dumps(rows, ensure_ascii=False, indent=1)); return 0
29
+ for r in rows:
30
+ v = '' if r['value'] is None else repr(r['value'])
31
+ print(f"{r['file']}:{r['line']}:{r['col']} @{r['index']} {r['name'] or '-':12} {r['role']:6} "
32
+ f"w={r['width']:<3} x{r['lines']} cap={r['capacity']:<3} {'filled' if r['filled'] else 'empty ':6} "
33
+ f"{r['policy'] or '':8} {'#' if r['in_comment'] else ' '} {v}")
34
+ return 0
35
+
36
+ def cmd_fill(a):
37
+ vals = parse_sets(a.set)
38
+ if a.json_values: vals.update(json.load(open(a.json_values)))
39
+ rc = 0
40
+ allow = set(a.allow.split(',')) if a.allow else None
41
+ for p in a.files:
42
+ try:
43
+ out = fill_text(read(p), vals, p, a.mode, a.overflow, tuple(a.clear or ()), allow)
44
+ except SlotError as e:
45
+ print(f'{p}: error: {e}', file=sys.stderr); rc = 2; continue
46
+ if a.in_place and p != '-':
47
+ open(p, 'w', encoding='utf-8').write(out)
48
+ else:
49
+ sys.stdout.write(out)
50
+ return rc
51
+
52
+ def fit_report(text, path, mode, targets, tab, show_preview):
53
+ slots, _ = scan_text(text, path, mode)
54
+ lines = text.split('\n'); out = []; bad = {t: 0 for t in targets}
55
+ by_line = {}
56
+ for s in slots:
57
+ for x in [s] + s.conts: by_line.setdefault(x.line, []).append((s, x))
58
+ for li in sorted(by_line):
59
+ ln = lines[li]
60
+ cl = layout([(ln, 'line')], tab)
61
+ lw = cl[-1].col + cl[-1].width if cl else 0
62
+ leg = legacy_ends([(ln, 'line')], tab)[-1]
63
+ grows = any(h.policy == 'grow' for h, _ in by_line[li])
64
+ for head, x in by_line[li]:
65
+ end = display_width(ln[:x.end], 0, tab)
66
+ st = {}
67
+ for t in targets:
68
+ s_ = 'over' if end > t else 'pushes' if lw > t else 'maybe' if leg > t else 'ok'
69
+ st[t] = s_
70
+ if s_ != 'ok': bad[t] += 1
71
+ label = (head.name or f'@{head.index}') + ('' if x is head else f'[+{x.line - head.line}]')
72
+ out.append(f"{path}:{li+1}: slot {label} col {x.col}..{end} (line {lw}{'' if leg == lw else f', per-codepoint {leg}'})"
73
+ f" {'grow' if grows else 'static'} " + ' '.join(f'{t}:{v}' for t, v in st.items()))
74
+ if show_preview:
75
+ for t in targets:
76
+ if st[t] in ('over', 'pushes') and (p := preview(cl, t)):
77
+ out.append(f" @{t}: {p['note']}" + (' (2-cell glyph straddles edge)' if p['straddle'] else ''))
78
+ out.append(f" word │{p['word'][0]}\n │{p['word'][1]}")
79
+ return out, bad
80
+
81
+ def cmd_fit(a):
82
+ targets = sorted(int(t) for t in a.targets.split(','))
83
+ rc = 0
84
+ for p in a.files:
85
+ text = read(p)
86
+ if a.set:
87
+ try: text = fill_text(text, parse_sets(a.set), p, a.mode, a.overflow)
88
+ except SlotError as e: print(f'{p}: error: {e}', file=sys.stderr); rc = 2; continue
89
+ out, bad = fit_report(text, p, a.mode, targets, a.tab, a.preview)
90
+ print('\n'.join(out)) if out else None
91
+ if bad.get(a.fail_at or targets[0]): rc = max(rc, 1)
92
+ return rc
93
+
94
+ def base_text(a, p):
95
+ if a.base: return read(a.base)
96
+ if a.git_base:
97
+ r = subprocess.run(['git', 'show', f'{a.git_base}:./{p}'], capture_output=True, text=True,
98
+ cwd=None)
99
+ return r.stdout if r.returncode == 0 else None # new file: nothing to compare
100
+ return None
101
+
102
+ def cmd_new(a):
103
+ pol = a.policy or ''
104
+ head = f'{a.name}{pol}:' if a.name else (f'{pol}:' if pol else '')
105
+ if a.width < len(head) + 1: raise SystemExit(f'width {a.width} leaves no room after {head!r}')
106
+ pre, post = a.comment, (' ' + CLOSE[a.comment.strip()] if a.comment.strip() in CLOSE else '')
107
+ lines = [pre + '<./' + head + a.pad * (a.width - len(head)) + '>' + post]
108
+ for _ in range(a.lines - 1):
109
+ lines.append(pre + '<./|' + a.pad * (a.width - 1) + '>' + post)
110
+ if a.md: lines = ['`' + l + '`' for l in lines]
111
+ print('\n'.join(lines)); return 0
112
+
113
+ CLOSE = {'<!--': '-->', '/*': '*/'}
114
+
115
+ def cmd_repad(a):
116
+ from .core import scan_text as sc, render
117
+ base, text = read(a.base), read(a.files[0])
118
+ bs, _ = sc(base, a.files[0], a.mode); ns, _ = sc(text, a.files[0], a.mode)
119
+ if len(bs) != len(ns): print('slot count differs; cannot repad', file=sys.stderr); return 2
120
+ lines = text.split('\n'); fixed = 0
121
+ for b, n in sorted(zip(bs, ns), key=lambda p: (p[1].line, -p[1].start)):
122
+ if b.width != n.width and not n.conts and b.policy != 'grow' and b.header == n.header:
123
+ if len(n.raw_value) and __import__('dotslot.width', fromlist=['cells']).cells(n.header + n.raw_value) <= b.width:
124
+ ln = lines[n.line]
125
+ lines[n.line] = ln[:n.start] + '<./' + render(n.header, n.raw_value, b.width, b.pad) + '>' + ln[n.end:]
126
+ fixed += 1
127
+ out = '\n'.join(lines)
128
+ if a.in_place: open(a.files[0], 'w', encoding='utf-8').write(out)
129
+ else: sys.stdout.write(out)
130
+ print(f'repadded {fixed} slot(s)', file=sys.stderr); return 0
131
+
132
+ def cmd_check(a):
133
+ rc = 0
134
+ req = set((a.require or '').split(',')) - {''}
135
+ allow = set(a.allow.split(',')) if a.allow else None
136
+ reports = []
137
+ for p in a.files:
138
+ text = read(p)
139
+ b = base_text(a, p)
140
+ if b is not None:
141
+ r = verify(b, text, p, a.mode, allow); r['file'] = p; reports.append(r)
142
+ if not a.json:
143
+ for c in r['changed']: print(f"{p}:{c['line']}: filled {c['slot']}: {c['before']!r} -> {c['after']!r}")
144
+ for x in r['violations']: print(f'{p}: VIOLATION: {x}')
145
+ print(f"{p}: {'fill-safe' if r['ok'] else 'NOT fill-safe'} ({len(r['changed'])} slot(s) changed)")
146
+ if not r['ok']: rc = 1
147
+ continue
148
+ slots, issues = scan_text(text, p, a.mode)
149
+ probs = [(sev, l, c, m) for sev, l, c, m in issues]
150
+ for s in slots:
151
+ if (a.require_filled or (s.name in req)) and not s.filled:
152
+ probs.append(('E', s.line, s.col, f'{s.name or "@%d" % s.index} is empty'))
153
+ names = {s.name for s in slots}
154
+ for n in req - names: probs.append(('E', 0, 0, f'required slot {n!r} not found'))
155
+ if a.fit:
156
+ out, bad = fit_report(text, p, a.mode, [a.fit], a.tab, False)
157
+ for o in out:
158
+ if f'{a.fit}:ok' not in o: probs.append(('E', 0, 0, 'fit: ' + o.split(': ', 1)[1]))
159
+ for sev, l, c, m in probs:
160
+ if not a.json: print(f'{p}:{l+1}:{c}: {sev}: {m}')
161
+ if sev == 'E' or (a.strict and sev == 'W'): rc = 1
162
+ if not probs and not a.json: print(f'{p}: ok ({len(slots)} slots)')
163
+ if a.json: print(json.dumps(reports, ensure_ascii=False, indent=1))
164
+ return rc
165
+
166
+ def main(argv=None):
167
+ ap = argparse.ArgumentParser(prog='dotslot', description='fixed-width <./____> slots (v0.2)')
168
+ sub = ap.add_subparsers(dest='cmd', required=True)
169
+ def common(sp):
170
+ sp.add_argument('files', nargs='+')
171
+ sp.add_argument('--mode', choices=['auto', 'prose', 'comments'], default='auto',
172
+ help='auto: bare slots only in prose files (.md .txt …); everything else comment-only')
173
+ s = sub.add_parser('scan'); common(s); s.add_argument('--json', action='store_true')
174
+ for name in ('fill', 'fit'):
175
+ f = sub.add_parser(name); common(f)
176
+ f.add_argument('--set', action='append', help='name=value or @N=value')
177
+ f.add_argument('--overflow', choices=['error', 'ellipsis', 'truncate', 'grow'], default='error',
178
+ help='policy for slots without a !/~/+ marker')
179
+ f.add_argument('--allow', help='comma list of slot names/@N that may be filled')
180
+ if name == 'fill':
181
+ f.add_argument('--json-values'); f.add_argument('-i', '--in-place', action='store_true')
182
+ f.add_argument('--clear', action='append', help='empty a slot again (name or @N)')
183
+ else:
184
+ f.add_argument('--targets', default='80,100,120'); f.add_argument('--tab', type=int, default=8)
185
+ f.add_argument('--preview', action='store_true'); f.add_argument('--fail-at', type=int)
186
+ n = sub.add_parser('new', help='print a correctly sized slot or block')
187
+ n.add_argument('name', nargs='?'); n.add_argument('--width', type=int, required=True)
188
+ n.add_argument('--lines', type=int, default=1); n.add_argument('--policy', choices=['!', '~', '+'])
189
+ n.add_argument('--comment', default='', help="prefix such as '# ' or '<!-- '"); n.add_argument('--pad', default='_')
190
+ n.add_argument('--md', action='store_true', help='wrap in backticks (Markdown code span)')
191
+ n.set_defaults(indent_only=False)
192
+ r = sub.add_parser('repad', help='restore slot widths from a base file (fixes agents that drop padding)')
193
+ r.add_argument('files', nargs=1); r.add_argument('--base', required=True); r.add_argument('-i', '--in-place', action='store_true')
194
+ r.add_argument('--mode', choices=['auto', 'prose', 'comments'], default='auto')
195
+ c = sub.add_parser('check'); common(c)
196
+ c.add_argument('--require', help='comma list of names that must be filled')
197
+ c.add_argument('--require-filled', action='store_true'); c.add_argument('--strict', action='store_true')
198
+ c.add_argument('--fit', type=int, help='also fail lines wider than N cells'); c.add_argument('--tab', type=int, default=8)
199
+ c.add_argument('--base', help='fill-safe check: compare against this earlier version of the file')
200
+ c.add_argument('--git-base', help='fill-safe check against a git revision (e.g. HEAD, origin/main)')
201
+ c.add_argument('--allow', help='with --base: only these slots may change')
202
+ c.add_argument('--json', action='store_true')
203
+ a = ap.parse_args(argv)
204
+ return dict(scan=cmd_scan, fill=cmd_fill, check=cmd_check, fit=cmd_fit, new=cmd_new, repad=cmd_repad)[a.cmd](a)