chatup 0.2.20__tar.gz → 0.2.21__tar.gz

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.
Files changed (105) hide show
  1. {chatup-0.2.20/src/chatup.egg-info → chatup-0.2.21}/PKG-INFO +6 -5
  2. {chatup-0.2.20 → chatup-0.2.21}/README.md +5 -4
  3. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/__init__.py +1 -1
  4. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/cli.py +2 -1
  5. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/commands/desktop.py +9 -0
  6. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/runtime/_managed_artifact.py +1 -1
  7. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/desktop.py +83 -12
  8. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/workspace/options.py +125 -6
  9. {chatup-0.2.20 → chatup-0.2.21/src/chatup.egg-info}/PKG-INFO +6 -5
  10. {chatup-0.2.20 → chatup-0.2.21}/tests/test_browser_backend_cli.py +3 -0
  11. {chatup-0.2.20 → chatup-0.2.21}/tests/test_chrome_for_testing.py +18 -3
  12. {chatup-0.2.20 → chatup-0.2.21}/tests/test_chromedriver.py +2 -2
  13. {chatup-0.2.20 → chatup-0.2.21}/tests/test_cli_tree_docs.py +1 -0
  14. {chatup-0.2.20 → chatup-0.2.21}/tests/test_playwright.py +1 -1
  15. {chatup-0.2.20 → chatup-0.2.21}/tests/test_version.py +1 -1
  16. {chatup-0.2.20 → chatup-0.2.21}/tests/test_workspace_setup.py +126 -10
  17. {chatup-0.2.20 → chatup-0.2.21}/LICENSE +0 -0
  18. {chatup-0.2.20 → chatup-0.2.21}/pyproject.toml +0 -0
  19. {chatup-0.2.20 → chatup-0.2.21}/setup.cfg +0 -0
  20. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/chrome_for_testing.py +0 -0
  21. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/chromedriver.py +0 -0
  22. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/commands/__init__.py +0 -0
  23. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/commands/chrome_for_testing.py +0 -0
  24. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/commands/chromedriver.py +0 -0
  25. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/commands/macos.py +0 -0
  26. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/commands/playwright.py +0 -0
  27. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/commands/remotion.py +0 -0
  28. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/config.py +0 -0
  29. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/const.py +0 -0
  30. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/interaction/__init__.py +0 -0
  31. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/interaction/command_schema.py +0 -0
  32. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/interaction/policy.py +0 -0
  33. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/interaction/warnings.py +0 -0
  34. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/playwright.py +0 -0
  35. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/runtime/__init__.py +0 -0
  36. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/runtime/chrome_for_testing.py +0 -0
  37. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/runtime/chromedriver.py +0 -0
  38. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/runtime/playwright.py +0 -0
  39. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/__init__.py +0 -0
  40. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/assets/hermes/install.sh +0 -0
  41. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/assets/nvm.sh +0 -0
  42. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/assets/remotion/.gitignore +0 -0
  43. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/assets/remotion/package-lock.json +0 -0
  44. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/assets/remotion/package.json +0 -0
  45. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/assets/remotion/src/index.jsx +0 -0
  46. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/cc_connect.py +0 -0
  47. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/chatgpt.py +0 -0
  48. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/claude.py +0 -0
  49. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/cli.py +0 -0
  50. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/codex.py +0 -0
  51. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/crs.py +0 -0
  52. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/cursor_agent.py +0 -0
  53. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/discourse.py +0 -0
  54. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/docker.py +0 -0
  55. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/elements.py +0 -0
  56. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/frp.py +0 -0
  57. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/gitea.py +0 -0
  58. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/hermes.py +0 -0
  59. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/lark_cli.py +0 -0
  60. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/mode_prompt.py +0 -0
  61. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/mysql.py +0 -0
  62. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/nginx.py +0 -0
  63. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/nodejs.py +0 -0
  64. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/opencode.py +0 -0
  65. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/remotion.py +0 -0
  66. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/service_credentials.py +0 -0
  67. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/shell_rc.py +0 -0
  68. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/twikoo.py +0 -0
  69. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/uv.py +0 -0
  70. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/workspace/__init__.py +0 -0
  71. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/workspace/cli.py +0 -0
  72. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/workspace/core.py +0 -0
  73. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/workspace/render.py +0 -0
  74. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/workspace/templates/default/en/AGENTS.md +0 -0
  75. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/workspace/templates/default/en/discussion/README.md +0 -0
  76. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/workspace/templates/default/en/projects/README.md +0 -0
  77. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/workspace/templates/default/zh/AGENTS.md +0 -0
  78. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/workspace/templates/default/zh/discussion/README.md +0 -0
  79. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/workspace/templates/default/zh/projects/README.md +0 -0
  80. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/workspace.py +0 -0
  81. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/zsh.py +0 -0
  82. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/setup/zulip.py +0 -0
  83. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/utils/__init__.py +0 -0
  84. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/utils/custom_logger.py +0 -0
  85. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/utils/pathing.py +0 -0
  86. {chatup-0.2.20 → chatup-0.2.21}/src/chatup/utils/platforming.py +0 -0
  87. {chatup-0.2.20 → chatup-0.2.21}/src/chatup.egg-info/SOURCES.txt +0 -0
  88. {chatup-0.2.20 → chatup-0.2.21}/src/chatup.egg-info/dependency_links.txt +0 -0
  89. {chatup-0.2.20 → chatup-0.2.21}/src/chatup.egg-info/entry_points.txt +0 -0
  90. {chatup-0.2.20 → chatup-0.2.21}/src/chatup.egg-info/requires.txt +0 -0
  91. {chatup-0.2.20 → chatup-0.2.21}/src/chatup.egg-info/top_level.txt +0 -0
  92. {chatup-0.2.20 → chatup-0.2.21}/tests/test_chatgpt_setup.py +0 -0
  93. {chatup-0.2.20 → chatup-0.2.21}/tests/test_codex_setup.py +0 -0
  94. {chatup-0.2.20 → chatup-0.2.21}/tests/test_crs_setup.py +0 -0
  95. {chatup-0.2.20 → chatup-0.2.21}/tests/test_cursor_agent_setup.py +0 -0
  96. {chatup-0.2.20 → chatup-0.2.21}/tests/test_hermes_model_defaults.py +0 -0
  97. {chatup-0.2.20 → chatup-0.2.21}/tests/test_playwright_cli.py +0 -0
  98. {chatup-0.2.20 → chatup-0.2.21}/tests/test_publish_workflow.py +0 -0
  99. {chatup-0.2.20 → chatup-0.2.21}/tests/test_pyproject_metadata.py +0 -0
  100. {chatup-0.2.20 → chatup-0.2.21}/tests/test_service_installers.py +0 -0
  101. {chatup-0.2.20 → chatup-0.2.21}/tests/test_setup_cli.py +0 -0
  102. {chatup-0.2.20 → chatup-0.2.21}/tests/test_twikoo_setup.py +0 -0
  103. {chatup-0.2.20 → chatup-0.2.21}/tests/test_uv_activation.py +0 -0
  104. {chatup-0.2.20 → chatup-0.2.21}/tests/test_windows_compat.py +0 -0
  105. {chatup-0.2.20 → chatup-0.2.21}/tests/test_zsh_alias_names.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chatup
