mkit-cli 0.3.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.
mkit/output.py ADDED
@@ -0,0 +1,377 @@
1
+ """Output for two readers: a person at a terminal and an agent reading a pipe.
2
+
3
+ Human mode keeps the look of the per-project kits: a bold title, one checked
4
+ line per finished step with its detail underneath, and a sticky run bar as the
5
+ last line, rewritten in place. Detail is buffered until its step closes so it
6
+ always sits under its own heading.
7
+
8
+ JSON mode is for anything that is not a terminal (an agent's shell, CI, a pipe)
9
+ or `--json`. stdout then carries exactly one JSON document, the result, and
10
+ stderr carries one JSON event per line while the command runs:
11
+
12
+ {"event": "step", "title": "Build release bundle", "index": 5, "total": 11}
13
+ {"event": "detail", "text": "..."} {"event": "warn", "text": "..."}
14
+ {"event": "skipped", "step": "...", "reason": "..."}
15
+ {"event": "upload", "percent": 40}
16
+
17
+ The result is always the same envelope, so a caller parses one shape:
18
+
19
+ {"ok": true, "command": "doctor", "exit": 0, "result": {...},
20
+ "warnings": [...], "skipped": [...]}
21
+ {"ok": false, "command": "deploy", "exit": 3, "error": {"code", "message", "hint", "retryable"}}
22
+
23
+ MKIT_OUTPUT=human|json overrides the terminal check. Every string passes
24
+ through secrets.redact before it is written anywhere.
25
+ """
26
+ import json
27
+ import os
28
+ import shutil
29
+ import sys
30
+ import time
31
+
32
+ from .secrets import redact
33
+
34
+ for _stream in (sys.stdout, sys.stderr):
35
+ try:
36
+ _stream.reconfigure(encoding='utf-8')
37
+ except Exception:
38
+ pass
39
+
40
+ _CODES = {
41
+ 'dim': '\x1b[90m', 'bold': '\x1b[1m', 'green': '\x1b[32m',
42
+ 'cyan': '\x1b[36m', 'yellow': '\x1b[33m', 'red': '\x1b[31m',
43
+ }
44
+ _RESET = '\x1b[0m'
45
+ BAR_WIDTH = 24
46
+ UPLOAD_WIDTH = 14
47
+ INDENT = ' '
48
+
49
+
50
+ def _supports(text):
51
+ try:
52
+ text.encode(sys.stdout.encoding or 'utf-8')
53
+ return True
54
+ except (UnicodeEncodeError, LookupError):
55
+ return False
56
+
57
+
58
+ def human(seconds):
59
+ seconds = int(round(seconds))
60
+ if seconds < 60:
61
+ return f'{seconds}s'
62
+ return f'{seconds // 60}m {seconds % 60:02d}s'
63
+
64
+
65
+ def _visible_len(text):
66
+ out, i = 0, 0
67
+ while i < len(text):
68
+ if text[i] == '\x1b':
69
+ i = text.find('m', i) + 1
70
+ continue
71
+ out += 1
72
+ i += 1
73
+ return out
74
+
75
+
76
+ class Output:
77
+ def __init__(self, mode=None, color=None):
78
+ forced = os.environ.get('MKIT_OUTPUT')
79
+ tty = sys.stdout.isatty()
80
+ self.json = (mode or forced or ('human' if tty else 'json')) == 'json'
81
+ self.tty = tty and not self.json
82
+ if color is None:
83
+ color = not os.environ.get('NO_COLOR')
84
+ self.color = self.tty and color
85
+ unicode_ok = _supports('█░✓')
86
+ self.fill, self.empty = ('█', '░') if unicode_ok else ('#', '-')
87
+ self.warnings = []
88
+ self.skipped = []
89
+ self._reset_run()
90
+
91
+ # ------------------------------------------------------------ plumbing
92
+
93
+ def _reset_run(self):
94
+ self.total = 0
95
+ self.done_count = 0
96
+ self.title = None
97
+ self.run_started = None
98
+ self.step_started = None
99
+ self.buffer = []
100
+ self.sticky = False
101
+ self.upload_pct = None
102
+ self.count = None
103
+ self.count_mark = -1
104
+ self.last_draw = 0.0
105
+
106
+ def paint(self, style, text):
107
+ if not self.color or not style:
108
+ return text
109
+ return f'{_CODES[style]}{text}{_RESET}'
110
+
111
+ def event(self, name, **fields):
112
+ """One JSON line on stderr (JSON mode only)."""
113
+ if not self.json:
114
+ return
115
+ payload = {'event': name, **fields}
116
+ sys.stderr.write(redact(json.dumps(payload, ensure_ascii=False, default=str)) + '\n')
117
+ sys.stderr.flush()
118
+
119
+ def _print(self, line=''):
120
+ self._clear_sticky()
121
+ print(redact(line))
122
+ self._draw_sticky()
123
+
124
+ def _clear_sticky(self):
125
+ if self.sticky:
126
+ sys.stdout.write('\r\x1b[2K')
127
+ self.sticky = False
128
+
129
+ def _bar(self, pct, width):
130
+ filled = int(round(pct / 100 * width))
131
+ return self.fill * filled + self.empty * (width - filled)
132
+
133
+ def _width(self):
134
+ # One short of the real width: writing the last column makes some
135
+ # terminals wrap, and a wrapped sticky line cannot be erased.
136
+ try:
137
+ return max(28, shutil.get_terminal_size(fallback=(80, 24)).columns - 1)
138
+ except Exception:
139
+ return 79
140
+
141
+ def _draw_sticky(self):
142
+ if not self.tty or not self.total:
143
+ return
144
+ width = self._width()
145
+ pct = int(round(self.done_count / self.total * 100))
146
+ uploading = self.upload_pct is not None
147
+ run_w = BAR_WIDTH if width >= 90 else (14 if width >= 60 else 8)
148
+ up_w = UPLOAD_WIDTH if width >= 100 else (8 if width >= 70 else 6)
149
+ segments = [
150
+ ('dim', ' run '),
151
+ ('cyan', self._bar(pct, run_w)),
152
+ ('dim', f' {pct:>3}% {min(self.done_count + 1, self.total)} of {self.total}'),
153
+ ]
154
+ if self.title and not (uploading and width < 96):
155
+ segments.append((None, ' ' + self.title))
156
+ if uploading:
157
+ counted = f' {self.count[0]}/{self.count[1]}' if self.count else ''
158
+ segments += [('dim', ' upload '), ('cyan', self._bar(self.upload_pct, up_w)),
159
+ ('dim', f' {self.upload_pct:>3}%{counted}')]
160
+ elif self.step_started:
161
+ segments.append(('dim', ' ' + human(time.time() - self.step_started)))
162
+ out, used = [], 0
163
+ for style, text in segments:
164
+ if used >= width:
165
+ break
166
+ if used + len(text) > width:
167
+ text = text[:width - used]
168
+ out.append(self.paint(style, text))
169
+ used += len(text)
170
+ sys.stdout.write('\r\x1b[2K' + ''.join(out))
171
+ sys.stdout.flush()
172
+ self.sticky = True
173
+
174
+ def _buffer(self, style, text, kind):
175
+ text = redact(str(text))
176
+ if self.json:
177
+ self.event(kind, text=text, step=self.title)
178
+ return
179
+ if self.title is None:
180
+ print(INDENT + self.paint(style, text))
181
+ return
182
+ self.buffer.append((style, text))
183
+ self._draw_sticky()
184
+
185
+ # ------------------------------------------------------------ run API
186
+
187
+ def start(self, title, total, subtitle=None):
188
+ self._reset_run()
189
+ self.total = total
190
+ self.run_started = time.time()
191
+ if self.json:
192
+ self.event('start', title=title, subtitle=subtitle, total=total)
193
+ return
194
+ print()
195
+ head = self.paint('bold', title)
196
+ if subtitle:
197
+ head += self.paint('dim', ' ' + subtitle)
198
+ print(redact(head))
199
+ print()
200
+
201
+ def step(self, title):
202
+ self.title = title
203
+ self.step_started = time.time()
204
+ self.buffer = []
205
+ self.upload_pct = None
206
+ self.count = None
207
+ self.count_mark = -1
208
+ self.event('step', title=title, index=self.done_count + 1, total=self.total)
209
+ self._draw_sticky()
210
+
211
+ def detail(self, message):
212
+ self._buffer(None, message, 'detail')
213
+
214
+ def ok(self, message='done'):
215
+ self._buffer('green', message, 'detail')
216
+
217
+ def warn(self, message):
218
+ self.warnings.append(redact(str(message)))
219
+ self._buffer('yellow', message, 'warn')
220
+
221
+ def skip(self, step, reason):
222
+ """A pipeline step that did not run. Collected into the result."""
223
+ self.skipped.append({'step': step, 'reason': redact(reason)})
224
+ if self.json:
225
+ self.event('skipped', step=step, reason=redact(reason))
226
+ else:
227
+ self._buffer('yellow', f'skipped: {reason}', 'skipped')
228
+
229
+ def done(self, note=None):
230
+ if self.title is None:
231
+ return
232
+ elapsed = time.time() - self.step_started if self.step_started else 0
233
+ index = self.done_count + 1
234
+ if self.json:
235
+ self.event('done', title=self.title, index=index, seconds=round(elapsed, 1), note=note)
236
+ else:
237
+ heading = (self.paint('dim', f' [{index:02d}/{self.total:02d}] ')
238
+ + self.paint('green', 'OK') + ' ' + self.paint('bold', self.title))
239
+ if elapsed >= 5:
240
+ heading += self.paint('dim', ' ' + human(elapsed))
241
+ if note:
242
+ heading += self.paint('dim', ' ' + note)
243
+ self._clear_sticky()
244
+ print(redact(heading))
245
+ for style, text in self.buffer:
246
+ print(INDENT + self.paint(style, text))
247
+ self.done_count = index
248
+ self.title = None
249
+ self.step_started = None
250
+ self.buffer = []
251
+ self.upload_pct = None
252
+ self.count = None
253
+ self._draw_sticky()
254
+
255
+ def upload(self, percent):
256
+ pct = max(0, min(100, int(percent)))
257
+ if self.json:
258
+ if pct != self.upload_pct:
259
+ self.event('upload', percent=pct)
260
+ self.upload_pct = pct
261
+ return
262
+ self.upload_pct = pct
263
+ self._draw_sticky()
264
+
265
+ def progress(self, done, total, item=None):
266
+ """Work counted in items (files uploaded, calls made). At a terminal it
267
+ is the upload bar with "done/total"; piped, a line at every tenth; in
268
+ JSON a progress event per item."""
269
+ if self.json:
270
+ self.event('progress', done=done, total=total, item=item, step=self.title)
271
+ return
272
+ pct = int(done * 100 / total) if total else 100
273
+ self.count = (done, total)
274
+ self.upload_pct = pct
275
+ if self.tty:
276
+ self.tick() if done < total else self._draw_sticky()
277
+ return
278
+ mark = pct // 10
279
+ if mark != self.count_mark or done == total:
280
+ self.count_mark = mark
281
+ print(redact(f'{INDENT}{self.title or "progress"}: {done}/{total}'), flush=True)
282
+
283
+ def tick(self, throttle=0.15):
284
+ now = time.time()
285
+ if now - self.last_draw < throttle:
286
+ return
287
+ self.last_draw = now
288
+ self._draw_sticky()
289
+
290
+ def finish(self, message):
291
+ total = time.time() - self.run_started if self.run_started else 0
292
+ if self.json:
293
+ self.event('finish', message=message, seconds=round(total, 1))
294
+ return
295
+ self._clear_sticky()
296
+ if self.tty and self.total:
297
+ print(self.paint('dim', ' run ') + self.paint('green', self._bar(100, BAR_WIDTH))
298
+ + self.paint('dim', f' 100% {self.total} of {self.total}'))
299
+ print()
300
+ colour = 'yellow' if self.skipped else 'green'
301
+ suffix = f' ({len(self.skipped)} skipped)' if self.skipped else ''
302
+ print(self.paint(colour, redact('DONE ' + message + suffix
303
+ + (f' in {human(total)}' if total >= 5 else ''))))
304
+
305
+ # ------------------------------------------------------------ plain text
306
+
307
+ def line(self, text='', style=None):
308
+ """A plain informational line. JSON mode sends it to stderr as a log event."""
309
+ if self.json:
310
+ if text:
311
+ self.event('log', text=redact(str(text)))
312
+ return
313
+ self._print(self.paint(style, str(text)))
314
+
315
+ def heading(self, text):
316
+ self.line()
317
+ self.line(text, 'bold')
318
+
319
+ def table(self, rows, headers):
320
+ """Aligned columns for humans. JSON callers read the result instead."""
321
+ if self.json or not rows:
322
+ return
323
+ cells = [[str(c) for c in headers]] + [[str(c) for c in r] for r in rows]
324
+ widths = [max(len(r[i]) for r in cells) for i in range(len(headers))]
325
+ for n, row in enumerate(cells):
326
+ text = ' ' + ' '.join(c.ljust(w) for c, w in zip(row, widths)).rstrip()
327
+ self._print(self.paint('dim' if n == 0 else None, text))
328
+
329
+ # ------------------------------------------------------------ endings
330
+
331
+ def result(self, command, data, exit_code=0):
332
+ if not self.json:
333
+ return
334
+ envelope = {'ok': True, 'command': command, 'exit': exit_code, 'result': data,
335
+ 'warnings': self.warnings, 'skipped': self.skipped}
336
+ print(redact(json.dumps(envelope, ensure_ascii=False, indent=2, default=str)))
337
+
338
+ def failure(self, command, error, exit_code):
339
+ """Report an MkitError. Human: headline, detail, tool tail. JSON: envelope."""
340
+ if self.json:
341
+ envelope = {'ok': False, 'command': command, 'exit': exit_code,
342
+ 'error': error.as_dict(), 'warnings': self.warnings,
343
+ 'skipped': self.skipped}
344
+ print(redact(json.dumps(envelope, ensure_ascii=False, indent=2, default=str)))
345
+ return
346
+ self._clear_sticky()
347
+ if self.buffer:
348
+ # The open step never finished, so its held lines were never shown;
349
+ # among them is often the one that matters ("bump commit undone").
350
+ title = self.title or 'In progress'
351
+ print(self.paint('dim', f' [--/{self.total:02d}] ') + self.paint('red', '!!')
352
+ + ' ' + self.paint('bold', title))
353
+ for style, text in self.buffer:
354
+ print(INDENT + self.paint(style, text))
355
+ self.buffer = []
356
+ headline = redact(error.message)
357
+ print()
358
+ print(self.paint('red', headline))
359
+ print(self.paint('dim', '-' * min(_visible_len(headline), self._width())))
360
+ if error.hint:
361
+ print(' ' + redact(error.hint))
362
+ if error.lines:
363
+ print()
364
+ print(self.paint('dim', ' Last output:'))
365
+ for line in error.lines:
366
+ print(' ' + redact(line))
367
+ print()
368
+
369
+
370
+ out = Output()
371
+
372
+
373
+ def configure(mode=None, color=None):
374
+ """Replace the shared instance once flags are parsed."""
375
+ global out
376
+ out = Output(mode=mode, color=color)
377
+ return out
mkit/proc.py ADDED
@@ -0,0 +1,154 @@
1
+ """Running noisy external tools.
2
+
3
+ Gradle and the Firebase CLI print hundreds of lines and bury their own cause
4
+ when they fail. Output is captured rather than inherited, only lines that
5
+ matter surface as step detail, every other line is a heartbeat for the run
6
+ bar, and a failure carries the tail with the boilerplate stripped.
7
+ """
8
+ import os
9
+ import re
10
+ import subprocess
11
+
12
+ from . import output
13
+ from .errors import MkitError
14
+
15
+ ANSI = re.compile(r'\x1b\[[0-9;]*[A-Za-z]')
16
+
17
+
18
+ class ToolFailed(MkitError):
19
+ code = 'TOOL_FAILED'
20
+
21
+ def __init__(self, command, returncode, tail):
22
+ super().__init__(f'{command} exited {returncode}', lines=tail,
23
+ hint='The last lines of its output are below.')
24
+ self.command = command
25
+ self.returncode = returncode
26
+
27
+
28
+ # Compile errors are worth seeing live even on a build that goes on to pass:
29
+ # Dart "lib/x.dart:3:5: Error:", Kotlin "e: file:///...", javac "error:".
30
+ # Kotlin's marker only counts at the start of a line: "Note: " contains it too.
31
+ _BUILD_SIGNALS = ('error:', 'Error:')
32
+
33
+ _BUILD_NOISE = (
34
+ 'Deprecated Gradle features were used', "You can use '--warning-mode",
35
+ 'For more on this, please refer to', 'Run with --stacktrace', 'Run with --info',
36
+ 'Run with --scan', 'Get more help at', 'actionable task', '* Try:', '* Get more help',
37
+ 'Note: Recompile with', 'Note: Some input files use', 'Daemon will be stopped', 'w: ',
38
+ )
39
+
40
+ _FIREBASE_SIGNALS = ('rules', 'indexes', 'deploy complete', 'error', 'warning')
41
+
42
+
43
+ def build_interesting(line):
44
+ return bool(line) and (line.startswith('e: ') or any(m in line for m in _BUILD_SIGNALS))
45
+
46
+
47
+ def firebase_interesting(line):
48
+ return any(w in line.lower() for w in _FIREBASE_SIGNALS)
49
+
50
+
51
+ def never(line):
52
+ return False
53
+
54
+
55
+ def useful_tail(lines, limit=25):
56
+ kept = []
57
+ for line in lines:
58
+ if any(m in line for m in _BUILD_NOISE):
59
+ continue
60
+ if not line.strip() and (not kept or not kept[-1].strip()):
61
+ continue
62
+ kept.append(line)
63
+ while kept and not kept[-1].strip():
64
+ kept.pop()
65
+ return kept[-limit:]
66
+
67
+
68
+ def stream(argv, command, interesting=build_interesting, cwd=None, env=None, quiet=False,
69
+ done_marker=None):
70
+ """Run a tool with its output captured; raise ToolFailed with the tail on a
71
+ non-zero exit. Returns the last 60 lines.
72
+
73
+ [done_marker] is a line a tool prints once its work is actually finished. A
74
+ tool that says so and *then* exits non-zero failed at something it does
75
+ after the job -- telemetry, a cleanup, a version check -- and treating that
76
+ as a failed deploy is worse than wrong: the caller rolls back a change the
77
+ tool already made. When the marker was seen the exit code is reported as a
78
+ warning and the run counts as a success.
79
+ """
80
+ out = output.out
81
+ try:
82
+ proc = subprocess.Popen([str(a) for a in argv], cwd=cwd or os.getcwd(), env=env,
83
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
84
+ encoding='utf-8', errors='replace', bufsize=1)
85
+ except FileNotFoundError as e:
86
+ raise MkitError(f'could not start {command}: {e}', code='TOOL_MISSING') from e
87
+ tail = []
88
+ # Tracked as the lines go by rather than read back off the tail, which only keeps the
89
+ # last 60 and would lose the marker behind a talkative epilogue.
90
+ finished = False
91
+ for raw in proc.stdout:
92
+ clean = ANSI.sub('', raw.rstrip()).strip()
93
+ tail.append(clean)
94
+ if len(tail) > 60:
95
+ tail.pop(0)
96
+ if done_marker and done_marker in clean:
97
+ finished = True
98
+ if interesting(clean) and not quiet:
99
+ out.detail(clean)
100
+ else:
101
+ out.tick()
102
+ code = proc.wait()
103
+ if code != 0:
104
+ if finished:
105
+ out.warn(f'{command} finished its work, then exited {code} on its own; continuing')
106
+ return tail
107
+ raise ToolFailed(command, code, useful_tail(tail))
108
+ return tail
109
+
110
+
111
+ def run(argv, cwd=None, env=None, check=True, timeout=None):
112
+ """Run quietly and return stdout (stderr folded in)."""
113
+ try:
114
+ result = subprocess.run([str(a) for a in argv], cwd=cwd, env=env, text=True,
115
+ encoding='utf-8', errors='replace', capture_output=True,
116
+ timeout=timeout)
117
+ except FileNotFoundError as e:
118
+ raise MkitError(f'could not start {argv[0]}: {e}', code='TOOL_MISSING') from e
119
+ text = (result.stdout or '') + (result.stderr or '')
120
+ if check and result.returncode != 0:
121
+ raise ToolFailed(os.path.basename(str(argv[0])) + ' ' + ' '.join(str(a) for a in argv[1:3]),
122
+ result.returncode, useful_tail(text.splitlines()))
123
+ return text
124
+
125
+
126
+ def detach(argv, cwd=None, env=None):
127
+ """Start a windowed tool (Android Studio) and return its pid.
128
+
129
+ Detached on purpose: mkit exits while the IDE stays open, and nothing of
130
+ the IDE's own chatter lands in mkit's output or JSON envelope.
131
+ """
132
+ kwargs = ({'creationflags': subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP}
133
+ if os.name == 'nt' else {'start_new_session': True})
134
+ try:
135
+ started = subprocess.Popen([str(a) for a in argv], cwd=cwd, env=env,
136
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
137
+ stderr=subprocess.DEVNULL, **kwargs)
138
+ except OSError as e:
139
+ raise MkitError(f'could not start {argv[0]}: {e}', code='TOOL_MISSING') from e
140
+ return started.pid
141
+
142
+
143
+ def interactive(argv, cwd=None, env=None):
144
+ """Run with the terminal attached (flutter run, tests). Returns the exit code.
145
+
146
+ Ctrl+C reaches the tool and mkit alike. mkit ignores it while the tool runs,
147
+ so the tool decides how to stop (flutter run detaches cleanly) and mkit then
148
+ ends with its exit code, instead of a second "interrupted" error on top."""
149
+ import signal
150
+ previous = signal.signal(signal.SIGINT, signal.SIG_IGN)
151
+ try:
152
+ return subprocess.run([str(a) for a in argv], cwd=cwd, env=env).returncode
153
+ finally:
154
+ signal.signal(signal.SIGINT, previous)
mkit/registry.py ADDED
@@ -0,0 +1,99 @@
1
+ """The command registry: one declaration per command, read by every front end.
2
+
3
+ A command declares its name, a one-line summary, its risk class, the features
4
+ it needs, and its arguments. The CLI builds argparse from this, `mkit commands
5
+ --json` and `mkit schema` print it for agents, and a later MCP server will turn
6
+ the same entries into tools. Nothing about a command is described twice.
7
+
8
+ Risk classes, lowest first:
9
+
10
+ read looks, never changes anything anywhere
11
+ plan reads remote state and computes a diff; changes nothing
12
+ write changes local files or git, or a remote thing that is not user
13
+ facing (an internal app-sharing link, symbols)
14
+ publish changes what users, testers or Play review see; always confirmed
15
+ """
16
+ from dataclasses import dataclass, field
17
+
18
+ RISKS = ('read', 'plan', 'write', 'publish')
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Arg:
23
+ name: str
24
+ help: str
25
+ kind: str = 'option' # positional | option | flag
26
+ short: str | None = None
27
+ long: bool = True # False: the short flag is the only spelling
28
+ type: type = str
29
+ choices: tuple | None = None
30
+ default: object = None
31
+ nargs: str | None = None
32
+ repeat: bool = False # flag that may be given more than once
33
+
34
+ def schema(self):
35
+ json_type = {str: 'string', int: 'integer', float: 'number'}.get(self.type, 'string')
36
+ if self.kind == 'flag':
37
+ json_type = 'boolean'
38
+ node = {'type': json_type, 'description': self.help}
39
+ if self.nargs in ('*', '+', '...'):
40
+ node = {'type': 'array', 'items': {'type': json_type}, 'description': self.help}
41
+ if self.choices:
42
+ node['enum'] = list(self.choices)
43
+ if self.default is not None:
44
+ node['default'] = self.default
45
+ return node
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Command:
50
+ name: str # "doctor", or "listing plan" for a group member
51
+ summary: str
52
+ risk: str
53
+ handler: object
54
+ args: tuple = ()
55
+ needs: tuple = () # features that must be on, e.g. ("play",)
56
+ needs_project: bool = True
57
+ examples: tuple = ()
58
+ description: str = ''
59
+
60
+ def describe(self):
61
+ required = [a.name for a in self.args if a.kind == 'positional' and a.nargs not in ('?', '*', '...')]
62
+ return {
63
+ 'name': self.name,
64
+ 'summary': self.summary,
65
+ 'risk': self.risk,
66
+ 'needs': list(self.needs),
67
+ 'needs_project': self.needs_project,
68
+ 'confirm': self.risk == 'publish',
69
+ 'input': {
70
+ 'type': 'object',
71
+ 'properties': {a.name: a.schema() for a in self.args},
72
+ 'required': required,
73
+ },
74
+ 'examples': list(self.examples),
75
+ }
76
+
77
+
78
+ REGISTRY: dict[str, Command] = {}
79
+ GROUPS: dict[str, str] = {}
80
+
81
+
82
+ def group(name, summary):
83
+ GROUPS[name] = summary
84
+
85
+
86
+ def command(name, summary, risk, args=(), needs=(), needs_project=True, examples=(),
87
+ description=''):
88
+ if risk not in RISKS:
89
+ raise ValueError(f'{name}: risk must be one of {RISKS}')
90
+
91
+ def wrap(fn):
92
+ if name in REGISTRY:
93
+ raise ValueError(f'command {name!r} registered twice')
94
+ REGISTRY[name] = Command(name=name, summary=summary, risk=risk, handler=fn,
95
+ args=tuple(args), needs=tuple(needs),
96
+ needs_project=needs_project, examples=tuple(examples),
97
+ description=description or (fn.__doc__ or '').strip())
98
+ return fn
99
+ return wrap
mkit/safety.py ADDED
@@ -0,0 +1,60 @@
1
+ """The confirmation gate in front of every publish-class command.
2
+
3
+ At a terminal, mkit shows what is about to change and asks the person to type
4
+ the app name. Anywhere else (an agent's shell, a script) there is nobody to
5
+ ask, so it refuses with exit 6 unless the caller passes both --yes and
6
+ --confirm <package>. Naming the package is deliberate: it proves the caller
7
+ knows which app it is about to change, so a --yes carried over from another
8
+ project's command cannot publish to the wrong app.
9
+
10
+ --dry-run skips the gate, because a dry run publishes nothing.
11
+
12
+ `safety.no_prompt` (in ~/.mkit/config.toml or mkit.toml) lists commands a
13
+ person at a terminal runs without typing the name: what is about to change is
14
+ still shown, then it goes ahead. It only drops the prompt; off a terminal those
15
+ commands still need --yes --confirm, so an agent never gets through on it.
16
+ """
17
+ import sys
18
+
19
+ from .errors import NeedsConfirm
20
+
21
+
22
+ def no_prompt(project):
23
+ """Commands that skip the typed confirmation at a terminal."""
24
+ value = project.get('safety.no_prompt', []) if project else []
25
+ if isinstance(value, str): # MKIT_SAFETY_NO_PROMPT="deploy,promote"
26
+ value = value.split(',')
27
+ return {str(name).strip() for name in value if str(name).strip()}
28
+
29
+
30
+ def confirm(ctx, action, changes=()):
31
+ """Return normally when the publish may go ahead; raise NeedsConfirm otherwise."""
32
+ if ctx.dry_run:
33
+ return
34
+ package = ctx.project.package if ctx.project else None
35
+ expected = package or (ctx.project.name if ctx.project else '')
36
+
37
+ if ctx.yes and ctx.confirm:
38
+ if ctx.confirm not in (package, ctx.project.name if ctx.project else None):
39
+ raise NeedsConfirm(f'--confirm {ctx.confirm} does not match this project ({expected})',
40
+ hint=f'Re-run with --confirm {expected} if this is the app you mean.')
41
+ return
42
+
43
+ interactive = not ctx.out.json and sys.stdin.isatty() and sys.stdout.isatty()
44
+ if not interactive:
45
+ raise NeedsConfirm(f'{action} changes {expected} and needs confirmation',
46
+ hint=f'A person must approve this. Re-run with --yes --confirm {expected}, '
47
+ f'or run it at a terminal. Use --dry-run to see what it would do.')
48
+
49
+ out = ctx.out
50
+ out.line()
51
+ out.line(action, 'bold')
52
+ for change in changes:
53
+ out.line(f' {change}')
54
+ out.line()
55
+ if getattr(ctx, 'command', '') in no_prompt(ctx.project):
56
+ out.line(' Going ahead (safety.no_prompt).')
57
+ return
58
+ typed = input(f' Type {ctx.project.name} to go ahead: ').strip()
59
+ if typed.lower() != ctx.project.name.lower():
60
+ raise NeedsConfirm('not confirmed; nothing was changed')