dotslot 0.2.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.
dotslot/__init__.py ADDED
@@ -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"
dotslot/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ import sys
2
+ from .cli import main
3
+ sys.exit(main())
dotslot/cli.py ADDED
@@ -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)
dotslot/core.py ADDED
@@ -0,0 +1,284 @@
1
+ r"""dotslot v0.2 — fixed-width fill-in slots `<./________>`.
2
+
3
+ Interior (between `<./` and `>`), width W = its display cells in the file:
4
+ empty <./________> padding only ('_' or ' ')
5
+ named <./status:____> IDENT [policy] ':' padding
6
+ filled <./status:done__> header + value + padding
7
+ <./done_____> unnamed value: no header, no cost
8
+ policy <./~:______> unnamed slot with a policy
9
+ cont <./|more text___> block continuation
10
+ policy ! error (default) ~ ellipsis + grow
11
+ Escapes inside a value: \\ \< \> \_ \: \| (the tool writes them for you)
12
+ """
13
+ from __future__ import annotations
14
+ import os, re
15
+ from dataclasses import dataclass, field
16
+ from .width import cells, cut, display_width
17
+
18
+ SLOT = re.compile(r'<\./((?:\\.|[^<>\\\n])*)>')
19
+ OPEN = re.compile(r'<\./')
20
+ IDENT = r'[A-Za-z_][\w-]*'
21
+ HEADER = re.compile(rf'(?:({IDENT})([!~+]?)|([!~+])):')
22
+ PAD = ' _'
23
+ POLICY = {'!': 'error', '~': 'ellipsis', '+': 'grow', '': None}
24
+ MARK = {v: k for k, v in POLICY.items() if v}
25
+ PROSE = {'.md', '.markdown', '.txt', '.text', '.rst', '.adoc', '.org', '.slot'}
26
+ COMMENT = {
27
+ **{e: ('#',) for e in '.py .sh .bash .zsh .yaml .yml .toml .rb .pl .r .mk .cfg .conf .dockerfile'.split()},
28
+ **{e: ('//', '/*') for e in '.js .mjs .cjs .ts .tsx .jsx .c .h .cc .cpp .go .rs .java .kt .swift .cs .css .scss .json5'.split()},
29
+ **{e: ('--',) for e in '.sql .lua .hs .elm'.split()},
30
+ **{e: (';',) for e in '.ini .el .lisp .clj .scm .asm'.split()},
31
+ **{e: ('<!--',) for e in '.html .htm .xml .svg .vue'.split()},
32
+ }
33
+ ALL_MARKERS = ('#', '//', '/*', '--', ';', '<!--')
34
+ CLOSERS = ('-->', '*/')
35
+
36
+ class SlotError(Exception):
37
+ pass
38
+
39
+ def unescape(s): return re.sub(r'\\(.)', r'\1', s)
40
+
41
+ def escape(v: str, unnamed: bool) -> str:
42
+ v = v.replace('\\', '\\\\').replace('<', '\\<').replace('>', '\\>')
43
+ if unnamed:
44
+ if v[:1] == '|': v = '\\' + v
45
+ m = HEADER.match(v)
46
+ if m: v = v[:m.end() - 1] + '\\:' + v[m.end():]
47
+ if v and v[-1] in PAD: v = v[:-1] + '\\' + v[-1]
48
+ return v
49
+
50
+ def split_pad(s):
51
+ """-> (body, padding) where padding is the trailing run of unescaped ' '/'_'."""
52
+ i = len(s)
53
+ while i > 0 and s[i - 1] in PAD:
54
+ # stop if this pad char is escaped (odd number of backslashes before it)
55
+ j, bs = i - 2, 0
56
+ while j >= 0 and s[j] == '\\': bs += 1; j -= 1
57
+ if bs % 2: break
58
+ i -= 1
59
+ return s[:i], s[i:]
60
+
61
+ @dataclass
62
+ class Slot:
63
+ line: int; start: int; end: int; col: int; interior: str
64
+ kind: str # empty | named | filled | cont
65
+ name: str | None = None
66
+ policy: str | None = None
67
+ header: str = '' # raw header incl ':' (or '|' for cont)
68
+ raw_value: str = '' # escaped form as in the file
69
+ pad: str = '_'
70
+ in_comment: bool = False
71
+ role: str = 'inline'
72
+ conts: list = field(default_factory=list)
73
+ index: int = 0
74
+
75
+ @property
76
+ def width(self): return cells(self.interior)
77
+ @property
78
+ def capacity(self): return self.width - cells(self.header)
79
+ @property
80
+ def value(self): return unescape(self.raw_value) if self.raw_value else None
81
+ @property
82
+ def filled(self): return bool(self.raw_value)
83
+ def block_value(self):
84
+ return ' '.join(p for p in [self.value or ''] + [c.value or '' for c in self.conts] if p)
85
+
86
+ def parse_interior(s: str):
87
+ if '\t' in s: return None
88
+ body, padding = split_pad(s)
89
+ pad = '_' if '_' in padding else (' ' if padding else '_')
90
+ if body.startswith('|'):
91
+ return dict(kind='cont', header='|', raw_value=body[1:], pad=pad)
92
+ m = HEADER.match(body)
93
+ if m:
94
+ name, pol = m.group(1), POLICY[m.group(2) or m.group(3) or '']
95
+ rest = body[m.end():]
96
+ return dict(kind='filled' if rest else ('named' if name else 'empty'), name=name, policy=pol,
97
+ header=m.group(0), raw_value=rest, pad=pad)
98
+ if not body:
99
+ return dict(kind='empty', pad=pad) if padding else None
100
+ return dict(kind='filled', raw_value=body, pad=pad)
101
+
102
+ def comment_start(line: str, markers) -> int:
103
+ """Index of the first comment marker outside '…' "…" `…` strings, or -1."""
104
+ q, i, n = None, 0, len(line)
105
+ while i < n:
106
+ c = line[i]
107
+ if q:
108
+ if c == '\\': i += 2; continue
109
+ if c == q: q = None
110
+ elif c in '"\'`':
111
+ # treat ' as a quote only when it pairs later on the line (Rust lifetimes, prose)
112
+ if c != "'" or line.find("'", i + 1) != -1: q = c
113
+ else:
114
+ for mk in markers:
115
+ if line.startswith(mk, i): return i
116
+ i += 1
117
+ return -1
118
+
119
+ def markers_for(path, mode):
120
+ ext = os.path.splitext(path or '')[1].lower()
121
+ base = os.path.basename(path or '').lower()
122
+ if mode == 'prose': return None
123
+ if mode == 'auto' and (ext in PROSE or path in ('-', '/dev/stdin')): return None
124
+ if base in ('dockerfile', 'makefile'): return ('#',)
125
+ return COMMENT.get(ext, ALL_MARKERS)
126
+
127
+ def scan_text(text: str, path: str = '-', mode: str = 'auto'):
128
+ lines = text.split('\n')
129
+ markers = markers_for(path, mode)
130
+ slots, issues = [], []
131
+ for i, ln in enumerate(lines):
132
+ taken = set()
133
+ cpos = comment_start(ln, markers) if markers else -1
134
+ for m in SLOT.finditer(ln):
135
+ taken.add(m.start())
136
+ col = display_width(ln[:m.start()])
137
+ if m.group(1) == '':
138
+ issues.append(('E', i, col, 'zero-width slot <./>')); continue
139
+ p = parse_interior(m.group(1))
140
+ if p is None:
141
+ issues.append(('W', i, col, f'slot-like text {m.group(0)!r} is not a valid slot (ignored)')); continue
142
+ if markers is not None and not (0 <= cpos < m.start()):
143
+ issues.append(('W', i, col, f'{m.group(0)!r} is outside a comment (ignored; use --mode prose)')); continue
144
+ s = Slot(i, m.start(), m.end(), col, m.group(1), in_comment=markers is not None, **p)
145
+ if (os.path.splitext(path)[1].lower() in ('.md', '.markdown') and '_' in m.group(1)
146
+ and ln[:m.start()].count('`') % 2 == 0):
147
+ issues.append(('W', i, col, f"{m.group(0)!r}: '_' outside a code span can render as emphasis in Markdown; wrap it in backticks"))
148
+ if s.kind == 'cont': s.role = 'cont'
149
+ slots.append(s)
150
+ for m in OPEN.finditer(ln):
151
+ if m.start() not in taken and (markers is None or 0 <= cpos < m.start()):
152
+ issues.append(('E', i, display_width(ln[:m.start()]), "unterminated slot: '<./' without '>' on the same line"))
153
+ by_line = {}
154
+ for s in slots: by_line.setdefault(s.line, []).append(s)
155
+ owned = set()
156
+ for s in slots:
157
+ if s.kind in ('named', 'filled') and s.name:
158
+ j = s.line + 1
159
+ while True:
160
+ c = next((x for x in by_line.get(j, []) if x.kind == 'cont' and x.col == s.col), None)
161
+ if not c: break
162
+ if c.width != s.width:
163
+ issues.append(('E', j, c.col, f'block {s.name}: continuation width {c.width} != head width {s.width}')); break
164
+ owned.add(id(c)); s.conts.append(c); j += 1
165
+ if s.conts: s.role = 'head'
166
+ for s in slots:
167
+ if s.kind == 'cont' and id(s) not in owned:
168
+ issues.append(('E', s.line, s.col, 'continuation <./|…> with no named slot directly above at the same column'))
169
+ top = [s for s in slots if s.role != 'cont']
170
+ widths = {}
171
+ for k, s in enumerate(top):
172
+ s.index = k + 1
173
+ if s.name: widths.setdefault(s.name, set()).add(s.width)
174
+ if s.capacity < 1 and s.policy != 'grow':
175
+ issues.append(('E', s.line, s.col, f'{s.name or "slot"}: header leaves {s.capacity} cells for a value (width {s.width})'))
176
+ for n, ws in widths.items():
177
+ if len(ws) > 1: issues.append(('W', 0, 0, f'name {n!r} used with different widths {sorted(ws)}'))
178
+ return top, issues
179
+
180
+ # ---------------------------------------------------------------- fill
181
+ def clean(v: str, block: bool):
182
+ v = str(v)
183
+ if block: v = re.sub(r'\s*\n\s*', ' ', v)
184
+ if '\t' in v or '\n' in v:
185
+ raise SlotError('value contains a tab or newline; inline slots hold one line')
186
+ return v.rstrip(' ')
187
+
188
+ def render(header, raw, width, pad):
189
+ body = header + raw
190
+ return body + pad * max(width - cells(body), 0)
191
+
192
+ def cut_escaped(v, cap, unnamed, ellipsis):
193
+ """Cut the *unescaped* value so its escaped form fits cap; prefer a word boundary for ellipsis."""
194
+ lo, best = 0, ''
195
+ for k in range(len(v), -1, -1):
196
+ head = v[:k].rstrip(' ') if ellipsis else v[:k]
197
+ e = escape(head, unnamed) + ('…' if ellipsis else '')
198
+ if cells(e) <= cap:
199
+ best = head; break
200
+ if ellipsis and ' ' in best:
201
+ wb = best[:best.rfind(' ')].rstrip(' ')
202
+ if wb and cells(wb) >= (cap * 2) // 3: best = wb
203
+ return escape(best, unnamed) + ('…' if ellipsis else '')
204
+
205
+ def fit_value(v, cap, policy, what, unnamed):
206
+ e = escape(v, unnamed)
207
+ if cells(e) <= cap: return e
208
+ if policy == 'ellipsis':
209
+ if cap < 2: raise SlotError(f'{what}: no room for an ellipsis')
210
+ return cut_escaped(v, cap, unnamed, True)
211
+ if policy == 'truncate': return cut_escaped(v, cap, unnamed, False)
212
+ if policy == 'grow': return e
213
+ raise SlotError(f'{what}: value is {cells(e)} cells, slot holds {cap} (overflow policy: error)')
214
+
215
+ def wrap_words(v, caps):
216
+ words, lines, k = [w for w in v.split(' ') if w], [], 0
217
+ for cap in caps:
218
+ cur = ''
219
+ while k < len(words):
220
+ w = words[k]; cand = (cur + ' ' + w) if cur else w
221
+ if cells(escape(cand, False)) <= cap: cur, k = cand, k + 1
222
+ elif not cur and cells(escape(w, False)) > cap:
223
+ head, _ = cut(w, max(cap - 1, 1))
224
+ if not head: break
225
+ cur, words[k] = head, w[len(head):]; break
226
+ else: break
227
+ lines.append(cur)
228
+ return lines, ' '.join(words[k:])
229
+
230
+ def fill_text(text, values, path='-', mode='auto', default_policy='error', clear=(), allow=None):
231
+ slots, issues = scan_text(text, path, mode)
232
+ errs = [x for x in issues if x[0] == 'E']
233
+ if errs: raise SlotError('; '.join(f'line {l+1}: {m}' for _, l, _, m in errs))
234
+ lines = text.split('\n'); edits, inserts, used = [], [], set()
235
+ for s in slots:
236
+ key = f'@{s.index}'
237
+ target = key if key in values or key in clear else s.name
238
+ if target is None or (target not in values and target not in clear): continue
239
+ if allow is not None and target not in allow:
240
+ raise SlotError(f'{target}: not in the allowed slot list')
241
+ if target in clear:
242
+ used.add(target)
243
+ for x in [s] + s.conts:
244
+ h = x.header
245
+ edits.append((x.line, x.start, x.end, '<./' + render(h, '', x.width, x.pad) + '>'))
246
+ continue
247
+ used.add(target)
248
+ pol = s.policy or default_policy; what = s.name or key; unnamed = not s.header
249
+ v = clean(values[target], bool(s.conts))
250
+ if not s.conts:
251
+ raw = fit_value(v, s.capacity, pol, what, unnamed)
252
+ edits.append((s.line, s.start, s.end, '<./' + render(s.header, raw, s.width, s.pad) + '>'))
253
+ continue
254
+ rows = [s] + s.conts; caps = [r.capacity for r in rows]
255
+ got, left = wrap_words(v, caps); extra = []
256
+ if left:
257
+ if pol == 'grow':
258
+ while left:
259
+ more, left2 = wrap_words(left, [s.width - 1])
260
+ if left2 == left: raise SlotError(f'{what}: cannot wrap {left!r}')
261
+ extra.append(more[0]); left = left2
262
+ elif pol == 'ellipsis':
263
+ last = (got[-1] + ' ' + left).strip()
264
+ got[-1] = None; edits_last = cut_escaped(last, caps[-1], False, True)
265
+ elif pol == 'truncate': pass
266
+ else:
267
+ raise SlotError(f'{what}: block holds {sum(caps)} cells in {len(caps)} lines; '
268
+ f'{cells(left)} cells left over (overflow policy: error)')
269
+ for r, g in zip(rows, got):
270
+ raw = edits_last if g is None else escape(g, False)
271
+ edits.append((r.line, r.start, r.end, '<./' + render(r.header, raw, r.width, r.pad) + '>'))
272
+ if extra:
273
+ last = rows[-1]; ln = lines[last.line]
274
+ before, after = ln[:last.start], ln[last.end:]
275
+ lead = before if not before.strip(' \t#/;*-!<') else ' ' * display_width(before)
276
+ tail = after if after.strip() in CLOSERS or not after.strip() else ''
277
+ inserts.append((last.line, [lead + '<./' + render('|', escape(e, False), s.width, last.pad) + '>' + tail for e in extra]))
278
+ missing = [k for k in list(values) + list(clear) if k not in used]
279
+ if missing: raise SlotError(f'no slot named {", ".join(missing)}')
280
+ for li, st, en, new in sorted(edits, key=lambda e: (e[0], -e[1])):
281
+ lines[li] = lines[li][:st] + new + lines[li][en:]
282
+ for after, new in sorted(inserts, reverse=True):
283
+ lines[after + 1:after + 1] = new
284
+ return '\n'.join(lines)
dotslot/mcp.py ADDED
@@ -0,0 +1,77 @@
1
+ """Minimal MCP server (stdio, JSON-RPC 2.0, newline-delimited) exposing dotslot.
2
+ Run: python -m dotslot.mcp --root /path/to/repo
3
+ The only write path is `dotslot_fill`, which changes slot interiors and re-verifies the result."""
4
+ from __future__ import annotations
5
+ import argparse, difflib, json, os, sys
6
+ from .core import scan_text, fill_text, SlotError
7
+ from .verify import verify
8
+ from . import __version__
9
+
10
+ TOOLS = [
11
+ dict(name='dotslot_scan', description='List the fill-in slots in a file (name, width, capacity, current value).',
12
+ inputSchema=dict(type='object', properties=dict(path=dict(type='string')), required=['path'])),
13
+ dict(name='dotslot_fill', description='Fill named or @N slots in a file. Only slot interiors change; '
14
+ 'the result is verified fill-safe before it is written. Use dry_run to preview the diff.',
15
+ inputSchema=dict(type='object', properties=dict(
16
+ path=dict(type='string'), values=dict(type='object', additionalProperties=dict(type='string')),
17
+ overflow=dict(type='string', enum=['error', 'ellipsis', 'truncate', 'grow']),
18
+ dry_run=dict(type='boolean')), required=['path', 'values'])),
19
+ dict(name='dotslot_check', description='Prove a file differs from a base version only inside slots.',
20
+ inputSchema=dict(type='object', properties=dict(path=dict(type='string'), base_text=dict(type='string'),
21
+ allow=dict(type='array', items=dict(type='string'))), required=['path', 'base_text'])),
22
+ ]
23
+
24
+ class Server:
25
+ def __init__(self, root): self.root = os.path.realpath(root)
26
+ def _path(self, p):
27
+ full = os.path.realpath(os.path.join(self.root, p))
28
+ if os.path.commonpath([full, self.root]) != self.root: raise SlotError(f'{p}: outside the server root')
29
+ return full
30
+ def scan(self, path):
31
+ slots, issues = scan_text(open(self._path(path), encoding='utf-8').read(), path)
32
+ return dict(slots=[dict(index=s.index, name=s.name, line=s.line + 1, width=s.width, lines=1 + len(s.conts),
33
+ capacity=s.capacity + sum(c.capacity for c in s.conts), policy=s.policy,
34
+ value=(s.block_value() if s.conts else s.value)) for s in slots],
35
+ issues=[f'{sev} line {l+1}: {m}' for sev, l, _, m in issues])
36
+ def fill(self, path, values, overflow='error', dry_run=False):
37
+ full = self._path(path); old = open(full, encoding='utf-8').read()
38
+ new = fill_text(old, values, path, 'auto', overflow or 'error')
39
+ r = verify(old, new, path)
40
+ if not r['ok']: raise SlotError('refusing to write: ' + '; '.join(r['violations']))
41
+ diff = ''.join(difflib.unified_diff(old.splitlines(True), new.splitlines(True), path, path))
42
+ if not dry_run: open(full, 'w', encoding='utf-8').write(new)
43
+ return dict(written=not dry_run, changed=r['changed'], diff=diff)
44
+ def check(self, path, base_text, allow=None):
45
+ new = open(self._path(path), encoding='utf-8').read()
46
+ return verify(base_text, new, path, 'auto', set(allow) if allow else None)
47
+
48
+ def handle(self, msg):
49
+ m, mid, p = msg.get('method'), msg.get('id'), msg.get('params') or {}
50
+ if m == 'initialize':
51
+ res = dict(protocolVersion=p.get('protocolVersion', '2025-06-18'), capabilities=dict(tools={}),
52
+ serverInfo=dict(name='dotslot', version=__version__))
53
+ elif m == 'tools/list': res = dict(tools=TOOLS)
54
+ elif m == 'tools/call':
55
+ name, args = p.get('name'), p.get('arguments') or {}
56
+ fn = dict(dotslot_scan=self.scan, dotslot_fill=self.fill, dotslot_check=self.check).get(name)
57
+ try:
58
+ if not fn: raise SlotError(f'unknown tool {name}')
59
+ out, err = fn(**args), False
60
+ except (SlotError, OSError, TypeError) as e:
61
+ out, err = dict(error=str(e)), True
62
+ res = dict(content=[dict(type='text', text=json.dumps(out, ensure_ascii=False))], isError=err)
63
+ elif mid is None: return None # notification
64
+ elif m == 'ping': res = {}
65
+ else: return dict(jsonrpc='2.0', id=mid, error=dict(code=-32601, message=f'unknown method {m}'))
66
+ return None if mid is None else dict(jsonrpc='2.0', id=mid, result=res)
67
+
68
+ def main(argv=None):
69
+ ap = argparse.ArgumentParser(prog='dotslot-mcp'); ap.add_argument('--root', default='.')
70
+ a = ap.parse_args(argv); srv = Server(a.root)
71
+ for line in sys.stdin:
72
+ if not line.strip(): continue
73
+ out = srv.handle(json.loads(line))
74
+ if out is not None: sys.stdout.write(json.dumps(out, ensure_ascii=False) + '\n'); sys.stdout.flush()
75
+
76
+ if __name__ == '__main__':
77
+ main()
dotslot/verify.py ADDED
@@ -0,0 +1,58 @@
1
+ """Fill-safe verification: prove that only slot interiors changed between two texts."""
2
+ from __future__ import annotations
3
+ import difflib
4
+ from .core import scan_text
5
+
6
+ def _skeleton(text, slots):
7
+ lines = text.split('\n')
8
+ spans = {}
9
+ for s in slots:
10
+ key = s.name or f'@{s.index}'
11
+ spans.setdefault(s.line, []).append((s.start, s.end, f'⟦{key}⟧'))
12
+ for c in s.conts:
13
+ spans.setdefault(c.line, []).append((c.start, c.end, f'⟦{key}|⟧'))
14
+ out = []
15
+ for i, ln in enumerate(lines):
16
+ for st, en, tok in sorted(spans.get(i, []), reverse=True):
17
+ ln = ln[:st] + tok + ln[en:]
18
+ out.append(ln)
19
+ grow = {f'⟦{s.name}|⟧' for s in slots if s.policy == 'grow' and s.name}
20
+ col = [] # collapse repeated continuation lines of grow blocks (grow may add lines)
21
+ for ln in out:
22
+ if col and ln == col[-1] and any(g in ln for g in grow): continue
23
+ col.append(ln)
24
+ return col
25
+
26
+ def verify(base, new, path='-', mode='auto', allow=None):
27
+ bs, bi = scan_text(base, path, mode)
28
+ ns, ni = scan_text(new, path, mode)
29
+ v, changed = [], []
30
+ nlines = new.split('\n')
31
+ for sev, l, c, m in ni:
32
+ if sev == 'E': v.append(f'line {l+1}: {m}')
33
+ sb, sn = _skeleton(base, bs), _skeleton(new, ns)
34
+ if sb != sn:
35
+ d = [x for x in difflib.unified_diff(sb, sn, 'base', 'new', lineterm='', n=0)][2:]
36
+ v.append('text outside slots changed:\n ' + '\n '.join(d[:12]))
37
+ if len(bs) != len(ns):
38
+ v.append(f'slot count changed: {len(bs)} -> {len(ns)}')
39
+ for a, b in zip(bs, ns):
40
+ key = a.name or f'@{a.index}'
41
+ if (a.name, a.policy) != (b.name, b.policy):
42
+ v.append(f'{key}: header changed {a.header!r} -> {b.header!r}')
43
+ continue
44
+ if a.policy != 'grow':
45
+ if a.width != b.width:
46
+ v.append(f'{key}: width changed {a.width} -> {b.width} (closing > moved)')
47
+ if b.width < a.width and '>' in nlines[b.line][b.end:]:
48
+ v.append(f"{key}: hint: a raw '>' in the value closed the slot early; "
49
+ f"escape it as '\\>' or fill with `dotslot fill`, which escapes for you")
50
+ if len(a.conts) != len(b.conts):
51
+ v.append(f'{key}: block height changed {1+len(a.conts)} -> {1+len(b.conts)}')
52
+ va = a.block_value() if a.conts else (a.value or '')
53
+ vb = b.block_value() if b.conts else (b.value or '')
54
+ if va != vb:
55
+ changed.append(dict(slot=key, line=b.line + 1, before=va, after=vb))
56
+ if allow is not None and key not in allow:
57
+ v.append(f'{key}: changed but not in the allowed list')
58
+ return dict(ok=not v, changed=changed, violations=v, slots=len(ns))
dotslot/width.py ADDED
@@ -0,0 +1,120 @@
1
+ """Display-width model, vendored from /workspace/artifacts/slotfit/slotfit.py (experiment g).
2
+ wcwidth>=0.9 grapheme widths; tabs from the absolute column; ANSI = 0 cells."""
3
+ from __future__ import annotations
4
+ import re, unicodedata
5
+ from dataclasses import dataclass
6
+ from wcwidth import wcswidth, wcwidth
7
+
8
+ ANSI = re.compile(r'\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]')
9
+ ZWJ = '\u200d'
10
+
11
+ def _extends(c: str) -> bool:
12
+ """True if c attaches to the previous cluster (zero-width joiners/marks/modifiers)."""
13
+ o = ord(c)
14
+ return (c == ZWJ or unicodedata.combining(c) or unicodedata.category(c) in ('Mn', 'Me')
15
+ or 0xFE00 <= o <= 0xFE0F or 0x1F3FB <= o <= 0x1F3FF or 0xE0020 <= o <= 0xE007F)
16
+
17
+ def clusters(s: str):
18
+ """Yield display units: ANSI escapes, tabs, and grapheme-ish clusters."""
19
+ i, n = 0, len(s)
20
+ while i < n:
21
+ m = ANSI.match(s, i)
22
+ if m:
23
+ yield m.group(0); i = m.end(); continue
24
+ j = i + 1
25
+ while j < n and (_extends(s[j]) or s[j - 1] == ZWJ):
26
+ j += 1
27
+ # regional-indicator pair = one flag
28
+ if 0x1F1E6 <= ord(s[i]) <= 0x1F1FF and j < n and 0x1F1E6 <= ord(s[j]) <= 0x1F1FF:
29
+ j += 1
30
+ yield s[i:j]; i = j
31
+
32
+ def unit_width(u: str, col: int, tab: int) -> int:
33
+ if u == '\t':
34
+ return tab - (col % tab)
35
+ if u.startswith('\x1b'):
36
+ return 0
37
+ w = wcswidth(u)
38
+ if w < 0: # contains a control char: count printable parts only
39
+ w = sum(max(wcwidth(c), 0) for c in u)
40
+ return w
41
+
42
+ def legacy_ends(parts, tab=8):
43
+ """Per-part end columns under per-codepoint wcwidth (pyte, older xterm, many
44
+ TUI libs): a ZWJ family, skin-tone emoji or flag counts 4-6 cells, VS16 adds 0."""
45
+ col, ends = 0, []
46
+ for text, _ in parts:
47
+ i = 0
48
+ while i < len(text):
49
+ m = ANSI.match(text, i)
50
+ if m:
51
+ i = m.end(); continue
52
+ c = text[i]
53
+ col += tab - (col % tab) if c == '\t' else max(wcwidth(c), 0)
54
+ i += 1
55
+ ends.append(col)
56
+ return ends
57
+
58
+ @dataclass
59
+ class Cell:
60
+ text: str; col: int; width: int; src: str # src: 'line' | slot name
61
+
62
+ def layout(parts, tab=8):
63
+ """parts: [(text, src)] -> list[Cell] with absolute columns."""
64
+ cells, col = [], 0
65
+ for text, src in parts:
66
+ for u in clusters(text):
67
+ w = unit_width(u, col, tab)
68
+ cells.append(Cell(u, col, w, src)); col += w
69
+ return cells
70
+
71
+ def _end(_c, start, s, tab):
72
+ col = start
73
+ for u in clusters(s):
74
+ col += unit_width(u, col, tab)
75
+ return col - start
76
+
77
+ def display_width(s: str, start: int = 0, tab: int = 8) -> int:
78
+ """Cells s occupies when it starts at column `start` (tabs depend on start)."""
79
+ return _end(None, start, s, tab)
80
+
81
+ def wrap_point(cells, target):
82
+ """Index of first cell that does not fit in `target` columns, or None."""
83
+ for i, c in enumerate(cells):
84
+ if c.col + c.width > target:
85
+ return i
86
+ return None
87
+
88
+ def preview(cells, target):
89
+ k = wrap_point(cells, target)
90
+ if k is None:
91
+ return None
92
+ straddle = cells[k].width == 2 and cells[k].col == target - 1
93
+ vis = lambda cs: ''.join(' ' * c.width if c.text == '\t' else c.text for c in cs)
94
+ hard = (vis(cells[:k]), vis(cells[k:]))
95
+ # word wrap: last whitespace at or before k
96
+ b = next((i for i in range(k, -1, -1) if cells[i].text in (' ', '\t')), None)
97
+ if b is None or b == 0:
98
+ word = hard; moved = None; how = 'no space before the edge: falls back to a hard break'
99
+ else:
100
+ line1 = vis(cells[:b]).rstrip(' ')
101
+ rest = cells[b + 1:]
102
+ line2 = vis(rest)
103
+ moved = line2.split(' ')[0] if line2 else ''
104
+ word = (line1, line2); how = f'word "{moved}" moves to the next line'
105
+ return dict(target=target, hard_break_col=cells[k].col, hard=hard,
106
+ straddle=straddle, word=word, moved_word=moved, note=how)
107
+
108
+
109
+ def cells(s: str) -> int:
110
+ """Cells of s at column 0 ignoring tab-stop position (slot interiors forbid tabs)."""
111
+ return display_width(s, 0)
112
+
113
+ def cut(s: str, n: int):
114
+ """Longest prefix of s that fits in n cells -> (prefix, its cells)."""
115
+ out, w = [], 0
116
+ for u in clusters(s):
117
+ uw = unit_width(u, w, 8)
118
+ if w + uw > n: break
119
+ out.append(u); w += uw
120
+ return ''.join(out), w
@@ -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,13 @@
1
+ dotslot/__init__.py,sha256=2ZSUkgTgIxw8nSPXRv_Uopskmg11Q9xJPdU6l8eyqNA,115
2
+ dotslot/__main__.py,sha256=81Ek5TDGfhRLYb38ohIwEISYL8FAiiMV84Jhf6pukg8,50
3
+ dotslot/cli.py,sha256=AT7qZV1t4Bf42B1k_9ssAiK_wRJitjezaPGdVGjxnFs,11064
4
+ dotslot/core.py,sha256=ZXwNS6hhDQE_0CLxgGWhAqX2PPNSEIfU8NTcF0tgEPQ,13204
5
+ dotslot/mcp.py,sha256=da6G3j8AZv6oAC4mLDOemSdjs0x-XyW0EziN647N6Ps,4726
6
+ dotslot/verify.py,sha256=xlQUvFWjjY262dtp4xIDzddOR88r1Vrn-9JFCC0XERY,2769
7
+ dotslot/width.py,sha256=rVgqDJv_bVu4r6hCAqvfdnHWY3UC5cp5QTgm46za_3g,4367
8
+ dotslot-0.2.1.dist-info/licenses/LICENSE,sha256=42pO6DNxO2putX_18oSeOATK_LiOL9hvFHu_9LalqfQ,1066
9
+ dotslot-0.2.1.dist-info/METADATA,sha256=eIqjNx_psPF56r03VcE_FiXzW3FSXNHdM2kOhkGPxRc,4828
10
+ dotslot-0.2.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
11
+ dotslot-0.2.1.dist-info/entry_points.txt,sha256=WCOmATg-oO8C8Z7jIGHkNoP0Jc47K646UEn4KfNSxYM,76
12
+ dotslot-0.2.1.dist-info/top_level.txt,sha256=zVvev3A5wUo5G5qrsM7VcAUXP4q3Jrsa9G8n-7cKVto,8
13
+ dotslot-0.2.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ dotslot = dotslot.cli:main
3
+ dotslot-mcp = dotslot.mcp:main
@@ -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.
@@ -0,0 +1 @@
1
+ dotslot