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/cli/hub.py ADDED
@@ -0,0 +1,237 @@
1
+ """CLI commands for IpHub (search, info, top, publish)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import click
10
+
11
+ from ipman.core.vetter import RiskLevel, assess_risk, vet_skill_content
12
+ from ipman.hub.client import IpHubClient
13
+ from ipman.hub.publisher import IpHubPublisher, PublishError, get_github_username
14
+
15
+
16
+ def _submit_report(
17
+ name: str, body: str,
18
+ ) -> subprocess.CompletedProcess[str]:
19
+ """Submit a report issue to IpHub via gh CLI."""
20
+ import subprocess as _sp
21
+ return _sp.run(
22
+ [
23
+ "gh", "issue", "create",
24
+ "--repo", "twisker/iphub",
25
+ "--title", f"[report] {name}",
26
+ "--body", body,
27
+ "--label", "report",
28
+ ],
29
+ capture_output=True, text=True, check=False,
30
+ )
31
+
32
+
33
+ def _get_hub_client() -> IpHubClient:
34
+ """Create an IpHubClient using configured hub URL."""
35
+ from ipman.core.config import load_config
36
+ cfg = load_config()
37
+ return IpHubClient(base_url=cfg.hub_url)
38
+
39
+
40
+ @click.group()
41
+ def hub() -> None:
42
+ """Browse and publish to the IpHub registry."""
43
+
44
+
45
+ @hub.command()
46
+ @click.argument("query", default="")
47
+ @click.option("--agent", default=None, help="Filter by agent (e.g. claude-code).")
48
+ def search(query: str, agent: str | None) -> None:
49
+ """Search IpHub for skills and packages."""
50
+ client = _get_hub_client()
51
+ results = client.search(query, agent=agent)
52
+
53
+ if not results:
54
+ click.echo("No results found.")
55
+ return
56
+
57
+ for r in results:
58
+ rtype = r.get("type", "skill")
59
+ tag = click.style(f"[{rtype}]", fg="cyan")
60
+ name = click.style(r["name"], bold=True)
61
+ desc = r.get("description", "")
62
+ installs = r.get("installs", 0)
63
+ click.echo(f" {tag} {name} {desc} ({installs} installs)")
64
+
65
+
66
+ @hub.command()
67
+ @click.argument("name")
68
+ def info(name: str) -> None:
69
+ """Show detailed info for a skill or package."""
70
+ client = _get_hub_client()
71
+ entry = client.lookup(name)
72
+
73
+ if entry is None:
74
+ raise click.ClickException(f"'{name}' not found in IpHub.")
75
+
76
+ click.secho(entry["name"], bold=True)
77
+ click.echo(f" Type: {entry.get('type', 'skill')}")
78
+ click.echo(f" Owner: {entry.get('owner', 'unknown')}")
79
+ click.echo(f" Description: {entry.get('description', '')}")
80
+
81
+ if entry.get("latest"):
82
+ click.echo(f" Latest: {entry['latest']}")
83
+ if entry.get("versions"):
84
+ click.echo(f" Versions: {', '.join(entry['versions'])}")
85
+ if entry.get("agents"):
86
+ click.echo(f" Agents: {', '.join(entry['agents'])}")
87
+
88
+ installs = entry.get("installs", 0)
89
+ users = entry.get("unique_users", 0)
90
+ click.echo(f" Installs: {installs} ({users} unique users)")
91
+
92
+
93
+ @hub.command()
94
+ @click.option("--limit", "-n", default=10, help="Number of entries to show.")
95
+ def top(limit: int) -> None:
96
+ """Show most installed skills and packages."""
97
+ client = _get_hub_client()
98
+ index = client.fetch_index()
99
+
100
+ entries: list[dict[str, Any]] = []
101
+ for section in ("skills", "packages"):
102
+ items = index.get(section, {})
103
+ for name, data in items.items():
104
+ entries.append({"name": name, **data})
105
+
106
+ entries.sort(key=lambda e: e.get("installs", 0), reverse=True)
107
+
108
+ if not entries:
109
+ click.echo("No entries in IpHub.")
110
+ return
111
+
112
+ click.secho("IpHub Top:", bold=True)
113
+ for i, e in enumerate(entries[:limit], 1):
114
+ rtype = e.get("type", "skill")
115
+ tag = click.style(f"[{rtype}]", fg="cyan")
116
+ name = click.style(e["name"], bold=True)
117
+ installs = e.get("installs", 0)
118
+ click.echo(f" {i}. {tag} {name} ({installs} installs)")
119
+
120
+
121
+ @hub.command()
122
+ @click.argument("source")
123
+ @click.option("--description", "-d", default="", help="Skill/package description.")
124
+ @click.option("--license", "license_", default=None, help="License (e.g. MIT).")
125
+ @click.option("--homepage", default=None, help="Project homepage URL.")
126
+ def publish(
127
+ source: str, description: str,
128
+ license_: str | None, homepage: str | None,
129
+ ) -> None:
130
+ """Publish a skill or IP package to IpHub.
131
+
132
+ SOURCE can be a skill name (e.g. web-scraper) or an .ip.yaml file path.
133
+ """
134
+ try:
135
+ username = get_github_username()
136
+ except PublishError as e:
137
+ raise click.ClickException(str(e)) from e
138
+
139
+ publisher = IpHubPublisher(username=username)
140
+
141
+ if source.endswith(".ip.yaml"):
142
+ # Publish IP package
143
+ path = Path(source)
144
+ if not path.exists():
145
+ raise click.ClickException(f"File not found: {source}")
146
+
147
+ from ipman.core.package import parse_ip_file
148
+ pkg = parse_ip_file(path)
149
+
150
+ # Pre-publish risk assessment
151
+ content = path.read_text(encoding="utf-8")
152
+ flags = vet_skill_content(content)
153
+ report = assess_risk(flags, skill_name=pkg.name)
154
+ if report.risk_level >= RiskLevel.HIGH:
155
+ click.secho(
156
+ f"Publish blocked: '{pkg.name}' rated "
157
+ f"{report.risk_level.name}",
158
+ fg="red", err=True,
159
+ )
160
+ for f in report.flags:
161
+ click.secho(f" {f.description}", fg="red", err=True)
162
+ raise click.ClickException(
163
+ "HIGH/EXTREME risk items cannot be published."
164
+ )
165
+
166
+ click.echo(
167
+ f"Publishing package '{pkg.name}' v{pkg.version} "
168
+ f"as @{username}...",
169
+ )
170
+ try:
171
+ pr_url = publisher.publish_package(pkg)
172
+ except PublishError as e:
173
+ raise click.ClickException(str(e)) from e
174
+ click.secho(f"PR created: {pr_url}", fg="green")
175
+ else:
176
+ # Publish skill
177
+ if not description:
178
+ raise click.ClickException(
179
+ "Description is required for skill publishing. "
180
+ "Use --description."
181
+ )
182
+
183
+ # Pre-publish risk assessment on description
184
+ flags = vet_skill_content(description)
185
+ report = assess_risk(flags, skill_name=source)
186
+ if report.risk_level >= RiskLevel.HIGH:
187
+ click.secho(
188
+ f"Publish blocked: '{source}' rated "
189
+ f"{report.risk_level.name}",
190
+ fg="red", err=True,
191
+ )
192
+ for f in report.flags:
193
+ click.secho(f" {f.description}", fg="red", err=True)
194
+ raise click.ClickException(
195
+ "HIGH/EXTREME risk items cannot be published."
196
+ )
197
+
198
+ click.echo(f"Publishing skill '{source}' as @{username}...")
199
+ try:
200
+ pr_url = publisher.publish_skill(
201
+ name=source,
202
+ description=description,
203
+ license_=license_,
204
+ homepage=homepage,
205
+ )
206
+ except PublishError as e:
207
+ raise click.ClickException(str(e)) from e
208
+ click.secho(f"PR created: {pr_url}", fg="green")
209
+
210
+
211
+ @hub.command("report")
212
+ @click.argument("name")
213
+ @click.option("--reason", "-r", required=True,
214
+ help="Reason for reporting (required).")
215
+ def report_cmd(name: str, reason: str) -> None:
216
+ """Report a suspicious skill or package."""
217
+ try:
218
+ username = get_github_username()
219
+ except PublishError as e:
220
+ raise click.ClickException(str(e)) from e
221
+
222
+ client = _get_hub_client()
223
+ entry = client.lookup(name)
224
+ if entry is None:
225
+ raise click.ClickException(f"'{name}' not found in IpHub.")
226
+
227
+ body = f"Report by @{username}: {reason}"
228
+ result = _submit_report(name, body)
229
+ if result.returncode != 0:
230
+ msg = result.stderr.strip() or "Failed to submit report"
231
+ raise click.ClickException(msg)
232
+
233
+ click.secho(
234
+ f"Reported '{name}'. Thank you for helping keep "
235
+ f"IpHub safe.",
236
+ fg="green",
237
+ )
ipman/cli/main.py ADDED
@@ -0,0 +1,37 @@
1
+ """IpMan CLI main entry point."""
2
+
3
+ import click
4
+
5
+ from ipman import __version__
6
+ from ipman.cli.env import env
7
+ from ipman.cli.hub import hub
8
+ from ipman.cli.pack import pack
9
+ from ipman.cli.skill import install, skill, uninstall
10
+ from ipman.utils.i18n import detect_locale, set_locale
11
+
12
+
13
+ @click.group()
14
+ @click.version_option(version=__version__, prog_name="ipman")
15
+ def cli() -> None:
16
+ """IpMan - Intelligence Package Manager.
17
+
18
+ Agent skill virtual environment manager.
19
+ Create, manage, and share isolated skill environments
20
+ for AI agent tools like Claude Code and OpenClaw.
21
+ """
22
+ set_locale(detect_locale())
23
+
24
+
25
+ cli.add_command(env)
26
+ cli.add_command(hub)
27
+ cli.add_command(pack)
28
+ cli.add_command(skill)
29
+ cli.add_command(install)
30
+ cli.add_command(uninstall)
31
+
32
+
33
+ @cli.command()
34
+ def info() -> None:
35
+ """Show IpMan installation info."""
36
+ click.echo(f"IpMan v{__version__}")
37
+ click.echo("https://github.com/twisker/ipman")
ipman/cli/pack.py ADDED
@@ -0,0 +1,67 @@
1
+ """CLI command for packing the current environment into an .ip.yaml file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from ipman.cli._common import resolve_agent as _resolve_agent
10
+ from ipman.core.package import IPPackage, SkillRef, dump_ip_file
11
+
12
+
13
+ @click.command()
14
+ @click.option("--name", "-n", required=True,
15
+ help="Package name for the .ip.yaml file.")
16
+ @click.option("--version", "-v", "pkg_version", default="1.0.0",
17
+ help="Package version (default: 1.0.0).")
18
+ @click.option("--description", "-d", default="",
19
+ help="Package description.")
20
+ @click.option("--output", "-o", "output_path", default=None,
21
+ type=click.Path(),
22
+ help="Output file path (default: <name>.ip.yaml).")
23
+ @click.option("--agent", "agent_name", default=None,
24
+ help="Agent tool to use (e.g. claude-code, openclaw).")
25
+ @click.option("--force", is_flag=True, default=False,
26
+ help="Overwrite existing output file.")
27
+ def pack(
28
+ name: str,
29
+ pkg_version: str,
30
+ description: str,
31
+ output_path: str | None,
32
+ agent_name: str | None,
33
+ force: bool,
34
+ ) -> None:
35
+ """Pack current environment skills into an .ip.yaml file.
36
+
37
+ Reads installed skills from the active agent and generates
38
+ a distributable IP package file.
39
+ """
40
+ adapter = _resolve_agent(agent_name)
41
+
42
+ # Determine output path
43
+ out = Path(output_path) if output_path else Path(f"{name}.ip.yaml")
44
+
45
+ if out.exists() and not force:
46
+ raise click.ClickException(
47
+ f"Output file already exists: {out}\n"
48
+ "Use --force to overwrite."
49
+ )
50
+
51
+ # Read skills from agent
52
+ skills_info = adapter.list_skills()
53
+ skill_refs = [SkillRef(name=s.name) for s in skills_info]
54
+
55
+ pkg = IPPackage(
56
+ name=name,
57
+ version=pkg_version,
58
+ description=description,
59
+ skills=skill_refs,
60
+ )
61
+
62
+ dump_ip_file(pkg, out)
63
+
64
+ click.secho(
65
+ f"Packed {len(skill_refs)} skill(s) into {out}",
66
+ fg="green",
67
+ )
ipman/cli/skill.py ADDED
@@ -0,0 +1,299 @@
1
+ """CLI commands for skill management (delegated to agent CLI)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ import click
9
+
10
+ from ipman.agents.base import AgentAdapter
11
+ from ipman.cli._common import resolve_agent as _resolve_agent
12
+ from ipman.core.config import SecurityMode, load_config
13
+ from ipman.core.security import Action, decide_action, log_security_event
14
+ from ipman.core.vetter import RiskLevel, VetReport, assess_risk, vet_skill_content
15
+
16
+ if TYPE_CHECKING:
17
+ from ipman.hub.client import IpHubClient
18
+
19
+
20
+ def _is_ip_file(source: str) -> bool:
21
+ """Check if the source argument looks like an .ip.yaml file path."""
22
+ return source.endswith(".ip.yaml")
23
+
24
+
25
+ def _run_vet(content: str, skill_name: str) -> VetReport:
26
+ """Run risk assessment on skill content."""
27
+ flags = vet_skill_content(content)
28
+ return assess_risk(flags, skill_name=skill_name)
29
+
30
+
31
+ def _enforce_security(
32
+ report: VetReport,
33
+ mode: SecurityMode,
34
+ source: str,
35
+ ) -> bool:
36
+ """Enforce security policy. Returns True if install should proceed."""
37
+ action = decide_action(report.risk_level, mode)
38
+ cfg = load_config()
39
+
40
+ if action == Action.BLOCK:
41
+ click.secho(
42
+ f"BLOCKED: '{report.skill_name}' — "
43
+ f"risk {report.risk_level.name}",
44
+ fg="red", err=True,
45
+ )
46
+ for f in report.flags:
47
+ click.secho(f" {f.description}", fg="red", err=True)
48
+ if cfg.log_enabled:
49
+ log_security_event(
50
+ log_path=cfg.log_path,
51
+ skill_name=report.skill_name,
52
+ source=source,
53
+ risk_level=report.risk_level,
54
+ action=action,
55
+ )
56
+ return False
57
+
58
+ if action in (Action.WARN_INSTALL, Action.WARN_CONFIRM):
59
+ click.secho(
60
+ f"WARNING: '{report.skill_name}' — "
61
+ f"risk {report.risk_level.name}",
62
+ fg="yellow", err=True,
63
+ )
64
+ for f in report.flags:
65
+ click.secho(f" {f.description}", fg="yellow", err=True)
66
+ if cfg.log_enabled:
67
+ log_security_event(
68
+ log_path=cfg.log_path,
69
+ skill_name=report.skill_name,
70
+ source=source,
71
+ risk_level=report.risk_level,
72
+ action=action,
73
+ )
74
+
75
+ return True
76
+
77
+
78
+ def _get_hub_client() -> IpHubClient:
79
+ """Create an IpHubClient using configured hub URL."""
80
+ from ipman.hub.client import IpHubClient
81
+ cfg = load_config()
82
+ return IpHubClient(base_url=cfg.hub_url)
83
+
84
+
85
+ def _install_from_hub(
86
+ name: str, adapter: AgentAdapter, *, dry_run: bool = False,
87
+ ) -> None:
88
+ """Install a skill or IP package by short name via IpHub."""
89
+ hub = _get_hub_client()
90
+ info = hub.lookup(name)
91
+ if info is None:
92
+ raise click.ClickException(
93
+ f"'{name}' not found in IpHub. "
94
+ "Check the name or use a file path for local .ip.yaml files."
95
+ )
96
+
97
+ entry_type = info.get("type", "skill")
98
+
99
+ if entry_type == "skill":
100
+ if dry_run:
101
+ click.echo(f"Would install skill: {name} (from IpHub)")
102
+ return
103
+ result = adapter.install_skill(name)
104
+ if result.returncode == 0:
105
+ click.secho(f"Installed '{name}' via {adapter.display_name}.", fg="green")
106
+ else:
107
+ msg = result.stderr.strip() or result.stdout.strip() or "Unknown error"
108
+ click.secho(f"Install failed: {msg}", fg="red", err=True)
109
+ raise SystemExit(1)
110
+ else:
111
+ # IP package — fetch version file and install all skills
112
+ registry = hub.fetch_registry(name)
113
+ if registry is None:
114
+ raise click.ClickException(f"Failed to fetch registry for '{name}'.")
115
+
116
+ skills = registry.get("skills", [])
117
+ if not skills:
118
+ click.secho(f"No skills in package '{name}'.", fg="yellow")
119
+ return
120
+
121
+ if dry_run:
122
+ click.echo(f"Would install {len(skills)} skill(s) from package '{name}':")
123
+ for s in skills:
124
+ click.echo(f" {s['name']}")
125
+ return
126
+
127
+ ok, failed = 0, 0
128
+ for s in skills:
129
+ r = adapter.install_skill(s["name"])
130
+ if r.returncode == 0:
131
+ click.secho(f" Installed '{s['name']}'", fg="green")
132
+ ok += 1
133
+ else:
134
+ msg = r.stderr.strip() or r.stdout.strip() or "Unknown error"
135
+ click.secho(f" Failed '{s['name']}': {msg}", fg="red", err=True)
136
+ failed += 1
137
+
138
+ click.echo(f"\n{ok} installed, {failed} failed (from package '{name}')")
139
+ if failed:
140
+ raise SystemExit(1)
141
+
142
+
143
+ def _install_from_ip_file(
144
+ path: Path, adapter: AgentAdapter, *, dry_run: bool = False,
145
+ ) -> None:
146
+ """Install all skills from an .ip.yaml file."""
147
+ from ipman.core.package import parse_ip_file
148
+
149
+ if not path.exists():
150
+ raise click.ClickException(f"IP file not found: {path}")
151
+
152
+ pkg = parse_ip_file(path)
153
+
154
+ if not pkg.skills:
155
+ click.secho(
156
+ f"No skills defined in {path.name} — nothing to install.",
157
+ fg="yellow",
158
+ )
159
+ return
160
+
161
+ if dry_run:
162
+ click.echo(f"Would install {len(pkg.skills)} skill(s) from {path.name}:")
163
+ for s in pkg.skills:
164
+ click.echo(f" {s.name}")
165
+ return
166
+
167
+ ok, failed = 0, 0
168
+ for s in pkg.skills:
169
+ result = adapter.install_skill(s.name)
170
+ if result.returncode == 0:
171
+ click.secho(f" Installed '{s.name}'", fg="green")
172
+ ok += 1
173
+ else:
174
+ msg = result.stderr.strip() or result.stdout.strip() or "Unknown error"
175
+ click.secho(f" Failed '{s.name}': {msg}", fg="red", err=True)
176
+ failed += 1
177
+
178
+ click.echo(f"\n{ok} installed, {failed} failed (from {path.name})")
179
+ if failed:
180
+ raise SystemExit(1)
181
+
182
+
183
+ @click.command()
184
+ @click.argument("source")
185
+ @click.option("--agent", "agent_name", default=None,
186
+ help="Agent tool to use (e.g. claude-code, openclaw).")
187
+ @click.option("--dry-run", is_flag=True, default=False,
188
+ help="Show what would be installed without executing.")
189
+ @click.option("--security", "security_mode", default=None,
190
+ type=click.Choice(
191
+ ["permissive", "default", "cautious", "strict"],
192
+ case_sensitive=False,
193
+ ),
194
+ help="Security mode override.")
195
+ @click.option("--vet", "force_vet", is_flag=True, default=False,
196
+ help="Force local risk assessment (even for IpHub).")
197
+ @click.option("--no-vet", "skip_vet", is_flag=True, default=False,
198
+ help="Skip local risk assessment.")
199
+ @click.option("--yes", "auto_yes", is_flag=True, default=False,
200
+ help="Auto-confirm security warnings.")
201
+ def install(
202
+ source: str,
203
+ agent_name: str | None,
204
+ dry_run: bool,
205
+ security_mode: str | None,
206
+ force_vet: bool,
207
+ skip_vet: bool,
208
+ auto_yes: bool,
209
+ ) -> None:
210
+ """Install a skill or an IP package.
211
+
212
+ SOURCE can be a skill name (e.g. web-scraper) or an .ip.yaml file path.
213
+ """
214
+ adapter = _resolve_agent(agent_name)
215
+
216
+ # Resolve security mode
217
+ cfg = load_config()
218
+ mode = SecurityMode(security_mode) if security_mode else cfg.security_mode
219
+
220
+ is_local = _is_ip_file(source)
221
+
222
+ # Determine whether to run local vet
223
+ should_vet = False
224
+ if skip_vet:
225
+ should_vet = False
226
+ elif force_vet:
227
+ should_vet = True
228
+ elif is_local:
229
+ # Local/URL sources: always vet by default
230
+ should_vet = True
231
+ elif mode == SecurityMode.STRICT:
232
+ # STRICT mode: vet everything
233
+ should_vet = True
234
+ # else: IpHub source in non-STRICT mode → trust existing label
235
+
236
+ # Run vet if needed
237
+ if should_vet and not dry_run:
238
+ if is_local:
239
+ path = Path(source)
240
+ if not path.exists():
241
+ raise click.ClickException(
242
+ f"IP file not found: {path}",
243
+ )
244
+ content = path.read_text(encoding="utf-8")
245
+ report = _run_vet(content, skill_name=source)
246
+ else:
247
+ # For hub skills, vet the registry content
248
+ report = _run_vet("", skill_name=source)
249
+
250
+ if not _enforce_security(report, mode, source):
251
+ raise SystemExit(1)
252
+
253
+ if is_local:
254
+ _install_from_ip_file(
255
+ Path(source), adapter, dry_run=dry_run,
256
+ )
257
+ else:
258
+ _install_from_hub(source, adapter, dry_run=dry_run)
259
+
260
+
261
+ @click.command()
262
+ @click.argument("name")
263
+ @click.option("--agent", "agent_name", default=None,
264
+ help="Agent tool to use (e.g. claude-code, openclaw).")
265
+ def uninstall(name: str, agent_name: str | None) -> None:
266
+ """Uninstall a skill via the agent's native CLI."""
267
+ adapter = _resolve_agent(agent_name)
268
+ result = adapter.uninstall_skill(name)
269
+ if result.returncode == 0:
270
+ click.secho(
271
+ f"Uninstalled '{name}' via {adapter.display_name}.",
272
+ fg="green",
273
+ )
274
+ else:
275
+ msg = result.stderr.strip() or result.stdout.strip() or "Unknown error"
276
+ click.secho(f"Uninstall failed: {msg}", fg="red", err=True)
277
+ raise SystemExit(1)
278
+
279
+
280
+ @click.group()
281
+ def skill() -> None:
282
+ """Manage skills in the current environment."""
283
+
284
+
285
+ @skill.command("list")
286
+ @click.option("--agent", "agent_name", default=None,
287
+ help="Agent tool to use (e.g. claude-code, openclaw).")
288
+ def list_cmd(agent_name: str | None) -> None:
289
+ """List installed skills via the agent's native CLI."""
290
+ adapter = _resolve_agent(agent_name)
291
+ skills = adapter.list_skills()
292
+ if not skills:
293
+ click.echo(f"No skills installed ({adapter.display_name}).")
294
+ return
295
+ for s in skills:
296
+ status = "" if s.enabled else click.style(" (disabled)", fg="yellow")
297
+ version = f" v{s.version}" if s.version else ""
298
+ click.echo(f" {s.name}{version}{status}")
299
+ click.echo(f"\n{len(skills)} skill(s) installed.")
ipman/core/__init__.py ADDED
File without changes