@microck/raft 0.5.0 → 0.7.0

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 (63) hide show
  1. package/README.md +27 -15
  2. package/deploy/build-image.py +4 -2
  3. package/deploy/raft-expire.py +109 -31
  4. package/deploy/verify-controls.py +6 -6
  5. package/deploy/verify-features.py +329 -19
  6. package/deploy/verify-jobs.py +264 -0
  7. package/deploy/verify-limits.py +22 -3
  8. package/deploy/verify-live.py +31 -6
  9. package/deploy/verify-output.py +208 -0
  10. package/deploy/verify-release.py +33 -4
  11. package/deploy/verify-run.py +571 -0
  12. package/deploy/verify-self-update.py +9 -1
  13. package/deploy/verify-sync.py +122 -0
  14. package/docs/benchmarks/read-latency-2026-10-29.json +70 -0
  15. package/docs/boat-capabilities.md +60 -7
  16. package/docs/boat-parity-design.md +1 -0
  17. package/docs/boot-performance.mdx +21 -0
  18. package/docs/contract.md +88 -13
  19. package/docs/first-box.mdx +22 -19
  20. package/docs/guides/background-jobs.mdx +38 -31
  21. package/docs/guides/backup-recovery.mdx +4 -3
  22. package/docs/guides/ci-jobs.mdx +209 -0
  23. package/docs/guides/config-policy.mdx +109 -35
  24. package/docs/guides/files.mdx +2 -2
  25. package/docs/guides/index.mdx +31 -0
  26. package/docs/guides/long-running-services.mdx +9 -3
  27. package/docs/guides/meta.json +7 -2
  28. package/docs/guides/project-setup.mdx +1 -1
  29. package/docs/guides/scripting-ci.mdx +36 -14
  30. package/docs/guides/snapshots-forks.mdx +4 -2
  31. package/docs/guides/troubleshooting.mdx +19 -2
  32. package/docs/guides/upgrades.mdx +11 -9
  33. package/docs/index.mdx +26 -28
  34. package/docs/maintainers/index.mdx +18 -0
  35. package/docs/maintainers/meta.json +12 -0
  36. package/docs/meta.json +5 -9
  37. package/docs/operations.mdx +17 -10
  38. package/docs/publishing.md +14 -4
  39. package/docs/reference/capacity.mdx +2 -1
  40. package/docs/reference/cli.md +135 -43
  41. package/docs/reference/configuration.mdx +10 -6
  42. package/docs/reference/meta.json +3 -1
  43. package/docs/releases/0.2.0.md +1 -0
  44. package/docs/releases/0.2.1.md +1 -0
  45. package/docs/releases/0.3.0.md +1 -0
  46. package/docs/releases/0.4.0.md +1 -0
  47. package/docs/releases/0.5.0.md +1 -0
  48. package/docs/releases/0.6.0.md +17 -0
  49. package/docs/releases/0.7.0.md +23 -0
  50. package/docs/releases/index.mdx +21 -0
  51. package/docs/releases/meta.json +15 -0
  52. package/docs/requirements.mdx +1 -1
  53. package/docs/reviews/docs-audit-2026-10-24.md +92 -0
  54. package/docs/reviews/performance-audit-2026-10-29.md +90 -0
  55. package/docs/site-development.md +1 -0
  56. package/docs/verification.mdx +22 -4
  57. package/package.json +3 -1
  58. package/raft.py +526 -193
  59. package/raft_files.py +13 -1
  60. package/raft_jobs.py +390 -0
  61. package/raft_output.py +166 -0
  62. package/raft_run.py +330 -0
  63. package/skills/raft-cli/SKILL.md +78 -182
package/raft_files.py CHANGED
@@ -34,14 +34,26 @@ def safe_member(member, destination):
34
34
  return tarfile.data_filter(member, destination)
35
35
 
36
36
 
37
- def pack_directory(source, stream):
37
+ def pack_directory(source, stream, *, include=None):
38
38
  source = Path(source).resolve(strict=True)
39
39
  if not source.is_dir():
40
40
  raise ValueError("Recursive transfer source must be a directory")
41
41
 
