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,173 @@
1
+ """Target plugin discovery and resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import importlib.util
7
+ import logging
8
+ import pkgutil
9
+ from importlib.metadata import entry_points
10
+ from pathlib import Path
11
+
12
+ from kanibako.targets.base import AgentInstall, Mount, ResourceMapping, ResourceScope, Target, TargetSetting
13
+ from kanibako.targets.no_agent import NoAgentTarget
14
+
15
+ __all__ = [
16
+ "AgentInstall", "Mount", "NoAgentTarget", "ResourceMapping", "ResourceScope",
17
+ "Target", "TargetSetting",
18
+ "discover_targets", "get_target", "resolve_target",
19
+ ]
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def _scan_plugin_modules(targets: dict[str, type[Target]]) -> None:
25
+ """Scan ``kanibako.plugins.*`` for Target subclasses (bind-mount fallback).
26
+
27
+ Entry points rely on dist-info metadata which doesn't travel via
28
+ bind-mount. This fallback imports all sub-packages of
29
+ ``kanibako.plugins`` and collects any ``Target`` subclasses found,
30
+ keyed by their ``name`` property.
31
+
32
+ Already-discovered targets (from entry points) are not overwritten.
33
+ """
34
+ try:
35
+ import kanibako.plugins as plugins_pkg
36
+ except ImportError:
37
+ return
38
+
39
+ for finder, module_name, ispkg in pkgutil.walk_packages(
40
+ plugins_pkg.__path__, prefix="kanibako.plugins."
41
+ ):
42
+ if module_name in ("kanibako.plugins",):
43
+ continue
44
+ try:
45
+ mod = importlib.import_module(module_name)
46
+ except Exception:
47
+ logger.debug("Failed to import plugin module %s", module_name, exc_info=True)
48
+ continue
49
+
50
+ for attr_name in dir(mod):
51
+ attr = getattr(mod, attr_name, None)
52
+ if (
53
+ isinstance(attr, type)
54
+ and issubclass(attr, Target)
55
+ and attr is not Target
56
+ and attr is not NoAgentTarget
57
+ ):
58
+ try:
59
+ instance = attr()
60
+ name = instance.name
61
+ except Exception:
62
+ continue
63
+ if name not in targets:
64
+ targets[name] = attr
65
+
66
+
67
+ def _scan_directory_plugins(directory: Path, targets: dict[str, type[Target]]) -> None:
68
+ """Scan a directory for .py files containing Target subclasses.
69
+
70
+ Files starting with ``_`` are skipped. Later directories in the
71
+ discovery chain override earlier ones (same target name replaces).
72
+ """
73
+ if not directory.is_dir():
74
+ return
75
+ for py_file in sorted(directory.glob("*.py")):
76
+ if py_file.name.startswith("_"):
77
+ continue
78
+ try:
79
+ spec = importlib.util.spec_from_file_location(
80
+ f"kanibako_plugin_{py_file.stem}", py_file,
81
+ )
82
+ if spec is None or spec.loader is None:
83
+ continue
84
+ mod = importlib.util.module_from_spec(spec)
85
+ spec.loader.exec_module(mod) # type: ignore[union-attr]
86
+ except Exception:
87
+ logger.debug("Failed to load plugin %s", py_file, exc_info=True)
88
+ continue
89
+ for attr_name in dir(mod):
90
+ attr = getattr(mod, attr_name, None)
91
+ if (
92
+ isinstance(attr, type)
93
+ and issubclass(attr, Target)
94
+ and attr is not Target
95
+ and attr is not NoAgentTarget
96
+ ):
97
+ try:
98
+ instance = attr()
99
+ name = instance.name
100
+ except Exception:
101
+ continue
102
+ targets[name] = attr # later overrides earlier
103
+
104
+
105
+ def discover_targets(project_path: Path | None = None) -> dict[str, type[Target]]:
106
+ """Scan entry points, plugin modules, and directories for targets.
107
+
108
+ Discovery order (later overrides earlier):
109
+
110
+ 1. Entry points (pip-installed packages)
111
+ 2. ``kanibako.plugins.*`` module scan (bind-mount fallback)
112
+ 3. User directory (``~/.local/share/kanibako/plugins/``)
113
+ 4. Project directory (``{project}/.kanibako/plugins/``)
114
+ """
115
+ targets: dict[str, type[Target]] = {}
116
+ # Group is agent-domain (a registry of agent adapters) → "kanibako.agents".
117
+ # NB: distinct from the crab-domain `kanibako.crabs` module; do not "unify".
118
+ eps = entry_points(group="kanibako.agents")
119
+ for ep in eps:
120
+ cls = ep.load()
121
+ targets[ep.name] = cls
122
+
123
+ # Fallback: scan kanibako.plugins.* for bind-mounted plugins
124
+ _scan_plugin_modules(targets)
125
+
126
+ # User-level file-drop plugins
127
+ from kanibako.paths import xdg
128
+
129
+ data_home = xdg("XDG_DATA_HOME", ".local/share")
130
+ _scan_directory_plugins(data_home / "kanibako" / "plugins", targets)
131
+
132
+ # Project-level file-drop plugins
133
+ if project_path is not None:
134
+ _scan_directory_plugins(project_path / ".kanibako" / "plugins", targets)
135
+
136
+ return targets
137
+
138
+
139
+ def get_target(name: str, project_path: Path | None = None) -> type[Target]:
140
+ """Look up a target class by name.
141
+
142
+ Raises ``KeyError`` if no target with that name is registered.
143
+ """
144
+ targets = discover_targets(project_path)
145
+ if name not in targets:
146
+ available = ", ".join(sorted(targets)) or "(none)"
147
+ raise KeyError(f"Unknown target '{name}'. Available: {available}")
148
+ return targets[name]
149
+
150
+
151
+ def resolve_target(
152
+ name: str | None = None, project_path: Path | None = None,
153
+ ) -> Target:
154
+ """Instantiate a target by name, or auto-detect.
155
+
156
+ If *name* is given, looks it up via entry points.
157
+ If *name* is None, iterates all discovered targets and returns the first
158
+ one whose ``detect()`` succeeds.
159
+
160
+ Raises ``KeyError`` if no matching target is found.
161
+ """
162
+ if name:
163
+ cls = get_target(name, project_path)
164
+ return cls()
165
+
166
+ # Auto-detect: try each target's detect() and return the first match.
167
+ targets = discover_targets(project_path)
168
+ for target_name, cls in targets.items():
169
+ instance = cls()
170
+ if instance.detect() is not None:
171
+ return instance
172
+
173
+ return NoAgentTarget()
@@ -0,0 +1,243 @@
1
+ """Target base classes: ABC for agent targets, Mount and AgentInstall dataclasses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass
7
+ from enum import Enum
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from kanibako.crabs import CrabConfig
13
+
14
+
15
+ class ResourceScope(Enum):
16
+ """How an agent resource is shared across projects."""
17
+
18
+ SHARED = "shared" # Shared across a workset (or the default workset)
19
+ PROJECT = "project" # Per-project, starts fresh
20
+ SEEDED = "seeded" # Per-project, seeded from workset template at creation
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ResourceMapping:
25
+ """Maps an agent resource path to its sharing scope."""
26
+
27
+ path: str # Relative path within agent home (e.g. "plugins/")
28
+ scope: ResourceScope # How this resource is shared
29
+ description: str = "" # Human-readable description
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class TargetSetting:
34
+ """Declares a runtime setting that a target plugin supports.
35
+
36
+ Used by ``setting_descriptors()`` to advertise what settings exist,
37
+ their defaults, and (optionally) valid choices.
38
+ """
39
+
40
+ key: str # Setting key in agent state dict (e.g. "model")
41
+ description: str # Human-readable description
42
+ default: str = "" # Default value when not overridden
43
+ choices: tuple[str, ...] = () # Valid values; empty = freeform
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Mount:
48
+ """A volume mount for a container."""
49
+
50
+ source: Path
51
+ destination: str
52
+ options: str = "" # e.g. "ro"
53
+
54
+ def to_volume_arg(self) -> str:
55
+ """Return the -v argument string for podman/docker."""
56
+ base = f"{self.source}:{self.destination}"
57
+ return f"{base}:{self.options}" if self.options else base
58
+
59
+
60
+ @dataclass
61
+ class AgentInstall:
62
+ """Information about an agent installation on the host."""
63
+
64
+ name: str # e.g. "claude"
65
+ binary: Path # host symlink/path to agent binary
66
+ install_dir: Path # root of agent installation
67
+
68
+
69
+ class Target(ABC):
70
+ """Abstract base class for agent targets.
71
+
72
+ A target encapsulates all agent-specific logic: detection, binary mounting,
73
+ home directory initialization, credential management, and CLI argument
74
+ building. Kanibako's core is agent-agnostic; all agent knowledge lives
75
+ in Target implementations.
76
+ """
77
+
78
+ @property
79
+ @abstractmethod
80
+ def name(self) -> str:
81
+ """Short identifier for this target (e.g. 'claude')."""
82
+ ...
83
+
84
+ @property
85
+ @abstractmethod
86
+ def display_name(self) -> str:
87
+ """Human-readable name (e.g. 'Claude Code')."""
88
+ ...
89
+
90
+ @abstractmethod
91
+ def detect(self) -> AgentInstall | None:
92
+ """Detect the agent installation on the host.
93
+
94
+ Returns an AgentInstall if found, or None if the agent is not installed.
95
+ """
96
+ ...
97
+
98
+ @abstractmethod
99
+ def binary_mounts(self, install: AgentInstall) -> list[Mount]:
100
+ """Return volume mounts needed to make the agent binary available in the container."""
101
+ ...
102
+
103
+ @abstractmethod
104
+ def init_home(self, home: Path, *, group_auth: bool = True) -> None:
105
+ """Initialize agent-specific files in the project home directory.
106
+
107
+ Called after kanibako core creates .bashrc/.profile. The target
108
+ should create its own config directories and files (e.g. .claude/).
109
+
110
+ *group_auth* is ``True`` (copy credentials from host) or ``False``
111
+ (skip credential copy — project manages its own credentials).
112
+ """
113
+ ...
114
+
115
+ @property
116
+ def has_binary(self) -> bool:
117
+ """Whether this target requires a host-installed binary."""
118
+ return True
119
+
120
+ def check_auth(self) -> bool:
121
+ """Check if the agent is authenticated. Returns True if ok."""
122
+ return True
123
+
124
+ def resource_mappings(self) -> list[ResourceMapping]:
125
+ """Declare how agent resources are shared across projects.
126
+
127
+ Returns a list of ResourceMapping entries describing which paths
128
+ within the agent's home directory are shared, project-scoped, or
129
+ seeded from workset defaults.
130
+
131
+ The default returns an empty list, meaning all agent resources
132
+ are treated as project-scoped (the current behavior).
133
+
134
+ Paths are relative to the agent's config directory within the
135
+ project shell (e.g. ".claude/" for ClaudeTarget).
136
+ """
137
+ return []
138
+
139
+ def default_shares(self) -> dict[str, str]:
140
+ """Declare default scoped shares for this agent's crab.
141
+
142
+ Returns a mapping of full scoped-share keys
143
+ ({scope}.path.share_{ro,rw}.{name}) to host_src:guest_dest bind
144
+ expressions. These become the CRAB level's *declared defaults* in the
145
+ share resolver — a user can override or suppress (terminal "") any of
146
+ them at a more-specific level. The default returns {} (no shares).
147
+ """
148
+ return {}
149
+
150
+ def default_seeds(self) -> dict[str, str]:
151
+ """Declare default copy-once-at-init seeds for this agent's crab.
152
+
153
+ Returns a mapping of full seed keys ({scope}.path.seeded.{name}) to
154
+ host_src:guest_dest expressions, injected as the CRAB level's declared
155
+ defaults in the seed resolver. A user can override or suppress (terminal
156
+ "" or the "empty" sentinel) any of them at a more-specific level. The
157
+ default returns {} (no seeds). No target ships a default seed yet.
158
+ """
159
+ return {}
160
+
161
+ def setting_descriptors(self) -> list[TargetSetting]:
162
+ """Declare what runtime settings this target supports.
163
+
164
+ Returns a list of TargetSetting entries describing the key name,
165
+ default value, valid choices, and human-readable description.
166
+
167
+ The default returns an empty list (no declared settings).
168
+ """
169
+ return []
170
+
171
+ def generate_crab_config(self) -> CrabConfig:
172
+ """Return a default CrabConfig for this target.
173
+
174
+ Subclasses should override to provide agent-specific defaults
175
+ (template variant, state knobs, shared caches, etc.).
176
+ """
177
+ from kanibako.crabs import CrabConfig as _CrabConfig
178
+
179
+ return _CrabConfig(name=self.display_name)
180
+
181
+ def apply_state(self, state: dict[str, str]) -> tuple[list[str], dict[str, str]]:
182
+ """Translate crab-state values into CLI args and env vars.
183
+
184
+ Returns ``(cli_args, env_vars)``. Base implementation ignores all
185
+ state keys. Subclasses override to handle known keys.
186
+ """
187
+ return [], {}
188
+
189
+ @property
190
+ def default_entrypoint(self) -> str | None:
191
+ """Binary name for container entrypoint. None = use bash."""
192
+ return None
193
+
194
+ def should_retry_new_session(self, output: str) -> bool:
195
+ """Check if agent output indicates ``--continue`` failed and a new session should be started."""
196
+ return False
197
+
198
+ @property
199
+ def config_dir_name(self) -> str:
200
+ """Agent config dir relative to home (e.g. '.claude'). Default: '.{name}'."""
201
+ return f".{self.name}"
202
+
203
+ def instruction_files(self) -> list[str]:
204
+ """Return filenames that should be layered across template levels.
205
+
206
+ These files are merged (concatenated with section markers) from
207
+ three layers: kanibako base, template, and user project. Each
208
+ filename is relative to the agent's config dir within the shell
209
+ directory (e.g. ``"CLAUDE.md"`` lives at ``shell/.claude/CLAUDE.md``).
210
+
211
+ The default returns an empty list (no instruction files merged).
212
+ """
213
+ return []
214
+
215
+ def credential_check_path(self, home: Path) -> Path | None:
216
+ """Path to check for credential existence, or None."""
217
+ return None
218
+
219
+ def invalidate_credentials(self, home: Path) -> None:
220
+ """Remove credential files when switching to distinct auth. Default: no-op."""
221
+
222
+ @abstractmethod
223
+ def refresh_credentials(self, home: Path) -> None:
224
+ """Refresh agent credentials from host into the project home."""
225
+ ...
226
+
227
+ @abstractmethod
228
+ def writeback_credentials(self, home: Path) -> None:
229
+ """Write back credentials from project home to host."""
230
+ ...
231
+
232
+ @abstractmethod
233
+ def build_cli_args(
234
+ self,
235
+ *,
236
+ safe_mode: bool,
237
+ resume_mode: bool,
238
+ new_session: bool,
239
+ is_new_project: bool,
240
+ extra_args: list[str],
241
+ ) -> list[str]:
242
+ """Build command-line arguments for the agent entrypoint."""
243
+ ...
@@ -0,0 +1,58 @@
1
+ """NoAgentTarget: built-in fallback target that runs a plain shell."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ from kanibako.targets.base import AgentInstall, Mount, Target
9
+
10
+ if TYPE_CHECKING:
11
+ from kanibako.crabs import CrabConfig
12
+
13
+
14
+ class NoAgentTarget(Target):
15
+ """Fallback target that launches /bin/sh without any agent binary."""
16
+
17
+ @property
18
+ def name(self) -> str:
19
+ return "no_agent"
20
+
21
+ @property
22
+ def display_name(self) -> str:
23
+ return "Shell"
24
+
25
+ @property
26
+ def has_binary(self) -> bool:
27
+ return False
28
+
29
+ def detect(self) -> AgentInstall | None:
30
+ return None
31
+
32
+ def binary_mounts(self, install: AgentInstall) -> list[Mount]:
33
+ return []
34
+
35
+ def init_home(self, home: Path, *, group_auth: bool = True) -> None:
36
+ pass
37
+
38
+ def refresh_credentials(self, home: Path) -> None:
39
+ pass
40
+
41
+ def writeback_credentials(self, home: Path) -> None:
42
+ pass
43
+
44
+ def build_cli_args(
45
+ self,
46
+ *,
47
+ safe_mode: bool,
48
+ resume_mode: bool,
49
+ new_session: bool,
50
+ is_new_project: bool,
51
+ extra_args: list[str],
52
+ ) -> list[str]:
53
+ return []
54
+
55
+ def generate_crab_config(self) -> CrabConfig:
56
+ from kanibako.crabs import CrabConfig as _CrabConfig
57
+
58
+ return _CrabConfig(name="Shell")
kanibako/templates.py ADDED
@@ -0,0 +1,60 @@
1
+ """Shell template resolution and application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+
9
+ def resolve_template(
10
+ templates_base: Path,
11
+ agent_name: str,
12
+ template_name: str = "standard",
13
+ ) -> Path | None:
14
+ """Return the path to the resolved template directory, or None for 'empty'.
15
+
16
+ Resolution order:
17
+ 1. {templates_base}/{agent_name}/{template_name}/
18
+ 2. {templates_base}/general/{template_name}/
19
+ 3. None (empty template — no files applied)
20
+ """
21
+ if template_name == "empty":
22
+ return None
23
+
24
+ agent_dir = templates_base / agent_name / template_name
25
+ if agent_dir.is_dir():
26
+ return agent_dir
27
+
28
+ general_dir = templates_base / "general" / template_name
29
+ if general_dir.is_dir():
30
+ return general_dir
31
+
32
+ return None
33
+
34
+
35
+ def apply_shell_template(
36
+ shell_path: Path,
37
+ templates_base: Path,
38
+ agent_name: str,
39
+ template_name: str = "standard",
40
+ ) -> None:
41
+ """Apply base + resolved template to a shell directory.
42
+
43
+ Layering:
44
+ 1. general/base/* is copied first (common skeleton)
45
+ 2. The resolved template overlays on top
46
+
47
+ No-op if the resolved template is None (the ``"empty"`` sentinel or no
48
+ template dirs exist on disk).
49
+ """
50
+ resolved = resolve_template(templates_base, agent_name, template_name)
51
+ if resolved is None:
52
+ return
53
+
54
+ # Layer 1: general/base (if it exists)
55
+ base_dir = templates_base / "general" / "base"
56
+ if base_dir.is_dir():
57
+ shutil.copytree(str(base_dir), str(shell_path), dirs_exist_ok=True)
58
+
59
+ # Layer 2: resolved template
60
+ shutil.copytree(str(resolved), str(shell_path), dirs_exist_ok=True)