kanibako-cli 1.5.0.dev14__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.
Files changed (85) hide show
  1. kanibako/__init__.py +3 -0
  2. kanibako/__main__.py +6 -0
  3. kanibako/auth_browser.py +296 -0
  4. kanibako/auth_parser.py +51 -0
  5. kanibako/browser_sidecar.py +183 -0
  6. kanibako/browser_state.py +103 -0
  7. kanibako/bun_sea.py +144 -0
  8. kanibako/cli.py +344 -0
  9. kanibako/commands/__init__.py +0 -0
  10. kanibako/commands/archive.py +228 -0
  11. kanibako/commands/box/__init__.py +22 -0
  12. kanibako/commands/box/_duplicate.py +395 -0
  13. kanibako/commands/box/_migrate.py +574 -0
  14. kanibako/commands/box/_parser.py +1178 -0
  15. kanibako/commands/clean.py +166 -0
  16. kanibako/commands/crab_cmd.py +480 -0
  17. kanibako/commands/diagnose.py +239 -0
  18. kanibako/commands/fork_cmd.py +51 -0
  19. kanibako/commands/helper_cmd.py +669 -0
  20. kanibako/commands/image.py +1300 -0
  21. kanibako/commands/install.py +152 -0
  22. kanibako/commands/refresh_credentials.py +67 -0
  23. kanibako/commands/restore.py +298 -0
  24. kanibako/commands/setup_cmd.py +89 -0
  25. kanibako/commands/start.py +1600 -0
  26. kanibako/commands/stop.py +116 -0
  27. kanibako/commands/system_cmd.py +224 -0
  28. kanibako/commands/upgrade.py +161 -0
  29. kanibako/commands/vault_cmd.py +199 -0
  30. kanibako/commands/workset_cmd.py +552 -0
  31. kanibako/config.py +514 -0
  32. kanibako/config_interface.py +573 -0
  33. kanibako/config_io.py +36 -0
  34. kanibako/container.py +607 -0
  35. kanibako/containerfiles.py +58 -0
  36. kanibako/containers/Containerfile.kanibako +99 -0
  37. kanibako/containers/Containerfile.template-android +55 -0
  38. kanibako/containers/Containerfile.template-dotnet +29 -0
  39. kanibako/containers/Containerfile.template-js +43 -0
  40. kanibako/containers/Containerfile.template-jvm +27 -0
  41. kanibako/containers/Containerfile.template-systems +46 -0
  42. kanibako/containers/__init__.py +0 -0
  43. kanibako/crabs.py +89 -0
  44. kanibako/errors.py +33 -0
  45. kanibako/freshness.py +67 -0
  46. kanibako/git.py +114 -0
  47. kanibako/helper_client.py +132 -0
  48. kanibako/helper_listener.py +538 -0
  49. kanibako/helpers.py +339 -0
  50. kanibako/hygiene.py +296 -0
  51. kanibako/image_sharing.py +133 -0
  52. kanibako/instructions.py +160 -0
  53. kanibako/log.py +31 -0
  54. kanibako/names.py +248 -0
  55. kanibako/paths.py +1483 -0
  56. kanibako/plugins/__init__.py +10 -0
  57. kanibako/registry.py +71 -0
  58. kanibako/rig_bundle.py +121 -0
  59. kanibako/rig_meta.py +92 -0
  60. kanibako/rig_registry.py +132 -0
  61. kanibako/rig_resolve.py +182 -0
  62. kanibako/rig_source.py +245 -0
  63. kanibako/scripts/__init__.py +0 -0
  64. kanibako/scripts/helper-init.sh +45 -0
  65. kanibako/scripts/kanibako-entry +12 -0
  66. kanibako/settings_resolve.py +312 -0
  67. kanibako/settings_seeds.py +154 -0
  68. kanibako/settings_shares.py +154 -0
  69. kanibako/shellenv.py +75 -0
  70. kanibako/snapshots.py +281 -0
  71. kanibako/targets/__init__.py +173 -0
  72. kanibako/targets/base.py +243 -0
  73. kanibako/targets/no_agent.py +58 -0
  74. kanibako/templates.py +60 -0
  75. kanibako/templates_image.py +224 -0
  76. kanibako/tweakcc.py +140 -0
  77. kanibako/tweakcc_cache.py +171 -0
  78. kanibako/utils.py +136 -0
  79. kanibako/workset.py +347 -0
  80. kanibako_cli-1.5.0.dev14.dist-info/METADATA +15 -0
  81. kanibako_cli-1.5.0.dev14.dist-info/RECORD +85 -0
  82. kanibako_cli-1.5.0.dev14.dist-info/WHEEL +5 -0
  83. kanibako_cli-1.5.0.dev14.dist-info/entry_points.txt +5 -0
  84. kanibako_cli-1.5.0.dev14.dist-info/licenses/LICENSE.md +594 -0
  85. kanibako_cli-1.5.0.dev14.dist-info/top_level.txt +1 -0
