ipman-cli 0.1.73__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.
ipman/core/config.py ADDED
@@ -0,0 +1,101 @@
1
+ """Configuration file loading and merging."""
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 typing import Any
10
+
11
+ import yaml
12
+
13
+ _DEFAULT_HUB_URL = (
14
+ "https://raw.githubusercontent.com/twisker/iphub/main"
15
+ )
16
+
17
+
18
+ class SecurityMode(Enum):
19
+ """Security enforcement level."""
20
+
21
+ PERMISSIVE = "permissive"
22
+ DEFAULT = "default"
23
+ CAUTIOUS = "cautious"
24
+ STRICT = "strict"
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class IpManConfig:
29
+ """Immutable configuration object."""
30
+
31
+ security_mode: SecurityMode = SecurityMode.DEFAULT
32
+ log_enabled: bool = True
33
+ log_path: Path = field(
34
+ default_factory=lambda: Path.home() / ".ipman" / "security.log",
35
+ )
36
+ hub_url: str = _DEFAULT_HUB_URL
37
+ agent_default: str = "auto"
38
+
39
+
40
+ def load_config(
41
+ *,
42
+ config_dir: Path | None = None,
43
+ ) -> IpManConfig:
44
+ """Load configuration with priority: env vars > file > defaults.
45
+
46
+ Args:
47
+ config_dir: Directory containing config.yaml.
48
+ Defaults to ``~/.ipman``.
49
+ """
50
+ if config_dir is None:
51
+ config_dir = Path.home() / ".ipman"
52
+
53
+ # --- Load file ---
54
+ data: dict[str, Any] = {}
55
+ config_file = config_dir / "config.yaml"
56
+ if config_file.exists():
57
+ raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
58
+ if isinstance(raw, dict):
59
+ data = raw
60
+
61
+ security = data.get("security", {}) or {}
62
+ hub = data.get("hub", {}) or {}
63
+ agent = data.get("agent", {}) or {}
64
+
65
+ # --- Security mode ---
66
+ mode_str = security.get("mode", "default")
67
+ try:
68
+ mode = SecurityMode(mode_str)
69
+ except ValueError:
70
+ mode = SecurityMode.DEFAULT
71
+
72
+ # --- Log ---
73
+ log_enabled = security.get("log_enabled", True)
74
+ log_path_str = security.get("log_path")
75
+ log_path = Path(log_path_str) if log_path_str else config_dir / "security.log"
76
+
77
+ # --- Hub ---
78
+ hub_url = hub.get("url", _DEFAULT_HUB_URL)
79
+
80
+ # --- Agent ---
81
+ agent_default = agent.get("default", "auto")
82
+
83
+ # --- Environment variable overrides ---
84
+ env_hub = os.environ.get("IPMAN_HUB_URL")
85
+ if env_hub:
86
+ hub_url = env_hub
87
+
88
+ env_mode = os.environ.get("IPMAN_SECURITY_MODE")
89
+ if env_mode:
90
+ try:
91
+ mode = SecurityMode(env_mode)
92
+ except ValueError:
93
+ pass # ignore invalid env value
94
+
95
+ return IpManConfig(
96
+ security_mode=mode,
97
+ log_enabled=log_enabled,
98
+ log_path=log_path,
99
+ hub_url=hub_url,
100
+ agent_default=agent_default,
101
+ )
@@ -0,0 +1,472 @@
1
+ """Virtual environment management for IpMan."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ import shutil
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import yaml
12
+
13
+ from ipman.agents.base import AgentAdapter
14
+ from ipman.utils.symlink import create_symlink, is_symlink, remove_symlink
15
+
16
+
17
+ class Scope(enum.Enum):
18
+ """Environment scope."""
19
+
20
+ PROJECT = "project"
21
+ USER = "user"
22
+ MACHINE = "machine"
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Path helpers
27
+ # ---------------------------------------------------------------------------
28
+
29
+ def get_ipman_home() -> Path:
30
+ """Return the global IpMan home directory (~/.ipman)."""
31
+ return Path.home() / ".ipman"
32
+
33
+
34
+ def get_project_ipman_dir(project_path: Path) -> Path:
35
+ """Return the .ipman directory for a project."""
36
+ return project_path / ".ipman"
37
+
38
+
39
+ def get_envs_root(scope: Scope, project_path: Path | None = None) -> Path:
40
+ """Return the envs/ root directory for the given scope."""
41
+ if scope == Scope.PROJECT:
42
+ if project_path is None:
43
+ msg = "project_path is required for project scope"
44
+ raise ValueError(msg)
45
+ return get_project_ipman_dir(project_path) / "envs"
46
+ if scope == Scope.USER:
47
+ return get_ipman_home() / "envs"
48
+ # MACHINE scope
49
+ if _is_windows():
50
+ return Path("C:/ProgramData/ipman/envs")
51
+ return Path("/opt/ipman/envs")
52
+
53
+
54
+ def get_env_path(name: str, scope: Scope, project_path: Path | None = None) -> Path:
55
+ """Return the full path to a named environment."""
56
+ return get_envs_root(scope, project_path) / name
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # CRUD operations
61
+ # ---------------------------------------------------------------------------
62
+
63
+ def create_env(
64
+ name: str,
65
+ adapter: AgentAdapter,
66
+ scope: Scope = Scope.PROJECT,
67
+ project_path: Path | None = None,
68
+ inherit: bool = False,
69
+ ) -> Path:
70
+ """Create a new virtual environment.
71
+
72
+ Returns the path to the created environment directory.
73
+ """
74
+ if project_path is None:
75
+ project_path = Path.cwd()
76
+
77
+ env_path = get_env_path(name, scope, project_path)
78
+
79
+ if env_path.exists():
80
+ msg = f"Environment '{name}' already exists at {env_path}"
81
+ raise FileExistsError(msg)
82
+
83
+ # Create the environment directory
84
+ env_path.mkdir(parents=True)
85
+
86
+ # Initialize agent-specific structure
87
+ adapter.init_env_dir(env_path)
88
+
89
+ # Optionally inherit existing skills from current agent config
90
+ if inherit:
91
+ _inherit_existing(adapter, env_path, project_path)
92
+
93
+ # Write ipman.yaml project config (for project scope)
94
+ if scope == Scope.PROJECT:
95
+ _ensure_project_config(project_path, adapter)
96
+
97
+ # Write env metadata
98
+ _write_env_metadata(env_path, name, scope, adapter)
99
+
100
+ return env_path
101
+
102
+
103
+ def delete_env(
104
+ name: str,
105
+ scope: Scope = Scope.PROJECT,
106
+ project_path: Path | None = None,
107
+ ) -> None:
108
+ """Delete a virtual environment."""
109
+ if project_path is None:
110
+ project_path = Path.cwd()
111
+
112
+ env_path = get_env_path(name, scope, project_path)
113
+
114
+ if not env_path.exists():
115
+ msg = f"Environment '{name}' does not exist"
116
+ raise FileNotFoundError(msg)
117
+
118
+ # Check if this env is currently active — deactivate first
119
+ config = _read_project_config(project_path)
120
+ if config and config.get("active_env") == name:
121
+ deactivate_env(project_path=project_path)
122
+
123
+ shutil.rmtree(env_path)
124
+
125
+
126
+ def list_envs(
127
+ scope: Scope = Scope.PROJECT,
128
+ project_path: Path | None = None,
129
+ ) -> list[dict[str, Any]]:
130
+ """List all environments for the given scope.
131
+
132
+ Returns a list of dicts with env metadata.
133
+ """
134
+ if project_path is None:
135
+ project_path = Path.cwd()
136
+
137
+ envs_root = get_envs_root(scope, project_path)
138
+
139
+ if not envs_root.exists():
140
+ return []
141
+
142
+ result = []
143
+ config = _read_project_config(project_path)
144
+ active_name = config.get("active_env") if config else None
145
+
146
+ for env_dir in sorted(envs_root.iterdir()):
147
+ if not env_dir.is_dir():
148
+ continue
149
+ meta_file = env_dir / "env.yaml"
150
+ if meta_file.exists():
151
+ meta = yaml.safe_load(meta_file.read_text(encoding="utf-8")) or {}
152
+ else:
153
+ meta = {"name": env_dir.name}
154
+ meta["active"] = meta.get("name", env_dir.name) == active_name
155
+ meta["path"] = str(env_dir)
156
+ result.append(meta)
157
+
158
+ return result
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # Activate / Deactivate
163
+ # ---------------------------------------------------------------------------
164
+
165
+ def activate_env(
166
+ name: str,
167
+ scope: Scope = Scope.PROJECT,
168
+ project_path: Path | None = None,
169
+ ) -> Path:
170
+ """Activate a virtual environment by symlinking the agent config dir.
171
+
172
+ Returns the path to the activated environment.
173
+ """
174
+ if project_path is None:
175
+ project_path = Path.cwd()
176
+
177
+ env_path = get_env_path(name, scope, project_path)
178
+ if not env_path.exists():
179
+ msg = f"Environment '{name}' does not exist"
180
+ raise FileNotFoundError(msg)
181
+
182
+ config = _read_project_config(project_path)
183
+ if not config:
184
+ msg = "No ipman.yaml found. Run 'ipman create' first."
185
+ raise FileNotFoundError(msg)
186
+
187
+ agent_config_dir_name = config.get("agent_config_dir", ".claude")
188
+ agent_config_path = project_path / agent_config_dir_name
189
+ backup_path = project_path / f"{agent_config_dir_name}.bak"
190
+
191
+ # If already a symlink (another env active), remove it
192
+ if is_symlink(agent_config_path):
193
+ remove_symlink(agent_config_path)
194
+ elif agent_config_path.exists():
195
+ # Real directory — back it up
196
+ if backup_path.exists():
197
+ msg = (
198
+ f"Backup already exists at {backup_path}. "
199
+ "Please resolve manually before activating."
200
+ )
201
+ raise FileExistsError(msg)
202
+ agent_config_path.rename(backup_path)
203
+
204
+ # Create the symlink
205
+ create_symlink(target=env_path, link=agent_config_path)
206
+
207
+ # Update project config
208
+ _update_project_config(project_path, active_env=name)
209
+
210
+ return env_path
211
+
212
+
213
+ def deactivate_env(
214
+ project_path: Path | None = None,
215
+ ) -> None:
216
+ """Deactivate the current virtual environment."""
217
+ if project_path is None:
218
+ project_path = Path.cwd()
219
+
220
+ config = _read_project_config(project_path)
221
+ if not config or not config.get("active_env"):
222
+ msg = "No environment is currently active"
223
+ raise RuntimeError(msg)
224
+
225
+ agent_config_dir_name = config.get("agent_config_dir", ".claude")
226
+ agent_config_path = project_path / agent_config_dir_name
227
+ backup_path = project_path / f"{agent_config_dir_name}.bak"
228
+
229
+ # Remove the symlink
230
+ if is_symlink(agent_config_path):
231
+ remove_symlink(agent_config_path)
232
+
233
+ # Restore backup if it exists
234
+ if backup_path.exists():
235
+ backup_path.rename(agent_config_path)
236
+
237
+ # Clear active env in config
238
+ _update_project_config(project_path, active_env=None)
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # Shell integration (activate script generation)
243
+ # ---------------------------------------------------------------------------
244
+
245
+ def build_prompt_tag(
246
+ project_path: Path | None = None,
247
+ ) -> str:
248
+ """Build the compact prompt tag showing active envs across all scopes.
249
+
250
+ Format: [ip:<machine><user><project_name>]
251
+ - machine active: * inactive: omitted
252
+ - user active: - inactive: omitted
253
+ - project active: env full name inactive: omitted
254
+
255
+ Examples:
256
+ [ip:*-myenv] = all three layers active
257
+ [ip:myenv] = only project
258
+ [ip:*myenv] = machine + project
259
+ [ip:*-] = machine + user, no project
260
+ """
261
+ if project_path is None:
262
+ project_path = Path.cwd()
263
+
264
+ parts: list[str] = []
265
+
266
+ # Machine layer
267
+ machine_envs = list_envs(Scope.MACHINE, project_path)
268
+ machine_active = any(e.get("active") for e in machine_envs)
269
+ if machine_active:
270
+ parts.append("*")
271
+
272
+ # User layer
273
+ user_envs = list_envs(Scope.USER, project_path)
274
+ user_active = any(e.get("active") for e in user_envs)
275
+ if user_active:
276
+ parts.append("-")
277
+
278
+ # Project layer
279
+ config = _read_project_config(project_path)
280
+ project_env = config.get("active_env") if config else None
281
+ if project_env:
282
+ parts.append(project_env)
283
+
284
+ if not parts:
285
+ return ""
286
+
287
+ return f"[ip:{''.join(parts)}]"
288
+
289
+
290
+ def get_env_status(
291
+ project_path: Path | None = None,
292
+ ) -> list[dict[str, Any]]:
293
+ """Get detailed status of active environments across all scopes.
294
+
295
+ Returns a list of dicts: {scope, name, agent, path}.
296
+ """
297
+ if project_path is None:
298
+ project_path = Path.cwd()
299
+
300
+ result: list[dict[str, Any]] = []
301
+
302
+ for scope in Scope:
303
+ try:
304
+ envs = list_envs(scope, project_path)
305
+ except (ValueError, OSError):
306
+ continue
307
+ for env in envs:
308
+ if env.get("active"):
309
+ result.append({
310
+ "scope": scope.value,
311
+ "name": env.get("name", "unknown"),
312
+ "agent": env.get("agent", "unknown"),
313
+ "path": env.get("path", ""),
314
+ })
315
+
316
+ return result
317
+
318
+
319
+ def generate_activate_script(
320
+ env_name: str,
321
+ shell: str = "bash",
322
+ prompt_tag: str = "",
323
+ ) -> str:
324
+ """Generate a shell script snippet for activation."""
325
+ if not prompt_tag:
326
+ prompt_tag = f"[ip:{env_name}]"
327
+ if shell in ("bash", "zsh"):
328
+ return _bash_activate_script(env_name, prompt_tag)
329
+ if shell == "fish":
330
+ return _fish_activate_script(env_name, prompt_tag)
331
+ if shell in ("powershell", "pwsh"):
332
+ return _powershell_activate_script(env_name, prompt_tag)
333
+ return _bash_activate_script(env_name, prompt_tag)
334
+
335
+
336
+ def generate_deactivate_script(shell: str = "bash") -> str:
337
+ """Generate a shell script snippet for deactivation."""
338
+ if shell in ("bash", "zsh"):
339
+ return _bash_deactivate_script()
340
+ if shell == "fish":
341
+ return _fish_deactivate_script()
342
+ if shell in ("powershell", "pwsh"):
343
+ return _powershell_deactivate_script()
344
+ return _bash_deactivate_script()
345
+
346
+
347
+ # ---------------------------------------------------------------------------
348
+ # Internal helpers
349
+ # ---------------------------------------------------------------------------
350
+
351
+ def _is_windows() -> bool:
352
+ import sys
353
+ return sys.platform == "win32"
354
+
355
+
356
+ def _write_env_metadata(
357
+ env_path: Path, name: str, scope: Scope, adapter: AgentAdapter
358
+ ) -> None:
359
+ meta = {
360
+ "name": name,
361
+ "scope": scope.value,
362
+ "agent": adapter.name,
363
+ "created": datetime.now(tz=timezone.utc).isoformat(),
364
+ }
365
+ meta_file = env_path / "env.yaml"
366
+ meta_file.write_text(yaml.dump(meta, default_flow_style=False), encoding="utf-8")
367
+
368
+
369
+ def _ensure_project_config(project_path: Path, adapter: AgentAdapter) -> None:
370
+ """Create or update .ipman/ipman.yaml."""
371
+ ipman_dir = get_project_ipman_dir(project_path)
372
+ ipman_dir.mkdir(parents=True, exist_ok=True)
373
+ config_file = ipman_dir / "ipman.yaml"
374
+ if config_file.exists():
375
+ return
376
+ config = {
377
+ "agent": adapter.name,
378
+ "agent_config_dir": adapter.config_dir_name,
379
+ "active_env": None,
380
+ }
381
+ content = yaml.dump(config, default_flow_style=False)
382
+ config_file.write_text(content, encoding="utf-8")
383
+
384
+
385
+ def _read_project_config(project_path: Path) -> dict[str, Any] | None:
386
+ config_file = get_project_ipman_dir(project_path) / "ipman.yaml"
387
+ if not config_file.exists():
388
+ return None
389
+ return yaml.safe_load(config_file.read_text(encoding="utf-8")) or {}
390
+
391
+
392
+ def _update_project_config(project_path: Path, active_env: str | None) -> None:
393
+ config_file = get_project_ipman_dir(project_path) / "ipman.yaml"
394
+ config = yaml.safe_load(config_file.read_text(encoding="utf-8")) or {}
395
+ config["active_env"] = active_env
396
+ content = yaml.dump(config, default_flow_style=False)
397
+ config_file.write_text(content, encoding="utf-8")
398
+
399
+
400
+ def _inherit_existing(
401
+ adapter: AgentAdapter, env_path: Path, project_path: Path
402
+ ) -> None:
403
+ """Copy existing agent config into the new environment."""
404
+ source = project_path / adapter.config_dir_name
405
+ if not source.exists() or is_symlink(source):
406
+ return
407
+ # Copy contents (not the directory itself) into env_path
408
+ for item in source.iterdir():
409
+ dest = env_path / item.name
410
+ if item.is_dir():
411
+ shutil.copytree(item, dest, dirs_exist_ok=True)
412
+ else:
413
+ shutil.copy2(item, dest)
414
+
415
+
416
+ def _bash_activate_script(env_name: str, prompt_tag: str) -> str:
417
+ return f"""\
418
+ export IPMAN_ENV="{env_name}"
419
+ export _IPMAN_OLD_PS1="$PS1"
420
+ PS1="{prompt_tag} $PS1"
421
+ export PS1
422
+ """
423
+
424
+
425
+ def _fish_activate_script(env_name: str, prompt_tag: str) -> str:
426
+ return f"""\
427
+ set -gx IPMAN_ENV "{env_name}"
428
+ set -gx _IPMAN_OLD_PROMPT (functions fish_prompt)
429
+ function fish_prompt
430
+ echo -n "{prompt_tag} "
431
+ eval $_IPMAN_OLD_PROMPT
432
+ end
433
+ """
434
+
435
+
436
+ def _powershell_activate_script(env_name: str, prompt_tag: str) -> str:
437
+ return f"""\
438
+ $env:IPMAN_ENV = "{env_name}"
439
+ $_ipman_old_prompt = $function:prompt
440
+ function prompt {{ "{prompt_tag} " + (& $_ipman_old_prompt) }}
441
+ """
442
+
443
+
444
+ def _bash_deactivate_script() -> str:
445
+ return """\
446
+ if [ -n "$_IPMAN_OLD_PS1" ]; then
447
+ PS1="$_IPMAN_OLD_PS1"
448
+ export PS1
449
+ unset _IPMAN_OLD_PS1
450
+ fi
451
+ unset IPMAN_ENV
452
+ """
453
+
454
+
455
+ def _fish_deactivate_script() -> str:
456
+ return """\
457
+ if set -q _IPMAN_OLD_PROMPT
458
+ eval "function fish_prompt; $_IPMAN_OLD_PROMPT; end"
459
+ set -e _IPMAN_OLD_PROMPT
460
+ end
461
+ set -e IPMAN_ENV
462
+ """
463
+
464
+
465
+ def _powershell_deactivate_script() -> str:
466
+ return """\
467
+ if ($null -ne $_ipman_old_prompt) {
468
+ $function:prompt = $_ipman_old_prompt
469
+ Remove-Variable _ipman_old_prompt
470
+ }
471
+ Remove-Item Env:IPMAN_ENV -ErrorAction SilentlyContinue
472
+ """