3
- Version: 0.2.20
3
+ Version: 0.2.21
4
4
  Summary: ChatArch setup CLI extracted from ChatTool
5
5
  Author-email: rexwzh <1073853456@qq.com>
6
6
  License-Expression: MIT
@@ -59,7 +59,7 @@ chatup doctor
59
59
  chatup uv
60
60
  chatup chrome-for-testing install --channel stable -I
61
61
  chatup playwright install 1.61.1 -I
62
- chatup workspace default ~/Playground
62
+ chatup workspace ~/Playground
63
63
  ```
64
64
 
65
65
  `chatup uv` 默认更新已有 `~/.bashrc`、`~/.zshrc` 的自动激活块;使用 `chatup uv --no-activate` 可保持启动配置不变。不会创建缺失的 rc 文件,Windows 跳过此项。
@@ -99,16 +99,17 @@ npm run studio
99
99
 
100
100
  需要已有 Node.js >=18.12 和 npm >=9。创建带锁文件的 Remotion 4.0.530 项目,检测本机 Chrome 并输出渲染命令;不覆盖已有无关目录,也不替换 Node 或下载浏览器。项目放在指定目录,已有 Blender/Chrome 可通过 `chatup macos --app blender --app chrome` 验证或安装。详见[Remotion 安装约定](https://arch.gh.wzhecnu.cn/ChatUp/commands/#remotion)。
101
101
 
102
- ## Chrome 与 iTerm2
102
+ ## Chrome、Snipaste 与 iTerm2
103
103
 
104
104
  ```bash
105
105
  chatup chrome --dry-run
106
106
  chatup chrome # 按系统安装普通 Google Chrome
107
+ chatup snipaste # 在 macOS 或 Windows 安装 Snipaste
107
108
  chatup iterm # 安装 iTerm2,仅限 macOS
108
109
  chatup chrome --sudo --yes # Linux:允许提权并确认包安装
109
110
  ```
110
111
 
111
- macOS 直接下载官方 DMG/ZIP 并校验开发者签名,无需 Homebrew;默认装到 `/Applications`,不可写时使用 `~/Applications`。Windows 使用 WinGet `Google.Chrome`;Linux x86_64 使用官方 DEB/RPM 与 apt/dnf/yum/zypper。已安装时验证并复用,不改默认浏览器、终端或用户配置。自动化浏览器仍使用 `chatup chrome-for-testing`。详见[安装约定](https://arch.gh.wzhecnu.cn/ChatUp/commands/#chrome-iterm)。
112
+ macOS 直接下载官方 DMG/ZIP 并校验开发者签名,无需 Homebrew;默认装到 `/Applications`,不可写时使用 `~/Applications`。Windows 使用 WinGet 精确包 `Google.Chrome.EXE`(当前用户安装)和 `liule.Snipaste`,需以 `--yes` 接受首次协议,Chrome 还会验证已安装程序的 Google 签名、产品身份和版本;Linux x86_64 支持 Chrome 的官方 DEB/RPM 与 apt/dnf/yum/zypper。已安装时验证并复用,不改默认浏览器、终端或用户配置。自动化浏览器仍使用 `chatup chrome-for-testing`。详见[安装约定](https://arch.gh.wzhecnu.cn/ChatUp/commands/#chrome-iterm)。
112
113
 
113
114
  ## ChatGPT / Codex 桌面应用
114
115
 
@@ -128,7 +129,7 @@ chatup chatgpt --yes # Windows 首次安装时接受 Store 协议
128
129
  | --- | --- |
129
130
  | 基础运行环境 | `doctor`、`uv`、`nodejs`、`docker`、`zsh`、`chrome-for-testing`、`chromedriver`、`playwright`、`frp` |
130
131
  | 工作区脚手架 | `workspace` |
131
- | 桌面应用 | `macos`(Snipaste/iTerm2/Chrome/Blender 多选)、`chrome`、`iterm`(仅 macOS)、`chatgpt`(含 Codex) |
132
+ | 桌面应用 | `macos`(Snipaste/iTerm2/Chrome/Blender 多选)、`chrome`、`snipaste`(macOS/Windows)、`iterm`(仅 macOS)、`chatgpt`(含 Codex) |
132
133
  | 视频项目 | `remotion` |
133
134
  | 本地服务安装 | `gitea`、`discourse`、`zulip`、`mysql`、`nginx`、`crs` |
134
135
  | Agent 工具链 | `cc-connect`、`claude`、`codex`、`cursor-agent`、`opencode`、`hermes`、`lark-cli` |
@@ -28,7 +28,7 @@ chatup doctor
28
28
  chatup uv
29
29
  chatup chrome-for-testing install --channel stable -I
30
30
  chatup playwright install 1.61.1 -I
31
- chatup workspace default ~/Playground
31
+ chatup workspace ~/Playground
32
32
  ```
33
33
 
34
34
  `chatup uv` 默认更新已有 `~/.bashrc`、`~/.zshrc` 的自动激活块;使用 `chatup uv --no-activate` 可保持启动配置不变。不会创建缺失的 rc 文件,Windows 跳过此项。
@@ -68,16 +68,17 @@ npm run studio
68
68
 
