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/hub/client.py ADDED
@@ -0,0 +1,132 @@
1
+ """IpHub client — fetch, cache, and search the index."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import urllib.request
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+ _DEFAULT_REPO = "twisker/iphub"
13
+ _DEFAULT_BRANCH = "main"
14
+ _INDEX_URL = (
15
+ "https://raw.githubusercontent.com"
16
+ f"/{_DEFAULT_REPO}/{_DEFAULT_BRANCH}/index.yaml"
17
+ )
18
+ _CACHE_TTL_SECONDS = 3600 # 1 hour
19
+
20
+
21
+ class IpHubClient:
22
+ """Client for reading the IpHub reference registry.
23
+
24
+ Fetches index.yaml from GitHub, caches locally, and provides
25
+ search/lookup over the index.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ cache_dir: Path | None = None,
31
+ index_url: str = _INDEX_URL,
32
+ base_url: str | None = None,
33
+ ) -> None:
34
+ self._base_url = base_url or (
35
+ "https://raw.githubusercontent.com"
36
+ f"/{_DEFAULT_REPO}/{_DEFAULT_BRANCH}"
37
+ )
38
+ self._index_url = index_url or f"{self._base_url}/index.yaml"
39
+ self._cache_dir = cache_dir or Path.home() / ".ipman" / "cache"
40
+ self._cache_file = self._cache_dir / "index.yaml"
41
+ self._index: dict[str, Any] | None = None
42
+
43
+ def fetch_index(
44
+ self, refresh: bool = False,
45
+ ) -> dict[str, Any]:
46
+ """Fetch index.yaml, using local cache if fresh."""
47
+ if not refresh and self._index is not None:
48
+ return self._index
49
+
50
+ if not refresh and self._cache_file.exists():
51
+ age = time.time() - self._cache_file.stat().st_mtime
52
+ if age < _CACHE_TTL_SECONDS:
53
+ self._index = yaml.safe_load(
54
+ self._cache_file.read_text(),
55
+ )
56
+ return self._index
57
+
58
+ # Fetch from remote
59
+ with urllib.request.urlopen(self._index_url) as resp:
60
+ raw = resp.read().decode()
61
+
62
+ self._cache_dir.mkdir(parents=True, exist_ok=True)
63
+ self._cache_file.write_text(raw)
64
+ self._index = yaml.safe_load(raw)
65
+ return self._index
66
+
67
+ def search(
68
+ self,
69
+ query: str,
70
+ agent: str | None = None,
71
+ ) -> list[dict[str, Any]]:
72
+ """Search index by keyword and optional agent filter."""
73
+ index = self._index or self.fetch_index()
74
+ results: list[dict[str, Any]] = []
75
+ q = query.lower()
76
+
77
+ for section in ("skills", "packages"):
78
+ items = index.get(section, {})
79
+ for name, info in items.items():
80
+ if agent and agent not in info.get("agents", []):
81
+ continue
82
+ desc = info.get("description", "").lower()
83
+ if q and q not in name.lower() and q not in desc:
84
+ continue
85
+ entry = {
86
+ "name": name,
87
+ "type": info.get("type", section),
88
+ **info,
89
+ }
90
+ results.append(entry)
91
+
92
+ return results
93
+
94
+ def lookup(self, name: str) -> dict[str, Any] | None:
95
+ """Look up a single skill or package by short name."""
96
+ index = self._index or self.fetch_index()
97
+ for section in ("skills", "packages"):
98
+ items = index.get(section, {})
99
+ if name in items:
100
+ return {"name": name, **items[name]}
101
+ return None
102
+
103
+ def fetch_registry(
104
+ self, name: str, version: str | None = None,
105
+ ) -> dict[str, Any] | None:
106
+ """Fetch the full registry file for a skill or package.
107
+
108
+ For skills: fetches registry/@owner/<name>.yaml
109
+ For packages: fetches registry/@owner/<name>/<version>.yaml
110
+ (defaults to latest version from index)
111
+ """
112
+ info = self.lookup(name)
113
+ if info is None:
114
+ return None
115
+
116
+ owner = info.get("owner", "").lstrip("@")
117
+ entry_type = info.get("type", "skill")
118
+
119
+ if entry_type == "skill":
120
+ url = self._registry_url(f"@{owner}/{name}.yaml")
121
+ else:
122
+ ver = version or info.get("latest", "1.0.0")
123
+ url = self._registry_url(f"@{owner}/{name}/{ver}.yaml")
124
+
125
+ with urllib.request.urlopen(url) as resp:
126
+ raw = resp.read().decode()
127
+ result: dict[str, Any] = yaml.safe_load(raw)
128
+ return result
129
+
130
+ def _registry_url(self, path: str) -> str:
131
+ """Build a URL for a registry file using the configured base."""
132
+ return f"{self._base_url}/registry/{path}"
ipman/hub/publisher.py ADDED
@@ -0,0 +1,274 @@
1
+ """IpHub publisher — fork, create registry files, submit PR via gh CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import subprocess
7
+ from datetime import date
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+ from ipman.core.package import IPPackage
13
+
14
+ _IPHUB_REPO = "twisker/iphub"
15
+
16
+
17
+ def _dump_yaml(data: dict[str, Any]) -> str:
18
+ """Dump dict to YAML string with standard formatting."""
19
+ return yaml.dump(
20
+ data, default_flow_style=False,
21
+ allow_unicode=True, sort_keys=False,
22
+ )
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Exceptions
27
+ # ---------------------------------------------------------------------------
28
+
29
+ class PublishError(Exception):
30
+ """Raised when a publish operation fails."""
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # GitHub identity
35
+ # ---------------------------------------------------------------------------
36
+
37
+ def get_github_username() -> str:
38
+ """Get the authenticated GitHub username via ``gh auth status``."""
39
+ result = subprocess.run(
40
+ ["gh", "api", "user", "--jq", ".login"],
41
+ capture_output=True, text=True, check=False,
42
+ )
43
+ if result.returncode != 0 or not result.stdout.strip():
44
+ raise PublishError(
45
+ "GitHub authentication required. Run `gh auth login` first."
46
+ )
47
+ return result.stdout.strip()
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Registry file generation
52
+ # ---------------------------------------------------------------------------
53
+
54
+ def generate_skill_registry(
55
+ *,
56
+ name: str,
57
+ description: str,
58
+ author: str,
59
+ license_: str | None = None,
60
+ homepage: str | None = None,
61
+ keywords: list[str] | None = None,
62
+ agents: dict[str, dict[str, str]] | None = None,
63
+ ) -> dict[str, Any]:
64
+ """Generate a skill registry dict (for registry/@owner/<name>.yaml)."""
65
+ data: dict[str, Any] = {
66
+ "type": "skill",
67
+ "name": name,
68
+ "description": description,
69
+ "author": author,
70
+ }
71
+ if license_:
72
+ data["license"] = license_
73
+ if homepage:
74
+ data["homepage"] = homepage
75
+ if keywords:
76
+ data["keywords"] = keywords
77
+ if agents:
78
+ data["agents"] = agents
79
+ return data
80
+
81
+
82
+ def generate_package_registry(
83
+ *,
84
+ name: str,
85
+ description: str,
86
+ author: str,
87
+ license_: str | None = None,
88
+ homepage: str | None = None,
89
+ ) -> dict[str, Any]:
90
+ """Generate a package meta.yaml dict."""
91
+ data: dict[str, Any] = {
92
+ "type": "ip",
93
+ "name": name,
94
+ "description": description,
95
+ "author": author,
96
+ }
97
+ if license_:
98
+ data["license"] = license_
99
+ if homepage:
100
+ data["homepage"] = homepage
101
+ return data
102
+
103
+
104
+ def generate_version_data(pkg: IPPackage) -> dict[str, Any]:
105
+ """Generate a package version file dict from an IPPackage."""
106
+ data: dict[str, Any] = {
107
+ "version": pkg.version,
108
+ "released": str(date.today()),
109
+ "skills": [{"name": s.name} for s in pkg.skills],
110
+ }
111
+ if pkg.dependencies:
112
+ deps = []
113
+ for d in pkg.dependencies:
114
+ entry: dict[str, Any] = {"name": d.name}
115
+ if d.version:
116
+ entry["version"] = d.version
117
+ if d.source:
118
+ entry["source"] = d.source
119
+ deps.append(entry)
120
+ data["dependencies"] = deps
121
+ return data
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Publisher
126
+ # ---------------------------------------------------------------------------
127
+
128
+ class IpHubPublisher:
129
+ """Orchestrates the publish workflow via gh CLI.
130
+
131
+ Steps:
132
+ 1. ensure_fork() — fork iphub repo if not already forked
133
+ 2. Create a branch on the fork
134
+ 3. Push registry file(s) to the branch
135
+ 4. Create a PR from fork branch to upstream main
136
+ """
137
+
138
+ def __init__(self, username: str) -> None:
139
+ self.username = username
140
+ self.fork_repo = f"{username}/iphub"
141
+
142
+ def _gh(self, args: list[str]) -> subprocess.CompletedProcess[str]:
143
+ """Run a gh CLI command."""
144
+ result = subprocess.run(
145
+ ["gh", *args],
146
+ capture_output=True, text=True, check=False,
147
+ )
148
+ if result.returncode != 0:
149
+ msg = result.stderr.strip() or result.stdout.strip() or "gh command failed"
150
+ raise PublishError(f"gh error: {msg}")
151
+ return result
152
+
153
+ def ensure_fork(self) -> None:
154
+ """Fork the iphub repo if not already forked."""
155
+ self._gh(["repo", "fork", _IPHUB_REPO, "--clone=false"])
156
+
157
+ def _push_file(
158
+ self, branch: str, path: str, content: str, message: str,
159
+ ) -> None:
160
+ """Create or update a file on a branch via GitHub API."""
161
+ encoded = base64.b64encode(content.encode()).decode()
162
+ self._gh([
163
+ "api", "-X", "PUT",
164
+ f"repos/{self.fork_repo}/contents/{path}",
165
+ "-f", f"message={message}",
166
+ "-f", f"content={encoded}",
167
+ "-f", f"branch={branch}",
168
+ ])
169
+
170
+ def _create_branch(self, branch: str) -> None:
171
+ """Create a branch on the fork from upstream main."""
172
+ # Get upstream main SHA
173
+ result = self._gh([
174
+ "api", f"repos/{_IPHUB_REPO}/git/ref/heads/main",
175
+ "--jq", ".object.sha",
176
+ ])
177
+ sha = result.stdout.strip()
178
+ # Create branch
179
+ self._gh([
180
+ "api", "-X", "POST",
181
+ f"repos/{self.fork_repo}/git/refs",
182
+ "-f", f"ref=refs/heads/{branch}",
183
+ "-f", f"sha={sha}",
184
+ ])
185
+
186
+ def _create_pr(self, branch: str, title: str, body: str) -> str:
187
+ """Create a PR from fork branch to upstream main."""
188
+ result = self._gh([
189
+ "pr", "create",
190
+ "--repo", _IPHUB_REPO,
191
+ "--head", f"{self.username}:{branch}",
192
+ "--base", "main",
193
+ "--title", title,
194
+ "--body", body,
195
+ ])
196
+ return result.stdout.strip()
197
+
198
+ def publish_skill(
199
+ self,
200
+ name: str,
201
+ description: str,
202
+ agents: dict[str, dict[str, str]] | None = None,
203
+ license_: str | None = None,
204
+ homepage: str | None = None,
205
+ keywords: list[str] | None = None,
206
+ ) -> str:
207
+ """Publish a skill to IpHub. Returns PR URL."""
208
+ self.ensure_fork()
209
+
210
+ registry = generate_skill_registry(
211
+ name=name,
212
+ description=description,
213
+ author=f"@{self.username}",
214
+ license_=license_,
215
+ homepage=homepage,
216
+ keywords=keywords,
217
+ agents=agents,
218
+ )
219
+ content = _dump_yaml(registry)
220
+
221
+ branch = f"publish/{name}"
222
+ self._create_branch(branch)
223
+
224
+ path = f"registry/@{self.username}/{name}.yaml"
225
+ self._push_file(
226
+ branch, path, content, f"Register skill: {name}",
227
+ )
228
+
229
+ return self._create_pr(
230
+ branch,
231
+ f"Register skill: {name}",
232
+ f"Register `{name}` skill by @{self.username}.",
233
+ )
234
+
235
+ def publish_package(self, pkg: IPPackage) -> str:
236
+ """Publish an IP package to IpHub. Returns PR URL."""
237
+ self.ensure_fork()
238
+
239
+ branch = f"publish/{pkg.name}-v{pkg.version}"
240
+ self._create_branch(branch)
241
+
242
+ # meta.yaml
243
+ meta = generate_package_registry(
244
+ name=pkg.name,
245
+ description=pkg.description,
246
+ author=f"@{self.username}",
247
+ license_=pkg.license,
248
+ )
249
+ meta_content = _dump_yaml(meta)
250
+ meta_path = (
251
+ f"registry/@{self.username}/{pkg.name}/meta.yaml"
252
+ )
253
+ self._push_file(
254
+ branch, meta_path, meta_content,
255
+ f"Register package: {pkg.name}",
256
+ )
257
+
258
+ # version file
259
+ version_data = generate_version_data(pkg)
260
+ version_content = _dump_yaml(version_data)
261
+ version_path = (
262
+ f"registry/@{self.username}"
263
+ f"/{pkg.name}/{pkg.version}.yaml"
264
+ )
265
+ self._push_file(
266
+ branch, version_path, version_content,
267
+ f"Add {pkg.name} v{pkg.version}",
268
+ )
269
+
270
+ return self._create_pr(
271
+ branch,
272
+ f"Register package: {pkg.name} v{pkg.version}",
273
+ f"Register `{pkg.name}` v{pkg.version} by @{self.username}.",
274
+ )
ipman/hub/stats.py ADDED
@@ -0,0 +1,52 @@
1
+ """IpHub install statistics reporting via GitHub counter issues."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from datetime import datetime, timezone
7
+
8
+ _IPHUB_REPO = "twisker/iphub"
9
+
10
+
11
+ class StatsError(Exception):
12
+ """Raised when stats reporting fails."""
13
+
14
+
15
+ def report_install(
16
+ name: str,
17
+ counter_issue: int,
18
+ *,
19
+ username: str | None = None,
20
+ ) -> None:
21
+ """Report a successful install to the counter issue.
22
+
23
+ Adds a comment (install count) and a reaction (unique user).
24
+ Failures raise StatsError but should be treated as non-fatal
25
+ by callers — install success is independent of stats reporting.
26
+ """
27
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
28
+ user_part = f" by @{username}" if username else ""
29
+ body = f"Installed `{name}`{user_part} at {ts}"
30
+
31
+ # Add comment
32
+ result = subprocess.run(
33
+ [
34
+ "gh", "issue", "comment", str(counter_issue),
35
+ "--repo", _IPHUB_REPO,
36
+ "--body", body,
37
+ ],
38
+ capture_output=True, text=True, check=False,
39
+ )
40
+ if result.returncode != 0:
41
+ msg = result.stderr.strip() or "Failed to report install stats"
42
+ raise StatsError(msg)
43
+
44
+ # Add reaction (thumbs up) for unique user counting
45
+ subprocess.run(
46
+ [
47
+ "gh", "api", "-X", "POST",
48
+ f"repos/{_IPHUB_REPO}/issues/{counter_issue}/reactions",
49
+ "-f", "content=+1",
50
+ ],
51
+ capture_output=True, text=True, check=False,
52
+ )
File without changes
ipman/utils/i18n.py ADDED
@@ -0,0 +1,113 @@
1
+ """Lightweight i18n — LANG-based Chinese/English auto-switch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ _locale: str = "en"
8
+
9
+ # ---------------------------------------------------------------------------
10
+ # Message catalog
11
+ # ---------------------------------------------------------------------------
12
+
13
+ _MESSAGES: dict[str, dict[str, str]] = {
14
+ "install.success": {
15
+ "en": "Installation complete.",
16
+ "zh": "安装完成。",
17
+ },
18
+ "install.installed": {
19
+ "en": "Installed '{name}'.",
20
+ "zh": "已安装 '{name}'。",
21
+ },
22
+ "install.failed": {
23
+ "en": "Install failed: {msg}",
24
+ "zh": "安装失败:{msg}",
25
+ },
26
+ "install.blocked": {
27
+ "en": "BLOCKED: '{name}' — risk {risk}",
28
+ "zh": "已阻止:'{name}' — 风险等级 {risk}",
29
+ },
30
+ "install.warned": {
31
+ "en": "WARNING: '{name}' — risk {risk}",
32
+ "zh": "警告:'{name}' — 风险等级 {risk}",
33
+ },
34
+ "uninstall.success": {
35
+ "en": "Uninstalled '{name}'.",
36
+ "zh": "已卸载 '{name}'。",
37
+ },
38
+ "pack.success": {
39
+ "en": "Packed {count} skill(s) into {path}",
40
+ "zh": "已将 {count} 个技能打包到 {path}",
41
+ },
42
+ "hub.search.no_results": {
43
+ "en": "No results found.",
44
+ "zh": "未找到结果。",
45
+ },
46
+ "hub.publish.blocked": {
47
+ "en": "Publish blocked: HIGH/EXTREME risk.",
48
+ "zh": "发布被阻止:风险等级为 HIGH/EXTREME。",
49
+ },
50
+ "hub.report.success": {
51
+ "en": "Reported '{name}'. Thank you for helping keep IpHub safe.",
52
+ "zh": "已举报 '{name}'。感谢您帮助维护 IpHub 安全。",
53
+ },
54
+ "env.created": {
55
+ "en": "Created environment '{name}'.",
56
+ "zh": "已创建环境 '{name}'。",
57
+ },
58
+ "env.activated": {
59
+ "en": "Activated environment '{name}'.",
60
+ "zh": "已激活环境 '{name}'。",
61
+ },
62
+ "env.deactivated": {
63
+ "en": "Deactivated environment.",
64
+ "zh": "已停用环境。",
65
+ },
66
+ "error.no_agent": {
67
+ "en": "No agent detected. Use --agent to specify one.",
68
+ "zh": "未检测到 Agent 工具。请使用 --agent 指定。",
69
+ },
70
+ "error.not_found": {
71
+ "en": "'{name}' not found.",
72
+ "zh": "未找到 '{name}'。",
73
+ },
74
+ "security.log_entry": {
75
+ "en": "{action} {name} source={source} risk={risk}",
76
+ "zh": "{action} {name} 来源={source} 风险={risk}",
77
+ },
78
+ }
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # API
83
+ # ---------------------------------------------------------------------------
84
+
85
+ def detect_locale() -> str:
86
+ """Detect locale from environment variables."""
87
+ for var in ("LC_ALL", "LANG", "LANGUAGE"):
88
+ val = os.environ.get(var, "")
89
+ if val.startswith("zh"):
90
+ return "zh"
91
+ return "en"
92
+
93
+
94
+ def set_locale(locale: str) -> None:
95
+ """Manually set the active locale."""
96
+ global _locale # noqa: PLW0603
97
+ _locale = locale
98
+
99
+
100
+ def get_locale() -> str:
101
+ """Get the current active locale."""
102
+ return _locale
103
+
104
+
105
+ def t(key: str, **kwargs: str | int) -> str:
106
+ """Translate a message key with optional format args."""
107
+ messages = _MESSAGES.get(key)
108
+ if messages is None:
109
+ return key
110
+ template = messages.get(_locale, messages.get("en", key))
111
+ if kwargs:
112
+ return template.format(**kwargs)
113
+ return template
ipman/utils/symlink.py ADDED
@@ -0,0 +1,84 @@
1
+ """Cross-platform symlink utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+
10
+ def create_symlink(target: Path, link: Path) -> None:
11
+ """Create a symlink at `link` pointing to `target`.
12
+
13
+ On Windows, uses directory junctions as a fallback if symlinks
14
+ require elevated privileges.
15
+ """
16
+ target = target.resolve()
17
+ _validate_no_traversal(target)
18
+
19
+ if link.exists() or link.is_symlink():
20
+ msg = f"Link path already exists: {link}"
21
+ raise FileExistsError(msg)
22
+
23
+ if sys.platform == "win32":
24
+ _create_windows_link(target, link)
25
+ else:
26
+ link.symlink_to(target)
27
+
28
+
29
+ def remove_symlink(link: Path) -> None:
30
+ """Remove a symlink (or junction on Windows).
31
+
32
+ Raises ValueError if the path is not a symlink.
33
+ """
34
+ if not link.is_symlink():
35
+ if sys.platform == "win32" and link.is_dir():
36
+ # Could be a junction on Windows
37
+ os.rmdir(link)
38
+ return
39
+ msg = f"Not a symlink: {link}"
40
+ raise ValueError(msg)
41
+ link.unlink()
42
+
43
+
44
+ def is_symlink(path: Path) -> bool:
45
+ """Check if path is a symlink (or junction on Windows)."""
46
+ if path.is_symlink():
47
+ return True
48
+ if sys.platform == "win32" and path.is_dir():
49
+ # Check for junction point
50
+ try:
51
+ return bool(os.readlink(path))
52
+ except OSError:
53
+ return False
54
+ return False
55
+
56
+
57
+ def resolve_symlink(path: Path) -> Path | None:
58
+ """Return the target of a symlink, or None if not a symlink."""
59
+ if is_symlink(path):
60
+ return Path(os.readlink(path))
61
+ return None
62
+
63
+
64
+ def _validate_no_traversal(target: Path) -> None:
65
+ """Validate that the resolved target doesn't traverse outside expectations."""
66
+ resolved = target.resolve()
67
+ if ".." in resolved.parts:
68
+ msg = f"Path traversal detected: {target}"
69
+ raise ValueError(msg)
70
+
71
+
72
+ def _create_windows_link(target: Path, link: Path) -> None:
73
+ """Create a symlink or directory junction on Windows."""
74
+ try:
75
+ link.symlink_to(target)
76
+ except OSError:
77
+ # Fallback to directory junction (no admin needed)
78
+ import subprocess
79
+
80
+ subprocess.run(
81
+ ["cmd", "/c", "mklink", "/J", str(link), str(target)],
82
+ check=True,
83
+ capture_output=True,
84
+ )