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/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
|
+
)
|
ipman/utils/__init__.py
ADDED
|
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
|
+
)
|