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,133 @@
1
+ """Nested container image sharing: mount host image storage read-only into child containers.
2
+
3
+ When kanibako runs inside a container (LXC/VM), nested podman pulls images
4
+ separately, duplicating hundreds of MB. This module provides opt-in sharing
5
+ of the host's image storage via podman's ``additionalImageStores`` mechanism.
6
+
7
+ The host's overlay storage is bind-mounted **read-only** into the child
8
+ container at a well-known path, and a ``storage.conf`` snippet is generated
9
+ so the child's podman can find the shared layers.
10
+
11
+ **Known limitations**:
12
+
13
+ - UID mapping: if host and child rootless podman use different subuid ranges,
14
+ layer files may be inaccessible. Works best when the child container runs
15
+ with ``--userns=keep-id`` (which kanibako already uses).
16
+ - The shared store is read-only; the child can pull new images on top but
17
+ cannot modify or delete shared layers.
18
+ - Only overlay storage driver is supported (the default for rootless podman).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import subprocess
24
+ from pathlib import Path
25
+
26
+ from kanibako.log import get_logger
27
+ from kanibako.targets.base import Mount
28
+
29
+ logger = get_logger("image_sharing")
30
+
31
+ # Well-known mount point inside child containers.
32
+ SHARED_STORE_CONTAINER_PATH = "/var/lib/shared-images"
33
+
34
+ # Container-side storage.conf path (rootless podman).
35
+ _STORAGE_CONF_CONTAINER_PATH = "/home/agent/.config/containers/storage.conf"
36
+
37
+
38
+ def detect_graph_root(runtime_cmd: str) -> Path | None:
39
+ """Detect the host podman/docker graph root (image storage directory).
40
+
41
+ Returns the ``GraphRoot`` path from ``podman info`` / ``docker info``,
42
+ or *None* if detection fails.
43
+ """
44
+ try:
45
+ result = subprocess.run(
46
+ [runtime_cmd, "info", "--format", "{{.Store.GraphRoot}}"],
47
+ capture_output=True,
48
+ text=True,
49
+ timeout=10,
50
+ )
51
+ if result.returncode != 0:
52
+ logger.debug(
53
+ "Failed to detect graph root: %s", result.stderr.strip(),
54
+ )
55
+ return None
56
+ graph_root = result.stdout.strip()
57
+ if not graph_root:
58
+ return None
59
+ path = Path(graph_root)
60
+ if not path.is_dir():
61
+ logger.debug("Graph root does not exist: %s", path)
62
+ return None
63
+ return path
64
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
65
+ logger.debug("Graph root detection failed: %s", exc)
66
+ return None
67
+
68
+
69
+ def generate_storage_conf(shared_store_path: str) -> str:
70
+ """Generate a ``storage.conf`` snippet for podman's additionalImageStores.
71
+
72
+ The generated config tells the child's podman to use the overlay driver
73
+ and look for additional (read-only) image layers at *shared_store_path*.
74
+
75
+ Parameters
76
+ ----------
77
+ shared_store_path:
78
+ The container-side path where the host's graph root is mounted
79
+ (typically ``/var/lib/shared-images``).
80
+ """
81
+ return (
82
+ "[storage]\n"
83
+ ' driver = "overlay"\n'
84
+ " [storage.options]\n"
85
+ f' additionalimagestores = ["{shared_store_path}"]\n'
86
+ )
87
+
88
+
89
+ def build_image_sharing_mounts(
90
+ runtime_cmd: str,
91
+ staging_dir: Path,
92
+ ) -> list[Mount]:
93
+ """Build the bind-mounts needed for image sharing.
94
+
95
+ Returns a list of mounts (may be empty if detection fails or the
96
+ storage path doesn't exist). On success returns two mounts:
97
+
98
+ 1. Host graph root -> ``/var/lib/shared-images`` (read-only)
99
+ 2. Generated ``storage.conf`` -> child's config dir (read-only)
100
+
101
+ Parameters
102
+ ----------
103
+ runtime_cmd:
104
+ Path to the container runtime binary (``podman`` or ``docker``).
105
+ staging_dir:
106
+ A host-side directory where the generated ``storage.conf`` will be
107
+ written. Should be under the project's metadata or cache path.
108
+ """
109
+ graph_root = detect_graph_root(runtime_cmd)
110
+ if graph_root is None:
111
+ logger.info("Image sharing: could not detect host graph root, skipping")
112
+ return []
113
+
114
+ logger.info("Image sharing: host graph root at %s", graph_root)
115
+
116
+ # Generate the storage.conf snippet
117
+ storage_conf_content = generate_storage_conf(SHARED_STORE_CONTAINER_PATH)
118
+ staging_dir.mkdir(parents=True, exist_ok=True)
119
+ storage_conf_path = staging_dir / "storage.conf"
120
+ storage_conf_path.write_text(storage_conf_content)
121
+
122
+ return [
123
+ Mount(
124
+ source=graph_root,
125
+ destination=SHARED_STORE_CONTAINER_PATH,
126
+ options="ro",
127
+ ),
128
+ Mount(
129
+ source=storage_conf_path,
130
+ destination=_STORAGE_CONF_CONTAINER_PATH,
131
+ options="ro",
132
+ ),
133
+ ]
@@ -0,0 +1,160 @@
1
+ """Layered instruction file merging.
2
+
3
+ Merges instruction files (e.g. CLAUDE.md) from three layers:
4
+ 1. **Kanibako base** — container environment documentation
5
+ 2. **Template** — shell template instructions
6
+ 3. **User project** — user's own instructions (highest priority, shown last)
7
+
8
+ Each layer is concatenated with section markers so the agent can see
9
+ where each part comes from.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+ from kanibako.log import get_logger
17
+
18
+ logger = get_logger("instructions")
19
+
20
+ # Section markers used to delimit layers in merged instruction files.
21
+ _MARKER_BASE = "# --- kanibako base ---"
22
+ _MARKER_TEMPLATE = "# --- template: {name} ---"
23
+ _MARKER_PROJECT = "# --- project ---"
24
+
25
+ # Sentinel that marks the end of managed sections. Content after this
26
+ # marker is preserved verbatim (not touched by future merges).
27
+ _MARKER_END = "# --- end managed ---"
28
+
29
+
30
+ def _read_layer(path: Path) -> str | None:
31
+ """Read a file and return its stripped content, or None if missing/empty."""
32
+ if not path.is_file():
33
+ return None
34
+ content = path.read_text().strip()
35
+ return content if content else None
36
+
37
+
38
+ def merge_instruction_content(
39
+ *,
40
+ base_content: str | None = None,
41
+ template_content: str | None = None,
42
+ template_name: str = "",
43
+ user_content: str | None = None,
44
+ ) -> str | None:
45
+ """Merge instruction file layers into a single string.
46
+
47
+ Returns the merged content with section markers, or None if all
48
+ layers are empty/missing.
49
+
50
+ Layer order (top to bottom):
51
+ 1. kanibako base
52
+ 2. template
53
+ 3. user project (last = highest visibility)
54
+ """
55
+ sections: list[str] = []
56
+
57
+ if base_content:
58
+ sections.append(f"{_MARKER_BASE}\n\n{base_content}")
59
+
60
+ if template_content:
61
+ marker = _MARKER_TEMPLATE.format(name=template_name or "default")
62
+ sections.append(f"{marker}\n\n{template_content}")
63
+
64
+ if user_content:
65
+ sections.append(f"{_MARKER_PROJECT}\n\n{user_content}")
66
+
67
+ if not sections:
68
+ return None
69
+
70
+ return "\n\n".join(sections) + "\n"
71
+
72
+
73
+ def merge_instruction_files(
74
+ *,
75
+ shell_path: Path,
76
+ config_dir_name: str,
77
+ instruction_files: list[str],
78
+ templates_base: Path | None = None,
79
+ agent_name: str = "",
80
+ template_name: str = "standard",
81
+ ) -> None:
82
+ """Merge instruction files from base/template/user layers.
83
+
84
+ For each filename in *instruction_files*:
85
+ 1. Read base content from ``templates_base/general/base/{config_dir_name}/{filename}``
86
+ 2. Read template content from the resolved template dir ``{config_dir_name}/{filename}``
87
+ 3. Read user content already in ``shell_path/{config_dir_name}/{filename}``
88
+ (placed there by template application or pre-existing)
89
+
90
+ The merged result replaces the file at ``shell_path/{config_dir_name}/{filename}``.
91
+
92
+ If the file only has content from a single layer, it is written without
93
+ section markers (clean output for the common case).
94
+ """
95
+ if not instruction_files:
96
+ return
97
+
98
+ config_dir = shell_path / config_dir_name
99
+
100
+ for filename in instruction_files:
101
+ dest = config_dir / filename
102
+
103
+ # Layer 1: kanibako base — from general/base/{config_dir}/{filename}
104
+ base_content: str | None = None
105
+ if templates_base:
106
+ base_path = templates_base / "general" / "base" / config_dir_name / filename
107
+ base_content = _read_layer(base_path)
108
+
109
+ # Layer 2: template — from resolved template dir
110
+ # The template was already applied by apply_shell_template() which
111
+ # copied all files including instruction files. We need to read
112
+ # the template's *original* version before it was overlaid.
113
+ template_content: str | None = None
114
+ if templates_base:
115
+ from kanibako.templates import resolve_template
116
+
117
+ resolved = resolve_template(templates_base, agent_name, template_name)
118
+ if resolved:
119
+ tmpl_path = resolved / config_dir_name / filename
120
+ template_content = _read_layer(tmpl_path)
121
+
122
+ # Layer 3: user project — whatever is at the destination *now*.
123
+ # After apply_shell_template(), this is the template's version of
124
+ # the file (which we already captured above). We need to detect
125
+ # whether the current content is identical to the template layer
126
+ # to avoid duplicating it.
127
+ user_content: str | None = None
128
+ current_content = _read_layer(dest)
129
+ if current_content:
130
+ # If the current file matches the template content exactly,
131
+ # it's not user content — the template just put it there.
132
+ # Same for base content.
133
+ if current_content != template_content and current_content != base_content:
134
+ user_content = current_content
135
+ elif template_content is None and base_content is None:
136
+ # File exists but no template/base layers — treat as user content.
137
+ user_content = current_content
138
+
139
+ # Count non-None layers to decide whether to use markers.
140
+ layers = [x for x in (base_content, template_content, user_content) if x]
141
+
142
+ if not layers:
143
+ # No content from any layer — skip (don't create empty file).
144
+ continue
145
+
146
+ if len(layers) == 1:
147
+ # Single layer — write without markers for clean output.
148
+ merged: str | None = layers[0] + "\n"
149
+ else:
150
+ merged = merge_instruction_content(
151
+ base_content=base_content,
152
+ template_content=template_content,
153
+ template_name=template_name,
154
+ user_content=user_content,
155
+ )
156
+
157
+ if merged:
158
+ config_dir.mkdir(parents=True, exist_ok=True)
159
+ dest.write_text(merged)
160
+ logger.debug("Merged instruction file: %s (%d layers)", dest, len(layers))
kanibako/log.py ADDED
@@ -0,0 +1,31 @@
1
+ """Logging setup for kanibako."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+
8
+
9
+ def setup_logging(verbose: bool = False) -> None:
10
+ """Configure the ``kanibako`` root logger.
11
+
12
+ Normal mode: WARNING+ to stderr.
13
+ Verbose mode: DEBUG+ to stderr with ``kanibako: <message>`` format.
14
+ """
15
+ logger = logging.getLogger("kanibako")
16
+ logger.handlers.clear()
17
+
18
+ handler = logging.StreamHandler(sys.stderr)
19
+ if verbose:
20
+ logger.setLevel(logging.DEBUG)
21
+ handler.setFormatter(logging.Formatter("kanibako: %(message)s"))
22
+ else:
23
+ logger.setLevel(logging.WARNING)
24
+
25
+ handler.setLevel(logging.DEBUG)
26
+ logger.addHandler(handler)
27
+
28
+
29
+ def get_logger(name: str) -> logging.Logger:
30
+ """Return a child logger under ``kanibako``."""
31
+ return logging.getLogger(f"kanibako.{name}")
kanibako/names.py ADDED
@@ -0,0 +1,248 @@
1
+ """Project name registry (names.yaml).
2
+
3
+ Central index at ``{data_path}/names.yaml`` mapping human-readable names to
4
+ project paths (for default-mode projects) and workset roots (for worksets).
5
+ Standalone projects are intentionally excluded — they have no central
6
+ registration.
7
+
8
+ The file has two sections::
9
+
10
+ projects:
11
+ myapp: /home/user/projects/myapp
12
+
13
+ worksets:
14
+ clientwork: /home/user/worksets/client
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+
21
+ from kanibako.config_io import dump_doc, load_doc
22
+ from kanibako.errors import ProjectError
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # I/O helpers
27
+ # ---------------------------------------------------------------------------
28
+
29
+ def _names_path(data_path: Path) -> Path:
30
+ return data_path / "names.yaml"
31
+
32
+
33
+ def _load(data_path: Path) -> dict[str, dict[str, str]]:
34
+ """Load names.yaml and return raw sections."""
35
+ path = _names_path(data_path)
36
+ if not path.is_file():
37
+ return {"projects": {}, "worksets": {}}
38
+ data = load_doc(path)
39
+ return {
40
+ "projects": {k: str(v) for k, v in data.get("projects", {}).items()},
41
+ "worksets": {k: str(v) for k, v in data.get("worksets", {}).items()},
42
+ }
43
+
44
+
45
+ def _save(data_path: Path, names: dict[str, dict[str, str]]) -> None:
46
+ """Write names.yaml from sections dict."""
47
+ path = _names_path(data_path)
48
+ data: dict = {}
49
+ for section in ("projects", "worksets"):
50
+ entries = names.get(section, {})
51
+ data[section] = {name: entries[name] for name in sorted(entries)}
52
+ dump_doc(path, data)
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Public API
57
+ # ---------------------------------------------------------------------------
58
+
59
+ def read_names(data_path: Path) -> dict[str, dict[str, str]]:
60
+ """Load names.yaml.
61
+
62
+ Returns ``{"projects": {name: path, ...}, "worksets": {name: path, ...}}``.
63
+ """
64
+ return _load(data_path)
65
+
66
+
67
+ def register_name(
68
+ data_path: Path,
69
+ name: str,
70
+ path: str,
71
+ section: str = "projects",
72
+ ) -> None:
73
+ """Register a name → path mapping.
74
+
75
+ Raises ``ProjectError`` if *name* is already registered in either section,
76
+ or if *path* resolves to ``$HOME``.
77
+ """
78
+ # Guard: never register $HOME as a project path.
79
+ if Path(path).resolve() == Path.home().resolve():
80
+ raise ProjectError(
81
+ "Refusing to register $HOME as a project path — this would "
82
+ "mount your entire home directory as the workspace."
83
+ )
84
+ names = _load(data_path)
85
+ # Check for duplicates across both sections.
86
+ for sec in ("projects", "worksets"):
87
+ if name in names[sec]:
88
+ raise ProjectError(
89
+ f"Name '{name}' is already registered"
90
+ f" ({sec}: {names[sec][name]})"
91
+ )
92
+ names[section][name] = path
93
+ _save(data_path, names)
94
+
95
+
96
+ def update_name_path(
97
+ data_path: Path,
98
+ name: str,
99
+ new_path: str,
100
+ section: str = "projects",
101
+ ) -> bool:
102
+ """Update the path for an existing registered name.
103
+
104
+ Returns True if the name was found and updated, False otherwise.
105
+ Raises ``ProjectError`` if *new_path* resolves to ``$HOME``.
106
+ """
107
+ if Path(new_path).resolve() == Path.home().resolve():
108
+ raise ProjectError(
109
+ "Refusing to register $HOME as a project path — this would "
110
+ "mount your entire home directory as the workspace."
111
+ )
112
+ names = _load(data_path)
113
+ if name not in names.get(section, {}):
114
+ return False
115
+ names[section][name] = new_path
116
+ _save(data_path, names)
117
+ return True
118
+
119
+
120
+ def unregister_name(
121
+ data_path: Path,
122
+ name: str,
123
+ section: str = "projects",
124
+ ) -> bool:
125
+ """Remove a name from the registry.
126
+
127
+ Returns True if the name was found and removed, False otherwise.
128
+ """
129
+ names = _load(data_path)
130
+ if name not in names.get(section, {}):
131
+ return False
132
+ del names[section][name]
133
+ _save(data_path, names)
134
+ return True
135
+
136
+
137
+ def lookup_by_path(
138
+ data_path: Path,
139
+ path: str,
140
+ ) -> tuple[str, str] | None:
141
+ """Find a registered name by its path value.
142
+
143
+ Returns ``(name, section)`` if found, ``None`` otherwise.
144
+ """
145
+ resolved = str(Path(path).resolve())
146
+ names = _load(data_path)
147
+ for section in ("projects", "worksets"):
148
+ for name, registered_path in names[section].items():
149
+ if str(Path(registered_path).resolve()) == resolved:
150
+ return name, section
151
+ return None
152
+
153
+
154
+ def resolve_name(
155
+ data_path: Path,
156
+ name: str,
157
+ cwd: Path | None = None,
158
+ ) -> tuple[str, str]:
159
+ """Look up a bare name and return ``(path, kind)``.
160
+
161
+ Resolution order:
162
+
163
+ 1. If *cwd* is inside a workset → check that workset's projects first
164
+ 2. ``[projects]`` section (default-mode projects)
165
+ 3. ``[worksets]`` section (workset names)
166
+
167
+ *kind* is ``"project"`` or ``"workset"``.
168
+ Raises ``ProjectError`` if no match is found.
169
+ """
170
+ names = _load(data_path)
171
+
172
+ # 1. Context-aware: if cwd is inside a registered workset, check its
173
+ # projects first.
174
+ if cwd is not None:
175
+ cwd_str = str(cwd.resolve())
176
+ for ws_name, ws_root in names["worksets"].items():
177
+ if cwd_str == ws_root or cwd_str.startswith(ws_root + "/"):
178
+ # cwd is inside this workset — check if name matches a
179
+ # workspace subdir.
180
+ ws_path = Path(ws_root)
181
+ candidate = ws_path / "workspaces" / name
182
+ if candidate.is_dir():
183
+ return str(candidate), "project"
184
+
185
+ # 2. Default-mode projects.
186
+ if name in names["projects"]:
187
+ return names["projects"][name], "project"
188
+
189
+ # 3. Worksets.
190
+ if name in names["worksets"]:
191
+ return names["worksets"][name], "workset"
192
+
193
+ raise ProjectError(f"Unknown project or workset: '{name}'")
194
+
195
+
196
+ def resolve_qualified_name(
197
+ data_path: Path,
198
+ qualified: str,
199
+ ) -> tuple[str, str]:
200
+ """Resolve a qualified name (``workset/project``).
201
+
202
+ Returns ``(project_workspace_path, workset_name)``.
203
+ Raises ``ProjectError`` if the workset or project is not found.
204
+ """
205
+ if "/" not in qualified:
206
+ raise ProjectError(
207
+ f"Not a qualified name (expected workset/project): '{qualified}'"
208
+ )
209
+ ws_name, proj_name = qualified.split("/", 1)
210
+ names = _load(data_path)
211
+
212
+ if ws_name not in names["worksets"]:
213
+ raise ProjectError(f"Unknown workset: '{ws_name}'")
214
+
215
+ ws_root = Path(names["worksets"][ws_name])
216
+ candidate = ws_root / "workspaces" / proj_name
217
+ if not candidate.is_dir():
218
+ raise ProjectError(
219
+ f"Project '{proj_name}' not found in workset '{ws_name}'"
220
+ )
221
+ return str(candidate), ws_name
222
+
223
+
224
+ def assign_name(
225
+ data_path: Path,
226
+ path: str,
227
+ section: str = "projects",
228
+ ) -> str:
229
+ """Auto-assign a name from the basename of *path*.
230
+
231
+ Handles collisions by appending a number: ``name``, ``name2``, ``name3``, ...
232
+ Registers the name and returns it.
233
+ """
234
+ base = Path(path).name
235
+ if not base:
236
+ base = "project"
237
+
238
+ names = _load(data_path)
239
+ all_names = set(names["projects"]) | set(names["worksets"])
240
+
241
+ candidate = base
242
+ n = 2
243
+ while candidate in all_names:
244
+ candidate = f"{base}{n}"
245
+ n += 1
246
+
247
+ register_name(data_path, candidate, path, section=section)
248
+ return candidate