42
+ allowed = None
43
+ if include is not None:
44
+ allowed = {"."}
45
+ for value in include:
46
+ path = PurePosixPath(value)
47
+ if path.is_absolute() or ".." in path.parts or ".git" in path.parts:
48
+ raise ValueError("Invalid filtered upload path: " + value)
49
+ allowed.add(str(path))
50
+ allowed.update(str(parent) for parent in path.parents)
51
+
42
52
  with tarfile.open(fileobj=stream, mode="w|") as archive:
43
53
 
44
54
  def add(parent, entry, name):
55
+ if allowed is not None and str(PurePosixPath(name)) not in allowed:
56
+ return
45
57
  # O_PATH pins the entry without opening a device or following a link.
46
58
  # All metadata, reads and descent then use that held object.
47
59
  descriptor = os.open(entry, os.O_PATH | os.O_NOFOLLOW, dir_fd=parent)
package/raft_jobs.py ADDED
@@ -0,0 +1,390 @@
1
+ """Host-owned, bounded results for guest systemd jobs.
2
+
3
+ The controller sends this stdlib-only module to the host. Supervisors are
4
+ transient host services, so closing the controller cannot lose result capture.
5
+ """
6
+
7
+ import fcntl
8
+ from contextlib import contextmanager
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ import re
13
+ import shutil
14
+ import stat
15
+ import subprocess
16
+ import sys
17
+ import time
18
+
19
+ ROOT = Path("/var/lib/raft-jobs")
20
+ LOG_LIMIT = 1024 * 1024
21
+ RECORD_LIMIT = 512
22
+ RETENTION = 7 * 86400
23
+ TERMINAL = {"completed", "cancelled", "interrupted", "failed"}
24
+ STATES = TERMINAL | {"starting", "running"}
25
+
26
+
27
+ def sync_directory(directory):
28
+ descriptor = os.open(directory, os.O_RDONLY | os.O_DIRECTORY)
29
+ try:
30
+ os.fsync(descriptor)
31
+ finally:
32
+ os.close(descriptor)
33
+
34
+
35
+ def save(directory, record):
36
+ staged = directory / "result.tmp"
37
+ with staged.open("w") as output:
38
+ json.dump(record, output)
39
+ output.flush()
40
+ os.fsync(output.fileno())
41
+ staged.replace(directory / "result.json")
42
+ sync_directory(directory)
43
+
44
+
45
+ def load(directory):
46
+ path = directory / "result.json"
47
+ try:
48
+ record = json.loads(path.read_text())
49
+ if (
50
+ not isinstance(record, dict)
51
+ or record.get("job") != directory.name
52
+ or not isinstance(record.get("box"), str)
53
+ or not isinstance(record.get("bootId"), str)
54
+ or not isinstance(record.get("state"), str)
55
+ or record["state"] not in STATES
56
+ or (record["state"] in TERMINAL and type(record.get("finishedAt")) is not int)
57
+ ):
58
+ raise ValueError("Invalid job record")
59
+ return record
60
+ except (FileNotFoundError, ValueError) as error:
61
+ raise ValueError(
62
+ f"Detached job not found or its record is unreadable: {path}. "
63
+ "It may have expired; inspect the host record and retained output if present."
64
+ ) from error
65
+
66
+
67
+ def boot_id():
68
+ return Path("/proc/sys/kernel/random/boot_id").read_text().strip()
69
+
70
+
71
+ @contextmanager
72
+ def private_store(root, create=False):
73
+ if create:
74
+ root.mkdir(mode=0o700, exist_ok=True)
75
+ sync_directory(root.parent)
76
+ descriptor = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
77
+ metadata = os.fstat(descriptor)
78
+ if metadata.st_uid != os.geteuid() or stat.S_IMODE(metadata.st_mode) != 0o700:
79
+ os.close(descriptor)
80
+ raise ValueError("Job store must be owned by the host operator with mode 0700")
81
+ try:
82
+ fcntl.flock(descriptor, fcntl.LOCK_EX)
83
+ yield
84
+ finally:
85
+ os.close(descriptor)
86
+
87
+
88
+ def capture(directory, command):
89
+ """Drain all output while retaining only the first LOG_LIMIT bytes on disk."""
90
+ dropped = 0
91
+ kept = 0
92
+ with (directory / "output").open("wb", buffering=0) as log:
93
+ with subprocess.Popen(
94
+ command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL
95
+ ) as process:
96
+ while chunk := process.stdout.read1(65536):
97
+ remaining = max(0, LOG_LIMIT - kept)
98
+ retained = chunk[:remaining]
99
+ if retained:
100
+ log.write(retained)
101
+ kept += len(retained)
102
+ dropped += len(chunk) - len(retained)
103
+ status = process.wait()
104
+ os.fsync(log.fileno())
105
+ return status, dropped
106
+
107
+
108
+ def worker(root, name, job):
109
+ directory = root / job
110
+ with private_store(root):
111
+ record = load(directory)
112
+ record.update(state="running", startedAt=int(time.time()))
113
+ save(directory, record)
114
+ command = [
115
+ "incus",
116
+ "--project",
117
+ "raft",
118
+ "exec",
119
+ name,
120
+ "--",
121
+ "systemd-run",
122
+ "--quiet",
123
+ "--wait",
124
+ "--pipe",
125
+ # Simple services treat several termination signals as success and discard
126
+ # their exit metadata. Oneshot retains those failures for inspection below.
127
+ "--service-type=oneshot",
128
+ "--unit",
129
+ job,
130
+ "--property=WorkingDirectory=/workspace",
131
+ "--",
132
+ *record["command"],
133
+ ]
134
+ try:
135
+ status, dropped = capture(directory, command)
136
+ # During shutdown the guest can still answer queries and return a payload
137
+ # status. Inspect the manager as well, including for zero-exit handlers.
138
+ inspected = subprocess.run(
139
+ [
140
+ "incus",
141
+ "--project",
142
+ "raft",
143
+ "exec",
144
+ name,
145
+ "--",
146
+ "sh",
147
+ "-c",
148
+ 'systemctl show "$1" -p ExecMainCode -p ExecMainStatus -p SubState '
149
+ "&& systemctl show -p SystemState",
150
+ "raft-job-status",
151
+ job,
152
+ ],
153
+ capture_output=True,
154
+ timeout=60,
155
+ )
156
+ properties = dict(
157
+ line.split("=", 1)
158
+ for line in inspected.stdout.decode(errors="replace").splitlines()
159
+ if "=" in line
160
+ )
161
+ confirmed = (
162
+ inspected.returncode == 0
163
+ and properties.get("SystemState") not in {None, "stopping"}
164
+ and (
165
+ status == 0
166
+ or (
167
+ properties.get("ExecMainCode", "0") != "0"
168
+ and properties.get("SubState") != "running"
169
+ )
170
+ )
171
+ )
172
+ exit_code = None
173
+ if confirmed:
174
+ exit_code = 0 if status == 0 else int(properties["ExecMainStatus"])
175
+ if status != 0 and properties["ExecMainCode"] != "1":
176
+ exit_code += 128
177
+ record.update(
178
+ state="completed" if confirmed else "interrupted",
179
+ exitCode=exit_code,
180
+ droppedBytes=dropped,
181
+ )
182
+ except subprocess.TimeoutExpired:
183
+ # Only the post-run inspection has a timeout; an unknown result is not success.
184
+ record.update(state="interrupted", exitCode=None, droppedBytes=dropped)
185
+ except Exception as error:
186
+ record.update(state="failed", exitCode=None, error=str(error))
187
+ with private_store(root):
188
+ if (directory / "cancelled").exists():
189
+ record.update(state="cancelled", exitCode=None)
190
+ record.pop("command", None)
191
+ record["finishedAt"] = int(time.time())
192
+ save(directory, record)
193
+
194
+
195
+ def prune(root):
196
+ # The store lock serializes reconciliation with worker result commits.
197
+ now = int(time.time())
198
+ current_boot = boot_id()
199
+ records = []
200
+ for directory in root.iterdir():
201
+ if directory.is_dir():
202
+ try:
203
+ records.append((directory, load(directory)))
204
+ except ValueError:
205
+ # Preserve damaged records and logs for inspection. A direct query
206
+ # still reports the record error, without disabling unrelated jobs.
207
+ continue
208
+ units = [
209
+ "raft-" + record["job"]
210
+ for _, record in records
211
+ if record["state"] not in TERMINAL and record["bootId"] == current_boot
212
+ ]
213
+ states = {}
214
+ if units:
215
+ try:
216
+ response = subprocess.run(
217
+ ["systemctl", "show", "-p", "Id", "-p", "ActiveState", *units],
218
+ capture_output=True,
219
+ text=True,
220
+ check=True,
221
+ timeout=10,
222
+ )
223
+ except subprocess.TimeoutExpired as error:
224
+ raise RuntimeError(
225
+ "Timed out reconciling detached jobs; retry the operation"
226
+ ) from error
227
+ for block in response.stdout.strip().split("\n\n"):
228
+ fields = dict(line.split("=", 1) for line in block.splitlines() if "=" in line)
229
+ if "Id" in fields:
230
+ states[fields["Id"].removesuffix(".service")] = fields.get("ActiveState")
231
+ for directory, record in records:
232
+ if record["state"] not in TERMINAL:
233
+ alive = record["bootId"] == current_boot and states.get("raft-" + record["job"]) in {
234
+ "active",
235
+ "activating",
236
+ "deactivating",
237
+ }
238
+ if not alive:
239
+ record.update(state="interrupted", exitCode=None, finishedAt=now)
240
+ record.pop("command", None)
241
+ save(directory, record)
242
+ if record["state"] in TERMINAL and now - record["finishedAt"] >= RETENTION:
243
+ shutil.rmtree(directory)
244
+
245
+
246
+ def start(root, name, job, payload, source):
247
+ if len(list(root.iterdir())) >= RECORD_LIMIT:
248
+ raise ValueError("Job store is full; results expire seven days after completion")
249
+ directory = root / job
250
+ directory.mkdir(mode=0o700)
251
+ try:
252
+ record = {
253
+ "job": job,
254
+ "box": name,
255
+ "state": "starting",
256
+ "exitCode": None,
257
+ "startedAt": None,
258
+ "finishedAt": None,
259
+ "droppedBytes": None,
260
+ "bootId": boot_id(),
261
+ "command": payload,
262
+ }
263
+ (directory / "output").touch(mode=0o600)
264
+ save(directory, record)
265
+ sync_directory(root)
266
+ except Exception:
267
+ # No supervisor has been submitted, so a partial allocation is safe to remove.
268
+ shutil.rmtree(directory)
269
+ raise
270
+ try:
271
+ response = subprocess.run(
272
+ [
273
+ "systemd-run",
274
+ "--quiet",
275
+ "--no-block",
276
+ "--collect",
277
+ "--unit",
278
+ "raft-" + job,
279
+ "--property=UMask=0077",
280
+ "--",
281
+ "python3",
282
+ "-c",
283
+ source,
284
+ "worker",
285
+ name,
286
+ job,
287
+ ],
288
+ capture_output=True,
289
+ timeout=10,
290
+ )
291
+ except subprocess.TimeoutExpired as error:
292
+ raise RuntimeError(
293
+ f"Timed out submitting {job}; inspect its retained status before retrying"
294
+ ) from error
295
+ if response.returncode:
296
+ shutil.rmtree(directory)
297
+ raise RuntimeError(response.stderr.decode(errors="replace").strip())
298
+ print(job)
299
+
300
+
301
+ def main(root=ROOT):
302
+ os.umask(0o077)
303
+ action, name, job = sys.argv[1:4]
304
+ if not re.fullmatch(r"rf-[a-f0-9]{16}", name) or not re.fullmatch(r"rfcmd-[a-f0-9]{32}", job):
305
+ raise ValueError("Invalid workspace or job identity")
306
+ if action == "worker":
307
+ worker(root, name, job)
308
+ return
309
+ if action == "start":
310
+ payload = json.loads(sys.argv[4])
311
+ if (
312
+ not isinstance(payload, list)
313
+ or not payload
314
+ or not all(isinstance(arg, str) and "\0" not in arg for arg in payload)
315
+ ):
316
+ raise ValueError("A detached job requires command arguments without NUL")
317
+ # The caller holds the host lifecycle lock. Probe before taking the
318
+ # shared job-store lock so an unresponsive guest cannot block other jobs.
319
+ try:
320
+ probe = subprocess.run(
321
+ ["incus", "--project", "raft", "exec", name, "--", "true"],
322
+ capture_output=True,
323
+ timeout=10,
324
+ )
325
+ except subprocess.TimeoutExpired as error:
326
+ raise RuntimeError("Workspace did not respond; no job was created") from error
327
+ if probe.returncode:
328
+ raise RuntimeError(
329
+ "Workspace is unavailable; no job was created: "
330
+ + probe.stderr.decode(errors="replace").strip()
331
+ )
332
+ with private_store(root, action == "start"):
333
+ prune(root)
334
+ if action == "start":
335
+ start(root, name, job, payload, sys.argv[5])
336
+ return
337
+ directory = root / job
338
+ record = load(directory)
339
+ if record["box"] != name:
340
+ raise ValueError("Detached job does not belong to this workspace")
341
+ if action == "status":
342
+ print(
343
+ json.dumps(
344
+ {
345
+ key: value
346
+ for key, value in record.items()
347
+ if key not in {"command", "bootId"}
348
+ }
349
+ )
350
+ )
351
+ elif action == "logs":
352
+ sys.stdout.buffer.write((directory / "output").read_bytes())
353
+ elif action == "cancel":
354
+ if record["state"] in {"completed", "cancelled"}:
355
+ return
356
+ marker = directory / "cancelled"
357
+ marker.touch(mode=0o600)
358
+ try:
359
+ response = subprocess.run(
360
+ ["incus", "--project", "raft", "exec", name, "--", "systemctl", "stop", job],
361
+ capture_output=True,
362
+ timeout=120,
363
+ )
364
+ except subprocess.TimeoutExpired as error:
365
+ raise RuntimeError(
366
+ "Timed out stopping the guest job; the cancellation request remains recorded. "
367
+ "Inspect the job and retry cancel."
368
+ ) from error
369
+ if response.returncode:
370
+ marker.unlink()
371
+ raise RuntimeError(
372
+ "Could not cancel job; it may still be starting: "
373
+ + response.stderr.decode(errors="replace").strip()
374
+ )
375
+ if record["state"] in TERMINAL:
376
+ # Reconciliation already confirmed the supervisor is gone, so
377
+ # cancel must finalize the result instead of waiting for a worker.
378
+ record.update(state="cancelled", exitCode=None, finishedAt=int(time.time()))
379
+ save(directory, record)
380
+ else:
381
+ raise ValueError("Unknown job operation")
382
+
383
+
384
+ if __name__ == "__main__":
385
+ try:
386
+ main()
387
+ except FileNotFoundError:
388
+ sys.exit("Detached job not found or its retained result has expired")
389
+ except (ValueError, RuntimeError, OSError) as error:
390
+ sys.exit(str(error))
package/raft_output.py ADDED
@@ -0,0 +1,166 @@
1
+ """JSONL output transport and parser-driven shell completion."""
2
+
3
+ import argparse
4
+ import base64
5
+ import json
6
+ import os
7
+ import select
8
+ import sys
9
+ import threading
10
+
11
+ _active = None
12
+
13
+
14
+ class UsageError(ValueError):
15
+ pass
16
+
17
+
18
+ class Parser(argparse.ArgumentParser):
19
+ def __init__(self, *args, **kwargs):
20
+ super().__init__(*args, **kwargs, allow_abbrev=False)
21
+
22
+ def error(self, message):
23
+ if _active is None:
24
+ super().error(message)
25
+ raise UsageError(message)
26
+
27
+
28
+ class JsonOutput:
29
+ """Capture OS descriptors too: subprocess bytes must not corrupt JSON stdout."""
30
+
31
+ def start(self):
32
+ global _active
33
+ sys.stdout.flush()
34
+ sys.stderr.flush()
35
+ self.output = os.dup(1)
36
+ self.saved = {fd: os.dup(fd) for fd in (1, 2)}
37
+ self.readers = {}
38
+ self.lock = threading.Lock()
39
+ self.closed = False
40
+ for fd, kind in ((1, "stdout"), (2, "stderr")):
41
+ reader, writer = os.pipe()
42
+ self.readers[reader] = kind
43
+ os.dup2(writer, fd)
44
+ os.close(writer)
45
+ _active = self
46
+ self.thread = threading.Thread(target=self.drain, daemon=True)
47
+ self.thread.start()
48
+ return self
49
+
50
+ def event(self, kind, **fields):
51
+ if self.closed:
52
+ return
53
+ encoded = (json.dumps({"type": kind, **fields}) + "\n").encode()
54
+ with self.lock:
55
+ if self.closed:
56
+ return
57
+ try:
58
+ while encoded:
59
+ encoded = encoded[os.write(self.output, encoded) :]
60
+ except BrokenPipeError:
61
+ # Continue draining so the command cannot deadlock on a full pipe
62
+ # after its consumer exits. No output can be delivered anymore.
63
+ self.closed = True
64
+
65
+ def drain(self):
66
+ readers = self.readers
67
+ while readers:
68
+ ready, _, _ = select.select(readers, [], [])
69
+ for fd in ready:
70
+ chunk = os.read(fd, 16384)
71
+ if chunk and not self.closed:
72
+ self.event(
73
+ readers[fd], encoding="base64", data=base64.b64encode(chunk).decode()
74
+ )
75
+ elif not chunk:
76
+ del readers[fd]
77
+ os.close(fd)
78
+
79
+ def finish(self, status):
80
+ global _active
81
+ sys.stdout.flush()
82
+ sys.stderr.flush()
83
+ for fd, original in self.saved.items():
84
+ os.dup2(original, fd)
85
+ os.close(original)
86
+ self.thread.join()
87
+ self.event("exit", code=status)
88
+ os.close(self.output)
89
+ _active = None
90
+
91
+
92
+ def data(value, *, indent=None):
93
+ if _active is None:
94
+ print(json.dumps(value, indent=indent))
95
+ else:
96
+ _active.event("data", value=value)
97
+
98
+
99
+ def error(code, message, *, prefix="raft"):
100
+ if _active is None:
101
+ print(f"{prefix}: {message}", file=sys.stderr, flush=True)
102
+ else:
103
+ _active.event("error", code=code, message=str(message))
104
+
105
+
106
+ def candidates(parser, words):
107
+ """Walk argparse's command tree without loading configuration or running commands."""
108
+ current = parser
109
+ waiting = None
110
+ for word in words[:-1]:
111
+ if word == "--":
112
+ return []
113
+ if waiting is not None:
114
+ waiting = None
115
+ continue
116
+ options = current._option_string_actions
117
+ option, separator, _ = word.partition("=")
118
+ if option in options:
119
+ action = options[option]
120
+ if action.nargs != 0 and not separator:
121
+ waiting = action
122
+ continue
123
+ child = next(
124
+ (a for a in current._actions if isinstance(a, argparse._SubParsersAction)), None
125
+ )
126
+ if child and word in child.choices:
127
+ current = child.choices[word]
128
+ prefix = words[-1] if words else ""
129
+ if waiting is not None:
130
+ values = [str(value) for value in (waiting.choices or [])]
131
+ else:
132
+ values = list(current._option_string_actions)
133
+ for action in current._actions:
134
+ if isinstance(action, argparse._SubParsersAction):
135
+ values += [name for name in action.choices if not name.startswith("_")]
136
+ elif not action.option_strings and action.choices:
137
+ values += [str(value) for value in action.choices]
138
+ return sorted({value for value in values if value.startswith(prefix)})
139
+
140
+
141
+ def completions(shell):
142
+ # These wrappers contain no command catalog. Each completion reads the current
143
+ # installed parser, including commands added after the script was generated.
144
+ if shell == "bash":
145
+ return """_raft_complete() {
146
+ local IFS=$'\\n'
147
+ COMPREPLY=( $(raft _complete -- "${COMP_WORDS[@]:1:COMP_CWORD}") )
148
+ }
149
+ complete -F _raft_complete raft
150
+ """
151
+ if shell == "zsh":
152
+ return """#compdef raft
153
+ _raft_complete() {
154
+ local -a matches
155
+ matches=("${(@f)$(raft _complete -- "${words[@]:1:$((CURRENT-1))}")}")
156
+ compadd -- "${matches[@]}"
157
+ }
158
+ compdef _raft_complete raft
159
+ """
160
+ return """function __raft_complete
161
+ set -l tokens (commandline -opc)
162
+ set -e tokens[1]
163
+ raft _complete -- $tokens (commandline -ct)
164
+ end
165
+ complete -c raft -f -a '(__raft_complete)'
166
+ """