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.
- kanibako/__init__.py +3 -0
- kanibako/__main__.py +6 -0
- kanibako/auth_browser.py +296 -0
- kanibako/auth_parser.py +51 -0
- kanibako/browser_sidecar.py +183 -0
- kanibako/browser_state.py +103 -0
- kanibako/bun_sea.py +144 -0
- kanibako/cli.py +344 -0
- kanibako/commands/__init__.py +0 -0
- kanibako/commands/archive.py +228 -0
- kanibako/commands/box/__init__.py +22 -0
- kanibako/commands/box/_duplicate.py +395 -0
- kanibako/commands/box/_migrate.py +574 -0
- kanibako/commands/box/_parser.py +1178 -0
- kanibako/commands/clean.py +166 -0
- kanibako/commands/crab_cmd.py +480 -0
- kanibako/commands/diagnose.py +239 -0
- kanibako/commands/fork_cmd.py +51 -0
- kanibako/commands/helper_cmd.py +669 -0
- kanibako/commands/image.py +1300 -0
- kanibako/commands/install.py +152 -0
- kanibako/commands/refresh_credentials.py +67 -0
- kanibako/commands/restore.py +298 -0
- kanibako/commands/setup_cmd.py +89 -0
- kanibako/commands/start.py +1600 -0
- kanibako/commands/stop.py +116 -0
- kanibako/commands/system_cmd.py +224 -0
- kanibako/commands/upgrade.py +161 -0
- kanibako/commands/vault_cmd.py +199 -0
- kanibako/commands/workset_cmd.py +552 -0
- kanibako/config.py +514 -0
- kanibako/config_interface.py +573 -0
- kanibako/config_io.py +36 -0
- kanibako/container.py +607 -0
- kanibako/containerfiles.py +58 -0
- kanibako/containers/Containerfile.kanibako +99 -0
- kanibako/containers/Containerfile.template-android +55 -0
- kanibako/containers/Containerfile.template-dotnet +29 -0
- kanibako/containers/Containerfile.template-js +43 -0
- kanibako/containers/Containerfile.template-jvm +27 -0
- kanibako/containers/Containerfile.template-systems +46 -0
- kanibako/containers/__init__.py +0 -0
- kanibako/crabs.py +89 -0
- kanibako/errors.py +33 -0
- kanibako/freshness.py +67 -0
- kanibako/git.py +114 -0
- kanibako/helper_client.py +132 -0
- kanibako/helper_listener.py +538 -0
- kanibako/helpers.py +339 -0
- kanibako/hygiene.py +296 -0
- kanibako/image_sharing.py +133 -0
- kanibako/instructions.py +160 -0
- kanibako/log.py +31 -0
- kanibako/names.py +248 -0
- kanibako/paths.py +1483 -0
- kanibako/plugins/__init__.py +10 -0
- kanibako/registry.py +71 -0
- kanibako/rig_bundle.py +121 -0
- kanibako/rig_meta.py +92 -0
- kanibako/rig_registry.py +132 -0
- kanibako/rig_resolve.py +182 -0
- kanibako/rig_source.py +245 -0
- kanibako/scripts/__init__.py +0 -0
- kanibako/scripts/helper-init.sh +45 -0
- kanibako/scripts/kanibako-entry +12 -0
- kanibako/settings_resolve.py +312 -0
- kanibako/settings_seeds.py +154 -0
- kanibako/settings_shares.py +154 -0
- kanibako/shellenv.py +75 -0
- kanibako/snapshots.py +281 -0
- kanibako/targets/__init__.py +173 -0
- kanibako/targets/base.py +243 -0
- kanibako/targets/no_agent.py +58 -0
- kanibako/templates.py +60 -0
- kanibako/templates_image.py +224 -0
- kanibako/tweakcc.py +140 -0
- kanibako/tweakcc_cache.py +171 -0
- kanibako/utils.py +136 -0
- kanibako/workset.py +347 -0
- kanibako_cli-1.5.0.dev14.dist-info/METADATA +15 -0
- kanibako_cli-1.5.0.dev14.dist-info/RECORD +85 -0
- kanibako_cli-1.5.0.dev14.dist-info/WHEEL +5 -0
- kanibako_cli-1.5.0.dev14.dist-info/entry_points.txt +5 -0
- kanibako_cli-1.5.0.dev14.dist-info/licenses/LICENSE.md +594 -0
- kanibako_cli-1.5.0.dev14.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1178 @@
|
|
|
1
|
+
"""Parser setup, list, info, config, and lifecycle commands for kanibako box."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from kanibako.config import (
|
|
12
|
+
config_file_path,
|
|
13
|
+
load_config,
|
|
14
|
+
load_merged_config,
|
|
15
|
+
write_project_config,
|
|
16
|
+
)
|
|
17
|
+
from kanibako.container import ContainerRuntime
|
|
18
|
+
from kanibako.errors import ContainerError, ProjectError
|
|
19
|
+
from kanibako.names import read_names, unregister_name
|
|
20
|
+
from kanibako.paths import (
|
|
21
|
+
ProjectMode,
|
|
22
|
+
xdg,
|
|
23
|
+
iter_projects,
|
|
24
|
+
iter_workset_projects,
|
|
25
|
+
load_std_paths,
|
|
26
|
+
resolve_any_project,
|
|
27
|
+
resolve_project,
|
|
28
|
+
resolve_standalone_project,
|
|
29
|
+
)
|
|
30
|
+
from kanibako.targets import resolve_target
|
|
31
|
+
from kanibako.utils import container_name_for, short_hash, write_project_gitignore
|
|
32
|
+
|
|
33
|
+
_MODE_CHOICES = ["default", "standalone", "workset"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def add_parser(subparsers: argparse._SubParsersAction) -> None:
|
|
37
|
+
from kanibako.commands.box._duplicate import run_duplicate
|
|
38
|
+
from kanibako.commands.box._migrate import run_migrate
|
|
39
|
+
|
|
40
|
+
p = subparsers.add_parser(
|
|
41
|
+
"box",
|
|
42
|
+
help="Project lifecycle commands for boxes (containers)",
|
|
43
|
+
description="Manage per-project session data for boxes (containers): create, list, migrate, duplicate, archive, extract, purge.",
|
|
44
|
+
)
|
|
45
|
+
box_sub = p.add_subparsers(dest="box_command", metavar="COMMAND")
|
|
46
|
+
|
|
47
|
+
# kanibako box create [path] [--name NAME] [--standalone] [--image IMAGE]
|
|
48
|
+
# [--no-vault] [--distinct-auth]
|
|
49
|
+
create_p = box_sub.add_parser(
|
|
50
|
+
"create",
|
|
51
|
+
help="Create a new kanibako project",
|
|
52
|
+
description="Create a new kanibako project in the current or given directory.",
|
|
53
|
+
)
|
|
54
|
+
create_p.add_argument(
|
|
55
|
+
"path", nargs="?", default=None,
|
|
56
|
+
help="Project directory (default: cwd). Created if it doesn't exist.",
|
|
57
|
+
)
|
|
58
|
+
create_p.add_argument(
|
|
59
|
+
"--name", default=None,
|
|
60
|
+
help="Project name override (default: auto-assigned from directory name)",
|
|
61
|
+
)
|
|
62
|
+
create_p.add_argument(
|
|
63
|
+
"--standalone", action="store_true",
|
|
64
|
+
help="Use standalone mode (all state inside the project directory)",
|
|
65
|
+
)
|
|
66
|
+
create_p.add_argument(
|
|
67
|
+
"-i", "--image", default=None,
|
|
68
|
+
help="Container image to use for this project (--rig is the preferred spelling)",
|
|
69
|
+
)
|
|
70
|
+
create_p.add_argument(
|
|
71
|
+
"--rig", dest="image", default=None,
|
|
72
|
+
help="Rig (image) to use; synonym for --image",
|
|
73
|
+
)
|
|
74
|
+
create_p.add_argument(
|
|
75
|
+
"--no-vault", action="store_true",
|
|
76
|
+
help="Disable vault directories (shared read-only and read-write mounts)",
|
|
77
|
+
)
|
|
78
|
+
create_p.add_argument(
|
|
79
|
+
"--distinct-auth", action="store_true",
|
|
80
|
+
help="Use distinct credentials (no sync from host)",
|
|
81
|
+
)
|
|
82
|
+
create_p.add_argument(
|
|
83
|
+
"--allow-home", action="store_true",
|
|
84
|
+
help="Permit a standalone project rooted at $HOME (mounts your entire "
|
|
85
|
+
"home directory; required to create one there)",
|
|
86
|
+
)
|
|
87
|
+
create_p.set_defaults(func=run_create)
|
|
88
|
+
|
|
89
|
+
# kanibako box list (default behavior)
|
|
90
|
+
list_p = box_sub.add_parser(
|
|
91
|
+
"list",
|
|
92
|
+
aliases=["ls"],
|
|
93
|
+
help="List known projects and their status (default)",
|
|
94
|
+
description="List all known kanibako projects with their hash, status, and path.",
|
|
95
|
+
)
|
|
96
|
+
list_p.add_argument(
|
|
97
|
+
"--all", "-a", action="store_true", dest="show_all",
|
|
98
|
+
help="Include orphaned projects in the listing",
|
|
99
|
+
)
|
|
100
|
+
list_p.add_argument(
|
|
101
|
+
"--active", action="store_true",
|
|
102
|
+
help="Show only active (running) boxes",
|
|
103
|
+
)
|
|
104
|
+
list_p.add_argument(
|
|
105
|
+
"--orphan", action="store_true",
|
|
106
|
+
help="Show only orphaned projects (missing workspace)",
|
|
107
|
+
)
|
|
108
|
+
list_p.add_argument(
|
|
109
|
+
"-q", "--quiet", action="store_true",
|
|
110
|
+
help="Output project names only, one per line",
|
|
111
|
+
)
|
|
112
|
+
list_p.set_defaults(func=run_list)
|
|
113
|
+
|
|
114
|
+
# kanibako box migrate
|
|
115
|
+
migrate_p = box_sub.add_parser(
|
|
116
|
+
"migrate",
|
|
117
|
+
help="Remap project data from old path to new path, or convert between modes",
|
|
118
|
+
description=(
|
|
119
|
+
"Move project session data from one path hash to another.\n"
|
|
120
|
+
"Use this after moving or renaming a project directory.\n"
|
|
121
|
+
"With --to, convert a project between modes (e.g. default to standalone)."
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
migrate_p.add_argument(
|
|
125
|
+
"old_path", nargs="?", default=None,
|
|
126
|
+
help="Original project directory path (for path remap), or project path (for --to)",
|
|
127
|
+
)
|
|
128
|
+
migrate_p.add_argument(
|
|
129
|
+
"new_path", nargs="?", default=None,
|
|
130
|
+
help="New project directory path (default: current working directory)",
|
|
131
|
+
)
|
|
132
|
+
migrate_p.add_argument(
|
|
133
|
+
"--to", dest="to_mode", choices=_MODE_CHOICES, default=None,
|
|
134
|
+
help="Convert project to a different mode",
|
|
135
|
+
)
|
|
136
|
+
migrate_p.add_argument(
|
|
137
|
+
"--force", action="store_true", help="Skip confirmation prompt",
|
|
138
|
+
)
|
|
139
|
+
migrate_p.add_argument(
|
|
140
|
+
"--workset", default=None,
|
|
141
|
+
help="Target workset name (required when --to workset)",
|
|
142
|
+
)
|
|
143
|
+
migrate_p.add_argument(
|
|
144
|
+
"--name", dest="project_name", default=None,
|
|
145
|
+
help="Project name in workset (default: directory basename)",
|
|
146
|
+
)
|
|
147
|
+
migrate_p.add_argument(
|
|
148
|
+
"--in-place", action="store_true", dest="in_place",
|
|
149
|
+
help="Keep workspace at current location (don't move into workset)",
|
|
150
|
+
)
|
|
151
|
+
migrate_p.set_defaults(func=run_migrate)
|
|
152
|
+
|
|
153
|
+
# kanibako box duplicate
|
|
154
|
+
duplicate_p = box_sub.add_parser(
|
|
155
|
+
"duplicate",
|
|
156
|
+
help="Duplicate a project (workspace + metadata) under a new path",
|
|
157
|
+
description=(
|
|
158
|
+
"Copy a project's workspace directory and kanibako metadata to a new path.\n"
|
|
159
|
+
"The metadata is re-keyed under the new path's hash.\n"
|
|
160
|
+
"With --to, duplicate into a different mode layout."
|
|
161
|
+
),
|
|
162
|
+
)
|
|
163
|
+
duplicate_p.add_argument("source_path", help="Existing project directory to duplicate")
|
|
164
|
+
duplicate_p.add_argument("new_path", help="Destination path for the duplicate")
|
|
165
|
+
duplicate_p.add_argument(
|
|
166
|
+
"--bare", action="store_true",
|
|
167
|
+
help="Copy only kanibako metadata, don't touch the workspace directory",
|
|
168
|
+
)
|
|
169
|
+
duplicate_p.add_argument(
|
|
170
|
+
"--to", dest="to_mode", choices=_MODE_CHOICES, default=None,
|
|
171
|
+
help="Duplicate into a different mode layout",
|
|
172
|
+
)
|
|
173
|
+
duplicate_p.add_argument(
|
|
174
|
+
"--force", action="store_true",
|
|
175
|
+
help="Skip confirmation, overwrite existing data/metadata at destination",
|
|
176
|
+
)
|
|
177
|
+
duplicate_p.add_argument(
|
|
178
|
+
"--workset", default=None,
|
|
179
|
+
help="Target workset name (required when --to workset)",
|
|
180
|
+
)
|
|
181
|
+
duplicate_p.add_argument(
|
|
182
|
+
"--name", dest="project_name", default=None,
|
|
183
|
+
help="Project name in workset (default: directory basename)",
|
|
184
|
+
)
|
|
185
|
+
duplicate_p.set_defaults(func=run_duplicate)
|
|
186
|
+
|
|
187
|
+
# kanibako box rm (was: forget)
|
|
188
|
+
rm_p = box_sub.add_parser(
|
|
189
|
+
"rm",
|
|
190
|
+
aliases=["delete"],
|
|
191
|
+
help="Unregister a project (optionally purge its metadata)",
|
|
192
|
+
description=(
|
|
193
|
+
"Remove a project from names.yaml without touching the workspace.\n"
|
|
194
|
+
"With --purge, also delete kanibako metadata (shell config, project.yaml, vault symlinks, logs)."
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
rm_p.add_argument(
|
|
198
|
+
"target",
|
|
199
|
+
help="Project name or workspace path to remove",
|
|
200
|
+
)
|
|
201
|
+
rm_p.add_argument(
|
|
202
|
+
"--purge", action="store_true",
|
|
203
|
+
help="Also delete kanibako metadata for this project",
|
|
204
|
+
)
|
|
205
|
+
rm_p.add_argument(
|
|
206
|
+
"--force", action="store_true",
|
|
207
|
+
help="Skip confirmation prompt (only relevant with --purge)",
|
|
208
|
+
)
|
|
209
|
+
rm_p.set_defaults(func=run_rm)
|
|
210
|
+
|
|
211
|
+
# kanibako box info / inspect
|
|
212
|
+
info_p = box_sub.add_parser(
|
|
213
|
+
"info",
|
|
214
|
+
aliases=["inspect"],
|
|
215
|
+
help="Show project details, status, and configuration",
|
|
216
|
+
description=(
|
|
217
|
+
"Show per-project status: mode, paths, container state, image, and credentials.\n"
|
|
218
|
+
"Replaces the top-level 'status' command."
|
|
219
|
+
),
|
|
220
|
+
)
|
|
221
|
+
info_p.add_argument("path", nargs="?", default=None, help="Project directory (default: cwd)")
|
|
222
|
+
info_p.set_defaults(func=run_info)
|
|
223
|
+
|
|
224
|
+
# kanibako box config [project] [key[=value]] [--effective] [--reset KEY]
|
|
225
|
+
# [--all] [--force] [--local]
|
|
226
|
+
config_p = box_sub.add_parser(
|
|
227
|
+
"config",
|
|
228
|
+
help="View or modify project configuration",
|
|
229
|
+
description=(
|
|
230
|
+
"Unified config interface for project settings.\n\n"
|
|
231
|
+
" box config show overrides for cwd project\n"
|
|
232
|
+
" box config myproj show overrides for named project\n"
|
|
233
|
+
" box config --effective show resolved values\n"
|
|
234
|
+
" box config model get the value of 'model'\n"
|
|
235
|
+
" box config model=sonnet set 'model' to 'sonnet'\n"
|
|
236
|
+
" box config env.MY_VAR=hello set env var\n"
|
|
237
|
+
" box config resource.plugins=/p set resource path\n"
|
|
238
|
+
" box config --reset model reset one key\n"
|
|
239
|
+
" box config --reset --all reset all overrides\n"
|
|
240
|
+
),
|
|
241
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
242
|
+
)
|
|
243
|
+
config_p.add_argument(
|
|
244
|
+
"args", nargs="*", default=[],
|
|
245
|
+
help="[project] [key[=value]]",
|
|
246
|
+
)
|
|
247
|
+
config_p.add_argument(
|
|
248
|
+
"--effective", action="store_true",
|
|
249
|
+
help="Show resolved values including inherited defaults",
|
|
250
|
+
)
|
|
251
|
+
config_p.add_argument(
|
|
252
|
+
"--reset", metavar="KEY", nargs="?", const="__ALL__", default=None,
|
|
253
|
+
help="Remove override for KEY (or all overrides with --all)",
|
|
254
|
+
)
|
|
255
|
+
config_p.add_argument(
|
|
256
|
+
"--all", action="store_true", dest="reset_all",
|
|
257
|
+
help="Reset all overrides (only valid with --reset)",
|
|
258
|
+
)
|
|
259
|
+
config_p.add_argument(
|
|
260
|
+
"--force", action="store_true",
|
|
261
|
+
help="Skip confirmation prompts",
|
|
262
|
+
)
|
|
263
|
+
config_p.add_argument(
|
|
264
|
+
"--local", action="store_true",
|
|
265
|
+
help="Set resource to project-isolated (resource keys only)",
|
|
266
|
+
)
|
|
267
|
+
config_p.set_defaults(func=run_config)
|
|
268
|
+
|
|
269
|
+
# kanibako box ps [--all] [-q/--quiet]
|
|
270
|
+
ps_p = box_sub.add_parser(
|
|
271
|
+
"ps",
|
|
272
|
+
help="List running kanibako containers",
|
|
273
|
+
description="List running kanibako containers with their project name, image, and status.",
|
|
274
|
+
)
|
|
275
|
+
ps_p.add_argument(
|
|
276
|
+
"--all", "-a", action="store_true", dest="show_all",
|
|
277
|
+
help="Include stopped containers",
|
|
278
|
+
)
|
|
279
|
+
ps_p.add_argument(
|
|
280
|
+
"-q", "--quiet", action="store_true",
|
|
281
|
+
help="Output container names only, one per line",
|
|
282
|
+
)
|
|
283
|
+
ps_p.set_defaults(func=run_ps)
|
|
284
|
+
|
|
285
|
+
# kanibako box move [project] <dest>
|
|
286
|
+
move_p = box_sub.add_parser(
|
|
287
|
+
"move",
|
|
288
|
+
help="Relocate a project workspace to a new directory",
|
|
289
|
+
description=(
|
|
290
|
+
"Move a project's workspace directory to a new location.\n"
|
|
291
|
+
"Updates names.yaml and recreates vault symlinks.\n"
|
|
292
|
+
"Cannot move projects that are inside a workset."
|
|
293
|
+
),
|
|
294
|
+
)
|
|
295
|
+
move_p.add_argument(
|
|
296
|
+
"args", nargs="+", metavar="ARG",
|
|
297
|
+
help="[project] <dest> — project name/path (optional if cwd) and destination",
|
|
298
|
+
)
|
|
299
|
+
move_p.add_argument(
|
|
300
|
+
"--force", action="store_true",
|
|
301
|
+
help="Skip confirmation prompt",
|
|
302
|
+
)
|
|
303
|
+
move_p.set_defaults(func=run_move)
|
|
304
|
+
|
|
305
|
+
# Reuse existing subcommand modules under box.
|
|
306
|
+
from kanibako.commands.archive import add_parser as add_archive_parser
|
|
307
|
+
from kanibako.commands.clean import add_parser as add_purge_parser
|
|
308
|
+
from kanibako.commands.restore import add_parser as add_extract_parser
|
|
309
|
+
from kanibako.commands.start import add_start_parser as _add_start_parser
|
|
310
|
+
from kanibako.commands.start import add_shell_parser as _add_shell_parser
|
|
311
|
+
from kanibako.commands.stop import add_parser as _add_stop_parser
|
|
312
|
+
|
|
313
|
+
from kanibako.commands.vault_cmd import add_vault_subparser
|
|
314
|
+
|
|
315
|
+
# box diagnose [project]
|
|
316
|
+
from kanibako.commands.diagnose import run_box_diagnose
|
|
317
|
+
|
|
318
|
+
diagnose_p = box_sub.add_parser(
|
|
319
|
+
"diagnose",
|
|
320
|
+
help="Check project box health",
|
|
321
|
+
)
|
|
322
|
+
diagnose_p.add_argument(
|
|
323
|
+
"project",
|
|
324
|
+
nargs="?",
|
|
325
|
+
default=None,
|
|
326
|
+
help="Project name or workspace path (default: cwd)",
|
|
327
|
+
)
|
|
328
|
+
diagnose_p.set_defaults(func=run_box_diagnose)
|
|
329
|
+
|
|
330
|
+
add_archive_parser(box_sub)
|
|
331
|
+
add_purge_parser(box_sub)
|
|
332
|
+
add_extract_parser(box_sub)
|
|
333
|
+
add_vault_subparser(box_sub)
|
|
334
|
+
|
|
335
|
+
# Register start, shell, stop as box subcommands (delegates to start.py/stop.py).
|
|
336
|
+
_add_start_parser(box_sub)
|
|
337
|
+
_add_shell_parser(box_sub)
|
|
338
|
+
_add_stop_parser(box_sub)
|
|
339
|
+
|
|
340
|
+
# Default to list if no subcommand given.
|
|
341
|
+
p.set_defaults(func=run_list)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def run_create(args: argparse.Namespace) -> int:
|
|
345
|
+
"""Create a new kanibako project (replaces ``kanibako init``)."""
|
|
346
|
+
config_file = config_file_path(xdg("XDG_CONFIG_HOME", ".config"))
|
|
347
|
+
config = load_config(config_file)
|
|
348
|
+
std = load_std_paths(config)
|
|
349
|
+
|
|
350
|
+
enable_vault = not getattr(args, "no_vault", False)
|
|
351
|
+
group_auth = False if getattr(args, "distinct_auth", False) else None
|
|
352
|
+
project_dir = args.path
|
|
353
|
+
|
|
354
|
+
# $HOME guard: a home-directory project mounts the entire home tree, so it
|
|
355
|
+
# must be (a) standalone and (b) an explicit opt-in via --allow-home. Local
|
|
356
|
+
# mode at $HOME is never permitted.
|
|
357
|
+
effective_path = Path(project_dir).resolve() if project_dir else Path.cwd().resolve()
|
|
358
|
+
if effective_path == Path.home().resolve():
|
|
359
|
+
if not args.standalone:
|
|
360
|
+
print(
|
|
361
|
+
"Error: Refusing to create a project at $HOME.\n"
|
|
362
|
+
"A home-directory project must be standalone and explicit:\n"
|
|
363
|
+
" kanibako create --standalone ~ --allow-home",
|
|
364
|
+
file=sys.stderr,
|
|
365
|
+
)
|
|
366
|
+
return 1
|
|
367
|
+
if not getattr(args, "allow_home", False):
|
|
368
|
+
print(
|
|
369
|
+
"Error: Refusing to create a standalone project at $HOME "
|
|
370
|
+
"without --allow-home.\n"
|
|
371
|
+
"This mounts your entire home directory as the project. If you "
|
|
372
|
+
"really mean it:\n"
|
|
373
|
+
" kanibako create --standalone ~ --allow-home",
|
|
374
|
+
file=sys.stderr,
|
|
375
|
+
)
|
|
376
|
+
return 1
|
|
377
|
+
|
|
378
|
+
# Create directory if it doesn't exist.
|
|
379
|
+
if project_dir is not None:
|
|
380
|
+
target = Path(project_dir)
|
|
381
|
+
if not target.exists():
|
|
382
|
+
target.mkdir(parents=True)
|
|
383
|
+
|
|
384
|
+
if args.standalone:
|
|
385
|
+
proj = resolve_standalone_project(
|
|
386
|
+
std, config, project_dir, initialize=True,
|
|
387
|
+
enable_vault=enable_vault, group_auth=group_auth,
|
|
388
|
+
)
|
|
389
|
+
else:
|
|
390
|
+
proj = resolve_project(
|
|
391
|
+
std, config, project_dir=project_dir, initialize=True,
|
|
392
|
+
enable_vault=enable_vault if not enable_vault else None,
|
|
393
|
+
name_override=getattr(args, "name", None),
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
if not proj.is_new:
|
|
397
|
+
print(
|
|
398
|
+
f"Error: project already initialized in {proj.project_path}",
|
|
399
|
+
file=sys.stderr,
|
|
400
|
+
)
|
|
401
|
+
return 1
|
|
402
|
+
|
|
403
|
+
# Persist image setting.
|
|
404
|
+
image = args.image or config.box_image
|
|
405
|
+
project_toml = proj.metadata_path / "project.yaml"
|
|
406
|
+
write_project_config(project_toml, image)
|
|
407
|
+
|
|
408
|
+
# Write .gitignore for standalone projects only.
|
|
409
|
+
if args.standalone:
|
|
410
|
+
write_project_gitignore(proj.project_path)
|
|
411
|
+
|
|
412
|
+
mode = "standalone" if args.standalone else "default"
|
|
413
|
+
print(f"Created {mode} project in {proj.project_path}")
|
|
414
|
+
return 0
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def run_ps(args: argparse.Namespace) -> int:
|
|
418
|
+
"""List running boxes (delegates to run_list with active-only filtering).
|
|
419
|
+
|
|
420
|
+
``ps`` shows active boxes by default. ``ps --all`` / ``ps -a`` shows
|
|
421
|
+
all boxes (active + inactive), equivalent to ``list``.
|
|
422
|
+
"""
|
|
423
|
+
show_all = getattr(args, "show_all", False)
|
|
424
|
+
# When ps --all is passed, show everything (like list).
|
|
425
|
+
# Otherwise, show active only (like list --active).
|
|
426
|
+
if not show_all:
|
|
427
|
+
args.active = True
|
|
428
|
+
return run_list(args)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def run_list(args: argparse.Namespace) -> int:
|
|
432
|
+
show_all = getattr(args, "show_all", False)
|
|
433
|
+
orphan_only = getattr(args, "orphan", False)
|
|
434
|
+
active_only = getattr(args, "active", False) and not show_all
|
|
435
|
+
quiet = getattr(args, "quiet", False)
|
|
436
|
+
|
|
437
|
+
config_file = config_file_path(xdg("XDG_CONFIG_HOME", ".config"))
|
|
438
|
+
config = load_config(config_file)
|
|
439
|
+
std = load_std_paths(config)
|
|
440
|
+
|
|
441
|
+
projects = iter_projects(std, config)
|
|
442
|
+
ws_data = iter_workset_projects(std, config)
|
|
443
|
+
|
|
444
|
+
if orphan_only:
|
|
445
|
+
return _list_orphans(projects, ws_data, std, quiet)
|
|
446
|
+
|
|
447
|
+
# Gather running container names for activity cross-reference.
|
|
448
|
+
running_containers: set[str] = set()
|
|
449
|
+
try:
|
|
450
|
+
runtime = ContainerRuntime()
|
|
451
|
+
for cname, _image, _status in runtime.list_running():
|
|
452
|
+
running_containers.add(cname)
|
|
453
|
+
except ContainerError:
|
|
454
|
+
pass # No runtime available — all projects show as stopped.
|
|
455
|
+
|
|
456
|
+
if not projects and not ws_data:
|
|
457
|
+
if not quiet:
|
|
458
|
+
print("No known projects.")
|
|
459
|
+
return 0
|
|
460
|
+
|
|
461
|
+
# Build reverse lookup from path → name using names.yaml.
|
|
462
|
+
names_data = read_names(std.data_path)
|
|
463
|
+
path_to_name: dict[str, str] = {v: k for k, v in names_data["projects"].items()}
|
|
464
|
+
|
|
465
|
+
any_output = False
|
|
466
|
+
|
|
467
|
+
if projects:
|
|
468
|
+
header_printed = False
|
|
469
|
+
for settings_path, project_path in projects:
|
|
470
|
+
# Directory name is now the project name (or hash for legacy).
|
|
471
|
+
dir_name = settings_path.name
|
|
472
|
+
proj_name = path_to_name.get(str(project_path), dir_name) if project_path else dir_name
|
|
473
|
+
if project_path is None:
|
|
474
|
+
status = "unknown"
|
|
475
|
+
label = "(no breadcrumb)"
|
|
476
|
+
elif project_path.is_dir():
|
|
477
|
+
# Check if container is running.
|
|
478
|
+
cname = f"kanibako-{proj_name}"
|
|
479
|
+
if cname in running_containers:
|
|
480
|
+
status = "active"
|
|
481
|
+
else:
|
|
482
|
+
status = "stopped"
|
|
483
|
+
label = str(project_path)
|
|
484
|
+
else:
|
|
485
|
+
status = "missing"
|
|
486
|
+
label = str(project_path)
|
|
487
|
+
|
|
488
|
+
# Skip orphans unless --all is given.
|
|
489
|
+
if status in ("missing", "unknown") and not show_all:
|
|
490
|
+
continue
|
|
491
|
+
|
|
492
|
+
# Skip inactive when --active filter is set.
|
|
493
|
+
if active_only and status != "active":
|
|
494
|
+
continue
|
|
495
|
+
|
|
496
|
+
any_output = True
|
|
497
|
+
if quiet:
|
|
498
|
+
print(proj_name)
|
|
499
|
+
else:
|
|
500
|
+
if not header_printed:
|
|
501
|
+
print(f"{'NAME':<18} {'STATUS':<10} {'PATH'}")
|
|
502
|
+
header_printed = True
|
|
503
|
+
print(f"{proj_name:<18} {status:<10} {label}")
|
|
504
|
+
|
|
505
|
+
for ws_name, ws, project_list in ws_data:
|
|
506
|
+
ws_items: list[tuple[str, str, str]] = []
|
|
507
|
+
for proj_name, proj_status in project_list:
|
|
508
|
+
if proj_status == "missing" and not show_all:
|
|
509
|
+
continue
|
|
510
|
+
# Determine activity status for healthy workset projects.
|
|
511
|
+
if proj_status not in ("missing",):
|
|
512
|
+
cname = f"kanibako-{proj_name}"
|
|
513
|
+
if cname in running_containers:
|
|
514
|
+
display_status = "active"
|
|
515
|
+
else:
|
|
516
|
+
display_status = "stopped" if proj_status == "ok" else proj_status
|
|
517
|
+
else:
|
|
518
|
+
display_status = proj_status
|
|
519
|
+
if active_only and display_status != "active":
|
|
520
|
+
continue
|
|
521
|
+
# Look up source_path from workset projects.
|
|
522
|
+
source = ""
|
|
523
|
+
for p in ws.projects:
|
|
524
|
+
if p.name == proj_name:
|
|
525
|
+
source = str(p.source_path)
|
|
526
|
+
break
|
|
527
|
+
ws_items.append((proj_name, display_status, source))
|
|
528
|
+
|
|
529
|
+
if not ws_items:
|
|
530
|
+
if not active_only and not quiet:
|
|
531
|
+
any_output = True
|
|
532
|
+
print()
|
|
533
|
+
print(f"Workset: {ws_name} ({ws.root})")
|
|
534
|
+
if not project_list:
|
|
535
|
+
print(" (no projects)")
|
|
536
|
+
continue
|
|
537
|
+
|
|
538
|
+
any_output = True
|
|
539
|
+
if quiet:
|
|
540
|
+
for proj_name, _status, _source in ws_items:
|
|
541
|
+
print(proj_name)
|
|
542
|
+
else:
|
|
543
|
+
print()
|
|
544
|
+
print(f"Workset: {ws_name} ({ws.root})")
|
|
545
|
+
print(f" {'NAME':<18} {'STATUS':<10} {'SOURCE'}")
|
|
546
|
+
for proj_name, display_status, source in ws_items:
|
|
547
|
+
print(f" {proj_name:<18} {display_status:<10} {source}")
|
|
548
|
+
|
|
549
|
+
if not any_output and not quiet:
|
|
550
|
+
if active_only:
|
|
551
|
+
print("No active boxes.")
|
|
552
|
+
else:
|
|
553
|
+
print("No known projects.")
|
|
554
|
+
|
|
555
|
+
return 0
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _list_orphans(
|
|
559
|
+
projects: list,
|
|
560
|
+
ws_data: list,
|
|
561
|
+
std,
|
|
562
|
+
quiet: bool,
|
|
563
|
+
) -> int:
|
|
564
|
+
"""List only orphaned projects (--orphan flag handler)."""
|
|
565
|
+
# Default-mode orphans: path missing or no breadcrumb.
|
|
566
|
+
ac_orphans = []
|
|
567
|
+
for metadata_path, project_path in projects:
|
|
568
|
+
if project_path is None or not project_path.is_dir():
|
|
569
|
+
ac_orphans.append((metadata_path, project_path))
|
|
570
|
+
|
|
571
|
+
# Workset orphans: workspace directory missing but project data exists.
|
|
572
|
+
ws_orphans: list[tuple[str, str]] = []
|
|
573
|
+
for ws_name, ws, project_list in ws_data:
|
|
574
|
+
for proj_name, status in project_list:
|
|
575
|
+
if status == "missing":
|
|
576
|
+
ws_orphans.append((ws_name, proj_name))
|
|
577
|
+
|
|
578
|
+
if not ac_orphans and not ws_orphans:
|
|
579
|
+
if not quiet:
|
|
580
|
+
print("No orphaned projects found.")
|
|
581
|
+
return 0
|
|
582
|
+
|
|
583
|
+
names_data = read_names(std.data_path)
|
|
584
|
+
path_to_name: dict[str, str] = {v: k for k, v in names_data["projects"].items()}
|
|
585
|
+
|
|
586
|
+
if ac_orphans:
|
|
587
|
+
if not quiet:
|
|
588
|
+
print(f"{'NAME':<18} {'PATH'}")
|
|
589
|
+
for metadata_path, project_path in ac_orphans:
|
|
590
|
+
dir_name = metadata_path.name
|
|
591
|
+
proj_name = path_to_name.get(str(project_path), dir_name) if project_path else dir_name
|
|
592
|
+
if quiet:
|
|
593
|
+
print(proj_name)
|
|
594
|
+
else:
|
|
595
|
+
label = str(project_path) if project_path else "(no breadcrumb)"
|
|
596
|
+
print(f"{proj_name:<18} {label}")
|
|
597
|
+
|
|
598
|
+
if ws_orphans:
|
|
599
|
+
if not quiet:
|
|
600
|
+
if ac_orphans:
|
|
601
|
+
print()
|
|
602
|
+
print(f"{'WORKSET':<18} {'PROJECT'}")
|
|
603
|
+
for ws_name, proj_name in ws_orphans:
|
|
604
|
+
if quiet:
|
|
605
|
+
print(proj_name)
|
|
606
|
+
else:
|
|
607
|
+
print(f"{ws_name:<18} {proj_name}")
|
|
608
|
+
|
|
609
|
+
if not quiet:
|
|
610
|
+
total = len(ac_orphans) + len(ws_orphans)
|
|
611
|
+
print(f"\n{total} orphaned project(s).")
|
|
612
|
+
print("Use 'kanibako box migrate' to remap, or 'kanibako box rm' to remove.")
|
|
613
|
+
return 0
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _purge_dir(target: Path) -> bool:
|
|
617
|
+
"""Remove *target*, tolerating files a rootless container created.
|
|
618
|
+
|
|
619
|
+
A box's shell dir can contain files owned by mapped subuids (root inside a
|
|
620
|
+
``--userns=keep-id`` container) that the host user cannot unlink, so a plain
|
|
621
|
+
``shutil.rmtree`` fails with EACCES. Fall back to ``podman unshare rm -rf``,
|
|
622
|
+
which deletes from within the user namespace. Returns True if *target* is
|
|
623
|
+
gone afterwards, False otherwise (caller warns rather than crashing).
|
|
624
|
+
"""
|
|
625
|
+
import shutil
|
|
626
|
+
|
|
627
|
+
try:
|
|
628
|
+
shutil.rmtree(target)
|
|
629
|
+
return True
|
|
630
|
+
except OSError:
|
|
631
|
+
pass
|
|
632
|
+
try:
|
|
633
|
+
from kanibako.container import ContainerError, ContainerRuntime
|
|
634
|
+
|
|
635
|
+
if ContainerRuntime().unshare_rm(target):
|
|
636
|
+
return True
|
|
637
|
+
except ContainerError:
|
|
638
|
+
pass
|
|
639
|
+
return not target.exists()
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def run_rm(args: argparse.Namespace) -> int:
|
|
643
|
+
"""Unregister a project from names.yaml, optionally purging metadata."""
|
|
644
|
+
from kanibako.names import lookup_by_path
|
|
645
|
+
from kanibako.paths import (
|
|
646
|
+
_remove_human_vault_symlink,
|
|
647
|
+
_remove_project_vault_symlink,
|
|
648
|
+
)
|
|
649
|
+
from kanibako.utils import confirm_prompt
|
|
650
|
+
|
|
651
|
+
config_file = config_file_path(xdg("XDG_CONFIG_HOME", ".config"))
|
|
652
|
+
config = load_config(config_file)
|
|
653
|
+
std = load_std_paths(config)
|
|
654
|
+
|
|
655
|
+
target = args.target
|
|
656
|
+
names = read_names(std.data_path)
|
|
657
|
+
|
|
658
|
+
# Resolve target: try as a registered name first, then as a path.
|
|
659
|
+
name: str | None = None
|
|
660
|
+
section: str | None = None
|
|
661
|
+
path: str | None = None
|
|
662
|
+
|
|
663
|
+
for sec in ("projects", "worksets"):
|
|
664
|
+
if target in names[sec]:
|
|
665
|
+
name = target
|
|
666
|
+
section = sec
|
|
667
|
+
path = names[sec][target]
|
|
668
|
+
break
|
|
669
|
+
|
|
670
|
+
if name is None:
|
|
671
|
+
# Try as a path (reverse lookup).
|
|
672
|
+
result = lookup_by_path(std.data_path, target)
|
|
673
|
+
if result is not None:
|
|
674
|
+
name, section = result
|
|
675
|
+
path = names[section][name]
|
|
676
|
+
|
|
677
|
+
if name is None or section is None:
|
|
678
|
+
print(f"Error: '{target}' is not a registered project or workset.", file=sys.stderr)
|
|
679
|
+
return 1
|
|
680
|
+
|
|
681
|
+
kind = "workset" if section == "worksets" else "project"
|
|
682
|
+
print(f"Removing {kind}: {name} ({path})")
|
|
683
|
+
|
|
684
|
+
# Unregister from names.yaml.
|
|
685
|
+
unregister_name(std.data_path, name, section=section)
|
|
686
|
+
print(f"Removed '{name}' from names.yaml")
|
|
687
|
+
|
|
688
|
+
if args.purge:
|
|
689
|
+
metadata_dir = std.boxes / name
|
|
690
|
+
|
|
691
|
+
if metadata_dir.is_dir():
|
|
692
|
+
if not args.force:
|
|
693
|
+
from kanibako.errors import UserCancelled
|
|
694
|
+
print()
|
|
695
|
+
try:
|
|
696
|
+
confirm_prompt(
|
|
697
|
+
f"Delete metadata at {metadata_dir}? This cannot be undone.\n"
|
|
698
|
+
"Type 'yes' to confirm: "
|
|
699
|
+
)
|
|
700
|
+
except UserCancelled:
|
|
701
|
+
print("Aborted (name was already unregistered).")
|
|
702
|
+
return 2
|
|
703
|
+
|
|
704
|
+
# Clean up vault symlinks before removing metadata.
|
|
705
|
+
vault_dir = std.data_path / config.paths_vault
|
|
706
|
+
_remove_human_vault_symlink(vault_dir, metadata_dir / "vault")
|
|
707
|
+
if path:
|
|
708
|
+
_remove_project_vault_symlink(Path(path))
|
|
709
|
+
|
|
710
|
+
if _purge_dir(metadata_dir):
|
|
711
|
+
print(f"Removed metadata: {metadata_dir}")
|
|
712
|
+
else:
|
|
713
|
+
print(
|
|
714
|
+
f"Warning: could not fully remove {metadata_dir} "
|
|
715
|
+
"(it may contain files created inside a container). "
|
|
716
|
+
f"Try: podman unshare rm -rf {metadata_dir}",
|
|
717
|
+
file=sys.stderr,
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
# Remove helper log directory if present.
|
|
721
|
+
log_dir = std.data_path / "logs" / name
|
|
722
|
+
if log_dir.is_dir():
|
|
723
|
+
_purge_dir(log_dir)
|
|
724
|
+
print(f"Removed logs: {log_dir}")
|
|
725
|
+
else:
|
|
726
|
+
print(f"No metadata directory found at {metadata_dir}")
|
|
727
|
+
else:
|
|
728
|
+
# Hint about --purge when metadata still exists.
|
|
729
|
+
metadata_dir = std.boxes / name
|
|
730
|
+
if metadata_dir.is_dir():
|
|
731
|
+
print(
|
|
732
|
+
f"Metadata still present at {metadata_dir}. "
|
|
733
|
+
f"Run 'kanibako box rm {name} --purge' to delete."
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
return 0
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def run_move(args: argparse.Namespace) -> int:
|
|
740
|
+
"""Move a project workspace to a new directory."""
|
|
741
|
+
import shutil as _shutil
|
|
742
|
+
|
|
743
|
+
from kanibako.names import lookup_by_path, update_name_path
|
|
744
|
+
from kanibako.paths import (
|
|
745
|
+
_remove_project_vault_symlink,
|
|
746
|
+
detect_project_mode,
|
|
747
|
+
)
|
|
748
|
+
from kanibako.utils import confirm_prompt as _confirm
|
|
749
|
+
|
|
750
|
+
config_file = config_file_path(xdg("XDG_CONFIG_HOME", ".config"))
|
|
751
|
+
config = load_config(config_file)
|
|
752
|
+
std = load_std_paths(config)
|
|
753
|
+
|
|
754
|
+
positional = args.args # 1 or 2 items: [project] <dest>
|
|
755
|
+
if len(positional) == 1:
|
|
756
|
+
project_dir = None
|
|
757
|
+
dest = positional[0]
|
|
758
|
+
elif len(positional) == 2:
|
|
759
|
+
project_dir = positional[0]
|
|
760
|
+
dest = positional[1]
|
|
761
|
+
else:
|
|
762
|
+
print("Error: expected [project] <dest>", file=sys.stderr)
|
|
763
|
+
return 1
|
|
764
|
+
|
|
765
|
+
# Resolve project.
|
|
766
|
+
try:
|
|
767
|
+
proj = resolve_any_project(std, config, project_dir=project_dir, initialize=False)
|
|
768
|
+
except Exception as e:
|
|
769
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
770
|
+
return 1
|
|
771
|
+
|
|
772
|
+
if not proj.metadata_path.is_dir():
|
|
773
|
+
print(f"Error: no project data found for {proj.project_path}", file=sys.stderr)
|
|
774
|
+
return 1
|
|
775
|
+
|
|
776
|
+
# Refuse if project is in a workset.
|
|
777
|
+
dm = detect_project_mode(proj.project_path, std, config)
|
|
778
|
+
if dm.mode == ProjectMode.workset:
|
|
779
|
+
print(
|
|
780
|
+
"Error: cannot move a workset project. "
|
|
781
|
+
"Use workset-level operations instead.",
|
|
782
|
+
file=sys.stderr,
|
|
783
|
+
)
|
|
784
|
+
return 1
|
|
785
|
+
|
|
786
|
+
dest_path = Path(dest).resolve()
|
|
787
|
+
source_path = proj.project_path
|
|
788
|
+
|
|
789
|
+
if dest_path == source_path:
|
|
790
|
+
print("Error: source and destination are the same.", file=sys.stderr)
|
|
791
|
+
return 1
|
|
792
|
+
|
|
793
|
+
if dest_path.exists():
|
|
794
|
+
print(f"Error: destination already exists: {dest_path}", file=sys.stderr)
|
|
795
|
+
return 1
|
|
796
|
+
|
|
797
|
+
# Check for running container.
|
|
798
|
+
lock_file = proj.metadata_path / ".kanibako.lock"
|
|
799
|
+
if lock_file.exists():
|
|
800
|
+
print(
|
|
801
|
+
"Error: lock file found — a container may be running for this project.\n"
|
|
802
|
+
"Stop the container first.",
|
|
803
|
+
file=sys.stderr,
|
|
804
|
+
)
|
|
805
|
+
return 1
|
|
806
|
+
|
|
807
|
+
# Confirm.
|
|
808
|
+
if not args.force:
|
|
809
|
+
print("Move project workspace:")
|
|
810
|
+
print(f" from: {source_path}")
|
|
811
|
+
print(f" to: {dest_path}")
|
|
812
|
+
print()
|
|
813
|
+
try:
|
|
814
|
+
_confirm("Type 'yes' to confirm: ")
|
|
815
|
+
except Exception:
|
|
816
|
+
print("Aborted.")
|
|
817
|
+
return 2
|
|
818
|
+
|
|
819
|
+
# 1. Move workspace directory.
|
|
820
|
+
try:
|
|
821
|
+
_shutil.move(str(source_path), str(dest_path))
|
|
822
|
+
except Exception as e:
|
|
823
|
+
print(f"Error: failed to move workspace: {e}", file=sys.stderr)
|
|
824
|
+
return 1
|
|
825
|
+
|
|
826
|
+
# 2. Update names.yaml path.
|
|
827
|
+
result = lookup_by_path(std.data_path, str(source_path))
|
|
828
|
+
if result is not None:
|
|
829
|
+
name, section = result
|
|
830
|
+
update_name_path(std.data_path, name, str(dest_path), section=section)
|
|
831
|
+
print(f"Updated names.yaml: {name} -> {dest_path}")
|
|
832
|
+
elif proj.name:
|
|
833
|
+
# Try by name directly.
|
|
834
|
+
update_name_path(std.data_path, proj.name, str(dest_path))
|
|
835
|
+
print(f"Updated names.yaml: {proj.name} -> {dest_path}")
|
|
836
|
+
|
|
837
|
+
# 3. Recreate vault symlinks (remove old, create new).
|
|
838
|
+
_remove_project_vault_symlink(dest_path)
|
|
839
|
+
vault_meta = proj.metadata_path / "vault"
|
|
840
|
+
if vault_meta.is_dir():
|
|
841
|
+
vault_link = dest_path / "vault"
|
|
842
|
+
if not vault_link.exists():
|
|
843
|
+
try:
|
|
844
|
+
vault_link.symlink_to(vault_meta)
|
|
845
|
+
except OSError:
|
|
846
|
+
print("Warning: could not recreate vault symlink.", file=sys.stderr)
|
|
847
|
+
|
|
848
|
+
print(f"Moved project to {dest_path}")
|
|
849
|
+
return 0
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _format_credential_age(creds_path: Path) -> str:
|
|
853
|
+
"""Return a human-readable age string for a credentials file, or 'n/a'."""
|
|
854
|
+
if not creds_path.is_file():
|
|
855
|
+
return "n/a (no credentials file)"
|
|
856
|
+
try:
|
|
857
|
+
mtime = creds_path.stat().st_mtime
|
|
858
|
+
except OSError:
|
|
859
|
+
return "n/a (unreadable)"
|
|
860
|
+
dt = datetime.fromtimestamp(mtime, tz=timezone.utc)
|
|
861
|
+
now = datetime.now(tz=timezone.utc)
|
|
862
|
+
delta = now - dt
|
|
863
|
+
total_seconds = int(delta.total_seconds())
|
|
864
|
+
if total_seconds < 60:
|
|
865
|
+
age = f"{total_seconds}s ago"
|
|
866
|
+
elif total_seconds < 3600:
|
|
867
|
+
age = f"{total_seconds // 60}m ago"
|
|
868
|
+
elif total_seconds < 86400:
|
|
869
|
+
age = f"{total_seconds // 3600}h ago"
|
|
870
|
+
else:
|
|
871
|
+
age = f"{total_seconds // 86400}d ago"
|
|
872
|
+
return f"{age} ({dt.strftime('%Y-%m-%d %H:%M:%S UTC')})"
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _check_container_running(proj) -> tuple[bool, str]:
|
|
876
|
+
"""Check if a kanibako container is running for this project.
|
|
877
|
+
|
|
878
|
+
Accepts a ``ProjectPaths`` (or duck-typed equivalent).
|
|
879
|
+
Returns ``(is_running, detail_string)``.
|
|
880
|
+
"""
|
|
881
|
+
container_name = container_name_for(proj)
|
|
882
|
+
try:
|
|
883
|
+
runtime = ContainerRuntime()
|
|
884
|
+
except ContainerError:
|
|
885
|
+
return False, "unknown (no container runtime)"
|
|
886
|
+
containers = runtime.list_running()
|
|
887
|
+
for name, image, status in containers:
|
|
888
|
+
if name == container_name:
|
|
889
|
+
return True, f"running ({container_name}: {image})"
|
|
890
|
+
# Check for stopped persistent container
|
|
891
|
+
if runtime.container_exists(container_name):
|
|
892
|
+
return False, f"stopped persistent ({container_name})"
|
|
893
|
+
return False, f"not running ({container_name})"
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def run_info(args: argparse.Namespace) -> int:
|
|
897
|
+
config_file = config_file_path(xdg("XDG_CONFIG_HOME", ".config"))
|
|
898
|
+
config = load_config(config_file)
|
|
899
|
+
|
|
900
|
+
try:
|
|
901
|
+
std = load_std_paths(config)
|
|
902
|
+
except Exception as e:
|
|
903
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
904
|
+
return 1
|
|
905
|
+
|
|
906
|
+
project_dir = getattr(args, "path", None)
|
|
907
|
+
raw = project_dir or os.getcwd()
|
|
908
|
+
raw_dir = Path(raw).resolve()
|
|
909
|
+
|
|
910
|
+
if not raw_dir.is_dir():
|
|
911
|
+
print(f"Error: directory does not exist: {raw_dir}", file=sys.stderr)
|
|
912
|
+
return 1
|
|
913
|
+
|
|
914
|
+
# Detect mode and resolve project paths (without initializing).
|
|
915
|
+
try:
|
|
916
|
+
proj = resolve_any_project(std, config, project_dir=project_dir, initialize=False)
|
|
917
|
+
except ProjectError as e:
|
|
918
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
919
|
+
return 1
|
|
920
|
+
|
|
921
|
+
# Check if the project has been initialized (has metadata on disk).
|
|
922
|
+
has_data = proj.metadata_path.is_dir()
|
|
923
|
+
|
|
924
|
+
if not has_data:
|
|
925
|
+
print(f"No project data found for: {proj.project_path}")
|
|
926
|
+
print()
|
|
927
|
+
if proj.group is not None and proj.group.is_default:
|
|
928
|
+
print("This directory has not been used with kanibako yet.")
|
|
929
|
+
print("Start a session with 'kanibako start', or create with:")
|
|
930
|
+
print(" kanibako box create")
|
|
931
|
+
else:
|
|
932
|
+
print("This directory has not been initialized.")
|
|
933
|
+
return 1
|
|
934
|
+
|
|
935
|
+
# Load merged config for image info.
|
|
936
|
+
project_toml = proj.metadata_path / "project.yaml"
|
|
937
|
+
workset_path = (proj.group.root / "config.yaml") if proj.group is not None else None
|
|
938
|
+
merged = load_merged_config(
|
|
939
|
+
config_file,
|
|
940
|
+
project_toml if project_toml.exists() else None,
|
|
941
|
+
workset_path=workset_path,
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
# Gather status info.
|
|
945
|
+
lock_file = proj.metadata_path / ".kanibako.lock"
|
|
946
|
+
lock_held = lock_file.exists()
|
|
947
|
+
|
|
948
|
+
container_running, container_detail = _check_container_running(proj)
|
|
949
|
+
|
|
950
|
+
# Resolve target for credential check path
|
|
951
|
+
try:
|
|
952
|
+
target = resolve_target(merged.box_crab or None)
|
|
953
|
+
creds_file = target.credential_check_path(proj.shell_path)
|
|
954
|
+
except (KeyError, Exception):
|
|
955
|
+
creds_file = None
|
|
956
|
+
cred_age = _format_credential_age(creds_file) if creds_file else "n/a (no target)"
|
|
957
|
+
|
|
958
|
+
# Display mode name with dashes for readability.
|
|
959
|
+
mode_display = proj.mode.value.replace("_", "-")
|
|
960
|
+
|
|
961
|
+
# Format output.
|
|
962
|
+
rows: list[tuple[str, str]] = [
|
|
963
|
+
("Name", proj.name or "(unnamed)"),
|
|
964
|
+
("Mode", mode_display),
|
|
965
|
+
("Project", str(proj.project_path)),
|
|
966
|
+
("Hash", short_hash(proj.project_hash)),
|
|
967
|
+
("Metadata", str(proj.metadata_path)),
|
|
968
|
+
("Shell", str(proj.shell_path)),
|
|
969
|
+
("Vault RO", str(proj.vault_ro_path)),
|
|
970
|
+
("Vault RW", str(proj.vault_rw_path)),
|
|
971
|
+
]
|
|
972
|
+
if proj.global_shared_path:
|
|
973
|
+
rows.append(("Shared", str(proj.global_shared_path)))
|
|
974
|
+
if proj.local_shared_path:
|
|
975
|
+
rows.append(("Local", str(proj.local_shared_path)))
|
|
976
|
+
rows.extend([
|
|
977
|
+
("Image", merged.box_image),
|
|
978
|
+
("Lock", "ACTIVE" if lock_held else "none"),
|
|
979
|
+
("Container", container_detail),
|
|
980
|
+
("Credentials", cred_age),
|
|
981
|
+
])
|
|
982
|
+
|
|
983
|
+
# Compute alignment width from longest label.
|
|
984
|
+
label_width = max(len(label) for label, _ in rows) + 1 # +1 for colon
|
|
985
|
+
for label, value in rows:
|
|
986
|
+
print(f" {label + ':':<{label_width}} {value}")
|
|
987
|
+
|
|
988
|
+
return 0
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def run_config(args: argparse.Namespace) -> int:
|
|
992
|
+
"""Unified config interface for project settings.
|
|
993
|
+
|
|
994
|
+
Handles get, set, show, reset operations via the config_interface engine.
|
|
995
|
+
Uses the known-key heuristic to disambiguate project names from config keys.
|
|
996
|
+
"""
|
|
997
|
+
from kanibako.config_interface import (
|
|
998
|
+
ConfigAction,
|
|
999
|
+
get_config_value,
|
|
1000
|
+
is_known_key,
|
|
1001
|
+
parse_config_arg,
|
|
1002
|
+
reset_all,
|
|
1003
|
+
reset_config_value,
|
|
1004
|
+
set_config_value,
|
|
1005
|
+
show_config,
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
config_file = config_file_path(xdg("XDG_CONFIG_HOME", ".config"))
|
|
1009
|
+
config = load_config(config_file)
|
|
1010
|
+
std = load_std_paths(config)
|
|
1011
|
+
|
|
1012
|
+
# Parse the positional args list: [project] [key[=value]]
|
|
1013
|
+
positional = args.args # list of 0-2 items
|
|
1014
|
+
project_dir: str | None = None
|
|
1015
|
+
key_value_arg: str | None = None
|
|
1016
|
+
|
|
1017
|
+
if len(positional) == 0:
|
|
1018
|
+
pass # show mode
|
|
1019
|
+
elif len(positional) == 1:
|
|
1020
|
+
# Is it a known key (or key=value), or a project name?
|
|
1021
|
+
arg = positional[0]
|
|
1022
|
+
if "=" in arg or is_known_key(arg):
|
|
1023
|
+
key_value_arg = arg
|
|
1024
|
+
else:
|
|
1025
|
+
project_dir = arg
|
|
1026
|
+
elif len(positional) == 2:
|
|
1027
|
+
project_dir = positional[0]
|
|
1028
|
+
key_value_arg = positional[1]
|
|
1029
|
+
else:
|
|
1030
|
+
print("Error: too many arguments (expected [project] [key[=value]])", file=sys.stderr)
|
|
1031
|
+
return 1
|
|
1032
|
+
|
|
1033
|
+
# Handle --reset mode
|
|
1034
|
+
if args.reset is not None:
|
|
1035
|
+
# --reset with --all: reset everything
|
|
1036
|
+
if args.reset_all or args.reset == "__ALL__":
|
|
1037
|
+
try:
|
|
1038
|
+
proj = resolve_any_project(std, config, project_dir=project_dir, initialize=False)
|
|
1039
|
+
except ProjectError as e:
|
|
1040
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
1041
|
+
return 1
|
|
1042
|
+
project_toml = proj.metadata_path / "project.yaml"
|
|
1043
|
+
env_path = proj.metadata_path / "env"
|
|
1044
|
+
msg = reset_all(
|
|
1045
|
+
config_path=project_toml,
|
|
1046
|
+
env_path=env_path,
|
|
1047
|
+
force=args.force,
|
|
1048
|
+
)
|
|
1049
|
+
print(msg)
|
|
1050
|
+
return 0
|
|
1051
|
+
|
|
1052
|
+
# --reset KEY: reset a specific key
|
|
1053
|
+
reset_key = args.reset
|
|
1054
|
+
try:
|
|
1055
|
+
proj = resolve_any_project(std, config, project_dir=project_dir, initialize=False)
|
|
1056
|
+
except ProjectError as e:
|
|
1057
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
1058
|
+
return 1
|
|
1059
|
+
project_toml = proj.metadata_path / "project.yaml"
|
|
1060
|
+
env_path = proj.metadata_path / "env"
|
|
1061
|
+
msg = reset_config_value(
|
|
1062
|
+
reset_key,
|
|
1063
|
+
config_path=project_toml,
|
|
1064
|
+
env_path=env_path,
|
|
1065
|
+
)
|
|
1066
|
+
print(msg)
|
|
1067
|
+
return 0
|
|
1068
|
+
|
|
1069
|
+
# Parse the key/value argument
|
|
1070
|
+
action, key, value = parse_config_arg(key_value_arg)
|
|
1071
|
+
|
|
1072
|
+
# --local flag forces a set operation (sets resource to project-isolated)
|
|
1073
|
+
if args.local and action == ConfigAction.get:
|
|
1074
|
+
action = ConfigAction.set
|
|
1075
|
+
|
|
1076
|
+
# Resolve the project
|
|
1077
|
+
try:
|
|
1078
|
+
proj = resolve_any_project(std, config, project_dir=project_dir, initialize=False)
|
|
1079
|
+
except ProjectError as e:
|
|
1080
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
1081
|
+
return 1
|
|
1082
|
+
|
|
1083
|
+
project_toml = proj.metadata_path / "project.yaml"
|
|
1084
|
+
env_global = std.data_path / "env"
|
|
1085
|
+
env_project = proj.metadata_path / "env"
|
|
1086
|
+
|
|
1087
|
+
if action == ConfigAction.show:
|
|
1088
|
+
workset_path = (
|
|
1089
|
+
(proj.group.root / "config.yaml") if proj.group is not None else None
|
|
1090
|
+
)
|
|
1091
|
+
crab_state = None
|
|
1092
|
+
env_resolved = None
|
|
1093
|
+
if args.effective:
|
|
1094
|
+
from kanibako.config import load_merged_config
|
|
1095
|
+
from kanibako.crabs import load_crab_config
|
|
1096
|
+
from kanibako.targets import resolve_target
|
|
1097
|
+
from kanibako.commands.start import (
|
|
1098
|
+
_build_config_env,
|
|
1099
|
+
_build_effective_state,
|
|
1100
|
+
)
|
|
1101
|
+
merged = load_merged_config(
|
|
1102
|
+
config_file, project_toml if project_toml.exists() else None,
|
|
1103
|
+
workset_path=workset_path,
|
|
1104
|
+
)
|
|
1105
|
+
try:
|
|
1106
|
+
target = resolve_target(merged.box_crab or None)
|
|
1107
|
+
except (KeyError, Exception):
|
|
1108
|
+
target = None
|
|
1109
|
+
agent_id = target.name if target else "general"
|
|
1110
|
+
crab_cfg_path = std.crabs / f"{agent_id}.yaml"
|
|
1111
|
+
if target and not crab_cfg_path.exists():
|
|
1112
|
+
crab_cfg = target.generate_crab_config()
|
|
1113
|
+
elif crab_cfg_path.exists():
|
|
1114
|
+
crab_cfg = load_crab_config(crab_cfg_path)
|
|
1115
|
+
else:
|
|
1116
|
+
crab_cfg = None
|
|
1117
|
+
if target is not None and crab_cfg is not None:
|
|
1118
|
+
crab_state = _build_effective_state(
|
|
1119
|
+
target, crab_cfg, project_toml,
|
|
1120
|
+
global_config_path=config_file,
|
|
1121
|
+
workset_config_path=workset_path,
|
|
1122
|
+
)
|
|
1123
|
+
workset_env_path = (
|
|
1124
|
+
proj.group.root / "env"
|
|
1125
|
+
if (proj.group is not None and not proj.group.is_default)
|
|
1126
|
+
else None
|
|
1127
|
+
)
|
|
1128
|
+
env_resolved = _build_config_env(
|
|
1129
|
+
std.data_path / "env",
|
|
1130
|
+
crab_cfg.env if crab_cfg is not None else {},
|
|
1131
|
+
workset_env_path,
|
|
1132
|
+
proj.metadata_path / "env",
|
|
1133
|
+
)
|
|
1134
|
+
return show_config(
|
|
1135
|
+
global_config_path=config_file,
|
|
1136
|
+
config_path=project_toml,
|
|
1137
|
+
env_global=env_global,
|
|
1138
|
+
env_project=env_project,
|
|
1139
|
+
effective=args.effective,
|
|
1140
|
+
workset_path=workset_path,
|
|
1141
|
+
crab_state=crab_state,
|
|
1142
|
+
env_resolved=env_resolved,
|
|
1143
|
+
)
|
|
1144
|
+
|
|
1145
|
+
if action == ConfigAction.get:
|
|
1146
|
+
val = get_config_value(
|
|
1147
|
+
key,
|
|
1148
|
+
global_config_path=config_file,
|
|
1149
|
+
project_toml=project_toml,
|
|
1150
|
+
env_global=env_global,
|
|
1151
|
+
env_project=env_project,
|
|
1152
|
+
)
|
|
1153
|
+
if val is not None:
|
|
1154
|
+
print(val)
|
|
1155
|
+
else:
|
|
1156
|
+
print("(not set)", file=sys.stderr)
|
|
1157
|
+
return 0
|
|
1158
|
+
|
|
1159
|
+
if action == ConfigAction.set:
|
|
1160
|
+
# Handle --local for resource keys
|
|
1161
|
+
if args.local:
|
|
1162
|
+
from kanibako.config_interface import _is_resource_key, _resolve_key
|
|
1163
|
+
canonical = _resolve_key(key)
|
|
1164
|
+
if not _is_resource_key(canonical):
|
|
1165
|
+
print("Error: --local only applies to resource.* keys", file=sys.stderr)
|
|
1166
|
+
return 1
|
|
1167
|
+
# --local means project-isolated (set scope to "project")
|
|
1168
|
+
value = "project"
|
|
1169
|
+
|
|
1170
|
+
msg = set_config_value(
|
|
1171
|
+
key, value,
|
|
1172
|
+
config_path=project_toml,
|
|
1173
|
+
env_path=env_project,
|
|
1174
|
+
)
|
|
1175
|
+
print(msg)
|
|
1176
|
+
return 0
|
|
1177
|
+
|
|
1178
|
+
return 0
|