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/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """IpMan - Intelligence Package Manager."""
2
+
3
+ from importlib.metadata import version as _version
4
+
5
+ __version__ = _version("ipman-cli")
File without changes
ipman/agents/base.py ADDED
@@ -0,0 +1,85 @@
1
+ """Base class for Agent tool adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from abc import ABC, abstractmethod
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+
11
+ @dataclass
12
+ class SkillInfo:
13
+ """Structured info about an installed skill."""
14
+
15
+ name: str
16
+ version: str = ""
17
+ enabled: bool = True
18
+ source: str = ""
19
+
20
+
21
+ class AgentAdapter(ABC):
22
+ """Base adapter for an Agent tool (e.g. Claude Code, OpenClaw).
23
+
24
+ Each adapter tells IpMan:
25
+ - What the agent's config directory is called
26
+ - How to detect if the agent is installed
27
+ - How to initialize a fresh environment directory for the agent
28
+ - How to install/uninstall/list skills via agent CLI commands
29
+ """
30
+
31
+ @property
32
+ @abstractmethod
33
+ def name(self) -> str:
34
+ """Agent identifier, e.g. 'claude-code'."""
35
+
36
+ @property
37
+ @abstractmethod
38
+ def display_name(self) -> str:
39
+ """Human-readable name, e.g. 'Claude Code'."""
40
+
41
+ @property
42
+ @abstractmethod
43
+ def config_dir_name(self) -> str:
44
+ """Name of the agent's config directory, e.g. '.claude'."""
45
+
46
+ @abstractmethod
47
+ def is_installed(self) -> bool:
48
+ """Check if this agent tool is installed on the system."""
49
+
50
+ @abstractmethod
51
+ def init_env_dir(self, env_path: Path) -> None:
52
+ """Initialize a fresh environment directory."""
53
+
54
+ def detect_in_project(self, project_path: Path) -> bool:
55
+ """Check if this agent is used in the given project directory."""
56
+ return (project_path / self.config_dir_name).exists()
57
+
58
+ # --- Skill CLI delegation (Sprint 2) ---
59
+
60
+ @abstractmethod
61
+ def install_skill(
62
+ self, name: str, **kwargs: str | None,
63
+ ) -> subprocess.CompletedProcess[str]:
64
+ """Install a skill via agent's native CLI command."""
65
+
66
+ @abstractmethod
67
+ def uninstall_skill(
68
+ self, name: str,
69
+ ) -> subprocess.CompletedProcess[str]:
70
+ """Uninstall a skill via agent's native CLI command."""
71
+
72
+ @abstractmethod
73
+ def list_skills(self) -> list[SkillInfo]:
74
+ """List installed skills via agent's native CLI command."""
75
+
76
+ def _run_cli(
77
+ self, args: list[str],
78
+ ) -> subprocess.CompletedProcess[str]:
79
+ """Run a CLI command and capture output."""
80
+ return subprocess.run(
81
+ args,
82
+ capture_output=True,
83
+ text=True,
84
+ check=False,
85
+ )
@@ -0,0 +1,75 @@
1
+ """Claude Code agent adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ from ipman.agents.base import AgentAdapter, SkillInfo
11
+
12
+
13
+ class ClaudeCodeAdapter(AgentAdapter):
14
+ """Adapter for Claude Code.
15
+
16
+ Skill operations delegate to `claude plugin` CLI commands.
17
+ """
18
+
19
+ @property
20
+ def name(self) -> str:
21
+ return "claude-code"
22
+
23
+ @property
24
+ def display_name(self) -> str:
25
+ return "Claude Code"
26
+
27
+ @property
28
+ def config_dir_name(self) -> str:
29
+ return ".claude"
30
+
31
+ def is_installed(self) -> bool:
32
+ return shutil.which("claude") is not None
33
+
34
+ def init_env_dir(self, env_path: Path) -> None:
35
+ """Create Claude Code environment structure."""
36
+ skills_dir = env_path / "skills"
37
+ skills_dir.mkdir(parents=True, exist_ok=True)
38
+
39
+ def install_skill(
40
+ self, name: str, **kwargs: str | None,
41
+ ) -> subprocess.CompletedProcess[str]:
42
+ """Install a plugin via ``claude plugin install``."""
43
+ args = ["claude", "plugin", "install", name]
44
+ scope = kwargs.get("scope")
45
+ if scope:
46
+ args.extend(["-s", scope])
47
+ return self._run_cli(args)
48
+
49
+ def uninstall_skill(
50
+ self, name: str,
51
+ ) -> subprocess.CompletedProcess[str]:
52
+ """Uninstall a plugin via ``claude plugin uninstall``."""
53
+ return self._run_cli(
54
+ ["claude", "plugin", "uninstall", name],
55
+ )
56
+
57
+ def list_skills(self) -> list[SkillInfo]:
58
+ """List installed plugins via ``claude plugin list --json``."""
59
+ result = self._run_cli(
60
+ ["claude", "plugin", "list", "--json"],
61
+ )
62
+ if result.returncode != 0:
63
+ return []
64
+ try:
65
+ plugins = json.loads(result.stdout)
66
+ except (json.JSONDecodeError, TypeError):
67
+ return []
68
+ return [
69
+ SkillInfo(
70
+ name=p.get("name", ""),
71
+ version=p.get("version", ""),
72
+ enabled=p.get("enabled", True),
73
+ )
74
+ for p in plugins
75
+ ]
@@ -0,0 +1,74 @@
1
+ """OpenClaw agent adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ from ipman.agents.base import AgentAdapter, SkillInfo
11
+
12
+
13
+ class OpenClawAdapter(AgentAdapter):
14
+ """Adapter for OpenClaw.
15
+
16
+ Skill operations delegate to ``clawhub`` CLI commands.
17
+ """
18
+
19
+ @property
20
+ def name(self) -> str:
21
+ return "openclaw"
22
+
23
+ @property
24
+ def display_name(self) -> str:
25
+ return "OpenClaw"
26
+
27
+ @property
28
+ def config_dir_name(self) -> str:
29
+ return ".openclaw"
30
+
31
+ def is_installed(self) -> bool:
32
+ return shutil.which("openclaw") is not None
33
+
34
+ def init_env_dir(self, env_path: Path) -> None:
35
+ """Create OpenClaw environment structure."""
36
+ skills_dir = env_path / "skills"
37
+ skills_dir.mkdir(parents=True, exist_ok=True)
38
+
39
+ def install_skill(
40
+ self, name: str, **kwargs: str | None,
41
+ ) -> subprocess.CompletedProcess[str]:
42
+ """Install a skill via ``clawhub install``."""
43
+ args = ["clawhub", "install", name]
44
+ hub = kwargs.get("hub")
45
+ if hub:
46
+ args.extend(["--hub", hub])
47
+ return self._run_cli(args)
48
+
49
+ def uninstall_skill(
50
+ self, name: str,
51
+ ) -> subprocess.CompletedProcess[str]:
52
+ """Uninstall a skill via ``clawhub uninstall``."""
53
+ return self._run_cli(
54
+ ["clawhub", "uninstall", name],
55
+ )
56
+
57
+ def list_skills(self) -> list[SkillInfo]:
58
+ """List installed skills via ``clawhub list --json``."""
59
+ result = self._run_cli(
60
+ ["clawhub", "list", "--json"],
61
+ )
62
+ if result.returncode != 0:
63
+ return []
64
+ try:
65
+ skills = json.loads(result.stdout)
66
+ except (json.JSONDecodeError, TypeError):
67
+ return []
68
+ return [
69
+ SkillInfo(
70
+ name=s.get("name", ""),
71
+ version=s.get("version", ""),
72
+ )
73
+ for s in skills
74
+ ]
@@ -0,0 +1,45 @@
1
+ """Agent adapter registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from ipman.agents.base import AgentAdapter
8
+ from ipman.agents.claude_code import ClaudeCodeAdapter
9
+ from ipman.agents.openclaw import OpenClawAdapter
10
+
11
+ # All known adapters, in detection priority order
12
+ _ADAPTERS: list[AgentAdapter] = [
13
+ ClaudeCodeAdapter(),
14
+ OpenClawAdapter(),
15
+ ]
16
+
17
+
18
+ def get_adapter(name: str) -> AgentAdapter:
19
+ """Get an adapter by agent name."""
20
+ for adapter in _ADAPTERS:
21
+ if adapter.name == name:
22
+ return adapter
23
+ known = ", ".join(a.name for a in _ADAPTERS)
24
+ msg = f"Unknown agent: '{name}'. Known agents: {known}"
25
+ raise ValueError(msg)
26
+
27
+
28
+ def detect_agent(project_path: Path | None = None) -> AgentAdapter | None:
29
+ """Auto-detect the agent used in a project directory."""
30
+ if project_path is None:
31
+ project_path = Path.cwd()
32
+ for adapter in _ADAPTERS:
33
+ if adapter.detect_in_project(project_path):
34
+ return adapter
35
+ return None
36
+
37
+
38
+ def detect_installed_agents() -> list[AgentAdapter]:
39
+ """Detect all agent tools installed on the system."""
40
+ return [a for a in _ADAPTERS if a.is_installed()]
41
+
42
+
43
+ def list_known_agents() -> list[str]:
44
+ """Return names of all known agents."""
45
+ return [a.name for a in _ADAPTERS]
ipman/cli/__init__.py ADDED
File without changes
ipman/cli/_common.py ADDED
@@ -0,0 +1,21 @@
1
+ """Shared CLI utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from ipman.agents.base import AgentAdapter
8
+ from ipman.agents.registry import detect_agent, get_adapter
9
+
10
+
11
+ def resolve_agent(agent_name: str | None) -> AgentAdapter:
12
+ """Resolve adapter from --agent flag or auto-detection."""
13
+ if agent_name:
14
+ return get_adapter(agent_name)
15
+ detected = detect_agent()
16
+ if detected is None:
17
+ raise click.ClickException(
18
+ "No agent detected. Use --agent to specify one "
19
+ "(e.g. --agent claude-code)."
20
+ )
21
+ return detected
ipman/cli/env.py ADDED
@@ -0,0 +1,271 @@
1
+ """CLI commands for virtual environment management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from ipman.agents.registry import detect_agent, get_adapter, list_known_agents
11
+ from ipman.core.environment import (
12
+ Scope,
13
+ activate_env,
14
+ build_prompt_tag,
15
+ create_env,
16
+ deactivate_env,
17
+ delete_env,
18
+ generate_activate_script,
19
+ generate_deactivate_script,
20
+ get_env_status,
21
+ list_envs,
22
+ )
23
+
24
+
25
+ def _resolve_scope(project: bool, user: bool, machine: bool) -> Scope:
26
+ if user:
27
+ return Scope.USER
28
+ if machine:
29
+ return Scope.MACHINE
30
+ return Scope.PROJECT
31
+
32
+
33
+ def _resolve_adapter(agent: str | None, project_path: Path) -> object:
34
+ """Resolve the agent adapter from --agent flag or auto-detection."""
35
+ if agent:
36
+ return get_adapter(agent)
37
+ detected = detect_agent(project_path)
38
+ if detected:
39
+ return detected
40
+ # Default to claude-code
41
+ return get_adapter("claude-code")
42
+
43
+
44
+ def _detect_shell() -> str:
45
+ shell_path = os.environ.get("SHELL", "")
46
+ if "fish" in shell_path:
47
+ return "fish"
48
+ if "zsh" in shell_path or "bash" in shell_path:
49
+ return "bash"
50
+ # Windows
51
+ if os.environ.get("PSMODULEPATH"):
52
+ return "powershell"
53
+ return "bash"
54
+
55
+
56
+ @click.group()
57
+ def env() -> None:
58
+ """Manage virtual skill environments."""
59
+
60
+
61
+ @env.command()
62
+ @click.argument("name")
63
+ @click.option("--project", "scope_project", is_flag=True, default=True,
64
+ help="Create in current project (default).")
65
+ @click.option("--user", "scope_user", is_flag=True, default=False,
66
+ help="Create for current user.")
67
+ @click.option("--machine", "scope_machine", is_flag=True, default=False,
68
+ help="Create for entire machine.")
69
+ @click.option("--agent", default=None,
70
+ help=f"Agent tool to target. Known: {', '.join(list_known_agents())}")
71
+ @click.option("--inherit", is_flag=True, default=False,
72
+ help="Inherit existing skills from current agent config.")
73
+ def create(
74
+ name: str,
75
+ scope_project: bool,
76
+ scope_user: bool,
77
+ scope_machine: bool,
78
+ agent: str | None,
79
+ inherit: bool,
80
+ ) -> None:
81
+ """Create a new virtual skill environment."""
82
+ project_path = Path.cwd()
83
+ scope = _resolve_scope(scope_project, scope_user, scope_machine)
84
+ adapter = _resolve_adapter(agent, project_path)
85
+
86
+ try:
87
+ env_path = create_env(
88
+ name=name,
89
+ adapter=adapter, # type: ignore[arg-type]
90
+ scope=scope,
91
+ project_path=project_path,
92
+ inherit=inherit,
93
+ )
94
+ click.secho(
95
+ f"Created environment '{name}' at {env_path}",
96
+ fg="green",
97
+ )
98
+ click.echo(f" Agent: {adapter.display_name}") # type: ignore[attr-defined]
99
+ click.echo(f" Scope: {scope.value}")
100
+ if inherit:
101
+ click.echo(" Inherited existing skills.")
102
+ click.echo(f"\nActivate with: ipman env activate {name}")
103
+ except FileExistsError as e:
104
+ click.secho(str(e), fg="red", err=True)
105
+ raise SystemExit(1) from None
106
+
107
+
108
+ @env.command()
109
+ @click.argument("name")
110
+ @click.option("--project", "scope_project", is_flag=True, default=True)
111
+ @click.option("--user", "scope_user", is_flag=True, default=False)
112
+ @click.option("--machine", "scope_machine", is_flag=True, default=False)
113
+ def activate(
114
+ name: str,
115
+ scope_project: bool,
116
+ scope_user: bool,
117
+ scope_machine: bool,
118
+ ) -> None:
119
+ """Activate a virtual skill environment.
120
+
121
+ To update your shell prompt, use:
122
+
123
+ eval "$(ipman env activate myenv)"
124
+ """
125
+ project_path = Path.cwd()
126
+ scope = _resolve_scope(scope_project, scope_user, scope_machine)
127
+
128
+ try:
129
+ activate_env(
130
+ name=name,
131
+ scope=scope,
132
+ project_path=project_path,
133
+ )
134
+ prompt_tag = build_prompt_tag(project_path)
135
+ shell = _detect_shell()
136
+ script = generate_activate_script(name, shell, prompt_tag)
137
+ # If stdout is a terminal, print human-friendly message
138
+ # If piped (eval), print only the script
139
+ if os.isatty(1):
140
+ click.secho(f"Activated '{name}'.", fg="green")
141
+ click.echo(f" Prompt tag: {prompt_tag}")
142
+ click.echo(
143
+ "\nTo update your shell prompt, run:\n"
144
+ f' eval "$(ipman env activate {name})"'
145
+ )
146
+ else:
147
+ click.echo(script, nl=False)
148
+ except (FileNotFoundError, FileExistsError) as e:
149
+ click.secho(str(e), fg="red", err=True)
150
+ raise SystemExit(1) from None
151
+
152
+
153
+ @env.command()
154
+ def deactivate() -> None:
155
+ """Deactivate the current virtual skill environment.
156
+
157
+ To update your shell prompt, use:
158
+
159
+ eval "$(ipman env deactivate)"
160
+ """
161
+ project_path = Path.cwd()
162
+
163
+ try:
164
+ deactivate_env(project_path=project_path)
165
+ shell = _detect_shell()
166
+ script = generate_deactivate_script(shell)
167
+ if os.isatty(1):
168
+ click.secho("Environment deactivated.", fg="green")
169
+ click.echo(
170
+ "\nTo restore your shell prompt, run:\n"
171
+ ' eval "$(ipman env deactivate)"'
172
+ )
173
+ else:
174
+ click.echo(script, nl=False)
175
+ except RuntimeError as e:
176
+ click.secho(str(e), fg="red", err=True)
177
+ raise SystemExit(1) from None
178
+
179
+
180
+ @env.command("delete")
181
+ @click.argument("name")
182
+ @click.option("--project", "scope_project", is_flag=True, default=True)
183
+ @click.option("--user", "scope_user", is_flag=True, default=False)
184
+ @click.option("--machine", "scope_machine", is_flag=True, default=False)
185
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation.")
186
+ def delete_cmd(
187
+ name: str,
188
+ scope_project: bool,
189
+ scope_user: bool,
190
+ scope_machine: bool,
191
+ yes: bool,
192
+ ) -> None:
193
+ """Delete a virtual skill environment."""
194
+ if not yes:
195
+ click.confirm(
196
+ f"Delete environment '{name}'? This cannot be undone.",
197
+ abort=True,
198
+ )
199
+
200
+ project_path = Path.cwd()
201
+ scope = _resolve_scope(scope_project, scope_user, scope_machine)
202
+
203
+ try:
204
+ delete_env(name=name, scope=scope, project_path=project_path)
205
+ click.secho(f"Deleted environment '{name}'.", fg="green")
206
+ except FileNotFoundError as e:
207
+ click.secho(str(e), fg="red", err=True)
208
+ raise SystemExit(1) from None
209
+
210
+
211
+ @env.command("list")
212
+ @click.option("--project", "scope_project", is_flag=True, default=True)
213
+ @click.option("--user", "scope_user", is_flag=True, default=False)
214
+ @click.option("--machine", "scope_machine", is_flag=True, default=False)
215
+ def list_cmd(
216
+ scope_project: bool,
217
+ scope_user: bool,
218
+ scope_machine: bool,
219
+ ) -> None:
220
+ """List all virtual skill environments."""
221
+ project_path = Path.cwd()
222
+ scope = _resolve_scope(scope_project, scope_user, scope_machine)
223
+
224
+ envs = list_envs(scope=scope, project_path=project_path)
225
+
226
+ if not envs:
227
+ click.echo(f"No environments found ({scope.value} scope).")
228
+ return
229
+
230
+ for e in envs:
231
+ marker = click.style(" *", fg="cyan") if e.get("active") else ""
232
+ name = e.get("name", "unknown")
233
+ agent = e.get("agent", "unknown")
234
+ created = e.get("created", "")
235
+ click.echo(
236
+ f" {name}{marker} (agent: {agent}, created: {created})"
237
+ )
238
+
239
+ active_count = sum(1 for e in envs if e.get("active"))
240
+ if active_count:
241
+ click.echo("\n * = currently active")
242
+
243
+
244
+ @env.command("status")
245
+ def status_cmd() -> None:
246
+ """Show detailed active environment status across all scopes."""
247
+ project_path = Path.cwd()
248
+ prompt_tag = build_prompt_tag(project_path)
249
+ status = get_env_status(project_path)
250
+
251
+ if not status:
252
+ click.echo("No active environments.")
253
+ return
254
+
255
+ click.echo(f"Prompt: {prompt_tag}\n")
256
+
257
+ scope_labels = {
258
+ "machine": ("*", "Machine"),
259
+ "user": ("-", "User"),
260
+ "project": ("", "Project"),
261
+ }
262
+
263
+ for entry in status:
264
+ scope = entry["scope"]
265
+ symbol, label = scope_labels.get(scope, ("", scope))
266
+ prefix = f" {symbol} " if symbol else " "
267
+ click.secho(f"{prefix}{label}", fg="cyan", nl=False)
268
+ click.echo(
269
+ f": {entry['name']} "
270
+ f"(agent: {entry['agent']}, path: {entry['path']})"
271
+ )