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 +5 -0
- ipman/agents/__init__.py +0 -0
- ipman/agents/base.py +85 -0
- ipman/agents/claude_code.py +75 -0
- ipman/agents/openclaw.py +74 -0
- ipman/agents/registry.py +45 -0
- ipman/cli/__init__.py +0 -0
- ipman/cli/_common.py +21 -0
- ipman/cli/env.py +271 -0
- ipman/cli/hub.py +237 -0
- ipman/cli/main.py +37 -0
- ipman/cli/pack.py +67 -0
- ipman/cli/skill.py +299 -0
- ipman/core/__init__.py +0 -0
- ipman/core/config.py +101 -0
- ipman/core/environment.py +472 -0
- ipman/core/package.py +188 -0
- ipman/core/resolver.py +160 -0
- ipman/core/security.py +84 -0
- ipman/core/vetter.py +193 -0
- ipman/hub/__init__.py +0 -0
- ipman/hub/client.py +132 -0
- ipman/hub/publisher.py +274 -0
- ipman/hub/stats.py +52 -0
- ipman/utils/__init__.py +0 -0
- ipman/utils/i18n.py +113 -0
- ipman/utils/symlink.py +84 -0
- ipman_cli-0.1.73.dist-info/METADATA +147 -0
- ipman_cli-0.1.73.dist-info/RECORD +32 -0
- ipman_cli-0.1.73.dist-info/WHEEL +4 -0
- ipman_cli-0.1.73.dist-info/entry_points.txt +2 -0
- ipman_cli-0.1.73.dist-info/licenses/LICENSE +201 -0
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
|