@@ -0,0 +1,116 @@
1
+ """kanibako stop: stop running kanibako containers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from kanibako.config import config_file_path, load_config
9
+ from kanibako.container import ContainerRuntime
10
+ from kanibako.errors import ContainerError
11
+ from kanibako.paths import xdg, load_std_paths, resolve_any_project
12
+ from kanibako.utils import container_name_for
13
+
14
+
15
+ def add_parser(subparsers: argparse._SubParsersAction) -> None:
16
+ p = subparsers.add_parser(
17
+ "stop",
18
+ help="Stop a running kanibako container",
19
+ description="Stop a running kanibako container for a project.",
20
+ )
21
+ p.add_argument(
22
+ "project", nargs="?", default=None,
23
+ help="Project name or path (default: cwd)",
24
+ )
25
+ p.add_argument(
26
+ "--all", action="store_true", dest="all_containers",
27
+ help="Stop all running kanibako containers",
28
+ )
29
+ p.add_argument(
30
+ "--force", action="store_true",
31
+ help="Skip confirmation prompt (only relevant with --all)",
32
+ )
33
+ p.set_defaults(func=run)
34
+
35
+
36
+ def run(args: argparse.Namespace) -> int:
37
+ try:
38
+ runtime = ContainerRuntime()
39
+ except ContainerError:
40
+ print(
41
+ "Error: No container runtime found.\n"
42
+ "Install podman (https://podman.io/) or Docker.",
43
+ file=sys.stderr,
44
+ )
45
+ return 1
46
+
47
+ if args.all_containers:
48
+ return _stop_all(runtime, force=getattr(args, "force", False))
49
+
50
+ return _stop_one(runtime, project_dir=getattr(args, "project", None))
51
+
52
+
53
+ def _stop_one(runtime: ContainerRuntime, *, project_dir: str | None) -> int:
54
+ """Stop the container for a single project."""
55
+ config_file = config_file_path(xdg("XDG_CONFIG_HOME", ".config"))
56
+ config = load_config(config_file)
57
+ std = load_std_paths(config)
58
+
59
+ proj = resolve_any_project(std, config, project_dir, initialize=False)
60
+ container_name = container_name_for(proj)
61
+
62
+ lock_file = proj.metadata_path / ".kanibako.lock"
63
+
64
+ if runtime.stop(container_name):
65
+ print(f"Stopped {container_name}")
66
+ # Clean up stopped container (persistent containers lack --rm)
67
+ if runtime.container_exists(container_name):
68
+ runtime.rm(container_name)
69
+ else:
70
+ print(f"No running container found for this project ({container_name})")
71
+ # Clean up stopped persistent container if it exists
72
+ if runtime.container_exists(container_name):
73
+ runtime.rm(container_name)
74
+ print(f"Removed stopped container: {container_name}")
75
+ else:
76
+ print("\nIf a stale lock file is blocking a new session, remove it manually:")
77
+ print(f" rm {lock_file}")
78
+
79
+ return 0
80
+
81
+
82
+ def _stop_all(runtime: ContainerRuntime, *, force: bool = False) -> int:
83
+ """Stop all running kanibako containers."""
84
+ containers = runtime.list_running()
85
+ if not containers:
86
+ print("No running kanibako containers found.")
87
+ return 0
88
+
89
+ # Confirmation prompt unless --force
90
+ if not force:
91
+ names = [name for name, _, _ in containers]
92
+ print(f"This will stop {len(containers)} running container(s):")
93
+ for n in names:
94
+ print(f" {n}")
95
+ print()
96
+ try:
97
+ answer = input("Continue? [y/N] ").strip().lower()
98
+ except (EOFError, KeyboardInterrupt):
99
+ answer = ""
100
+ if answer not in ("y", "yes"):
101
+ print("Aborted.")
102
+ return 2
103
+
104
+ stopped = 0
105
+ for name, image, status in containers:
106
+ if runtime.stop(name):
107
+ print(f"Stopped {name}")
108
+ # Clean up stopped container (persistent containers lack --rm)
109
+ if runtime.container_exists(name):
110
+ runtime.rm(name)
111
+ stopped += 1
112
+ else:
113
+ print(f"Failed to stop {name}", file=sys.stderr)
114
+
115
+ print(f"\nStopped {stopped} container(s).")
116
+ return 0
@@ -0,0 +1,224 @@
1
+ """kanibako system: global configuration, self-update, and system info."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from kanibako import __version__
9
+ from kanibako.config import config_file_path, load_config
10
+ from kanibako.paths import xdg
11
+
12
+
13
+ def add_parser(subparsers: argparse._SubParsersAction) -> None:
14
+ p = subparsers.add_parser(
15
+ "system",
16
+ help="Global configuration, upgrades, and system information",
17
+ description="Manage global kanibako configuration and perform system tasks.",
18
+ )
19
+ sys_sub = p.add_subparsers(dest="system_command", metavar="COMMAND")
20
+
21
+ # system info (default)
22
+ info_p = sys_sub.add_parser(
23
+ "info",
24
+ aliases=["inspect"],
25
+ help="Show system information",
26
+ )
27
+ info_p.set_defaults(func=run_info)
28
+
29
+ # system config [key[=value]] [--effective] [--reset] [--all] [--force]
30
+ config_p = sys_sub.add_parser(
31
+ "config",
32
+ help="View or modify global configuration",
33
+ )
34
+ config_p.add_argument(
35
+ "key_value", nargs="?", default=None,
36
+ help="key or key=value",
37
+ )
38
+ config_p.add_argument(
39
+ "--effective", action="store_true",
40
+ help="Show all resolved values including defaults",
41
+ )
42
+ config_p.add_argument(
43
+ "--reset", action="store_true",
44
+ help="Remove an override (revert to default)",
45
+ )
46
+ config_p.add_argument(
47
+ "--all", action="store_true", dest="all_keys",
48
+ help="With --reset: remove all overrides",
49
+ )
50
+ config_p.add_argument(
51
+ "--force", action="store_true",
52
+ help="Skip confirmation prompts",
53
+ )
54
+ config_p.set_defaults(func=run_config)
55
+
56
+ # system upgrade [--check]
57
+ from kanibako.commands.upgrade import run as run_upgrade_fn
58
+
59
+ upgrade_p = sys_sub.add_parser(
60
+ "upgrade",
61
+ help="Upgrade kanibako to the latest version",
62
+ )
63
+ upgrade_p.add_argument(
64
+ "--check", action="store_true",
65
+ help="Check for updates without installing",
66
+ )
67
+ upgrade_p.set_defaults(func=run_upgrade_fn)
68
+
69
+ # system diagnose
70
+ from kanibako.commands.diagnose import run_system_diagnose
71
+
72
+ diagnose_p = sys_sub.add_parser(
73
+ "diagnose",
74
+ help="Check system health (runtime, images, agents, storage)",
75
+ )
76
+ diagnose_p.set_defaults(func=run_system_diagnose)
77
+
78
+ # Default to info when 'system' is run without a subcommand
79
+ p.set_defaults(func=run_info)
80
+
81
+
82
+ def run_info(args: argparse.Namespace) -> int:
83
+ """Show system information: version, paths, runtime."""
84
+ import platform
85
+
86
+ config_home = xdg("XDG_CONFIG_HOME", ".config")
87
+ cf = config_file_path(config_home)
88
+
89
+ print(f"Kanibako v{__version__}")
90
+ print(f"Python: {platform.python_version()}")
91
+
92
+ if cf.exists():
93
+ print(f"Config: {cf}")
94
+ config = load_config(cf)
95
+ from pathlib import Path
96
+
97
+ from kanibako.paths import resolve_system_paths
98
+ data_home = xdg("XDG_DATA_HOME", ".local/share")
99
+ data_path = resolve_system_paths(
100
+ config.system_paths, data_home=data_home, home=Path.home(),
101
+ )["system.path.data"]
102
+ print(f"Data: {data_path}")
103
+ else:
104
+ print(
105
+ "Config: (not initialized — run 'kanibako setup' or just 'kanibako start')"
106
+ )
107
+
108
+ # Container runtime
109
+ try:
110
+ import subprocess
111
+
112
+ from kanibako.container import ContainerRuntime
113
+
114
+ runtime = ContainerRuntime()
115
+ result = subprocess.run(
116
+ [runtime.cmd, "--version"], capture_output=True, text=True,
117
+ )
118
+ version = result.stdout.strip() if result.returncode == 0 else "unknown"
119
+ print(f"Runtime: {runtime.cmd} ({version})")
120
+ except Exception:
121
+ print(
122
+ "Runtime: not found — install podman (https://podman.io/) or Docker"
123
+ )
124
+
125
+ # Install method
126
+ try:
127
+ from kanibako.commands.upgrade import _get_repo_dir
128
+
129
+ repo = _get_repo_dir()
130
+ if repo is not None:
131
+ print(f"Install: git ({repo})")
132
+ else:
133
+ print("Install: pip")
134
+ except Exception:
135
+ print("Install: pip")
136
+
137
+ # Agent count
138
+ try:
139
+ from kanibako.targets import discover_targets
140
+
141
+ targets = discover_targets()
142
+ count = len(targets)
143
+ if count > 0:
144
+ print(
145
+ f"Agents: {count} detected (use 'kanibako crab list' for details)"
146
+ )
147
+ else:
148
+ print(
149
+ "Agents: none (install a plugin: pip install kanibako-agent-claude)"
150
+ )
151
+ except Exception:
152
+ pass
153
+
154
+ print()
155
+ print("Tip: Run 'kanibako system diagnose' for a full health check.")
156
+
157
+ return 0
158
+
159
+
160
+ def run_config(args: argparse.Namespace) -> int:
161
+ """View or modify global configuration."""
162
+ config_home = xdg("XDG_CONFIG_HOME", ".config")
163
+ cf = config_file_path(config_home)
164
+
165
+ from kanibako.config_interface import (
166
+ ConfigAction,
167
+ get_config_value,
168
+ is_known_key,
169
+ parse_config_arg,
170
+ reset_all,
171
+ reset_config_value,
172
+ set_config_value,
173
+ show_config,
174
+ )
175
+
176
+ key_value = getattr(args, "key_value", None)
177
+ action, key, value = parse_config_arg(key_value)
178
+
179
+ # --reset --all
180
+ if args.reset and getattr(args, "all_keys", False):
181
+ msg = reset_all(config_path=cf, force=args.force)
182
+ print(msg)
183
+ return 0
184
+
185
+ # --reset <key>
186
+ if args.reset:
187
+ if not key:
188
+ print(
189
+ "Error: --reset requires a key (or use --reset --all).",
190
+ file=sys.stderr,
191
+ )
192
+ return 1
193
+ msg = reset_config_value(key, config_path=cf)
194
+ print(msg)
195
+ return 0
196
+
197
+ # show (no args)
198
+ if action == ConfigAction.show:
199
+ show_config(
200
+ global_config_path=cf,
201
+ config_path=cf,
202
+ effective=args.effective,
203
+ )
204
+ return 0
205
+
206
+ # get
207
+ if action == ConfigAction.get:
208
+ if not is_known_key(key):
209
+ print(f"Error: unknown config key: {key}", file=sys.stderr)
210
+ return 1
211
+ val = get_config_value(key, global_config_path=cf)
212
+ if val is None:
213
+ print(f"{key}: (not set)")
214
+ else:
215
+ print(f"{key}={val}")
216
+ return 0
217
+
218
+ # set
219
+ if action == ConfigAction.set:
220
+ msg = set_config_value(key, value, config_path=cf, is_system=True)
221
+ print(msg)
222
+ return 0
223
+
224
+ return 0
@@ -0,0 +1,161 @@
1
+ """kanibako upgrade: update kanibako itself from git."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+
11
+ def add_parser(subparsers: argparse._SubParsersAction) -> None:
12
+ p = subparsers.add_parser(
13
+ "upgrade",
14
+ help="Upgrade kanibako to the latest version",
15
+ description="Upgrade kanibako by pulling the latest changes from git.",
16
+ )
17
+ p.add_argument(
18
+ "--check", action="store_true",
19
+ help="Check for updates without installing",
20
+ )
21
+ p.set_defaults(func=run)
22
+
23
+
24
+ def _get_repo_dir() -> Path | None:
25
+ """Find the kanibako git repository directory."""
26
+ # Start from this file's location and walk up to find .git
27
+ current = Path(__file__).resolve().parent
28
+ for _ in range(5): # Don't walk up forever
29
+ if (current / ".git").is_dir():
30
+ return current
31
+ parent = current.parent
32
+ if parent == current:
33
+ break
34
+ current = parent
35
+ return None
36
+
37
+
38
+ def _git(*args: str, cwd: Path) -> subprocess.CompletedProcess:
39
+ """Run a git command in the given directory."""
40
+ return subprocess.run(
41
+ ["git", *args],
42
+ cwd=cwd,
43
+ capture_output=True,
44
+ text=True,
45
+ )
46
+
47
+
48
+ def _get_current_commit(repo: Path) -> str | None:
49
+ """Get the current commit hash."""
50
+ result = _git("rev-parse", "HEAD", cwd=repo)
51
+ if result.returncode == 0:
52
+ return result.stdout.strip()
53
+ return None
54
+
55
+
56
+ def _get_remote_commit(repo: Path) -> str | None:
57
+ """Get the latest remote commit hash after fetching."""
58
+ # Fetch latest from remote
59
+ result = _git("fetch", cwd=repo)
60
+ if result.returncode != 0:
61
+ return None
62
+
63
+ # Get the upstream branch name
64
+ result = _git("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}", cwd=repo)
65
+ if result.returncode != 0:
66
+ # No upstream configured, try origin/main or origin/master
67
+ for branch in ("origin/main", "origin/master"):
68
+ result = _git("rev-parse", branch, cwd=repo)
69
+ if result.returncode == 0:
70
+ return result.stdout.strip()
71
+ return None
72
+
73
+ upstream = result.stdout.strip()
74
+ result = _git("rev-parse", upstream, cwd=repo)
75
+ if result.returncode == 0:
76
+ return result.stdout.strip()
77
+ return None
78
+
79
+
80
+ def _get_commit_count_behind(repo: Path) -> int | None:
81
+ """Get number of commits behind upstream."""
82
+ result = _git("rev-list", "--count", "HEAD..@{u}", cwd=repo)
83
+ if result.returncode == 0:
84
+ try:
85
+ return int(result.stdout.strip())
86
+ except ValueError:
87
+ pass
88
+ return None
89
+
90
+
91
+ def run(args: argparse.Namespace) -> int:
92
+ repo = _get_repo_dir()
93
+ if repo is None:
94
+ print("Error: Could not find kanibako git repository.", file=sys.stderr)
95
+ print("kanibako upgrade only works for git-based installations.", file=sys.stderr)
96
+ return 1
97
+
98
+ # Check for uncommitted changes
99
+ result = _git("status", "--porcelain", cwd=repo)
100
+ if result.returncode != 0:
101
+ print("Error: Failed to check git status.", file=sys.stderr)
102
+ return 1
103
+
104
+ has_changes = bool(result.stdout.strip())
105
+
106
+ current = _get_current_commit(repo)
107
+ if current is None:
108
+ print("Error: Failed to get current commit.", file=sys.stderr)
109
+ return 1
110
+
111
+ print(f"Repository: {repo}")
112
+ print(f"Current: {current[:8]}")
113
+
114
+ # Fetch and check for updates
115
+ print("Checking for updates...", end=" ", flush=True)
116
+ remote = _get_remote_commit(repo)
117
+ if remote is None:
118
+ print("failed")
119
+ print("Error: Failed to fetch from remote.", file=sys.stderr)
120
+ return 1
121
+
122
+ if current == remote:
123
+ print("up to date")
124
+ return 0
125
+
126
+ behind = _get_commit_count_behind(repo)
127
+ behind_str = f"{behind} commit(s)" if behind else "updates"
128
+ print(f"{behind_str} available")
129
+ print(f"Latest: {remote[:8]}")
130
+
131
+ if args.check:
132
+ return 0
133
+
134
+ # Actually upgrade
135
+ if has_changes:
136
+ print()
137
+ print("Warning: You have uncommitted changes in the repository.", file=sys.stderr)
138
+ print("Stash or commit them before upgrading.", file=sys.stderr)
139
+ return 1
140
+
141
+ print()
142
+ print("Pulling latest changes...")
143
+ result = _git("pull", "--ff-only", cwd=repo)
144
+ if result.returncode != 0:
145
+ print("Error: git pull failed.", file=sys.stderr)
146
+ if result.stderr:
147
+ print(result.stderr, file=sys.stderr)
148
+ return 1
149
+
150
+ print("Upgraded successfully.")
151
+
152
+ # Check if pyproject.toml changed (might need reinstall)
153
+ result = _git("diff", "--name-only", f"{current}..HEAD", cwd=repo)
154
+ changed_files = result.stdout.strip().split("\n") if result.stdout.strip() else []
155
+
156
+ if "pyproject.toml" in changed_files:
157
+ print()
158
+ print("Note: pyproject.toml changed. You may need to reinstall:")
159
+ print(f" pip install -e {repo}")
160
+
161
+ return 0
@@ -0,0 +1,199 @@
1
+ """kanibako vault: manage vault snapshots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from kanibako.config import config_file_path, load_config
9
+ from kanibako.paths import xdg, load_std_paths, resolve_any_project
10
+ from kanibako.snapshots import (
11
+ _DEFAULT_MAX_SNAPSHOTS,
12
+ create_snapshot,
13
+ list_snapshots,
14
+ prune_snapshots,
15
+ restore_snapshot,
16
+ )
17
+
18
+
19
+ def add_vault_subparser(parent_sub: argparse._SubParsersAction) -> None:
20
+ """Register vault as a subcommand (used by box parser to nest under box)."""
21
+ p = parent_sub.add_parser(
22
+ "vault",
23
+ help="Vault snapshot commands (snapshot, list, restore, prune)",
24
+ description="Manage vault share-rw snapshots.",
25
+ )
26
+ _add_vault_subcommands(p)
27
+
28
+
29
+ def add_parser(subparsers: argparse._SubParsersAction) -> None:
30
+ """Register vault as a top-level command (kept for backward compat during transition)."""
31
+ p = subparsers.add_parser(
32
+ "vault",
33
+ help="Vault snapshot commands (snapshot, list, restore, prune)",
34
+ description="Manage vault share-rw snapshots.",
35
+ )
36
+ _add_vault_subcommands(p)
37
+
38
+
39
+ def _add_vault_subcommands(p: argparse.ArgumentParser) -> None:
40
+ vs = p.add_subparsers(dest="vault_command", metavar="COMMAND")
41
+
42
+ # kanibako box vault snapshot [project]
43
+ snap_p = vs.add_parser(
44
+ "snapshot",
45
+ help="Create a snapshot of vault share-rw",
46
+ description="Create a point-in-time snapshot of the vault share-rw directory.",
47
+ )
48
+ snap_p.add_argument(
49
+ "project", nargs="?", default=None,
50
+ help="Project directory or name (default: cwd)",
51
+ )
52
+ snap_p.set_defaults(func=run_snapshot)
53
+
54
+ # kanibako box vault list [project] [-q/--quiet]
55
+ list_p = vs.add_parser(
56
+ "list",
57
+ help="List vault snapshots (default)",
58
+ description="Show all snapshots for the current project's vault.",
59
+ )
60
+ list_p.add_argument(
61
+ "project", nargs="?", default=None,
62
+ help="Project directory or name (default: cwd)",
63
+ )
64
+ list_p.add_argument(
65
+ "-q", "--quiet", action="store_true",
66
+ help="Output snapshot names only, one per line",
67
+ )
68
+ list_p.set_defaults(func=run_list)
69
+
70
+ # kanibako box vault restore <name> [project] [--force]
71
+ restore_p = vs.add_parser(
72
+ "restore",
73
+ help="Restore vault share-rw from a snapshot",
74
+ description="Replace the current share-rw contents with a snapshot.",
75
+ )
76
+ restore_p.add_argument("name", help="Snapshot name (e.g. 20260221T103000Z.tar.xz)")
77
+ restore_p.add_argument(
78
+ "project", nargs="?", default=None,
79
+ help="Project directory or name (default: cwd)",
80
+ )
81
+ restore_p.add_argument(
82
+ "--force", action="store_true",
83
+ help="Skip confirmation prompt",
84
+ )
85
+ restore_p.set_defaults(func=run_restore)
86
+
87
+ # kanibako box vault prune [project] [--keep N] [--force]
88
+ prune_p = vs.add_parser(
89
+ "prune",
90
+ help="Remove old snapshots",
91
+ description="Prune old vault snapshots, keeping the most recent ones.",
92
+ )
93
+ prune_p.add_argument(
94
+ "project", nargs="?", default=None,
95
+ help="Project directory or name (default: cwd)",
96
+ )
97
+ prune_p.add_argument(
98
+ "--keep", type=int, default=_DEFAULT_MAX_SNAPSHOTS,
99
+ help=f"Number of snapshots to keep (default: {_DEFAULT_MAX_SNAPSHOTS})",
100
+ )
101
+ prune_p.add_argument(
102
+ "--force", action="store_true",
103
+ help="Skip confirmation prompt",
104
+ )
105
+ prune_p.set_defaults(func=run_prune)
106
+
107
+ p.set_defaults(func=run_list)
108
+
109
+
110
+ def _resolve_vault_rw(project_dir: str | None):
111
+ """Resolve the vault share-rw path for the current project."""
112
+ config_file = config_file_path(xdg("XDG_CONFIG_HOME", ".config"))
113
+ config = load_config(config_file)
114
+ std = load_std_paths(config)
115
+ proj = resolve_any_project(std, config, project_dir, initialize=False)
116
+
117
+ if not proj.enable_vault:
118
+ print("Vault is disabled for this project.", file=sys.stderr)
119
+ return None
120
+ return proj.vault_rw_path
121
+
122
+
123
+ def run_snapshot(args: argparse.Namespace) -> int:
124
+ project_dir = getattr(args, "project", None)
125
+ vault_rw = _resolve_vault_rw(project_dir)
126
+ if vault_rw is None:
127
+ return 1
128
+
129
+ snap = create_snapshot(vault_rw)
130
+ if snap is None:
131
+ print("Nothing to snapshot (share-rw is empty or missing).", file=sys.stderr)
132
+ return 0
133
+
134
+ print(f"Snapshot created: {snap.name}")
135
+ return 0
136
+
137
+
138
+ def run_list(args: argparse.Namespace) -> int:
139
+ project_dir = getattr(args, "project", None)
140
+ vault_rw = _resolve_vault_rw(project_dir)
141
+ if vault_rw is None:
142
+ return 1
143
+
144
+ quiet = getattr(args, "quiet", False)
145
+
146
+ snaps = list_snapshots(vault_rw)
147
+ if not snaps:
148
+ if not quiet:
149
+ print("No snapshots found.")
150
+ return 0
151
+
152
+ for name, ts, size in snaps:
153
+ if quiet:
154
+ print(name)
155
+ else:
156
+ size_str = _human_size(size)
157
+ print(f" {name} {ts} {size_str}")
158
+
159
+ return 0
160
+
161
+
162
+ def run_restore(args: argparse.Namespace) -> int:
163
+ project_dir = getattr(args, "project", None)
164
+ vault_rw = _resolve_vault_rw(project_dir)
165
+ if vault_rw is None:
166
+ return 1
167
+
168
+ try:
169
+ restore_snapshot(vault_rw, args.name)
170
+ except FileNotFoundError as e:
171
+ print(f"Error: {e}", file=sys.stderr)
172
+ return 1
173
+
174
+ print(f"Restored vault share-rw from {args.name}")
175
+ return 0
176
+
177
+
178
+ def run_prune(args: argparse.Namespace) -> int:
179
+ project_dir = getattr(args, "project", None)
180
+ vault_rw = _resolve_vault_rw(project_dir)
181
+ if vault_rw is None:
182
+ return 1
183
+
184
+ removed = prune_snapshots(vault_rw, max_keep=args.keep)
185
+ if removed:
186
+ print(f"Pruned {removed} snapshot(s), keeping {args.keep}.")
187
+ else:
188
+ print("Nothing to prune.")
189
+ return 0
190
+
191
+
192
+ def _human_size(nbytes: int) -> str:
193
+ """Format byte count as human-readable string."""
194
+ size = float(nbytes)
195
+ for unit in ("B", "KB", "MB", "GB"):
196
+ if size < 1024:
197
+ return f"{size:.0f}{unit}" if unit == "B" else f"{size:.1f}{unit}"
198
+ size /= 1024
199
+ return f"{size:.1f}TB"