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,574 @@
1
+ """Migrate and cross-mode conversion logic for kanibako box."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import shutil
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import cast
10
+
11
+ from kanibako.config import config_file_path, load_config
12
+ from kanibako.names import assign_name, unregister_name
13
+ from kanibako.paths import (
14
+ ProjectMode,
15
+ WorksetSpec,
16
+ _ensure_human_vault_symlink,
17
+ _find_workset_for_path,
18
+ _remove_human_vault_symlink,
19
+ _remove_project_vault_symlink,
20
+ _resolve_local_dir,
21
+ xdg,
22
+ detect_project_mode,
23
+ load_std_paths,
24
+ resolve_standalone_project,
25
+ resolve_project,
26
+ resolve_workset_project,
27
+ )
28
+ from kanibako.utils import confirm_prompt, project_hash
29
+
30
+
31
+ def run_migrate(args: argparse.Namespace) -> int:
32
+ import os
33
+
34
+ config_file = config_file_path(xdg("XDG_CONFIG_HOME", ".config"))
35
+ config = load_config(config_file)
36
+ std = load_std_paths(config)
37
+
38
+ # Cross-mode conversion.
39
+ if getattr(args, "to_mode", None) is not None:
40
+ return _run_convert(args, std, config)
41
+
42
+ # Same-mode path remap: old_path is required.
43
+ if args.old_path is None:
44
+ print("Error: old_path is required for path remap (use --to for mode conversion).", file=sys.stderr)
45
+ return 1
46
+
47
+ # Resolve paths — old path may no longer exist, so use str directly.
48
+ old_path = Path(args.old_path).resolve()
49
+ new_path = Path(args.new_path).resolve() if args.new_path else Path(os.getcwd()).resolve()
50
+
51
+ # Validate: paths must differ.
52
+ if old_path == new_path:
53
+ print("Error: old and new paths are the same.", file=sys.stderr)
54
+ return 1
55
+
56
+ # Validate: new path must exist as a directory.
57
+ if not new_path.is_dir():
58
+ print(f"Error: new path does not exist as a directory: {new_path}", file=sys.stderr)
59
+ return 1
60
+
61
+ new_hash = project_hash(str(new_path))
62
+
63
+ # Find old project directory.
64
+ old_name, old_project_dir = _resolve_local_dir(
65
+ std.data_path, str(old_path), std.boxes,
66
+ )
67
+
68
+ # Validate: old project data must exist.
69
+ if not old_project_dir.is_dir():
70
+ print(
71
+ f"Error: no project data found for old path: {old_path}",
72
+ file=sys.stderr,
73
+ )
74
+ print(f" (expected: {old_project_dir})", file=sys.stderr)
75
+ return 1
76
+
77
+ # Find or assign new project directory.
78
+ new_name, new_project_dir = _resolve_local_dir(
79
+ std.data_path, str(new_path), std.boxes,
80
+ )
81
+
82
+ # Validate: new project data must NOT already exist.
83
+ if new_project_dir.is_dir():
84
+ print(
85
+ f"Error: project data already exists for new path: {new_path}",
86
+ file=sys.stderr,
87
+ )
88
+ print(" Use 'kanibako box rm --purge' to remove it first.", file=sys.stderr)
89
+ return 1
90
+
91
+ # Warn if lock file exists.
92
+ lock_file = old_project_dir / ".kanibako.lock"
93
+ if lock_file.exists():
94
+ print(
95
+ "Warning: lock file found — a container may be running for this project.",
96
+ file=sys.stderr,
97
+ )
98
+ if not args.force:
99
+ try:
100
+ confirm_prompt("Continue anyway? Type 'yes' to confirm: ")
101
+ except Exception:
102
+ print("Aborted.")
103
+ return 2
104
+
105
+ # Confirm with user.
106
+ if not args.force:
107
+ print("Migrate project data:")
108
+ print(f" from: {old_path}")
109
+ print(f" to: {new_path}")
110
+ print()
111
+ try:
112
+ confirm_prompt("Type 'yes' to confirm: ")
113
+ except Exception:
114
+ print("Aborted.")
115
+ return 2
116
+
117
+ # Remove old human-friendly symlink before rename.
118
+ human_vault_dir = std.data_path / config.paths_vault
119
+ _remove_human_vault_symlink(human_vault_dir, old_project_dir / "vault")
120
+
121
+ # Update names.yaml: unregister old name, assign new name.
122
+ if old_name:
123
+ unregister_name(std.data_path, old_name)
124
+ new_name = assign_name(std.data_path, str(new_path))
125
+ new_project_dir = std.boxes / new_name
126
+
127
+ # Rename project directory (includes home/ inside it).
128
+ old_project_dir.rename(new_project_dir)
129
+
130
+ # Update workspace path in project.yaml.
131
+ from kanibako.config import read_project_meta, write_project_meta
132
+ project_toml = new_project_dir / "project.yaml"
133
+ meta = read_project_meta(project_toml)
134
+ if meta:
135
+ write_project_meta(
136
+ project_toml,
137
+ mode=meta["mode"],
138
+ layout=meta["layout"],
139
+ workspace=str(new_path),
140
+ shell=str(new_project_dir / "shell"),
141
+ vault_ro=meta.get("vault_ro", ""),
142
+ vault_rw=meta.get("vault_rw", ""),
143
+ enable_vault=meta.get("enable_vault", True),
144
+ group_auth=bool(meta.get("group_auth", True)),
145
+ metadata=str(new_project_dir),
146
+ project_hash=new_hash,
147
+ global_shared=meta.get("global_shared", ""),
148
+ local_shared=meta.get("local_shared", ""),
149
+ name=new_name,
150
+ )
151
+
152
+ # Create new human-friendly symlink (best-effort).
153
+ vault_parent = new_project_dir / "vault"
154
+ if vault_parent.is_dir():
155
+ _ensure_human_vault_symlink(human_vault_dir, new_path, vault_parent)
156
+
157
+ print("Migrated project data:")
158
+ print(f" from: {old_path} ({old_name})")
159
+ print(f" to: {new_path} ({new_name})")
160
+ return 0
161
+
162
+
163
+ # -- Cross-mode conversion helpers --
164
+
165
+ def _run_convert(args: argparse.Namespace, std, config) -> int:
166
+ """Dispatch cross-mode conversion based on --to flag."""
167
+ import os
168
+
169
+ to_mode_str = args.to_mode
170
+
171
+ # Convert TO workset: separate code path.
172
+ if to_mode_str == "workset":
173
+ return _convert_to_workset(args, std, config)
174
+
175
+ # Resolve project path (positional arg or cwd).
176
+ raw_path = args.old_path or os.getcwd()
177
+ project_path = Path(raw_path).resolve()
178
+
179
+ if not project_path.is_dir():
180
+ print(f"Error: project path does not exist: {project_path}", file=sys.stderr)
181
+ return 1
182
+
183
+ # Detect current mode.
184
+ current_mode = detect_project_mode(project_path, std, config).mode
185
+
186
+ # Convert FROM workset: separate code path.
187
+ if current_mode == ProjectMode.workset:
188
+ return _convert_from_workset(args, project_path, std, config)
189
+
190
+ # Parse target mode.
191
+ target_mode = ProjectMode.standalone if to_mode_str == "standalone" else ProjectMode.default
192
+
193
+ if current_mode == target_mode:
194
+ print(f"Error: project is already in {current_mode.value} mode.", file=sys.stderr)
195
+ return 1
196
+
197
+ # Resolve current project paths.
198
+ # default<->standalone: architectural boundary (centralized vs in-workspace metadata), not re-rooting — kept distinct (#71 B2).
199
+ if current_mode == ProjectMode.default:
200
+ proj = resolve_project(std, config, project_dir=str(project_path), initialize=False)
201
+ else:
202
+ proj = resolve_standalone_project(std, config, project_dir=str(project_path), initialize=False)
203
+
204
+ # Check that project data exists.
205
+ if not proj.metadata_path.is_dir():
206
+ print(f"Error: no project data found for {project_path}", file=sys.stderr)
207
+ return 1
208
+
209
+ # Lock file warning.
210
+ lock_file = proj.metadata_path / ".kanibako.lock"
211
+ if lock_file.exists():
212
+ print(
213
+ "Warning: lock file found — a container may be running for this project.",
214
+ file=sys.stderr,
215
+ )
216
+ if not args.force:
217
+ print("Aborted.")
218
+ return 2
219
+
220
+ # Confirm with user.
221
+ if not args.force:
222
+ print(f"Convert project to {target_mode.value} mode:")
223
+ print(f" project: {project_path}")
224
+ print(f" from: {current_mode.value}")
225
+ print(f" to: {target_mode.value}")
226
+ print()
227
+ try:
228
+ confirm_prompt("Type 'yes' to confirm: ")
229
+ except Exception:
230
+ print("Aborted.")
231
+ return 2
232
+
233
+ # Dispatch.
234
+ # default<->standalone: architectural boundary (centralized vs in-workspace metadata), not re-rooting — kept distinct (#71 B2).
235
+ if target_mode == ProjectMode.standalone:
236
+ _convert_local_to_standalone(project_path, std, config, proj)
237
+ else:
238
+ _convert_standalone_to_local(project_path, std, config, proj)
239
+
240
+ print(f"Converted project to {target_mode.value} mode:")
241
+ print(f" project: {project_path}")
242
+ return 0
243
+
244
+
245
+ def _convert_local_to_standalone(project_path, std, config, proj):
246
+ """Convert a default-mode project to standalone layout."""
247
+ from kanibako.utils import write_project_gitignore
248
+
249
+ dst_metadata = project_path / ".kanibako"
250
+ dst_shell = dst_metadata / "shell"
251
+
252
+ # Copy metadata (excluding lock file and shell/ directory).
253
+ shutil.copytree(
254
+ proj.metadata_path, dst_metadata,
255
+ ignore=shutil.ignore_patterns(".kanibako.lock", "shell"),
256
+ )
257
+
258
+ # Copy shell.
259
+ if proj.shell_path.is_dir():
260
+ shutil.copytree(proj.shell_path, dst_shell)
261
+
262
+ # Write .gitignore entries for .kanibako/.
263
+ write_project_gitignore(project_path)
264
+
265
+ # Write vault .gitignore if vault exists but gitignore doesn't.
266
+ vault_dir = project_path / "vault"
267
+ if vault_dir.is_dir():
268
+ vault_gitignore = vault_dir / ".gitignore"
269
+ if not vault_gitignore.exists():
270
+ vault_gitignore.write_text("rw/\n")
271
+
272
+ # Remove vault symlinks before cleaning up old default-mode data.
273
+ human_vault_dir = std.data_path / config.paths_vault
274
+ _remove_human_vault_symlink(human_vault_dir, proj.metadata_path / "vault")
275
+ _remove_project_vault_symlink(project_path)
276
+
277
+ # Unregister the default-mode project name.
278
+ if proj.name:
279
+ unregister_name(std.data_path, proj.name)
280
+
281
+ # Clean up old default-mode data.
282
+ shutil.rmtree(proj.metadata_path)
283
+
284
+
285
+ def _convert_standalone_to_local(project_path, std, config, proj):
286
+ """Convert a standalone project to default-mode layout."""
287
+ # Assign a name for the new default-mode project.
288
+ project_name = assign_name(std.data_path, str(project_path))
289
+ settings_base = std.boxes
290
+ dst_project = settings_base / project_name
291
+
292
+ # Copy metadata (excluding lock file and shell/).
293
+ shutil.copytree(
294
+ proj.metadata_path, dst_project,
295
+ ignore=shutil.ignore_patterns(".kanibako.lock", "shell"),
296
+ )
297
+
298
+ # Copy shell into the settings dir.
299
+ if proj.shell_path.is_dir():
300
+ dst_shell = dst_project / "shell"
301
+ shutil.copytree(proj.shell_path, dst_shell)
302
+
303
+ # Create human-friendly vault symlink if robust layout.
304
+ vault_parent = dst_project / "vault"
305
+ if vault_parent.is_dir():
306
+ human_vault_dir = std.data_path / config.paths_vault
307
+ _ensure_human_vault_symlink(human_vault_dir, project_path, vault_parent)
308
+
309
+ # Clean up old standalone data.
310
+ shutil.rmtree(proj.metadata_path)
311
+
312
+
313
+ # -- Workset conversion helpers --
314
+
315
+ def _copy_into_workset(ws, proj_name, src_proj, source_path, source_mode, copy_workspace) -> None:
316
+ """Re-root a resolved project into a workset group (register + copy).
317
+
318
+ Shared by migrate's ``_convert_to_workset`` and duplicate's
319
+ ``_duplicate_to_workset`` — both place a project under the same workset
320
+ group roots (``projects_dir`` for metadata/shell, ``workspaces_dir`` for the
321
+ tree). The only per-caller variation is whether the workspace tree is copied
322
+ (migrate: ``not in_place``; duplicate: ``not bare``); the standalone
323
+ ``.kanibako`` ignore filter is uniform. Callers handle source-side cleanup.
324
+ """
325
+ from kanibako.workset import add_project
326
+
327
+ # Register project in workset (creates skeleton dirs).
328
+ add_project(ws, proj_name, source_path)
329
+
330
+ # Copy metadata (excluding lock, breadcrumb, and home/).
331
+ dst_project = ws.projects_dir / proj_name
332
+ shutil.copytree(
333
+ src_proj.metadata_path, dst_project,
334
+ ignore=shutil.ignore_patterns(".kanibako.lock", "shell"),
335
+ dirs_exist_ok=True,
336
+ )
337
+
338
+ # Copy home.
339
+ if src_proj.shell_path.is_dir():
340
+ dst_home = dst_project / "shell"
341
+ shutil.copytree(src_proj.shell_path, dst_home, dirs_exist_ok=True)
342
+
343
+ # Copy workspace tree into the workset (exclude standalone metadata).
344
+ if copy_workspace:
345
+ dst_workspace = ws.workspaces_dir / proj_name
346
+ ignore = None
347
+ if source_mode == ProjectMode.standalone:
348
+ ignore = shutil.ignore_patterns(".kanibako")
349
+ shutil.copytree(source_path, dst_workspace, ignore=ignore, dirs_exist_ok=True)
350
+
351
+
352
+ def _convert_to_workset(args, std, config) -> int:
353
+ """Convert a default-mode or standalone project into a workset."""
354
+ import os
355
+
356
+ from kanibako.workset import list_worksets, load_workset
357
+
358
+ ws_name = getattr(args, "workset", None)
359
+ if not ws_name:
360
+ print("Error: --workset is required when converting to workset mode.", file=sys.stderr)
361
+ return 1
362
+
363
+ # Load target workset.
364
+ registry = list_worksets(std)
365
+ if ws_name not in registry:
366
+ print(f"Error: workset '{ws_name}' not found.", file=sys.stderr)
367
+ return 1
368
+ ws = load_workset(registry[ws_name])
369
+
370
+ # Resolve source project.
371
+ raw_path = args.old_path or os.getcwd()
372
+ project_path = Path(raw_path).resolve()
373
+
374
+ if not project_path.is_dir():
375
+ print(f"Error: project path does not exist: {project_path}", file=sys.stderr)
376
+ return 1
377
+
378
+ current_mode = detect_project_mode(project_path, std, config).mode
379
+ if current_mode == ProjectMode.workset:
380
+ print("Error: project is already in workset mode.", file=sys.stderr)
381
+ return 1
382
+
383
+ # Determine project name.
384
+ proj_name = getattr(args, "project_name", None) or project_path.name
385
+
386
+ # Validate name not taken.
387
+ for p in ws.projects:
388
+ if p.name == proj_name:
389
+ print(f"Error: project '{proj_name}' already exists in workset '{ws_name}'.", file=sys.stderr)
390
+ return 1
391
+
392
+ # Resolve source paths.
393
+ # default<->standalone: architectural boundary (centralized vs in-workspace metadata), not re-rooting — kept distinct (#71 B2).
394
+ if current_mode == ProjectMode.default:
395
+ src_proj = resolve_project(std, config, project_dir=str(project_path), initialize=False)
396
+ else:
397
+ src_proj = resolve_standalone_project(std, config, project_dir=str(project_path), initialize=False)
398
+
399
+ if not src_proj.metadata_path.is_dir():
400
+ print(f"Error: no project data found for {project_path}", file=sys.stderr)
401
+ return 1
402
+
403
+ # Lock file warning.
404
+ lock_file = src_proj.metadata_path / ".kanibako.lock"
405
+ if lock_file.exists():
406
+ print(
407
+ "Warning: lock file found — a container may be running for this project.",
408
+ file=sys.stderr,
409
+ )
410
+ if not args.force:
411
+ print("Aborted.")
412
+ return 2
413
+
414
+ in_place = getattr(args, "in_place", False)
415
+
416
+ # Confirm.
417
+ if not args.force:
418
+ action = "in-place (workspace stays)" if in_place else "move workspace into workset"
419
+ print(f"Convert project to workset mode ({action}):")
420
+ print(f" project: {project_path}")
421
+ print(f" workset: {ws_name}")
422
+ print(f" name: {proj_name}")
423
+ print()
424
+ try:
425
+ confirm_prompt("Type 'yes' to confirm: ")
426
+ except Exception:
427
+ print("Aborted.")
428
+ return 2
429
+
430
+ # Re-root the project into the workset group (move workspace unless --in-place).
431
+ _copy_into_workset(ws, proj_name, src_proj, project_path, current_mode, copy_workspace=not in_place)
432
+
433
+ # Remove vault symlinks before cleaning up old metadata.
434
+ if current_mode == ProjectMode.default:
435
+ human_vault_dir = std.data_path / config.paths_vault
436
+ _remove_human_vault_symlink(human_vault_dir, src_proj.metadata_path / "vault")
437
+ # Unregister old default-mode name.
438
+ if src_proj.name:
439
+ unregister_name(std.data_path, src_proj.name)
440
+ _remove_project_vault_symlink(project_path)
441
+
442
+ # Clean up old metadata.
443
+ shutil.rmtree(src_proj.metadata_path)
444
+ if src_proj.shell_path.is_dir():
445
+ shutil.rmtree(src_proj.shell_path)
446
+
447
+ print("Converted project to workset mode:")
448
+ print(f" workset: {ws_name}/{proj_name}")
449
+ return 0
450
+
451
+
452
+ def _convert_from_workset(args, project_path, std, config) -> int:
453
+ """Convert a workset project to default or standalone mode."""
454
+ to_mode_str = args.to_mode
455
+
456
+ ws, proj_name = _find_workset_for_path(project_path, std)
457
+ if proj_name is None:
458
+ print("Error: not inside a specific project workspace.", file=sys.stderr)
459
+ return 1
460
+ src_proj = resolve_workset_project(
461
+ WorksetSpec.from_workset(ws), proj_name, std, config, initialize=False,
462
+ )
463
+
464
+ target_mode = ProjectMode.standalone if to_mode_str == "standalone" else ProjectMode.default
465
+
466
+ if not src_proj.metadata_path.is_dir():
467
+ print(f"Error: no project data found for {project_path}", file=sys.stderr)
468
+ return 1
469
+
470
+ # Lock file warning.
471
+ lock_file = src_proj.metadata_path / ".kanibako.lock"
472
+ if lock_file.exists():
473
+ print(
474
+ "Warning: lock file found — a container may be running for this project.",
475
+ file=sys.stderr,
476
+ )
477
+ if not args.force:
478
+ print("Aborted.")
479
+ return 2
480
+
481
+ # Determine destination path: use source_path from workset project.
482
+ found_proj = None
483
+ for p in ws.projects:
484
+ if p.name == proj_name:
485
+ found_proj = p
486
+ break
487
+ dest_path = found_proj.source_path if found_proj else project_path
488
+
489
+ # Confirm.
490
+ if not args.force:
491
+ print(f"Convert workset project to {target_mode.value} mode:")
492
+ print(f" workset: {ws.name}/{proj_name}")
493
+ print(f" target: {dest_path}")
494
+ print()
495
+ try:
496
+ confirm_prompt("Type 'yes' to confirm: ")
497
+ except Exception:
498
+ print("Aborted.")
499
+ return 2
500
+
501
+ # Target layout differs per mode; default<->standalone: architectural boundary (centralized vs in-workspace metadata), not re-rooting — kept distinct (#71 B2).
502
+ if target_mode == ProjectMode.default:
503
+ _convert_ws_to_local(src_proj, dest_path, std, config)
504
+ else:
505
+ _convert_ws_to_standalone(src_proj, dest_path)
506
+
507
+ # Move workspace from workset to destination if it exists and differs.
508
+ ws_workspace = ws.workspaces_dir / proj_name
509
+ in_place = getattr(args, "in_place", False)
510
+ if not in_place and ws_workspace.is_dir() and ws_workspace != dest_path:
511
+ dest_path.mkdir(parents=True, exist_ok=True)
512
+ shutil.copytree(ws_workspace, dest_path, dirs_exist_ok=True)
513
+
514
+ # Remove workset registration + workset dirs.
515
+ from kanibako.workset import Workset, remove_project
516
+
517
+ remove_project(cast("Workset", ws), proj_name, remove_files=True)
518
+
519
+ print(f"Converted project to {target_mode.value} mode:")
520
+ print(f" project: {dest_path}")
521
+ return 0
522
+
523
+
524
+ def _convert_ws_to_local(src_proj, dest_path, std, config):
525
+ """Copy workset project metadata into default-mode layout."""
526
+ # Assign a name for the new default-mode project.
527
+ project_name = assign_name(std.data_path, str(dest_path))
528
+ projects_base = std.boxes
529
+ dst_project = projects_base / project_name
530
+
531
+ # Copy metadata (excluding lock and home/).
532
+ shutil.copytree(
533
+ src_proj.metadata_path, dst_project,
534
+ ignore=shutil.ignore_patterns(".kanibako.lock", "shell"),
535
+ )
536
+
537
+ # Copy home.
538
+ if src_proj.shell_path.is_dir():
539
+ dst_home = dst_project / "shell"
540
+ shutil.copytree(src_proj.shell_path, dst_home)
541
+
542
+ # Create human-friendly vault symlink if robust layout.
543
+ vault_parent = dst_project / "vault"
544
+ if vault_parent.is_dir():
545
+ human_vault_dir = std.data_path / config.paths_vault
546
+ _ensure_human_vault_symlink(human_vault_dir, dest_path, vault_parent)
547
+
548
+
549
+ def _convert_ws_to_standalone(src_proj, dest_path):
550
+ """Copy workset project metadata into standalone layout."""
551
+ from kanibako.utils import write_project_gitignore
552
+
553
+ dest_path.mkdir(parents=True, exist_ok=True)
554
+ dst_metadata = dest_path / ".kanibako"
555
+ dst_shell = dst_metadata / "shell"
556
+
557
+ # Copy metadata (excluding lock and shell/).
558
+ shutil.copytree(
559
+ src_proj.metadata_path, dst_metadata,
560
+ ignore=shutil.ignore_patterns(".kanibako.lock", "shell"),
561
+ )
562
+
563
+ # Copy shell.
564
+ if src_proj.shell_path.is_dir():
565
+ shutil.copytree(src_proj.shell_path, dst_shell)
566
+
567
+ write_project_gitignore(dest_path)
568
+
569
+ # Write vault .gitignore if vault exists.
570
+ vault_dir = dest_path / "vault"
571
+ if vault_dir.is_dir():
572
+ vault_gitignore = vault_dir / ".gitignore"
573
+ if not vault_gitignore.exists():
574
+ vault_gitignore.write_text("rw/\n")