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,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