69
69
  需要已有 Node.js >=18.12 和 npm >=9。创建带锁文件的 Remotion 4.0.530 项目,检测本机 Chrome 并输出渲染命令;不覆盖已有无关目录,也不替换 Node 或下载浏览器。项目放在指定目录,已有 Blender/Chrome 可通过 `chatup macos --app blender --app chrome` 验证或安装。详见[Remotion 安装约定](https://arch.gh.wzhecnu.cn/ChatUp/commands/#remotion)。
70
70
 
71
- ## Chrome 与 iTerm2
71
+ ## Chrome、Snipaste 与 iTerm2
72
72
 
73
73
  ```bash
74
74
  chatup chrome --dry-run
75
75
  chatup chrome # 按系统安装普通 Google Chrome
76
+ chatup snipaste # 在 macOS 或 Windows 安装 Snipaste
76
77
  chatup iterm # 安装 iTerm2,仅限 macOS
77
78
  chatup chrome --sudo --yes # Linux:允许提权并确认包安装
78
79
  ```
79
80
 
80
- macOS 直接下载官方 DMG/ZIP 并校验开发者签名,无需 Homebrew;默认装到 `/Applications`,不可写时使用 `~/Applications`。Windows 使用 WinGet `Google.Chrome`;Linux x86_64 使用官方 DEB/RPM 与 apt/dnf/yum/zypper。已安装时验证并复用,不改默认浏览器、终端或用户配置。自动化浏览器仍使用 `chatup chrome-for-testing`。详见[安装约定](https://arch.gh.wzhecnu.cn/ChatUp/commands/#chrome-iterm)。
81
+ macOS 直接下载官方 DMG/ZIP 并校验开发者签名,无需 Homebrew;默认装到 `/Applications`,不可写时使用 `~/Applications`。Windows 使用 WinGet 精确包 `Google.Chrome.EXE`(当前用户安装)和 `liule.Snipaste`,需以 `--yes` 接受首次协议,Chrome 还会验证已安装程序的 Google 签名、产品身份和版本;Linux x86_64 支持 Chrome 的官方 DEB/RPM 与 apt/dnf/yum/zypper。已安装时验证并复用,不改默认浏览器、终端或用户配置。自动化浏览器仍使用 `chatup chrome-for-testing`。详见[安装约定](https://arch.gh.wzhecnu.cn/ChatUp/commands/#chrome-iterm)。
81
82
 
82
83
  ## ChatGPT / Codex 桌面应用
83
84
 
@@ -97,7 +98,7 @@ chatup chatgpt --yes # Windows 首次安装时接受 Store 协议
97
98
  | --- | --- |
98
99
  | 基础运行环境 | `doctor`、`uv`、`nodejs`、`docker`、`zsh`、`chrome-for-testing`、`chromedriver`、`playwright`、`frp` |
99
100
  | 工作区脚手架 | `workspace` |
100
- | 桌面应用 | `macos`(Snipaste/iTerm2/Chrome/Blender 多选)、`chrome`、`iterm`(仅 macOS)、`chatgpt`(含 Codex) |
101
+ | 桌面应用 | `macos`(Snipaste/iTerm2/Chrome/Blender 多选)、`chrome`、`snipaste`(macOS/Windows)、`iterm`(仅 macOS)、`chatgpt`(含 Codex) |
101
102
  | 视频项目 | `remotion` |
102
103
  | 本地服务安装 | `gitea`、`discourse`、`zulip`、`mysql`、`nginx`、`crs` |
103
104
  | Agent 工具链 | `cc-connect`、`claude`、`codex`、`cursor-agent`、`opencode`、`hermes`、`lark-cli` |
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.2.20"
5
+ __version__ = "0.2.21"
@@ -6,7 +6,7 @@ from chatstyle import add_tree_option
6
6
  from chatup import __version__
7
7
  from chatup.commands.chrome_for_testing import cli as chrome_for_testing_cli
8
8
  from chatup.commands.chromedriver import cli as chromedriver_cli
9
- from chatup.commands.desktop import chrome_cli, iterm_cli
9
+ from chatup.commands.desktop import chrome_cli, iterm_cli, snipaste_cli
10
10
  from chatup.commands.macos import macos_cli
11
11
  from chatup.commands.playwright import cli as playwright_cli
12
12
  from chatup.commands.remotion import remotion_cli
@@ -36,6 +36,7 @@ main.add_command(chrome_for_testing_cli)
36
36
  main.add_command(chromedriver_cli)
37
37
  main.add_command(playwright_cli)
38
38
  main.add_command(chrome_cli)
39
+ main.add_command(snipaste_cli)
39
40
  main.add_command(iterm_cli)
40
41
  main.add_command(macos_cli)
41
42
  main.add_command(remotion_cli)
@@ -44,6 +44,15 @@ def chrome_cli(**kwargs) -> None:
44
44
  _install("chrome", **kwargs)
45
45
 
46
46
 
47
+ @click.command(name="snipaste")
48
+ @click.option("--dry-run", is_flag=True, help="Show the installation plan without downloading or installing.")
49
+ @click.option("--yes", "-y", is_flag=True, help="Accept Windows agreements.")
50
+ @click.option("--log-level", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"], case_sensitive=False), default="INFO")
51
+ def snipaste_cli(**kwargs) -> None:
52
+ """Install Snipaste for macOS or Windows."""
53
+ _install("snipaste", **kwargs)
54
+
55
+
47
56
  @click.command(name="iterm")
48
57
  @click.option("--dry-run", is_flag=True, help="Show the installation plan without downloading or installing.")
49
58
  @click.option("--log-level", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"], case_sensitive=False), default="INFO")
@@ -79,7 +79,7 @@ def safe_extract_zip(
79
79
  f"Symlink escapes {label} archive root: {member.filename}"
80
80
  )
81
81
  target.parent.mkdir(parents=True, exist_ok=True)
82
- target.symlink_to(link_value)
82
+ target.symlink_to(link_value, target_is_directory=resolved_link.is_dir())
83
83
  continue
84
84
  if member.is_dir() or stat.S_ISDIR(mode):
85
85
  target.mkdir(parents=True, exist_ok=True)
@@ -1,8 +1,10 @@
1
- """Native installers for Google Chrome and supported macOS desktop apps."""
1
+ """Native installers for Chrome, Snipaste and supported macOS desktop apps."""
2
2
  from __future__ import annotations
3
3
 
4
4
  import logging
5
5
  import hashlib
6
+ import base64
7
+ import json
6
8
  import os
7
9
  from pathlib import Path
8
10
  import platform
@@ -22,6 +24,8 @@ logger = logging.getLogger(__name__)
22
24
  MAC_APPS = {
23
25
  "chrome": {
24
26
  "app": "Google Chrome",
27
+ "winget_id": "Google.Chrome.EXE",
28
+ "winget_scope": "user",
25
29
  "bundle": "Google Chrome.app",
26
30
  "bundle_id": "com.google.Chrome",
27
31
  "team_id": "EQHXZ8M8AV",
@@ -38,6 +42,7 @@ MAC_APPS = {
38
42
  },
39
43
  "snipaste": {
40
44
  "app": "Snipaste",
45
+ "winget_id": "liule.Snipaste",
41
46
  "bundle": "Snipaste.app",
42
47
  "bundle_id": "com.Snipaste",
43
48
  "team_id": "NGTL73P583",
@@ -73,15 +78,15 @@ def _is_root() -> bool:
73
78
  return hasattr(os, "geteuid") and os.geteuid() == 0
74
79
 
75
80
 
76
- def plan_desktop_install(
77
- app: str, *, sudo: bool = False, yes: bool = False,
78
- ) -> dict[str, Any]:
81
+ def plan_desktop_install(app: str, *, sudo: bool = False, yes: bool = False) -> dict[str, Any]:
79
82
  """Describe the current platform's installation without writes or processes."""
80
83
  spec = MAC_APPS[app]
81
84
  system = platform.system()
82
85
  plan = {"app": spec["app"], "platform": system}
83
- if app != "chrome" and system != "Darwin":
86
+ if system != "Darwin" and app in {"iterm", "blender"}:
84
87
  raise RuntimeError(f"{spec['app']} is supported on macOS only; current platform: {system}.")
88
+ if system not in {"Darwin", "Windows"} and app == "snipaste":
89
+ raise RuntimeError(f"{spec['app']} is supported on macOS and Windows only; current platform: {system}.")
85
90
  if system == "Darwin":
86
91
  if platform.machine().lower() not in {"arm64", "aarch64", "x86_64", "amd64"}:
87
92
  raise RuntimeError(f"Unsupported macOS architecture: {platform.machine()}.")
@@ -92,13 +97,24 @@ def plan_desktop_install(
92
97
  directory = directories[0] if os.access(directories[0], os.W_OK) else directories[1]
93
98
  return {**plan, **spec, "method": "macos", "path": str(existing or directory / spec["bundle"])}
94
99
  if system == "Windows":
95
- selector = ["--id", "Google.Chrome", "--exact", "--source", "winget", "--disable-interactivity"]
100
+ package_id = spec.get("winget_id")
101
+ if not package_id:
102
+ raise RuntimeError(f"{spec['app']} is supported on macOS only; current platform: {system}.")
103
+ selector = ["--id", package_id, "--exact", "--source", "winget", "--disable-interactivity"]
96
104
  command = ["winget", "install", *selector, "--silent", "--no-upgrade"]
105
+ if spec.get("winget_scope"):
106
+ command += ["--scope", spec["winget_scope"]]
97
107
  verify = ["winget", "list", *selector]
98
108
  if yes:
99
109
  command += ["--accept-package-agreements", "--accept-source-agreements"]
100
110
  verify += ["--accept-source-agreements"]
101
- return {**plan, "method": "winget", "command": command, "verify_command": verify}
111
+ return {
112
+ **plan,
113
+ "method": "winget",
114
+ "winget_id": package_id,
115
+ "command": command,
116
+ "verify_command": verify,
117
+ }
102
118
  if system == "Linux":
103
119
  if platform.machine().lower() not in {"x86_64", "amd64"}:
104
120
  raise RuntimeError("Google Chrome for Linux requires x86_64; Google does not provide a Linux ARM package.")
@@ -274,22 +290,77 @@ def _install_linux(plan: dict[str, Any], *, sudo: bool) -> dict[str, Any]:
274
290
  return {**plan, **installed, "status": "installed", "verified": True}
275
291
 
276
292
 
293
+ def _google_signed_file(path: Path) -> dict[str, str]:
294
+ powershell = shutil.which("powershell.exe") or shutil.which("pwsh.exe")
295
+ if not powershell:
296
+ raise RuntimeError("PowerShell is required to verify Google's Authenticode signature.")
297
+ # Encode the script and quote its sole path literal, including apostrophes.
298
+ literal = str(path).replace("'", "''")
299
+ script = (
300
+ "$ErrorActionPreference = 'Stop'; "
301
+ "$ProgressPreference = 'SilentlyContinue'; "
302
+ "$env:PSModulePath = Join-Path $PSHOME 'Modules'; "
303
+ "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new(); "
304
+ f"$file = Get-Item -LiteralPath '{literal}'; "
305
+ "$sig = Get-AuthenticodeSignature -LiteralPath $file.FullName; "
306
+ "$publisher = if ($sig.SignerCertificate) { "
307
+ "$sig.SignerCertificate.GetNameInfo([System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false) "
308
+ "} else { '' }; "
309
+ "@{status=[string]$sig.Status; publisher=$publisher; "
310
+ "product=$file.VersionInfo.ProductName; version=$file.VersionInfo.ProductVersion; "
311
+ "original=$file.VersionInfo.OriginalFilename} | ConvertTo-Json -Compress"
312
+ )
313
+ encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
314
+ result = _run([powershell, "-NoProfile", "-NonInteractive", "-OutputFormat", "Text",
315
+ "-EncodedCommand", encoded], timeout=120)
316
+ details = json.loads(result.stdout.lstrip("\ufeff"))
317
+ if not isinstance(details, dict) or details.get("status") != "Valid" or details.get("publisher") != "Google LLC":
318
+ raise RuntimeError(f"Google Authenticode signature verification failed: {path}.")
319
+ version = details.get("version", "")
320
+ if (details.get("product") != "Google Chrome"
321
+ or str(details.get("original", "")).lower() != "chrome.exe"
322
+ or not isinstance(version, str) or not re.fullmatch(r"\d+(?:\.\d+){3}", version)):
323
+ raise RuntimeError(f"Cannot verify installed Google Chrome identity/version: {path}.")
324
+ return {"binary": str(path), "path": str(path), "version": version}
325
+
326
+
327
+ def _windows_chrome() -> dict[str, str] | None:
328
+ for variable in ("LOCALAPPDATA", "PROGRAMW6432", "PROGRAMFILES", "PROGRAMFILES(X86)"):
329
+ root = os.environ.get(variable)
330
+ if root:
331
+ binary = Path(root) / "Google/Chrome/Application/chrome.exe"
332
+ if binary.exists():
333
+ return _google_signed_file(binary)
334
+ return None
335
+
336
+
277
337
  def _install_windows(plan: dict[str, Any]) -> dict[str, Any]:
338
+ is_chrome = plan["winget_id"] == "Google.Chrome.EXE"
339
+ existing = _windows_chrome() if is_chrome else None
340
+ if existing:
341
+ return {**plan, **existing, "status": "already_installed", "verified": True}
278
342
  executable = shutil.which("winget")
279
343
  if not executable:
280
- raise RuntimeError("WinGet is required to install Google Chrome: https://aka.ms/getwinget")
344
+ raise RuntimeError(f"WinGet is required to install {plan['app']}: https://aka.ms/getwinget")
281
345
 
282
346
  def installed():
283
347
  result = _run([executable, *plan["verify_command"][1:]], timeout=120, check=False)
284
- return result.returncode == 0 and re.search(r"\bGoogle\.Chrome\b", result.stdout) is not None
348
+ return result.returncode == 0 and re.search(
349
+ rf"(?<![\w.-]){re.escape(plan['winget_id'])}(?![\w.-])", result.stdout,
350
+ ) is not None
285
351
 
286
352
  if installed():
353
+ if is_chrome:
354
+ raise RuntimeError("Google Chrome package record exists, but signed chrome.exe was not found.")
287
355
  return {**plan, "status": "already_installed", "verified": True}
288
- logger.info("Installing Google Chrome with WinGet; use --yes to accept source/package agreements")
356
+ logger.info("Installing %s with WinGet; use --yes to accept source/package agreements", plan["app"])
289
357
  _run([executable, *plan["command"][1:]], capture=False)
290
358
  if not installed():
291
- raise RuntimeError("Google Chrome installation verification failed: no matching WinGet package record.")
292
- return {**plan, "status": "installed", "verified": True}
359
+ raise RuntimeError(f"{plan['app']} installation verification failed: no matching WinGet package record.")
360
+ details = _windows_chrome() if is_chrome else {}
361
+ if is_chrome and not details:
362
+ raise RuntimeError("Google Chrome installation verification failed: signed chrome.exe not found.")
363
+ return {**plan, **details, "status": "installed", "verified": True}
293
364
 
294
365
 
295
366
  def setup_desktop_app(
@@ -1,6 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from pathlib import Path
4
+ import os
5
+ import shutil
6
+ import stat
4
7
  import subprocess
5
8
 
6
9
  import click
@@ -27,14 +30,60 @@ Topic directories such as `chatarch/package-development` and `chatarch/package-r
27
30
  """
28
31
 
29
32
 
33
+ def _resolve_git_command() -> str | None:
34
+ git = shutil.which("git")
35
+ if git:
36
+ return git
37
+ if os.name != "nt":
38
+ return None
39
+
40
+ candidates: list[Path] = []
41
+ local_app_data = os.environ.get("LOCALAPPDATA")
42
+ if local_app_data:
43
+ candidates.extend(
44
+ [
45
+ Path(local_app_data) / "Programs" / "Git" / "cmd" / "git.exe",
46
+ Path(local_app_data) / "Programs" / "Git" / "bin" / "git.exe",
47
+ ]
48
+ )
49
+ for env_name in ("ProgramFiles", "ProgramFiles(x86)"):
50
+ root = os.environ.get(env_name)
51
+ if root:
52
+ candidates.extend(
53
+ [
54
+ Path(root) / "Git" / "cmd" / "git.exe",
55
+ Path(root) / "Git" / "bin" / "git.exe",
56
+ ]
57
+ )
58
+
59
+ for candidate in candidates:
60
+ if candidate.is_file():
61
+ return str(candidate)
62
+ return None
63
+
64
+
65
+ _GIT_NOT_FOUND_MESSAGE = (
66
+ "Git executable not found. Install Git and make sure `git` is available on PATH. "
67
+ "On Windows, standard Git for Windows locations are also supported. "
68
+ "Then rerun this command."
69
+ )
70
+
71
+
30
72
  def _run_git(
31
73
  args: list[str], workdir: Path | None = None
32
74
  ) -> subprocess.CompletedProcess[str]:
33
- command = ["git"]
75
+ git = _resolve_git_command()
76
+ if git is None:
77
+ raise click.ClickException(_GIT_NOT_FOUND_MESSAGE)
78
+
79
+ command = [git]
34
80
  if workdir is not None:
35
81
  command.extend(["-C", str(workdir)])
36
82
  command.extend(args)
37
- return subprocess.run(command, capture_output=True, text=True)
83
+ try:
84
+ return subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace")
85
+ except FileNotFoundError as exc:
86
+ raise click.ClickException(_GIT_NOT_FOUND_MESSAGE) from exc
38
87
 
39
88
 
40
89
  def _clone_or_update_repo(
@@ -92,15 +141,85 @@ def apply_chattool_option(
92
141
  }
93
142
 
94
143
 
144
+ def _paths_point_to_same_entry(source: Path, target: Path) -> bool:
145
+ try:
146
+ return source.exists() and target.exists() and source.samefile(target)
147
+ except OSError:
148
+ return False
149
+
150
+
151
+ def _is_windows_reparse_point(path: Path) -> bool:
152
+ if os.name != "nt":
153
+ return False
154
+ try:
155
+ info = path.stat(follow_symlinks=False)
156
+ except (AttributeError, OSError, ValueError):
157
+ return False
158
+ return info.st_reparse_tag == stat.IO_REPARSE_TAG_MOUNT_POINT
159
+
160
+
161
+ def _remove_link_path(path: Path) -> None:
162
+ if os.name == "nt" and path.is_dir():
163
+ path.rmdir()
164
+ else:
165
+ path.unlink()
166
+
167
+
168
+ def _create_windows_junction(source: Path, target: Path) -> None:
169
+ comspec = os.environ.get("ComSpec", "cmd")
170
+ # Pass paths through quoted environment expansion, not cmd command syntax.
171
+ env = {**os.environ, "CHATUP_LINK_SOURCE": str(source), "CHATUP_LINK_TARGET": str(target)}
172
+ command = (
173
+ f'{subprocess.list2cmdline([comspec])} /d /v:off /c '
174
+ 'mklink /J "%CHATUP_LINK_TARGET%" "%CHATUP_LINK_SOURCE%"'
175
+ )
176
+ result = subprocess.run(
177
+ command,
178
+ capture_output=True,
179
+ text=True,
180
+ errors="replace",
181
+ env=env,
182
+ )
183
+ if result.returncode != 0:
184
+ message = result.stderr.strip() or result.stdout.strip()
185
+ raise click.ClickException(message or f"Failed to create junction: {target}")
186
+
187
+
188
+ def _create_windows_link_fallback(source: Path, target: Path, original: OSError) -> None:
189
+ if os.name != "nt":
190
+ raise click.ClickException(
191
+ f"Failed to create symlink {target} -> {source}: {original}"
192
+ ) from original
193
+ try:
194
+ if source.is_dir():
195
+ _create_windows_junction(source.resolve(), target)
196
+ else:
197
+ os.link(source, target)
198
+ except (OSError, click.ClickException) as exc:
199
+ raise click.ClickException(
200
+ "Failed to create a workspace link on Windows. Enable Developer Mode "
201
+ "or run with symlink privileges; directory junction/file hard-link "
202
+ f"fallback also failed for {target} -> {source}: {exc}"
203
+ ) from exc
204
+
205
+
95
206
  def _ensure_symlink(source: Path, target: Path) -> None:
96
207
  target.parent.mkdir(parents=True, exist_ok=True)
97
- if target.is_symlink():
98
- target.unlink()
208
+ if target.is_symlink() or _is_windows_reparse_point(target):
209
+ if _paths_point_to_same_entry(source, target):
210
+ return
211
+ _remove_link_path(target)
99
212
  elif target.exists():
213
+ if _paths_point_to_same_entry(source, target):
214
+ return
100
215
  raise click.ClickException(
101
- f"Refusing to replace existing non-symlink path: {target}"
216
+ f"Refusing to replace existing non-link path: {target}"
102
217
  )
103
- target.symlink_to(source)
218
+
219
+ try:
220
+ target.symlink_to(source, target_is_directory=source.is_dir())
221
+ except OSError as exc:
222
+ _create_windows_link_fallback(source, target, exc)
104
223
 
105
224
 
106
225
  def _ensure_local_skill_group(workspace_dir: Path) -> Path:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chatup
3
- Version: 0.2.20
3
+ Version: 0.2.21
4
4
  Summary: ChatArch setup CLI extracted from ChatTool
5
5
  Author-email: rexwzh <1073853456@qq.com>
6
6
  License-Expression: MIT
@@ -59,7 +59,7 @@ chatup doctor
59
59
  chatup uv
60
60
  chatup chrome-for-testing install --channel stable -I
61
61
  chatup playwright install 1.61.1 -I
62
- chatup workspace default ~/Playground
62
+ chatup workspace ~/Playground
63
63
  ```
64
64
 
65
65
  `chatup uv` 默认更新已有 `~/.bashrc`、`~/.zshrc` 的自动激活块;使用 `chatup uv --no-activate` 可保持启动配置不变。不会创建缺失的 rc 文件,Windows 跳过此项。
@@ -99,16 +99,17 @@ npm run studio
99
99
 
100
100
  需要已有 Node.js >=18.12 和 npm >=9。创建带锁文件的 Remotion 4.0.530 项目,检测本机 Chrome 并输出渲染命令;不覆盖已有无关目录,也不替换 Node 或下载浏览器。项目放在指定目录,已有 Blender/Chrome 可通过 `chatup macos --app blender --app chrome` 验证或安装。详见[Remotion 安装约定](https://arch.gh.wzhecnu.cn/ChatUp/commands/#remotion)。
101
101
 
102
- ## Chrome 与 iTerm2
102
+ ## Chrome、Snipaste 与 iTerm2
103
103
 
104
104
  ```bash
105
105
  chatup chrome --dry-run
106
106
  chatup chrome # 按系统安装普通 Google Chrome
107
+ chatup snipaste # 在 macOS 或 Windows 安装 Snipaste
107
108
  chatup iterm # 安装 iTerm2,仅限 macOS
108
109
  chatup chrome --sudo --yes # Linux:允许提权并确认包安装
109
110
  ```
110
111
 
111
- macOS 直接下载官方 DMG/ZIP 并校验开发者签名,无需 Homebrew;默认装到 `/Applications`,不可写时使用 `~/Applications`。Windows 使用 WinGet `Google.Chrome`;Linux x86_64 使用官方 DEB/RPM 与 apt/dnf/yum/zypper。已安装时验证并复用,不改默认浏览器、终端或用户配置。自动化浏览器仍使用 `chatup chrome-for-testing`。详见[安装约定](https://arch.gh.wzhecnu.cn/ChatUp/commands/#chrome-iterm)。
112
+ macOS 直接下载官方 DMG/ZIP 并校验开发者签名,无需 Homebrew;默认装到 `/Applications`,不可写时使用 `~/Applications`。Windows 使用 WinGet 精确包 `Google.Chrome.EXE`(当前用户安装)和 `liule.Snipaste`,需以 `--yes` 接受首次协议,Chrome 还会验证已安装程序的 Google 签名、产品身份和版本;Linux x86_64 支持 Chrome 的官方 DEB/RPM 与 apt/dnf/yum/zypper。已安装时验证并复用,不改默认浏览器、终端或用户配置。自动化浏览器仍使用 `chatup chrome-for-testing`。详见[安装约定](https://arch.gh.wzhecnu.cn/ChatUp/commands/#chrome-iterm)。
112
113
 
113
114
  ## ChatGPT / Codex 桌面应用
114
115
 
@@ -128,7 +129,7 @@ chatup chatgpt --yes # Windows 首次安装时接受 Store 协议
128
129
  | --- | --- |
129
130
  | 基础运行环境 | `doctor`、`uv`、`nodejs`、`docker`、`zsh`、`chrome-for-testing`、`chromedriver`、`playwright`、`frp` |
130
131
  | 工作区脚手架 | `workspace` |
131
- | 桌面应用 | `macos`(Snipaste/iTerm2/Chrome/Blender 多选)、`chrome`、`iterm`(仅 macOS)、`chatgpt`(含 Codex) |
132
+ | 桌面应用 | `macos`(Snipaste/iTerm2/Chrome/Blender 多选)、`chrome`、`snipaste`(macOS/Windows)、`iterm`(仅 macOS)、`chatgpt`(含 Codex) |
132
133
  | 视频项目 | `remotion` |
133
134
  | 本地服务安装 | `gitea`、`discourse`、`zulip`、`mysql`、`nginx`、`crs` |
134
135
  | Agent 工具链 | `cc-connect`、`claude`、`codex`、`cursor-agent`、`opencode`、`hermes`、`lark-cli` |
@@ -15,8 +15,11 @@ def test_browser_artifacts_have_independent_top_level_backends():
15
15
  assert "chromedriver" in commands
16
16
  assert "playwright" in commands
17
17
  assert "chrome" in commands
18
+ assert "snipaste" in commands
18
19
  assert not isinstance(main.commands["chrome"], click.Group)
20
+ assert not isinstance(main.commands["snipaste"], click.Group)
19
21
  assert "regular Google Chrome" in main.commands["chrome"].help
22
+ assert "Snipaste" in main.commands["snipaste"].help
20
23
  assert "browser" not in commands
21
24
  assert "chromium" not in commands
22
25
 
@@ -263,7 +263,7 @@ def test_resolve_rejects_non_object_metadata(tmp_path):
263
263
  )
264
264
 
265
265
 
266
- def test_force_install_rejects_symlink_root(tmp_path):
266
+ def test_force_install_rejects_symlink_root(tmp_path, require_symlink_privilege):
267
267
  home = tmp_path / "chrome-for-testing"
268
268
  outside = tmp_path / "outside"
269
269
  outside.mkdir()
@@ -281,7 +281,7 @@ def test_force_install_rejects_symlink_root(tmp_path):
281
281
  )
282
282
 
283
283
 
284
- def test_install_rejects_untrusted_resolver_identity_and_parent_symlink(tmp_path):
284
+ def test_install_rejects_untrusted_resolver_identity_and_parent_symlink(tmp_path, require_symlink_privilege):
285
285
  home = tmp_path / "chrome-for-testing"
286
286
  downloaded = False
287
287
 
@@ -402,7 +402,22 @@ def test_safe_extract_rejects_path_traversal_and_special_files(tmp_path):
402
402
  safe_extract_chrome_for_testing_zip(special, tmp_path / "special-out")
403
403
 
404
404
 
405
- def test_safe_extract_allows_internal_symlink_and_rejects_escape(tmp_path):
405
+ def test_safe_extract_marks_directory_symlinks(tmp_path, monkeypatch):
406
+ internal = tmp_path / "internal.zip"
407
+ with zipfile.ZipFile(internal, "w") as bundle:
408
+ bundle.writestr("app/Versions/1/chrome", "binary")
409
+ link = zipfile.ZipInfo("app/Versions/Current")
410
+ link.external_attr = (stat.S_IFLNK | 0o777) << 16
411
+ bundle.writestr(link, "1")
412
+
413
+ calls = []
414
+ monkeypatch.setattr(Path, "symlink_to", lambda self, target, target_is_directory=False:
415
+ calls.append((target, target_is_directory)))
416
+ safe_extract_chrome_for_testing_zip(internal, tmp_path / "output")
417
+ assert calls == [("1", True)]
418
+
419
+
420
+ def test_safe_extract_allows_internal_symlink_and_rejects_escape(tmp_path, require_symlink_privilege):
406
421
  internal = tmp_path / "internal.zip"
407
422
  with zipfile.ZipFile(internal, "w") as bundle:
408
423
  bundle.writestr("app/Versions/1/chrome", "binary")
@@ -400,7 +400,7 @@ def test_chromedriver_rejects_non_object_metadata(tmp_path):
400
400
  )
401
401
 
402
402
 
403
- def test_chromedriver_force_install_rejects_symlink_root(tmp_path):
403
+ def test_chromedriver_force_install_rejects_symlink_root(tmp_path, require_symlink_privilege):
404
404
  home = tmp_path / "chromedriver"
405
405
  outside = tmp_path / "outside"
406
406
  outside.mkdir()
@@ -419,7 +419,7 @@ def test_chromedriver_force_install_rejects_symlink_root(tmp_path):
419
419
 
420
420
 
421
421
  def test_driver_install_rejects_untrusted_resolver_identity_and_parent_symlink(
422
- tmp_path,
422
+ tmp_path, require_symlink_privilege,
423
423
  ):
424
424
  home = tmp_path / "chromedriver"
425
425
  downloaded = False
@@ -81,5 +81,6 @@ def test_cli_tree_is_in_mkdocs_nav_and_backend_contracts_are_complete():
81
81
  missing = sorted(item for item in required if item not in text)
82
82
  assert not missing, f"{name} is missing backend contract terms: {missing}"
83
83
  assert "chatup chrome" in text
84
+ assert "chatup snipaste" in text
84
85
  assert "chatup iterm" in text
85
86
  assert "from chatup.chrome import" not in text
@@ -88,7 +88,7 @@ def test_install_requires_exact_version_supported_browser_and_node(tmp_path):
88
88
  )
89
89
 
90
90
 
91
- def test_install_rejects_parent_symlink_escape(tmp_path):
91
+ def test_install_rejects_parent_symlink_escape(tmp_path, require_symlink_privilege):
92
92
  home = tmp_path / "playwright"
93
93
  outside = tmp_path / "outside"
94
94
  outside.mkdir()
@@ -5,7 +5,7 @@ from chatup.cli import main
5
5
 
6
6
 
7
7
  def test_version_present():
8
- assert __version__ == "0.2.20"
8
+ assert __version__ == "0.2.21"
9
9
 
10
10
 
11
11
  def test_cli_help():
@@ -1,10 +1,22 @@
1
1
  from pathlib import Path
2
+ import os
3
+ from types import SimpleNamespace
2
4
 
5
+ import click
6
+ import pytest
3
7
  from click.testing import CliRunner
4
8
 
5
9
  from chatup.cli import main
6
10
 
7
11
 
12
+ def _assert_link_points_to(link: Path, target: Path) -> None:
13
+ assert link.exists()
14
+ try:
15
+ assert link.resolve() == target.resolve() or link.samefile(target)
16
+ except OSError:
17
+ assert False, f"{link} does not point to {target}"
18
+
19
+
8
20
  def test_workspace_template_includes_chatarch_lifecycle_dirs(tmp_path):
9
21
  workspace_dir = tmp_path / "workspace"
10
22
 
@@ -49,8 +61,7 @@ def test_workspace_chatblog_missing_docs_creates_placeholder_and_public_link(tmp
49
61
  assert result.exit_code == 0, result.output
50
62
  public_link = workspace_dir / "public" / "chatblog"
51
63
  docs_dir = workspace_dir / "core" / "ChatBlog" / "docs"
52
- assert public_link.is_symlink()
53
- assert public_link.resolve() == docs_dir
64
+ _assert_link_points_to(public_link, docs_dir)
54
65
  assert (docs_dir / "README.md").exists()
55
66
  assert "Public link source: docs" in result.output
56
67
 
@@ -75,8 +86,7 @@ def test_workspace_chatblog_uses_docs_even_when_source_posts_exists(tmp_path, mo
75
86
 
76
87
  assert result.exit_code == 0, result.output
77
88
  public_link = workspace_dir / "public" / "chatblog"
78
- assert public_link.is_symlink()
79
- assert public_link.resolve() == workspace_dir / "core" / "ChatBlog" / "docs"
89
+ _assert_link_points_to(public_link, workspace_dir / "core" / "ChatBlog" / "docs")
80
90
  assert "Public link source: docs" in result.output
81
91
 
82
92
 
@@ -123,13 +133,20 @@ def test_workspace_all_extras_clone_chattool_chatblog_chatmemory(tmp_path, monke
123
133
  assert Path("core/ChatTool") in cloned
124
134
  assert Path("core/ChatBlog") in cloned
125
135
  assert Path("core/ChatMemory") in cloned
126
- assert (workspace_dir / "public" / "chatblog").is_symlink()
136
+ _assert_link_points_to(
137
+ workspace_dir / "public" / "chatblog",
138
+ workspace_dir / "core" / "ChatBlog" / "docs",
139
+ )
127
140
  assert not (workspace_dir / "skills" / "trae").exists()
128
- assert (workspace_dir / "skills" / "chatarch").is_symlink()
129
- assert (workspace_dir / "skills" / "common").is_symlink()
130
- assert (workspace_dir / "skills" / "agents").is_symlink()
131
- assert (workspace_dir / "skills" / "README.md").is_symlink()
132
- assert (workspace_dir / "skills" / "README.md").resolve() == workspace_dir / "core" / "ChatMemory" / "Skills" / "README.md"
141
+ for group in ["chatarch", "common", "agents"]:
142
+ _assert_link_points_to(
143
+ workspace_dir / "skills" / group,
144
+ workspace_dir / "core" / "ChatMemory" / "Skills" / group,
145
+ )
146
+ _assert_link_points_to(
147
+ workspace_dir / "skills" / "README.md",
148
+ workspace_dir / "core" / "ChatMemory" / "Skills" / "README.md",
149
+ )
133
150
  assert not (workspace_dir / "skills" / "package-development").exists()
134
151
  assert not (workspace_dir / "skills" / "package-review").exists()
135
152
  local_readme = (workspace_dir / "skills" / "local" / "README.md").read_text(encoding="utf-8")
@@ -138,3 +155,102 @@ def test_workspace_all_extras_clone_chattool_chatblog_chatmemory(tmp_path, monke
138
155
  assert "available under `skills/chatarch/`" in local_readme
139
156
  assert "chatarch/package-review are available under skills/chatarch/" in result.output
140
157
  assert "Skills README:" in result.output
158
+
159
+
160
+ def test_workspace_extra_repo_reports_missing_git(tmp_path, monkeypatch):
161
+ from chatup.setup.workspace import options
162
+
163
+ monkeypatch.setattr(options, "_resolve_git_command", lambda: None)
164
+
165
+ with pytest.raises(click.ClickException) as excinfo:
166
+ options._clone_or_update_repo(
167
+ "https://example.invalid/repo.git",
168
+ tmp_path / "workspace" / "core" / "Repo",
169
+ interactive=False,
170
+ can_prompt=False,
171
+ )
172
+
173
+ assert "Git executable not found" in str(excinfo.value)
174
+
175
+
176
+ def test_workspace_resolves_windows_git_standard_location(tmp_path, monkeypatch):
177
+ from chatup.setup.workspace import options
178
+
179
+ git = tmp_path / "Programs" / "Git" / "cmd" / "git.exe"
180
+ git.parent.mkdir(parents=True)
181
+ git.write_text("fake git", encoding="utf-8")
182
+
183
+ monkeypatch.setattr(options.shutil, "which", lambda name: None)
184
+ monkeypatch.setattr(options, "os", SimpleNamespace(name="nt", environ=os.environ))
185
+ monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
186
+ monkeypatch.delenv("ProgramFiles", raising=False)
187
+ monkeypatch.delenv("ProgramFiles(x86)", raising=False)
188
+
189
+ assert type(Path()) is type(tmp_path)
190
+ assert options._resolve_git_command() == str(git)
191
+
192
+
193
+ def test_workspace_directory_link_uses_directory_symlink_flag(tmp_path, monkeypatch):
194
+ from chatup.setup.workspace import options
195
+
196
+ source = tmp_path / "source"
197
+ target = tmp_path / "target"
198
+ source.mkdir()
199
+ captured = {}
200
+
201
+ def fake_symlink_to(self, source_path, target_is_directory=False):
202
+ captured["source"] = source_path
203
+ captured["target_is_directory"] = target_is_directory
204
+
205
+ monkeypatch.setattr(Path, "symlink_to", fake_symlink_to)
206
+
207
+ options._ensure_symlink(source, target)
208
+
209
+ assert captured == {"source": source, "target_is_directory": True}
210
+
211
+
212
+ def test_workspace_directory_link_falls_back_to_windows_junction(tmp_path, monkeypatch):
213
+ from chatup.setup.workspace import options
214
+
215
+ source = tmp_path / "source"
216
+ target = tmp_path / "target"
217
+ source.mkdir()
218
+ created = []
219
+
220
+ def fail_symlink_to(self, source_path, target_is_directory=False):
221
+ raise OSError("symlink privilege missing")
222
+
223
+ def fake_junction(source_path, target_path):
224
+ created.append((source_path, target_path))
225
+ target_path.mkdir()
226
+
227
+ monkeypatch.setattr(Path, "symlink_to", fail_symlink_to)
228
+ monkeypatch.setattr(options, "os", SimpleNamespace(name="nt", environ=os.environ))
229
+ monkeypatch.setattr(options, "_create_windows_junction", fake_junction)
230
+
231
+ assert type(Path()) is type(tmp_path)
232
+ options._ensure_symlink(source, target)
233
+
234
+ assert created == [(source.resolve(), target)]
235
+ assert target.is_dir()
236
+
237
+
238
+ @pytest.mark.skipif(os.name != "nt", reason="native Windows junctions")
239
+ @pytest.mark.parametrize("name", ["space dir", "a&b", "literal%PATH%!name"])
240
+ def test_windows_junction_preserves_literal_paths_and_target_data(tmp_path, name):
241
+ from chatup.setup.workspace import options
242
+
243
+ source = tmp_path / name
244
+ source.mkdir()
245
+ sentinel = source / "keep.txt"
246
+ sentinel.write_text("keep", encoding="utf-8")
247
+ target = tmp_path / (name + "-link")
248
+ options._create_windows_junction(source, target)
249
+ try:
250
+ assert options._is_windows_reparse_point(target)
251
+ assert target.samefile(source)
252
+ options._ensure_symlink(source, target)
253
+ assert (target / "keep.txt").read_text(encoding="utf-8") == "keep"
254
+ finally:
255
+ options._remove_link_path(target)
256
+ assert sentinel.read_text(encoding="utf-8") == "keep"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes