Explorar o código

Merge pull request #2430 from visuddhinanda/development

Development
visuddhinanda hai 1 semana
pai
achega
e3579e9dc0

+ 7 - 1
plugins/wikipali/README.md

@@ -28,7 +28,13 @@
 - **往 wikipali.org 写数据**。写入是覆盖式的:相同位置(book / paragraph / word_start / word_end / channel)的已有句子会被替换。插件在每次写入前会回显目标并要求确认;
 - **只用 Python 标准库**,不装任何依赖,不建虚拟环境。
 
-它**不会**接触你的密码:登录由 `wp_login.py` 完成,密码经 `getpass` 读入内存,不落盘、不进日志、不进对话。这个脚本必须由你本人在真正的终端里执行,Claude 不代跑。
+它**不会**接触你的密码:登录由 `wikipali-login` 完成,密码只读入内存,不落盘、不进日志、不进对话。
+
+- 有终端时用 `getpass` 读;
+- **没有终端时(Claude Desktop、IDE、AI 代跑)自动弹出操作系统的密码对话框**——你把密码输给系统,AI 全程看不到;
+- 两者都不可用时它会明确报错并给出办法(Claude Desktop 按 <kbd>Ctrl</kbd>+<kbd>`</kbd> 有内置终端,仅本地会话;SSH 场景凭据在远端,要在那台机器上登录)。
+
+登录是一次性的:token 有效期 365 天,同一台机器上所有副本共用 `~/.wikipali/credentials.json`。
 
 ## 用法
 

+ 23 - 9
plugins/wikipali/bin/wikipali-login

@@ -33,6 +33,7 @@ from client import fmt_ts, make_client, mask, token_expiry  # noqa: E402
 from creds import CREDS_PATH  # noqa: E402
 from cmd_write import iso_now  # noqa: E402
 from errors import ApiError, WpError  # noqa: E402
+import askpass  # noqa: E402
 
 
 def main(argv=None):
@@ -56,29 +57,42 @@ def main(argv=None):
         print("注意:该站点的凭据与线上四站不通用。")
 
     interactive = sys.stdin.isatty()
-    if not interactive and not args.password_stdin:
-        # Claude Code 的 `!` 前缀、CI、管道都会走到这里:密码提示无处输入,
-        # 直接说清楚该怎么办,不要让用户对着一个静默的提示符发愣
+    use_gui = not interactive and not args.password_stdin and askpass.gui_available()
+
+    if not interactive and not args.password_stdin and not use_gui:
+        # 没有 TTY 也没有图形界面:说清楚该怎么办,别让用户对着静默的提示符发愣
         print(
-            "错误:当前不是交互式终端,无法安全地读取密码。\n"
-            "  · 请另开一个真正的终端执行本脚本(Claude Code 的 `!` 前缀不行);\n"
-            "  · 或在自动化环境里用管道:... | wikipali-login --username <名字> --password-stdin",
+            "错误:当前既没有交互式终端,也没有可用的图形界面,无法安全地读取密码。\n"
+            "  · Claude Desktop:按 Ctrl+` 打开内置终端,在里面跑 wikipali-login(仅本地会话有);\n"
+            "  · SSH / 远程开发机:在该机器上另开一个终端执行;\n"
+            "  · 自动化环境:... | wikipali-login --username <名字> --password-stdin。\n"
+            "无论哪种,都不要把密码写进命令行参数,也不要打进与 AI 的对话里。",
             file=sys.stderr,
         )
         return 1
 
     username = args.username
     if not username:
-        if not interactive:
+        if use_gui:
+            print("已弹出系统对话框,请在其中输入用户名与密码(本程序看不到你的输入之外的任何东西)。")
+            username = (askpass.ask("用户名或邮箱", secret=False) or "").strip()
+        elif not interactive:
             print("错误:--password-stdin 模式必须同时给 --username。", file=sys.stderr)
             return 1
-        username = input("用户名或邮箱:").strip()
+        else:
+            username = input("用户名或邮箱:").strip()
     if not username:
         print("错误:用户名为空。", file=sys.stderr)
         return 1
 
     if args.password_stdin:
         password = sys.stdin.readline().rstrip("\n")
+    elif use_gui:
+        # 密码由用户直接输给系统对话框,不经过终端、不经过 argv,调用方看不到
+        password = askpass.ask(f"{username} 的 WikiPali 密码")
+        if password is None:
+            print("已取消(对话框被关闭或超时)。", file=sys.stderr)
+            return 130
     else:
         try:
             password = getpass.getpass("密码(不会被保存):")
@@ -126,7 +140,7 @@ def main(argv=None):
     exp = token_expiry(token)
     print(f"登录成功:{current.get('nickName')}(realName={current.get('realName')},用作 studio_name)")
     print(f"token {mask(token)} 到期 {fmt_ts(exp)},已写入 {CREDS_PATH}(0600)")
-    print("下一步:python3 wp.py ensure-model --name <模型标识>")
+    print("下一步:wikipali ensure-model --name <模型标识>")
     return 0
 
 

+ 79 - 0
plugins/wikipali/lib/askpass.py

@@ -0,0 +1,79 @@
+"""在没有终端的环境里,用操作系统的密码对话框读取密码。
+
+为什么需要它:Claude Desktop、IDE 集成、agent 代跑等场景都没有 TTY,`getpass`
+用不了。而让用户把密码打进对话、或放进命令行参数,都会让密码进入会话上下文或
+`ps` / shell history——那是真正的泄漏。
+
+系统对话框解决了这个矛盾:**密码由用户直接输给操作系统,不经过终端、不经过 argv、
+调用方(包括 agent)全程看不到**,只从子进程的 stdout 拿回来,留在本进程内存里。
+
+支持 zenity / kdialog(Linux)、osascript(macOS)、PowerShell(Windows)。
+没有图形界面时返回 None,由调用方给出可操作的提示。
+"""
+
+import os
+import shutil
+import subprocess
+import sys
+
+DIALOG_TIMEOUT = 180
+
+
+def gui_available():
+    """当前环境有没有可用的图形界面。"""
+    if sys.platform == 'darwin':
+        return shutil.which('osascript') is not None
+    if os.name == 'nt':
+        return True
+    if not (os.environ.get('DISPLAY') or os.environ.get('WAYLAND_DISPLAY')):
+        return False
+    return shutil.which('zenity') is not None or shutil.which('kdialog') is not None
+
+
+def _run(cmd):
+    """跑对话框命令,返回用户输入;取消或失败返回 None。
+
+    stdout 只在本函数内流转,绝不打印——它装着密码。
+    """
+    try:
+        proc = subprocess.run(cmd, capture_output=True, text=True, timeout=DIALOG_TIMEOUT)
+    except (OSError, subprocess.TimeoutExpired):
+        return None
+    if proc.returncode != 0:
+        return None
+    return proc.stdout.rstrip('\n')
+
+
+def ask(prompt, title='WikiPali', secret=True):
+    """弹一个对话框问用户要一行输入。secret=True 时输入被遮蔽。
+
+    返回字符串;用户取消、超时或没有图形界面时返回 None。
+    """
+    if sys.platform == 'darwin':
+        hidden = ' with hidden answer' if secret else ''
+        script = (f'display dialog "{prompt}" with title "{title}" '
+                  f'default answer ""{hidden}')
+        out = _run(['osascript', '-e', script, '-e',
+                    'text returned of result'])
+        return out
+
+    if os.name == 'nt':
+        if secret:
+            ps = (f'$s = Read-Host -AsSecureString "{prompt}"; '
+                  '[Runtime.InteropServices.Marshal]::PtrToStringAuto('
+                  '[Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))')
+        else:
+            ps = f'Read-Host "{prompt}"'
+        return _run(['powershell', '-NoProfile', '-Command', ps])
+
+    if shutil.which('zenity'):
+        if secret:
+            # --password 只给密码框;要用户名时用 --entry
+            return _run(['zenity', '--password', '--title', f'{title} — {prompt}'])
+        return _run(['zenity', '--entry', '--title', title, '--text', prompt])
+
+    if shutil.which('kdialog'):
+        flag = '--password' if secret else '--inputbox'
+        return _run(['kdialog', '--title', title, flag, prompt])
+
+    return None

+ 5 - 3
plugins/wikipali/lib/client.py

@@ -116,8 +116,10 @@ class Client:
         token = (self.bucket.get("user") or {}).get("token")
         if not token:
             raise WpError(
-                "尚未登录。请自己执行(Claude Code 里用 ! 前缀):\n"
-                "    ! python3 " + os.path.join(os.path.dirname(os.path.abspath(__file__)), "wp_login.py")
+                "尚未登录。执行 wikipali-login 即可——没有终端时它会弹出系统密码框,\n"
+                "密码由你直接输给操作系统,AI 看不到。\n"
+                "(若命令不在 PATH 上,用 ${CLAUDE_PLUGIN_ROOT}/bin/wikipali-login,"
+                "或重启会话让 PATH 生效。)"
             )
         return token
 
@@ -125,7 +127,7 @@ class Client:
     def model(self):
         model = self.bucket.get("model") or {}
         if not model.get("token"):
-            raise WpError("尚未取得模型身份 token。请先跑:python3 wp.py ensure-model --name <模型名>")
+            raise WpError("尚未取得模型身份 token。请先跑:wikipali ensure-model --name <模型名>")
         return model
 
     def save(self):

+ 4 - 4
plugins/wikipali/lib/cmd_site.py

@@ -22,7 +22,7 @@ def cmd_endpoint(args):
             note(f"注意:当前地址来自 {src},是一次性覆盖,未写入凭据文件。")
         if current_url not in [s["url"] for s in SITES]:
             print(f"  *) {current_url:<32} 自定义地址  ← 当前")
-        print("\n切换:python3 wp.py endpoint <序号|www|www.cc|next|next.cc|local|完整url>")
+        print("\n切换:wikipali endpoint <序号|www|www.cc|next|next.cc|local|完整url>")
         return 0
 
     url = normalize_api_url(expand_site_alias(args.target))
@@ -49,7 +49,7 @@ def cmd_whoami(args):
         print(f"用户     : {user.get('username', '?')}  uid={user.get('uid', '?')}")
         print(f"           token {mask(user['token'])}  到期 {fmt_ts(exp)}{'  ⚠ 已过期' if expired else ''}")
     else:
-        print("用户     : 未登录(! python3 scripts/wp_login.py)")
+        print("用户     : 未登录(wikipali-login)")
 
     model = client.bucket.get("model") or {}
     if model.get("token"):
@@ -58,7 +58,7 @@ def cmd_whoami(args):
         print(f"模型     : {model.get('name', '?')}  uid={model.get('uid', '?')}")
         print(f"           token {mask(model['token'])}  到期 {fmt_ts(exp)}{'  ⚠ 已过期' if expired else ''}")
     else:
-        print("模型     : 未建立(python3 wp.py ensure-model --name <模型名>)")
+        print("模型     : 未建立(wikipali ensure-model --name <模型名>)")
 
     tokens = client.bucket.get("access_tokens") or {}
     if tokens:
@@ -71,7 +71,7 @@ def cmd_whoami(args):
             name = item.get("channel_name") or ""
             print(f"  {uid[:8]}… {name:<24} {scope:<10} 到期 {fmt_ts(exp)}{'  ⚠ 已过期' if expired else ''}")
     else:
-        print("access token:无(python3 wp.py grant <channel>)")
+        print("access token:无(wikipali grant <channel>)")
 
     if args.check:
         try:

+ 2 - 2
plugins/wikipali/lib/cmd_write.py

@@ -173,7 +173,7 @@ def pick_channel(client, given, interactive=True):
         raise WpError(f"找不到 channel:{given}")
 
     if not (interactive and sys.stdin.isatty()):
-        raise WpError("未指定 channel,且当前不是交互式终端。请先跑 `wp.py channels` 再用 --channel 指定。")
+        raise WpError("未指定 channel,且当前不是交互式终端。请先跑 `wikipali channels` 再用 --channel 指定。")
 
     print("可编辑 channel:")
     for idx, ch in enumerate(rows, 1):
@@ -454,7 +454,7 @@ def refresh_model_token(client):
     note("⚠ 模型 token 被拒(过期或已撤销),正在重新签发……")
     model = client.bucket.get("model") or {}
     if not model.get("uid"):
-        raise WpError("缓存里没有模型 uid,无法重签。请跑:python3 wp.py ensure-model --name <模型名>")
+        raise WpError("缓存里没有模型 uid,无法重签。请跑:wikipali ensure-model --name <模型名>")
     try:
         issued = client.call("GET", f"v2/ai-model-token/{model['uid']}", token=client.user_token)
     except ApiError as exc:

+ 3 - 3
plugins/wikipali/lib/errors.py

@@ -18,8 +18,8 @@ def explain_api_error(exc, what):
     if exc.status == 401:
         return WpError(
             f"{what}:401 凭据失效或已被撤销。\n"
-            "  · 用户 token 失效 → 重新登录:! python3 scripts/wp_login.py\n"
-            "  · 模型 token 失效或被撤销 → 重跑:python3 wp.py ensure-model\n"
+            "  · 用户 token 失效 → 重新登录:wikipali-login\n"
+            "  · 模型 token 失效或被撤销 → 重跑:wikipali ensure-model\n"
             "  不要自动重试。"
         )
     if exc.status == 403:
