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
kanibako/paths.py ADDED
@@ -0,0 +1,1483 @@
1
+ """XDG resolution, project hash computation, directory creation, and initialization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum
8
+ from pathlib import Path
9
+ from collections.abc import Mapping, Sequence
10
+ from typing import NamedTuple, Protocol
11
+
12
+ import yaml
13
+
14
+ from kanibako.config import (
15
+ KanibakoConfig,
16
+ config_file_path,
17
+ load_config,
18
+ migrate_config,
19
+ read_project_meta,
20
+ write_project_meta,
21
+ )
22
+ from kanibako.config_io import load_doc
23
+ from kanibako.errors import ConfigError, ProjectError, WorksetError
24
+ from kanibako.settings_resolve import (
25
+ LevelView,
26
+ ResolveCtx,
27
+ SettingsError,
28
+ _Unset,
29
+ expand_expr,
30
+ resolve_value,
31
+ )
32
+ from kanibako.names import (
33
+ assign_name,
34
+ read_names,
35
+ register_name,
36
+ resolve_name,
37
+ )
38
+ from kanibako.utils import project_hash
39
+
40
+
41
+ class ProjectMode(Enum):
42
+ """How a project's persistent state is organized on disk."""
43
+
44
+ default = "default"
45
+ workset = "workset"
46
+ standalone = "standalone"
47
+
48
+
49
+ class DetectionResult(NamedTuple):
50
+ """Result of project mode detection.
51
+
52
+ *mode* is the detected project mode. *project_root* is the ancestor
53
+ directory where the marker was found (may differ from the original
54
+ *project_dir* when the user is in a subdirectory).
55
+ """
56
+
57
+ mode: ProjectMode
58
+ project_root: Path
59
+
60
+
61
+ class ProjectLayout(Enum):
62
+ """Directory layout variant within a project mode.
63
+
64
+ - **simple**: shell and vault live inside the workspace (minimal footprint)
65
+ - **default**: shell in boxes, vault in workspace (middle ground)
66
+ - **robust**: full separation — all four folders are top-level siblings
67
+ """
68
+
69
+ simple = "simple"
70
+ default = "default"
71
+ robust = "robust"
72
+
73
+
74
+ # Default layout per mode.
75
+ _DEFAULT_LAYOUT = {
76
+ ProjectMode.default: ProjectLayout.default,
77
+ ProjectMode.workset: ProjectLayout.robust,
78
+ ProjectMode.standalone: ProjectLayout.simple,
79
+ }
80
+
81
+
82
+ @dataclass
83
+ class StandardPaths:
84
+ """Resolved XDG and kanibako standard directory paths."""
85
+
86
+ config_home: Path
87
+ data_home: Path
88
+ state_home: Path
89
+ cache_home: Path
90
+ config_file: Path
91
+ data_path: Path
92
+ state_path: Path
93
+ cache_path: Path
94
+ # System-level derived directories (settings-framework "system.path.*").
95
+ boxes: Path
96
+ crabs: Path
97
+ comms: Path
98
+ share_ro: Path
99
+ share_rw: Path
100
+ templates: Path
101
+ ws_hints: Path
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class ProjectGroup:
106
+ """Descriptor of a project's grouping (default workset or named workset).
107
+
108
+ Captures the default-vs-workset difference as *data* rather than control
109
+ flow. The implicit default group is the *default workset* (``is_default``
110
+ is True); a named workset forms a non-default group rooted at the workset
111
+ root. Standalone projects belong to no group (``ProjectPaths.group`` is
112
+ None).
113
+
114
+ *local_shared_base* is the root under which the local-shared path lives
115
+ (``base / config.paths_shared``): the standard data path for the default
116
+ group, the workset root for a workset group.
117
+ """
118
+
119
+ name: str
120
+ root: Path
121
+ is_default: bool
122
+ local_shared_base: Path
123
+
124
+
125
+ @dataclass
126
+ class ProjectPaths:
127
+ """Resolved paths for a specific project."""
128
+
129
+ project_path: Path
130
+ project_hash: str
131
+ metadata_path: Path # host-only: project.yaml, breadcrumb, lock
132
+ shell_path: Path # mounted as /home/agent
133
+ vault_ro_path: Path # {project}/vault/ro (→ /home/agent/share-ro)
134
+ vault_rw_path: Path # {project}/vault/rw (→ /home/agent/share-rw)
135
+ is_new: bool = field(default=False)
136
+ mode: ProjectMode = field(default=ProjectMode.default)
137
+ layout: ProjectLayout = field(default=ProjectLayout.default)
138
+ enable_vault: bool = field(default=True)
139
+ group_auth: bool = field(default=True)
140
+ name: str = field(default="")
141
+ global_shared_path: Path | None = field(default=None)
142
+ local_shared_path: Path | None = field(default=None)
143
+ group: ProjectGroup | None = field(default=None)
144
+
145
+
146
+ class _WorksetLike(Protocol):
147
+ """Structural type for the attributes :meth:`WorksetSpec.from_workset` reads.
148
+
149
+ Avoids importing the concrete :class:`kanibako.workset.Workset` into
150
+ ``paths.py`` (which ``workset.py`` imports from, creating a cycle).
151
+ """
152
+
153
+ name: str
154
+ root: Path
155
+ group_auth: bool
156
+ is_default: bool
157
+
158
+ @property
159
+ def projects_dir(self) -> Path: ...
160
+
161
+ @property
162
+ def workspaces_dir(self) -> Path: ...
163
+
164
+ @property
165
+ def vault_dir(self) -> Path: ...
166
+
167
+ @property
168
+ def projects(self) -> Sequence[_WorksetProjectLike]: ...
169
+
170
+
171
+ class _WorksetProjectLike(Protocol):
172
+ """Structural type for the workset project attributes read here."""
173
+
174
+ @property
175
+ def name(self) -> str: ...
176
+
177
+ @property
178
+ def source_path(self) -> Path: ...
179
+
180
+
181
+ @dataclass(frozen=True)
182
+ class WorksetSpec:
183
+ """Primitive view of a workset, decoupled from :class:`kanibako.workset.Workset`.
184
+
185
+ Carries only the values the path resolver and project listings need, so
186
+ ``paths.py`` does not import the ``workset`` module (which depends on
187
+ ``paths.py``). Callers holding a full ``Workset`` build one with
188
+ :meth:`from_workset`.
189
+ """
190
+
191
+ name: str
192
+ root: Path
193
+ group_auth: bool
194
+ projects_dir: Path
195
+ workspaces_dir: Path
196
+ vault_dir: Path
197
+ project_names: tuple[str, ...]
198
+ is_default: bool = False
199
+
200
+ @classmethod
201
+ def from_workset(cls, ws: _WorksetLike) -> WorksetSpec:
202
+ """Build a :class:`WorksetSpec` from a ``Workset``-like object."""
203
+ return cls(
204
+ name=ws.name,
205
+ root=ws.root,
206
+ group_auth=ws.group_auth,
207
+ projects_dir=ws.projects_dir,
208
+ workspaces_dir=ws.workspaces_dir,
209
+ vault_dir=ws.vault_dir,
210
+ project_names=tuple(p.name for p in ws.projects),
211
+ is_default=ws.is_default,
212
+ )
213
+
214
+
215
+ def xdg(env_var: str, default_suffix: str) -> Path:
216
+ """Resolve an XDG directory from environment or default under $HOME."""
217
+ val = os.environ.get(env_var, "")
218
+ if val:
219
+ return Path(val).resolve()
220
+ return Path.home() / default_suffix
221
+
222
+
223
+ # ---------------------------------------------------------------------------
224
+ # System-level path tier (settings-framework "system.path.*")
225
+ # ---------------------------------------------------------------------------
226
+ #
227
+ # These model the system-level derived directories as resolver-backed path
228
+ # expressions. Keys are the FULL dotted names so ``@``-refs (e.g.
229
+ # ``@system.path.data``) resolve against the same table. Defaults reproduce
230
+ # today's flat ``KanibakoConfig.paths_*`` behavior byte-for-byte: the data path
231
+ # is ``$XDG_DATA_HOME/kanibako`` and the other dirs (and the ``ws_hints`` file)
232
+ # hang off it.
233
+ SYSTEM_PATH_DEFAULTS: dict[str, str] = {
234
+ "system.path.data": "$XDG_DATA_HOME/kanibako",
235
+ "system.path.boxes": "@system.path.data/boxes",
236
+ "system.path.crabs": "@system.path.data/crabs",
237
+ "system.path.comms": "@system.path.data/comms",
238
+ "system.path.share_ro": "@system.path.data/share_ro",
239
+ "system.path.share_rw": "@system.path.data/share_rw",
240
+ "system.path.templates": "@system.path.data/templates",
241
+ "system.path.ws_hints": "@system.path.data/worksets.yaml",
242
+ }
243
+
244
+
245
+ def resolve_system_paths(
246
+ set_values: Mapping[str, str], *, data_home: Path, home: Path,
247
+ ) -> dict[str, Path]:
248
+ """Resolve the ``system.path.*`` tier to concrete host paths.
249
+
250
+ *set_values* holds raw user-set expressions keyed by their full dotted name
251
+ (``system.path.<leaf>``); typically the global config's ``system_paths``.
252
+ *data_home* is the already-resolved XDG data base (e.g. ``~/.local/share``)
253
+ exposed to expressions as ``$XDG_DATA_HOME``; *home* expands a leading
254
+ ``~``. Returns ``{full_dotted_key: Path}`` for every key in
255
+ :data:`SYSTEM_PATH_DEFAULTS`.
256
+ """
257
+ ctx = ResolveCtx(
258
+ crab_name=None,
259
+ workset_name=None,
260
+ host_home=str(home),
261
+ xdg={"XDG_DATA_HOME": str(data_home)},
262
+ )
263
+ levels = [
264
+ LevelView("system", values=dict(set_values), defaults=SYSTEM_PATH_DEFAULTS)
265
+ ]
266
+
267
+ def lookup(ref: str, chain: tuple[str, ...]) -> str:
268
+ rv = resolve_value(ref, levels=levels, ctx=ctx, lookup=lookup)
269
+ if isinstance(rv, _Unset):
270
+ raise SettingsError(f"Unknown @-reference: {ref}")
271
+ return expand_expr(
272
+ rv.value, space="host", ctx=ctx, lookup=lookup, chain=chain,
273
+ )
274
+
275
+ resolved: dict[str, Path] = {}
276
+ for key in SYSTEM_PATH_DEFAULTS:
277
+ rv = resolve_value(key, levels=levels, ctx=ctx, lookup=lookup)
278
+ if isinstance(rv, _Unset): # Unreachable: every key has a default.
279
+ raise SettingsError(f"Unresolvable system path: {key}")
280
+ expanded = expand_expr(rv.value, space="host", ctx=ctx, lookup=lookup)
281
+ resolved[key] = Path(expanded)
282
+ return resolved
283
+
284
+
285
+ def _migrate_global_env(config_home: Path, data_path: Path) -> None:
286
+ """Move global env file from old config_home/kanibako/env to data_path/env."""
287
+ old = config_home / "kanibako" / "env"
288
+ new = data_path / "env"
289
+ if old.is_file() and not new.exists():
290
+ import shutil
291
+ data_path.mkdir(parents=True, exist_ok=True)
292
+ shutil.move(str(old), str(new))
293
+ import sys
294
+ print(f"Migrated: {old} → {new}", file=sys.stderr)
295
+
296
+
297
+ def _migrate_settings_to_boxes(data_path: Path, boxes_path: Path) -> None:
298
+ """Rename the legacy ``data_path/settings`` dir to the resolved boxes dir."""
299
+ old = data_path / "settings"
300
+ if old.is_dir() and not boxes_path.exists():
301
+ old.rename(boxes_path)
302
+ import sys
303
+ print(f"Migrated: {old} → {boxes_path}", file=sys.stderr)
304
+
305
+
306
+ def load_std_paths(config: KanibakoConfig | None = None) -> StandardPaths:
307
+ """Compute all standard kanibako directories.
308
+
309
+ If *config* is None, it is loaded from the config file (which must exist).
310
+ Directories are created as needed.
311
+ """
312
+ config_home = xdg("XDG_CONFIG_HOME", ".config")
313
+ data_home = xdg("XDG_DATA_HOME", ".local/share")
314
+ state_home = xdg("XDG_STATE_HOME", ".local/state")
315
+ cache_home = xdg("XDG_CACHE_HOME", ".cache")
316
+
317
+ # Migrate config file from old subdir location if needed.
318
+ migrate_config(config_home)
319
+ config_file = config_file_path(config_home)
320
+
321
+ if config is None:
322
+ if not config_file.exists():
323
+ raise ConfigError(
324
+ f"{config_file} is missing. Run any kanibako command to initialize."
325
+ )
326
+ config = load_config(config_file)
327
+
328
+ # Resolve the system-level path tier (settings-framework "system.path.*").
329
+ resolved = resolve_system_paths(
330
+ config.system_paths, data_home=data_home, home=Path.home(),
331
+ )
332
+ data_path = resolved["system.path.data"]
333
+ # state/cache paths track the data dir's leaf name (unchanged behavior:
334
+ # default leaf "kanibako" under each XDG base).
335
+ rel = data_path.name
336
+ state_path = state_home / rel
337
+ cache_path = cache_home / rel
338
+
339
+ # Migrate settings/ -> boxes/ if needed.
340
+ _migrate_settings_to_boxes(data_path, resolved["system.path.boxes"])
341
+
342
+ # Migrate global env file from config_home/kanibako/env to data_path/env.
343
+ _migrate_global_env(config_home, data_path)
344
+
345
+ # Ensure directories exist.
346
+ config_file.parent.mkdir(parents=True, exist_ok=True)
347
+ data_path.mkdir(parents=True, exist_ok=True)
348
+ state_path.mkdir(parents=True, exist_ok=True)
349
+ cache_path.mkdir(parents=True, exist_ok=True)
350
+
351
+ return StandardPaths(
352
+ config_home=config_home,
353
+ data_home=data_home,
354
+ state_home=state_home,
355
+ cache_home=cache_home,
356
+ config_file=config_file,
357
+ data_path=data_path,
358
+ state_path=state_path,
359
+ cache_path=cache_path,
360
+ boxes=resolved["system.path.boxes"],
361
+ crabs=resolved["system.path.crabs"],
362
+ comms=resolved["system.path.comms"],
363
+ share_ro=resolved["system.path.share_ro"],
364
+ share_rw=resolved["system.path.share_rw"],
365
+ templates=resolved["system.path.templates"],
366
+ ws_hints=resolved["system.path.ws_hints"],
367
+ )
368
+
369
+
370
+ def resolve_project(
371
+ std: StandardPaths,
372
+ config: KanibakoConfig,
373
+ project_dir: str | None = None,
374
+ *,
375
+ initialize: bool = False,
376
+ layout: ProjectLayout | None = None,
377
+ enable_vault: bool | None = None,
378
+ name_override: str | None = None,
379
+ ) -> ProjectPaths:
380
+ """Resolve (and optionally initialize) per-project paths.
381
+
382
+ When *initialize* is True (used by ``start``), missing project directories
383
+ are created and credential templates are copied in. When False (used by
384
+ subcommands like ``archive``/``purge``), the paths are merely computed.
385
+
386
+ *layout* overrides the default layout for new projects. Existing projects
387
+ read their layout from ``project.yaml``.
388
+
389
+ *enable_vault* controls whether vault directories are created and mounted.
390
+ Defaults to True for new projects; existing projects read from ``project.yaml``.
391
+ """
392
+ raw = project_dir or os.getcwd()
393
+ # If the user passed a bare token (no path separator) and no file/dir of
394
+ # that name exists in cwd, try resolving it as a registered project name.
395
+ # Falls through to path resolution on miss so the eventual error stays
396
+ # informative.
397
+ if raw and "/" not in raw and not Path(raw).exists():
398
+ try:
399
+ resolved, kind = resolve_name(std.data_path, raw, cwd=Path.cwd())
400
+ if kind == "project":
401
+ raw = resolved
402
+ except ProjectError:
403
+ pass
404
+ project_path = Path(raw).resolve()
405
+
406
+ if not project_path.is_dir():
407
+ raise ProjectError(f"Project path '{project_path}' does not exist.")
408
+
409
+ phash = project_hash(str(project_path))
410
+ project_path_str = str(project_path)
411
+
412
+ # Determine the project directory: name-based (boxes/{name}/).
413
+ project_name, project_dir_path = _resolve_local_dir(
414
+ std.data_path, project_path_str, std.boxes,
415
+ )
416
+
417
+ metadata_path = project_dir_path
418
+
419
+ # Check for stored paths in project.yaml (enables user overrides).
420
+ project_toml = metadata_path / "project.yaml"
421
+ meta = read_project_meta(project_toml)
422
+ if meta:
423
+ actual_layout = ProjectLayout(meta["layout"]) if meta.get("layout") else _DEFAULT_LAYOUT[ProjectMode.default]
424
+ shell_path = Path(meta["shell"]) if meta["shell"] else metadata_path / "shell"
425
+ vault_ro_path = Path(meta["vault_ro"]) if meta["vault_ro"] else project_path / "vault" / "ro"
426
+ vault_rw_path = Path(meta["vault_rw"]) if meta["vault_rw"] else project_path / "vault" / "rw"
427
+ actual_vault_enabled = meta.get("enable_vault", True) if enable_vault is None else enable_vault
428
+ else:
429
+ actual_layout = layout or _DEFAULT_LAYOUT[ProjectMode.default]
430
+ shell_path, vault_ro_path, vault_rw_path = _compute_project_paths(
431
+ actual_layout, metadata_path, project_path,
432
+ vault_root=_local_vault_root(actual_layout, metadata_path, project_path),
433
+ )
434
+ actual_vault_enabled = enable_vault if enable_vault is not None else True
435
+
436
+ # Auth mode for the default group: the default workset's
437
+ # group_auth (from {data_path}/config.yaml) is the base; a project may
438
+ # narrow shared→distinct via its own meta — mirroring the named-workset
439
+ # logic in resolve_workset_project. No-op on upgrade: default_workset's
440
+ # group_auth is True until a user runs `workset config default group_auth`,
441
+ # and existing project meta froze group_auth=True at init.
442
+ from kanibako.workset import default_workset
443
+ actual_group_auth = default_workset(std).group_auth
444
+ if actual_group_auth and meta:
445
+ actual_group_auth = bool(meta.get("group_auth", True))
446
+
447
+ is_new = False
448
+ if initialize and not project_dir_path.is_dir():
449
+ # Guard: refuse to implicitly create a project rooted at $HOME.
450
+ if project_path == Path.home().resolve():
451
+ raise ProjectError(
452
+ "Refusing to create a project rooted at $HOME — this would "
453
+ "mount your entire home directory as the workspace.\n"
454
+ "If you really want a project here, use:\n"
455
+ " kanibako create --standalone ~ --allow-home"
456
+ )
457
+ # New project: assign a name first, then create boxes/{name}/.
458
+ # An explicit override (e.g. `kanibako create --name X`) registers
459
+ # strictly; collisions error rather than auto-suffix.
460
+ if name_override:
461
+ register_name(std.data_path, name_override, project_path_str)
462
+ project_name = name_override
463
+ else:
464
+ project_name = assign_name(std.data_path, project_path_str)
465
+ project_dir_path = std.boxes / project_name
466
+ metadata_path = project_dir_path
467
+ # Recompute paths with the name-based directory.
468
+ shell_path, vault_ro_path, vault_rw_path = _compute_project_paths(
469
+ actual_layout, metadata_path, project_path,
470
+ vault_root=_local_vault_root(actual_layout, metadata_path, project_path),
471
+ )
472
+ project_toml = metadata_path / "project.yaml"
473
+
474
+ _init_project(
475
+ std, metadata_path, shell_path,
476
+ vault_ro_path, vault_rw_path, project_path,
477
+ enable_vault=actual_vault_enabled,
478
+ )
479
+ _global_shared = std.data_path / config.paths_shared / "global"
480
+ _local_shared = std.data_path / config.paths_shared
481
+ write_project_meta(
482
+ project_toml,
483
+ mode="default",
484
+ layout=actual_layout.value,
485
+ workspace=str(project_path),
486
+ shell=str(shell_path),
487
+ vault_ro=str(vault_ro_path),
488
+ vault_rw=str(vault_rw_path),
489
+ enable_vault=actual_vault_enabled,
490
+ metadata=str(metadata_path),
491
+ project_hash=phash,
492
+ global_shared=str(_global_shared),
493
+ local_shared=str(_local_shared),
494
+ name=project_name,
495
+ )
496
+ import sys
497
+ print(f"Project name: {project_name}", file=sys.stderr)
498
+ is_new = True
499
+
500
+ if initialize:
501
+ # Recovery: ensure shell exists even if metadata_path was present.
502
+ if not shell_path.is_dir():
503
+ shell_path.mkdir(parents=True, exist_ok=True)
504
+ _bootstrap_shell(shell_path)
505
+ # Backfill project.yaml for old-format projects (pre-v0.8).
506
+ if metadata_path.is_dir() and read_project_meta(metadata_path / "project.yaml") is None:
507
+ _global_shared_bf = std.data_path / config.paths_shared / "global"
508
+ _local_shared_bf = std.data_path / config.paths_shared
509
+ # Use directory name as project name (name-based dirs).
510
+ _bf_name = metadata_path.name if not metadata_path.name.startswith(phash[:8]) else ""
511
+ write_project_meta(
512
+ metadata_path / "project.yaml",
513
+ mode="default",
514
+ layout=actual_layout.value,
515
+ workspace=str(project_path),
516
+ shell=str(shell_path),
517
+ vault_ro=str(vault_ro_path),
518
+ vault_rw=str(vault_rw_path),
519
+ enable_vault=actual_vault_enabled,
520
+ metadata=str(metadata_path),
521
+ project_hash=phash,
522
+ global_shared=str(_global_shared_bf),
523
+ local_shared=str(_local_shared_bf),
524
+ name=_bf_name,
525
+ )
526
+ # Convenience symlink when vault lives outside the workspace.
527
+ if actual_vault_enabled:
528
+ _ensure_vault_symlink(project_path, vault_ro_path)
529
+ # Human-friendly symlink for robust layout.
530
+ if actual_layout == ProjectLayout.robust:
531
+ human_vault_dir = std.data_path / config.paths_vault
532
+ _ensure_human_vault_symlink(
533
+ human_vault_dir, project_path, vault_ro_path.parent,
534
+ )
535
+ if is_new:
536
+ import sys
537
+ print(
538
+ f"\nNOTE: In robust layout, the default-workset vault "
539
+ f"is linked from\n{human_vault_dir}. You can create a "
540
+ f"symlink from your home directory with:\n"
541
+ f" ln -s {human_vault_dir} $HOME/kanibako_vault",
542
+ file=sys.stderr,
543
+ )
544
+
545
+ # Resolve shared paths: prefer stored values (enables user overrides).
546
+ _computed_global_shared = std.data_path / config.paths_shared / "global"
547
+ _computed_local_shared = std.data_path / config.paths_shared
548
+ if meta and meta.get("global_shared"):
549
+ _computed_global_shared = Path(meta["global_shared"])
550
+ if meta and meta.get("local_shared"):
551
+ _computed_local_shared = Path(meta["local_shared"])
552
+
553
+ return ProjectPaths(
554
+ project_path=project_path,
555
+ project_hash=phash,
556
+ metadata_path=metadata_path,
557
+ shell_path=shell_path,
558
+ vault_ro_path=vault_ro_path,
559
+ vault_rw_path=vault_rw_path,
560
+ is_new=is_new,
561
+ mode=ProjectMode.default,
562
+ layout=actual_layout,
563
+ enable_vault=actual_vault_enabled,
564
+ group_auth=actual_group_auth,
565
+ name=project_name,
566
+ global_shared_path=_computed_global_shared,
567
+ local_shared_path=_computed_local_shared,
568
+ group=ProjectGroup(
569
+ name="default",
570
+ root=std.data_path,
571
+ is_default=True,
572
+ local_shared_base=std.data_path,
573
+ ),
574
+ )
575
+
576
+
577
+ def _resolve_local_dir(
578
+ data_path: Path,
579
+ project_path_str: str,
580
+ boxes_dir: Path,
581
+ ) -> tuple[str, Path]:
582
+ """Find the boxes directory for a default-mode project.
583
+
584
+ Looks up the project name via names.yaml reverse lookup and returns
585
+ ``(project_name, boxes_dir/{name}/)`` path. *boxes_dir* is the resolved
586
+ ``system.path.boxes`` directory (``std.boxes``); *data_path* is still
587
+ needed to read ``names.yaml``.
588
+
589
+ Returns ``("", empty_path)`` when no name is registered — the caller
590
+ (``resolve_project``) will assign a name during initialization.
591
+ """
592
+ names = read_names(data_path)
593
+ # Reverse lookup: path → name.
594
+ for name, path in names["projects"].items():
595
+ if path == project_path_str:
596
+ return name, boxes_dir / name
597
+
598
+ return "", boxes_dir / "__unregistered__"
599
+
600
+
601
+
602
+ def _compute_project_paths(
603
+ layout: ProjectLayout, metadata_path: Path, project_path: Path,
604
+ *, vault_root: Path,
605
+ ) -> tuple[Path, Path, Path]:
606
+ """Compute ``(shell, vault_ro, vault_rw)`` for default and workset modes.
607
+
608
+ The only structural difference between default and workset is *where* the
609
+ vault lives in the non-``simple`` layouts; the caller expresses that by
610
+ passing ``vault_root`` — the parent directory under which ``ro`` and
611
+ ``rw`` are placed. The ``simple`` layout always keeps shell and
612
+ vault inside the workspace and ignores *vault_root*.
613
+
614
+ Caller-supplied *vault_root* must reproduce the existing per-mode policy:
615
+
616
+ - **default**: ``default`` → ``project_path/"vault"``; ``robust`` →
617
+ ``metadata_path/"vault"``.
618
+ - **workset**: ``default``/``robust`` → ``vault_base/project_name``.
619
+ """
620
+ if layout == ProjectLayout.simple:
621
+ shell = project_path / ".shell"
622
+ vault_ro = project_path / "vault" / "ro"
623
+ vault_rw = project_path / "vault" / "rw"
624
+ else: # default / robust
625
+ shell = metadata_path / "shell"
626
+ vault_ro = vault_root / "ro"
627
+ vault_rw = vault_root / "rw"
628
+ return shell, vault_ro, vault_rw
629
+
630
+
631
+ def _local_vault_root(layout: ProjectLayout, metadata_path: Path, project_path: Path) -> Path:
632
+ """Vault parent dir for default mode in the non-``simple`` layouts."""
633
+ if layout == ProjectLayout.robust:
634
+ return metadata_path / "vault"
635
+ return project_path / "vault" # default
636
+
637
+
638
+ def _compute_standalone_paths(
639
+ layout: ProjectLayout, metadata_path: Path, project_path: Path,
640
+ ) -> tuple[Path, Path, Path]:
641
+ """Compute (shell, vault_ro, vault_rw) for standalone mode."""
642
+ if layout == ProjectLayout.robust:
643
+ shell = project_path / "shell"
644
+ vault_ro = project_path / "vault" / "ro"
645
+ vault_rw = project_path / "vault" / "rw"
646
+ else: # simple (default for standalone)
647
+ shell = metadata_path / "shell"
648
+ vault_ro = project_path / "vault" / "ro"
649
+ vault_rw = project_path / "vault" / "rw"
650
+ return shell, vault_ro, vault_rw
651
+
652
+
653
+ _SHELL_D_SOURCE_LINE = 'for _f in ~/.shell.d/*.sh; do [ -r "$_f" ] && . "$_f"; done\nunset _f'
654
+
655
+
656
+ def _bootstrap_shell(shell_path: Path) -> None:
657
+ """Write minimal shell skeleton files into a new shell directory."""
658
+ bashrc = shell_path / ".bashrc"
659
+ if not bashrc.exists():
660
+ bashrc.write_text(
661
+ "# kanibako shell environment\n"
662
+ "[ -f /etc/bashrc ] && . /etc/bashrc\n"
663
+ 'export PS1="${KANIBAKO_PS1:-(kanibako) \\u@\\h:\\w\\$ }"\n'
664
+ "# Source user init scripts\n"
665
+ f"{_SHELL_D_SOURCE_LINE}\n"
666
+ )
667
+ profile = shell_path / ".profile"
668
+ if not profile.exists():
669
+ profile.write_text(
670
+ "# kanibako login profile\n"
671
+ "[ -f ~/.bashrc ] && . ~/.bashrc\n"
672
+ )
673
+ # Create shell.d drop-in directory.
674
+ shell_d = shell_path / ".shell.d"
675
+ shell_d.mkdir(exist_ok=True)
676
+
677
+
678
+ def _upgrade_shell(shell_path: Path) -> None:
679
+ """Patch an existing shell directory to add shell.d support.
680
+
681
+ Idempotent — safe to call every launch. Creates ``.shell.d/`` if missing
682
+ and appends the source line to ``.bashrc`` if absent. No-op if
683
+ *shell_path* does not exist yet.
684
+ """
685
+ if not shell_path.is_dir():
686
+ return
687
+ shell_d = shell_path / ".shell.d"
688
+ shell_d.mkdir(exist_ok=True)
689
+
690
+ bashrc = shell_path / ".bashrc"
691
+ if not bashrc.is_file():
692
+ return
693
+ content = bashrc.read_text()
694
+ if ".shell.d/" in content:
695
+ return
696
+ # Append source line.
697
+ if content and not content.endswith("\n"):
698
+ content += "\n"
699
+ content += "# Source user init scripts\n"
700
+ content += f"{_SHELL_D_SOURCE_LINE}\n"
701
+ bashrc.write_text(content)
702
+
703
+
704
+ def _ensure_vault_symlink(project_path: Path, vault_ro_path: Path) -> None:
705
+ """Create a convenience symlink from project_path/vault when vault lives elsewhere.
706
+
707
+ In local tree and WS default/tree layouts, vault dirs are stored outside the
708
+ project workspace. The symlink lets the user discover vault via their
709
+ project directory. No-op when vault is already under project_path or the
710
+ symlink target already matches.
711
+ """
712
+ vault_parent = vault_ro_path.parent # e.g. metadata_path/vault or vault_base/name
713
+ link = project_path / "vault"
714
+
715
+ # Vault already lives under project_path — no symlink needed.
716
+ try:
717
+ if vault_parent.resolve() == link.resolve():
718
+ return
719
+ except OSError:
720
+ pass
721
+
722
+ if link.is_symlink():
723
+ # Symlink exists — update only if target differs.
724
+ if link.resolve() == vault_parent.resolve():
725
+ return
726
+ link.unlink()
727
+ elif link.exists():
728
+ # A real directory or file exists — don't overwrite.
729
+ return
730
+
731
+ try:
732
+ link.symlink_to(vault_parent)
733
+ except OSError:
734
+ pass # Best-effort; non-fatal if we can't create the symlink.
735
+
736
+
737
+ def _ensure_human_vault_symlink(
738
+ vault_dir: Path, project_path: Path, vault_parent: Path,
739
+ ) -> Path | None:
740
+ """Create a human-friendly symlink ``{vault_dir}/{basename}`` → *vault_parent*.
741
+
742
+ *vault_dir* is e.g. ``{data_path}/vault``. *project_path* is the user's
743
+ workspace directory whose basename is used as the symlink name.
744
+ *vault_parent* is the hash-based vault directory (``…/boxes/{hash}/vault``).
745
+
746
+ Collision handling: if *basename* already points elsewhere, tries
747
+ ``{name}1``, ``{name}2``, … up to ``{name}99``.
748
+
749
+ Returns the created/existing symlink ``Path`` on success, ``None`` on
750
+ failure or if *vault_parent* does not exist.
751
+ """
752
+ if not vault_parent.is_dir():
753
+ return None
754
+
755
+ vault_dir.mkdir(parents=True, exist_ok=True)
756
+ basename = project_path.name
757
+
758
+ # Try the plain name first, then name1..name99.
759
+ candidates = [basename] + [f"{basename}{i}" for i in range(1, 100)]
760
+ for name in candidates:
761
+ link = vault_dir / name
762
+ if link.is_symlink():
763
+ try:
764
+ if link.resolve() == vault_parent.resolve():
765
+ return link # Already correct — idempotent.
766
+ except OSError:
767
+ pass
768
+ continue # Points elsewhere — try next candidate.
769
+ if link.exists():
770
+ continue # Real file/dir — skip.
771
+ # Slot is free.
772
+ try:
773
+ link.symlink_to(vault_parent)
774
+ return link
775
+ except OSError:
776
+ return None # Best-effort.
777
+ return None # All 100 candidates exhausted.
778
+
779
+
780
+ def _remove_human_vault_symlink(vault_dir: Path, vault_parent: Path) -> bool:
781
+ """Remove the human-friendly symlink that points to *vault_parent*.
782
+
783
+ Scans *vault_dir* for the first symlink whose target resolves to
784
+ *vault_parent* and removes it. Removes *vault_dir* itself if empty
785
+ afterwards.
786
+
787
+ Returns True if a symlink was removed, False otherwise.
788
+ """
789
+ if not vault_dir.is_dir():
790
+ return False
791
+ try:
792
+ for entry in vault_dir.iterdir():
793
+ if entry.is_symlink():
794
+ try:
795
+ if entry.resolve() == vault_parent.resolve():
796
+ entry.unlink()
797
+ # Clean up empty vault_dir.
798
+ if not any(vault_dir.iterdir()):
799
+ vault_dir.rmdir()
800
+ return True
801
+ except OSError:
802
+ continue
803
+ except OSError:
804
+ pass
805
+ return False
806
+
807
+
808
+ def _remove_project_vault_symlink(project_path: Path) -> bool:
809
+ """Remove ``{project_path}/vault`` if it is a symlink (not a real dir).
810
+
811
+ Returns True if a symlink was removed, False otherwise.
812
+ """
813
+ link = project_path / "vault"
814
+ if link.is_symlink():
815
+ try:
816
+ link.unlink()
817
+ return True
818
+ except OSError:
819
+ pass
820
+ return False
821
+
822
+
823
+ def _init_common(
824
+ std: StandardPaths,
825
+ metadata_path: Path,
826
+ shell_path: Path,
827
+ vault_ro_path: Path,
828
+ vault_rw_path: Path,
829
+ project_path: Path,
830
+ *,
831
+ enable_vault: bool = True,
832
+ ) -> None:
833
+ """Shared first-time project setup: create directories, bootstrap shell.
834
+
835
+ This helper is called by both ``_init_project`` (default) and
836
+ ``_init_standalone_project``. It performs every step common to both
837
+ modes: print message, create metadata and shell dirs, bootstrap the
838
+ shell, and set up vault directories when enabled.
839
+
840
+ Credential copy is handled separately by ``target.init_home()`` in
841
+ ``start.py``, after template application.
842
+ """
843
+ import sys
844
+
845
+ print(
846
+ f"[One Time Setup] Initializing kanibako in {project_path}... ",
847
+ end="",
848
+ flush=True,
849
+ file=sys.stderr,
850
+ )
851
+ metadata_path.mkdir(parents=True, exist_ok=True)
852
+
853
+ # Create persistent agent shell (mounted as /home/agent).
854
+ shell_path.mkdir(parents=True, exist_ok=True)
855
+ _bootstrap_shell(shell_path)
856
+
857
+ # Vault directories (skip when vault is disabled).
858
+ if enable_vault:
859
+ vault_ro_path.mkdir(parents=True, exist_ok=True)
860
+ vault_rw_path.mkdir(parents=True, exist_ok=True)
861
+ # .gitignore in vault/ to exclude rw from version control.
862
+ vault_dir = vault_ro_path.parent
863
+ gitignore = vault_dir / ".gitignore"
864
+ if not gitignore.exists():
865
+ gitignore.write_text("rw/\n")
866
+
867
+ print("done.", file=sys.stderr)
868
+
869
+
870
+ def _init_project(
871
+ std: StandardPaths,
872
+ metadata_path: Path,
873
+ shell_path: Path,
874
+ vault_ro_path: Path,
875
+ vault_rw_path: Path,
876
+ project_path: Path,
877
+ *,
878
+ enable_vault: bool = True,
879
+ ) -> None:
880
+ """First-time project setup: create directories, copy credentials from host."""
881
+ _init_common(
882
+ std, metadata_path, shell_path,
883
+ vault_ro_path, vault_rw_path, project_path,
884
+ enable_vault=enable_vault,
885
+ )
886
+
887
+
888
+
889
+ def _find_local_ancestor(target: Path, data_path: Path, boxes_dir: Path) -> Path | None:
890
+ """Find the deepest registered default-mode project that is an ancestor of *target*.
891
+
892
+ Reads ``names.yaml`` and, for each entry whose registered path is a
893
+ prefix of *target*, checks that ``boxes_dir/{name}/`` actually exists on
894
+ disk. Among all valid matches, the deepest (most path components)
895
+ wins. Returns the matched path or ``None``. *boxes_dir* is the resolved
896
+ ``system.path.boxes`` directory (``std.boxes``).
897
+ """
898
+ names = read_names(data_path)
899
+ best: Path | None = None
900
+ best_depth = -1
901
+ for name, path_str in names["projects"].items():
902
+ registered = Path(path_str)
903
+ try:
904
+ target.relative_to(registered)
905
+ except ValueError:
906
+ continue
907
+ # Only accept if boxes_dir/{name}/ exists on disk.
908
+ if not (boxes_dir / name).is_dir():
909
+ continue
910
+ depth = len(registered.parts)
911
+ if depth > best_depth:
912
+ best = registered
913
+ best_depth = depth
914
+ return best
915
+
916
+
917
+ def _is_standalone_meta_dir(meta_dir: Path) -> bool:
918
+ """True only if *meta_dir* is a real standalone project metadata directory.
919
+
920
+ A bare directory named ``.kanibako``/``kanibako`` is NOT sufficient: the
921
+ kanibako container image bakes an empty ``~/.kanibako`` runtime/IPC dir into
922
+ every container home (helper socket + log), which must never be mistaken for
923
+ a standalone project marker. Require a parseable ``project.yaml`` that
924
+ declares ``mode = "standalone"``.
925
+ """
926
+ toml = meta_dir / "project.yaml"
927
+ if not meta_dir.is_dir() or not toml.is_file():
928
+ return False
929
+ try:
930
+ meta = read_project_meta(toml)
931
+ except (OSError, ValueError, yaml.YAMLError): # malformed/unreadable file
932
+ return False
933
+ return bool(meta and meta.get("mode") == "standalone")
934
+
935
+
936
+ def detect_project_mode(
937
+ project_dir: Path,
938
+ std: StandardPaths,
939
+ config: KanibakoConfig,
940
+ ) -> DetectionResult:
941
+ """Infer which project mode applies to *project_dir*.
942
+
943
+ Walks ancestor directories (up to ``$HOME`` or filesystem root) looking
944
+ for project markers. Returns a ``DetectionResult`` with the detected
945
+ mode and the ancestor directory where the marker was found.
946
+
947
+ Detection order:
948
+ 1. Workset — *project_dir* lives inside a registered workset root
949
+ (``workspaces/`` subdirectory first, then the root itself).
950
+ 2. Default (name-based) — one-pass scan of ``names.yaml``;
951
+ deepest registered path that is an ancestor of *project_dir* wins.
952
+ Requires ``boxes/{name}/`` to exist on disk.
953
+ 3. Walk ancestors for standalone markers — a ``.kanibako`` or
954
+ ``kanibako`` **directory** exists inside the ancestor.
955
+ ``.kanibako`` takes priority.
956
+ 4. Default — ``default`` mode at the original *project_dir*.
957
+ """
958
+ resolved = project_dir.resolve()
959
+ home = Path.home().resolve()
960
+
961
+ # 1. Workset check (no walk needed — relative_to handles subdirs).
962
+ ws_result = _check_workset(resolved, std)
963
+ if ws_result is not None:
964
+ return ws_result
965
+
966
+ # 2. Name-based default-mode check (one-pass scan, deepest match wins).
967
+ ac_ancestor = _find_local_ancestor(resolved, std.data_path, std.boxes)
968
+ if ac_ancestor is not None:
969
+ return DetectionResult(ProjectMode.default, ac_ancestor)
970
+
971
+ # 3. Walk ancestors for standalone markers.
972
+ current = resolved
973
+ while True:
974
+ # Standalone check: .kanibako/ or kanibako/ directory with a real
975
+ # standalone project.yaml. A bare directory is not enough (the
976
+ # container image bakes an empty ~/.kanibako runtime/IPC dir).
977
+ if _is_standalone_meta_dir(current / ".kanibako"):
978
+ return DetectionResult(ProjectMode.standalone, current)
979
+ if _is_standalone_meta_dir(current / "kanibako"):
980
+ return DetectionResult(ProjectMode.standalone, current)
981
+
982
+ # Stop conditions: reached $HOME or filesystem root.
983
+ if current == home:
984
+ break
985
+ parent = current.parent
986
+ if parent == current:
987
+ break
988
+ current = parent
989
+
990
+ # 4. Default: default mode at the original directory.
991
+ return DetectionResult(ProjectMode.default, resolved)
992
+
993
+
994
+ def _check_workset(
995
+ resolved_dir: Path,
996
+ std: StandardPaths,
997
+ ) -> DetectionResult | None:
998
+ """Check whether *resolved_dir* is inside a registered workset.
999
+
1000
+ Returns a ``DetectionResult`` if found, ``None`` otherwise.
1001
+ Checks ``workspaces/`` first (specific project), then the workset root
1002
+ itself (inside workset but not necessarily a project workspace).
1003
+ """
1004
+ worksets_toml = std.ws_hints
1005
+ if not worksets_toml.is_file():
1006
+ return None
1007
+
1008
+ _data = load_doc(worksets_toml)
1009
+
1010
+ for _root_str in _data.get("worksets", {}).values():
1011
+ ws_root = Path(_root_str).resolve()
1012
+ ws_workspaces = ws_root / "workspaces"
1013
+ # Check workspaces/ first (more specific).
1014
+ try:
1015
+ resolved_dir.relative_to(ws_workspaces)
1016
+ return DetectionResult(ProjectMode.workset, resolved_dir)
1017
+ except ValueError:
1018
+ pass
1019
+ # Then check workset root itself.
1020
+ try:
1021
+ resolved_dir.relative_to(ws_root)
1022
+ return DetectionResult(ProjectMode.workset, resolved_dir)
1023
+ except ValueError:
1024
+ continue
1025
+
1026
+ return None
1027
+
1028
+
1029
+ def resolve_workset_project(
1030
+ ws: WorksetSpec,
1031
+ project_name: str,
1032
+ std: StandardPaths,
1033
+ config: KanibakoConfig,
1034
+ *,
1035
+ initialize: bool = False,
1036
+ layout: ProjectLayout | None = None,
1037
+ enable_vault: bool | None = None,
1038
+ ) -> ProjectPaths:
1039
+ """Resolve per-project paths for a project inside a workset.
1040
+
1041
+ *ws* is a lightweight :class:`WorksetSpec` describing the workset's name,
1042
+ root, directory layout, auth mode, and registered project names. Callers
1043
+ holding a full ``Workset`` object pass ``WorksetSpec.from_workset(ws)``.
1044
+
1045
+ Raises ``WorksetError`` if *project_name* is not registered in *ws*.
1046
+ """
1047
+ # Look up project in workset.
1048
+ if project_name not in ws.project_names:
1049
+ raise WorksetError(
1050
+ f"Project '{project_name}' not found in workset '{ws.name}'."
1051
+ )
1052
+
1053
+ # Name-based paths (not hash-based).
1054
+ project_path = ws.workspaces_dir / project_name
1055
+ project_dir = ws.projects_dir / project_name
1056
+ metadata_path = project_dir
1057
+
1058
+ # Check for stored paths in project.yaml (enables user overrides).
1059
+ project_toml = metadata_path / "project.yaml"
1060
+ meta = read_project_meta(project_toml)
1061
+ if meta:
1062
+ actual_layout = ProjectLayout(meta["layout"]) if meta.get("layout") else _DEFAULT_LAYOUT[ProjectMode.workset]
1063
+ shell_path = Path(meta["shell"]) if meta["shell"] else project_dir / "shell"
1064
+ vault_ro_path = Path(meta["vault_ro"]) if meta["vault_ro"] else ws.vault_dir / project_name / "ro"
1065
+ vault_rw_path = Path(meta["vault_rw"]) if meta["vault_rw"] else ws.vault_dir / project_name / "rw"
1066
+ actual_vault_enabled = meta.get("enable_vault", True) if enable_vault is None else enable_vault
1067
+ else:
1068
+ actual_layout = layout or _DEFAULT_LAYOUT[ProjectMode.workset]
1069
+ shell_path, vault_ro_path, vault_rw_path = _compute_project_paths(
1070
+ actual_layout, metadata_path, project_path,
1071
+ vault_root=ws.vault_dir / project_name,
1072
+ )
1073
+ actual_vault_enabled = enable_vault if enable_vault is not None else True
1074
+
1075
+ # Auth mode: workset-level overrides project-level.
1076
+ actual_group_auth = ws.group_auth
1077
+ if actual_group_auth and meta:
1078
+ actual_group_auth = bool(meta.get("group_auth", True))
1079
+
1080
+ # Hash the resolved workspace path for container naming.
1081
+ phash = project_hash(str(project_path.resolve()))
1082
+
1083
+ is_new = False
1084
+ if initialize and not shell_path.is_dir():
1085
+ _init_workset_project(std, metadata_path, shell_path)
1086
+ _ws_global_shared = std.data_path / config.paths_shared / "global"
1087
+ _ws_local_shared = ws.root / config.paths_shared
1088
+ write_project_meta(
1089
+ project_toml,
1090
+ mode="workset",
1091
+ layout=actual_layout.value,
1092
+ workspace=str(project_path),
1093
+ shell=str(shell_path),
1094
+ vault_ro=str(vault_ro_path),
1095
+ vault_rw=str(vault_rw_path),
1096
+ enable_vault=actual_vault_enabled,
1097
+ group_auth=actual_group_auth,
1098
+ metadata=str(metadata_path),
1099
+ project_hash=phash,
1100
+ global_shared=str(_ws_global_shared),
1101
+ local_shared=str(_ws_local_shared),
1102
+ )
1103
+ is_new = True
1104
+
1105
+ if initialize:
1106
+ # Recovery: ensure shell exists.
1107
+ if not shell_path.is_dir():
1108
+ shell_path.mkdir(parents=True, exist_ok=True)
1109
+ _bootstrap_shell(shell_path)
1110
+ # Convenience symlink when vault lives outside the workspace.
1111
+ if actual_vault_enabled:
1112
+ _ensure_vault_symlink(project_path, vault_ro_path)
1113
+
1114
+ # Resolve shared paths: prefer stored values (enables user overrides).
1115
+ _ws_computed_global = std.data_path / config.paths_shared / "global"
1116
+ _ws_computed_local = ws.root / config.paths_shared
1117
+ if meta and meta.get("global_shared"):
1118
+ _ws_computed_global = Path(meta["global_shared"])
1119
+ if meta and meta.get("local_shared"):
1120
+ _ws_computed_local = Path(meta["local_shared"])
1121
+
1122
+ return ProjectPaths(
1123
+ project_path=project_path,
1124
+ project_hash=phash,
1125
+ metadata_path=metadata_path,
1126
+ shell_path=shell_path,
1127
+ vault_ro_path=vault_ro_path,
1128
+ vault_rw_path=vault_rw_path,
1129
+ is_new=is_new,
1130
+ mode=ProjectMode.workset,
1131
+ layout=actual_layout,
1132
+ enable_vault=actual_vault_enabled,
1133
+ group_auth=actual_group_auth,
1134
+ name=project_name,
1135
+ global_shared_path=_ws_computed_global,
1136
+ local_shared_path=_ws_computed_local,
1137
+ group=ProjectGroup(
1138
+ name=ws.name,
1139
+ root=ws.root,
1140
+ is_default=False,
1141
+ local_shared_base=ws.root,
1142
+ ),
1143
+ )
1144
+
1145
+
1146
+ def _init_workset_project(
1147
+ std: StandardPaths,
1148
+ metadata_path: Path,
1149
+ shell_path: Path,
1150
+ ) -> None:
1151
+ """First-time workset project setup: bootstrap shell directory.
1152
+
1153
+ Does not create vault ``.gitignore`` files (vault lives under the workset
1154
+ root, not inside a user git repo).
1155
+
1156
+ Credential copy is handled separately by ``target.init_home()`` in
1157
+ ``start.py``, after template application.
1158
+ """
1159
+ import sys
1160
+
1161
+ print(
1162
+ f"[One Time Setup] Initializing workset project in {metadata_path}... ",
1163
+ end="",
1164
+ flush=True,
1165
+ file=sys.stderr,
1166
+ )
1167
+ metadata_path.mkdir(parents=True, exist_ok=True)
1168
+
1169
+ # Create persistent agent shell (mounted as /home/agent).
1170
+ shell_path.mkdir(parents=True, exist_ok=True)
1171
+ _bootstrap_shell(shell_path)
1172
+
1173
+ print("done.", file=sys.stderr)
1174
+
1175
+
1176
+ def iter_projects(std: StandardPaths, config: KanibakoConfig) -> list[tuple[Path, Path | None]]:
1177
+ """Return ``(metadata_path, project_path | None)`` for every known project.
1178
+
1179
+ *project_path* is read from ``project.yaml`` (``workspace`` field) when
1180
+ available, falling back to ``project-path.txt`` for backward compat.
1181
+ """
1182
+ projects_dir = std.boxes
1183
+ if not projects_dir.is_dir():
1184
+ return []
1185
+ results: list[tuple[Path, Path | None]] = []
1186
+ for entry in sorted(projects_dir.iterdir()):
1187
+ if not entry.is_dir():
1188
+ continue
1189
+ project_path: Path | None = None
1190
+ # Prefer project.yaml workspace field.
1191
+ meta = read_project_meta(entry / "project.yaml")
1192
+ if meta and meta.get("workspace"):
1193
+ project_path = Path(meta["workspace"])
1194
+ else:
1195
+ # Backward compat: fall back to breadcrumb file.
1196
+ breadcrumb = entry / "project-path.txt"
1197
+ if breadcrumb.is_file():
1198
+ text = breadcrumb.read_text().strip()
1199
+ if text:
1200
+ project_path = Path(text)
1201
+ results.append((entry, project_path))
1202
+ return results
1203
+
1204
+
1205
+ def iter_workset_projects(
1206
+ std: StandardPaths,
1207
+ config: KanibakoConfig,
1208
+ ) -> list[tuple[str, _WorksetLike, list[tuple[str, str]]]]:
1209
+ """Return workset project info for all registered worksets.
1210
+
1211
+ Each entry is ``(workset_name, workset, [(project_name, status), ...])``.
1212
+ The workset object is a concrete ``kanibako.workset.Workset`` typed
1213
+ structurally as :class:`_WorksetLike` (so ``paths.py`` need not import
1214
+ ``workset``). Status is ``"ok"``, ``"missing"`` (no workspace), or
1215
+ ``"no-data"`` (no project dir).
1216
+ """
1217
+ import sys
1218
+
1219
+ from kanibako.workset import list_worksets, load_workset
1220
+
1221
+ registry = list_worksets(std)
1222
+ results: list[tuple[str, _WorksetLike, list[tuple[str, str]]]] = []
1223
+
1224
+ for ws_name in sorted(registry):
1225
+ root = registry[ws_name]
1226
+ if not root.is_dir():
1227
+ print(
1228
+ f"Warning: workset '{ws_name}' root missing: {root}",
1229
+ file=sys.stderr,
1230
+ )
1231
+ continue
1232
+ try:
1233
+ ws = load_workset(root)
1234
+ except Exception as exc:
1235
+ print(
1236
+ f"Warning: failed to load workset '{ws_name}': {exc}",
1237
+ file=sys.stderr,
1238
+ )
1239
+ continue
1240
+
1241
+ project_list: list[tuple[str, str]] = []
1242
+ for proj in ws.projects:
1243
+ has_project_dir = (ws.projects_dir / proj.name).is_dir()
1244
+ has_workspace = (ws.workspaces_dir / proj.name).is_dir()
1245
+ if has_project_dir and has_workspace:
1246
+ status = "ok"
1247
+ elif has_project_dir and not has_workspace:
1248
+ status = "missing"
1249
+ else:
1250
+ status = "no-data"
1251
+ project_list.append((proj.name, status))
1252
+
1253
+ results.append((ws_name, ws, project_list))
1254
+
1255
+ return results
1256
+
1257
+
1258
+ def _find_workset_for_path(project_dir: Path, std: StandardPaths) -> tuple[_WorksetLike, str | None]:
1259
+ """Return ``(workset, project_name)`` for a path inside a workset.
1260
+
1261
+ The returned object is a concrete ``kanibako.workset.Workset`` (typed
1262
+ structurally as :class:`_WorksetLike` to avoid importing ``workset`` into
1263
+ ``paths.py``); callers that need a :class:`WorksetSpec` for
1264
+ :func:`resolve_workset_project` wrap it via ``WorksetSpec.from_workset``.
1265
+
1266
+ *project_dir* may be the workspace root, a subdirectory within it,
1267
+ or anywhere inside the workset root. When *project_dir* is inside
1268
+ ``workspaces/{name}/``, the project name is returned. When inside
1269
+ the workset root but not in a specific workspace, ``None`` is returned
1270
+ as the project name.
1271
+
1272
+ Raises ``WorksetError`` if *project_dir* does not belong to any
1273
+ registered workset.
1274
+ """
1275
+ from kanibako.workset import list_worksets, load_workset
1276
+
1277
+ registry = list_worksets(std)
1278
+ resolved = project_dir.resolve()
1279
+ for _name, root in registry.items():
1280
+ ws_root = root.resolve()
1281
+ ws_workspaces = ws_root / "workspaces"
1282
+ # Check workspaces/ first (specific project).
1283
+ try:
1284
+ rel = resolved.relative_to(ws_workspaces)
1285
+ project_name = rel.parts[0] if rel.parts else None
1286
+ ws = load_workset(root)
1287
+ return ws, project_name
1288
+ except ValueError:
1289
+ pass
1290
+ # Then check workset root itself.
1291
+ try:
1292
+ resolved.relative_to(ws_root)
1293
+ ws = load_workset(root)
1294
+ return ws, None
1295
+ except ValueError:
1296
+ continue
1297
+ raise WorksetError(f"No workset found for path: {project_dir}")
1298
+
1299
+
1300
+ def resolve_any_project(
1301
+ std: StandardPaths,
1302
+ config: KanibakoConfig,
1303
+ project_dir: str | None = None,
1304
+ *,
1305
+ initialize: bool = False,
1306
+ ) -> ProjectPaths:
1307
+ """Auto-detect project mode and resolve paths accordingly.
1308
+
1309
+ Uses ``detect_project_mode`` to walk ancestor directories and find the
1310
+ project root. The resolved *project_root* (not the raw CWD) is passed
1311
+ to the appropriate resolver.
1312
+ """
1313
+ raw = project_dir or os.getcwd()
1314
+ # CLI front-door: a bare token (no path separator) that doesn't exist in
1315
+ # cwd may be a registered project/workset name. resolve_project also does
1316
+ # this lookup, but resolve_any_project must do it FIRST -- otherwise
1317
+ # Path(raw).resolve() below path-ifies the name before detect_project_mode
1318
+ # sees it.
1319
+ if raw and "/" not in raw and not Path(raw).exists():
1320
+ try:
1321
+ resolved, kind = resolve_name(std.data_path, raw, cwd=Path.cwd())
1322
+ if kind == "project":
1323
+ raw = resolved
1324
+ except ProjectError:
1325
+ pass
1326
+ raw_dir = Path(raw).resolve()
1327
+ detection = detect_project_mode(raw_dir, std, config)
1328
+ root_str = str(detection.project_root)
1329
+
1330
+ if detection.mode == ProjectMode.workset:
1331
+ ws, proj_name = _find_workset_for_path(raw_dir, std)
1332
+ if proj_name is None:
1333
+ raise WorksetError(
1334
+ f"Inside workset '{ws.name}' but not in a specific project workspace. "
1335
+ f"Change to a project directory under {ws.workspaces_dir}/."
1336
+ )
1337
+ return resolve_workset_project(
1338
+ WorksetSpec.from_workset(ws), proj_name, std, config, initialize=initialize,
1339
+ )
1340
+ if detection.mode == ProjectMode.standalone:
1341
+ return resolve_standalone_project(std, config, root_str, initialize=initialize)
1342
+ return resolve_project(std, config, project_dir=root_str, initialize=initialize)
1343
+
1344
+
1345
+ def resolve_standalone_project(
1346
+ std: StandardPaths,
1347
+ config: KanibakoConfig,
1348
+ project_dir: str | None = None,
1349
+ *,
1350
+ initialize: bool = False,
1351
+ layout: ProjectLayout | None = None,
1352
+ enable_vault: bool | None = None,
1353
+ group_auth: bool | None = None,
1354
+ ) -> ProjectPaths:
1355
+ """Resolve (and optionally initialize) per-project paths for standalone mode.
1356
+
1357
+ All project state lives inside *project_dir* itself.
1358
+ No data is written to ``$XDG_DATA_HOME``.
1359
+ """
1360
+ raw = project_dir or os.getcwd()
1361
+ project_path = Path(raw).resolve()
1362
+
1363
+ if not project_path.is_dir():
1364
+ raise ProjectError(f"Project path '{project_path}' does not exist.")
1365
+
1366
+ phash = project_hash(str(project_path))
1367
+
1368
+ # Determine metadata_path (depends on layout for standalone).
1369
+ # For tree layout: {project}/kanibako (no dot)
1370
+ # For simple (default): {project}/.kanibako (dot prefix)
1371
+ # Check both locations for existing projects.
1372
+ dot_meta = project_path / ".kanibako"
1373
+ nodot_meta = project_path / "kanibako"
1374
+
1375
+ # Check for stored paths in existing metadata.
1376
+ meta = None
1377
+ actual_layout = None
1378
+ if dot_meta.is_dir():
1379
+ meta = read_project_meta(dot_meta / "project.yaml")
1380
+ metadata_path = dot_meta
1381
+ elif nodot_meta.is_dir():
1382
+ meta = read_project_meta(nodot_meta / "project.yaml")
1383
+ metadata_path = nodot_meta
1384
+ else:
1385
+ # New project — determine layout and metadata_path.
1386
+ actual_layout = layout or _DEFAULT_LAYOUT[ProjectMode.standalone]
1387
+ if actual_layout == ProjectLayout.robust:
1388
+ metadata_path = nodot_meta
1389
+ else:
1390
+ metadata_path = dot_meta
1391
+
1392
+ if meta:
1393
+ actual_layout = ProjectLayout(meta["layout"]) if meta.get("layout") else _DEFAULT_LAYOUT[ProjectMode.standalone]
1394
+ shell_path = Path(meta["shell"]) if meta["shell"] else metadata_path / "shell"
1395
+ vault_ro_path = Path(meta["vault_ro"]) if meta["vault_ro"] else project_path / "vault" / "ro"
1396
+ vault_rw_path = Path(meta["vault_rw"]) if meta["vault_rw"] else project_path / "vault" / "rw"
1397
+ actual_vault_enabled = meta.get("enable_vault", True) if enable_vault is None else enable_vault
1398
+ else:
1399
+ if actual_layout is None:
1400
+ actual_layout = layout or _DEFAULT_LAYOUT[ProjectMode.standalone]
1401
+ shell_path, vault_ro_path, vault_rw_path = _compute_standalone_paths(
1402
+ actual_layout, metadata_path, project_path,
1403
+ )
1404
+ actual_vault_enabled = enable_vault if enable_vault is not None else True
1405
+
1406
+ project_toml = metadata_path / "project.yaml"
1407
+
1408
+ # Auth mode for standalone: explicit param > meta > default.
1409
+ # Standalone projects are NOT in the default group, so they do
1410
+ # not consult the default workset config.yaml.
1411
+ actual_group_auth = (
1412
+ group_auth
1413
+ if group_auth is not None
1414
+ else (bool(meta.get("group_auth", True)) if meta else True)
1415
+ )
1416
+
1417
+ is_new = False
1418
+ if initialize and not metadata_path.is_dir():
1419
+ _init_standalone_project(
1420
+ std, metadata_path, shell_path,
1421
+ vault_ro_path, vault_rw_path, project_path,
1422
+ enable_vault=actual_vault_enabled,
1423
+ )
1424
+ write_project_meta(
1425
+ project_toml,
1426
+ mode="standalone",
1427
+ layout=actual_layout.value,
1428
+ workspace=str(project_path),
1429
+ shell=str(shell_path),
1430
+ vault_ro=str(vault_ro_path),
1431
+ vault_rw=str(vault_rw_path),
1432
+ enable_vault=actual_vault_enabled,
1433
+ group_auth=actual_group_auth,
1434
+ metadata=str(metadata_path),
1435
+ project_hash=phash,
1436
+ )
1437
+ is_new = True
1438
+
1439
+ if initialize:
1440
+ # Recovery: ensure shell exists.
1441
+ if not shell_path.is_dir():
1442
+ shell_path.mkdir(parents=True, exist_ok=True)
1443
+ _bootstrap_shell(shell_path)
1444
+
1445
+ return ProjectPaths(
1446
+ project_path=project_path,
1447
+ project_hash=phash,
1448
+ metadata_path=metadata_path,
1449
+ shell_path=shell_path,
1450
+ vault_ro_path=vault_ro_path,
1451
+ vault_rw_path=vault_rw_path,
1452
+ is_new=is_new,
1453
+ mode=ProjectMode.standalone,
1454
+ layout=actual_layout,
1455
+ enable_vault=actual_vault_enabled,
1456
+ group_auth=actual_group_auth,
1457
+ global_shared_path=None,
1458
+ )
1459
+
1460
+
1461
+ def _init_standalone_project(
1462
+ std: StandardPaths,
1463
+ metadata_path: Path,
1464
+ shell_path: Path,
1465
+ vault_ro_path: Path,
1466
+ vault_rw_path: Path,
1467
+ project_path: Path,
1468
+ *,
1469
+ enable_vault: bool = True,
1470
+ ) -> None:
1471
+ """First-time standalone project setup: all state inside project dir.
1472
+
1473
+ Unlike workset init, this *does* create vault directories and a
1474
+ ``.gitignore`` (vault lives inside the user's project, likely a git repo).
1475
+
1476
+ Credential copy is handled separately by ``target.init_home()`` in
1477
+ ``start.py``, after template application.
1478
+ """
1479
+ _init_common(
1480
+ std, metadata_path, shell_path,
1481
+ vault_ro_path, vault_rw_path, project_path,
1482
+ enable_vault=enable_vault,
1483
+ )