@@ -27,7 +27,7 @@ def explain_api_error(exc, what):
     if exc.status == 404:
         return WpError(
             f"{what}:404。若这是较新的端点,可能是当前站点跑的是稳定版代码、端点尚未上线;\n"
-            "  可切到最新版试试:python3 wp.py endpoint next\n"
+            "  可切到最新版试试:wikipali endpoint next\n"
             "  否则才是资源真的不存在。"
         )
     if exc.status == 409:

+ 11 - 1
plugins/wikipali/references/conventions.md

@@ -78,4 +78,14 @@ AI-汉译-Nissaya(**AI 生成**,deepseek-v3)(216:35)         ← 机器译
 ## 凭据
 
 `~/.wikipali/credentials.json`(0600)。**任何 skill 都不得打印 token 全文,也不得
-`cat` 这个文件。** 密码只由 `wikipali-login` 接触,且必须由用户本人在真正的终端里跑。
+`cat` 这个文件。**
+
+密码只由 `wikipali-login` 接触,且**只从三个地方进来**:真实终端的 `getpass`、操作系统的
+密码对话框、显式的 `--password-stdin` 管道。它永远不经过命令行参数,也不该经过与 AI 的
+对话——两者都会留痕(`ps` / shell history / 会话记录)。
+
+没有终端时(Claude Desktop、IDE、agent 代跑)`wikipali-login` 会自动弹系统密码框,所以
+**可以直接执行它**;只有既无终端又无图形界面时才需要用户自己开终端。Claude Desktop 的
+内置终端按 <kbd>Ctrl</kbd>+<kbd>`</kbd> 打开(仅本地会话)。
+
+登录是**一次性**的:用户 token 有效期 365 天,同一台机器上所有副本共用这一份凭据。

+ 10 - 5
plugins/wikipali/skills/write/SKILL.md

@@ -22,10 +22,15 @@ metadata:
 
 ## 铁律
 
-1. **永远不要向用户索要密码,也不要代跑 `wp_login.py`。** 需要登录时,请用户**另开一个真正的终端**执行
-   `wikipali-login`(若不在 PATH 上,把插件目录下 `bin/wikipali-login` 的完整路径写给他们)。
-   不要让他们用 Claude Code 的 `!` 前缀——那里没有交互式终端,密码提示无处输入;也不要建议把密码放进命令行参数或直接打在对话里。
-2. **写入前必须让用户确认。** `wp.py write` 默认会回显目标并等确认;只有用户已经明确同意本次写入时,才可以加 `-y`。
+1. **永远不要向用户索要密码。** 需要登录时直接执行 `wikipali-login` 即可——没有终端时它会
+   **弹出操作系统的密码对话框**,用户把密码输给系统,你全程看不到。绝不允许的三件事:
+   向用户索要密码、把密码写进命令行参数、让用户把密码打进对话(那会进入会话记录)。
+
+   若它报「既没有交互式终端也没有图形界面」,把这几条转达给用户:
+   - **Claude Desktop**:按 <kbd>Ctrl</kbd>+<kbd>`</kbd> 打开内置终端,在里面跑 `wikipali-login`(内置终端仅本地会话有);
+   - **SSH / 远程开发机**:凭据存在远端,要在**那台机器**上开终端登录;
+   - 自动化环境:`... | wikipali-login --username <名字> --password-stdin`。
+2. **写入前必须让用户确认。** `wikipali write` 默认会回显目标并等确认;只有用户已经明确同意本次写入时,才可以加 `-y`。
 3. **绝不打印 token 全文**(`~/.wikipali/credentials.json` 里的任何值)。脚本自己会打码,不要 `cat` 那个文件。
 4. **`count` 不等于提交条数就是有句子没写进去**,必须如实报告给用户,不要说「已全部写入」。
 5. **收到 401 不要自动重试**,按脚本的提示走。
@@ -92,7 +97,7 @@ wikipali --api next write …  # 只影响这一次调用
 
 | 现象 | 处置 |
 |---|---|
-| 401 | 用户 token 失效 → 请用户重跑 `wp_login.py`;模型 token 失效或被撤销 → 重跑 `ensure-model` |
+| 401 | 用户 token 失效 → 重跑 `wikipali-login`;模型 token 失效或被撤销 → 重跑 `ensure-model` |
 | 403 | 不是 channel 的 owner/协作者,或不是模型 owner。指出缺哪项权限,别换个姿势重试 |
 | `count: 0`(签 access token) | 对该 channel 无编辑权。**中止写入**,不要继续 |
 | `count` 小于提交条数 | 逐条差集已由脚本列出,如实转达 |