chatup 0.2.4__tar.gz → 0.2.6__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.
- {chatup-0.2.4/src/chatup.egg-info → chatup-0.2.6}/PKG-INFO +3 -3
- {chatup-0.2.4 → chatup-0.2.6}/README.md +2 -2
- {chatup-0.2.4 → chatup-0.2.6}/pyproject.toml +3 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/__init__.py +1 -1
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/commands/chrome_for_testing.py +1 -1
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/commands/chromedriver.py +1 -1
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/commands/playwright.py +1 -1
- chatup-0.2.6/src/chatup/config.py +45 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/interaction/command_schema.py +21 -5
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/interaction/policy.py +17 -3
- chatup-0.2.6/src/chatup/setup/cursor_agent.py +457 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/elements.py +132 -0
- {chatup-0.2.4 → chatup-0.2.6/src/chatup.egg-info}/PKG-INFO +3 -3
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup.egg-info/SOURCES.txt +3 -0
- chatup-0.2.6/src/chatup.egg-info/entry_points.txt +5 -0
- chatup-0.2.6/tests/test_cursor_agent_setup.py +289 -0
- {chatup-0.2.4 → chatup-0.2.6}/tests/test_pyproject_metadata.py +19 -6
- {chatup-0.2.4 → chatup-0.2.6}/tests/test_version.py +2 -2
- chatup-0.2.4/src/chatup.egg-info/entry_points.txt +0 -2
- {chatup-0.2.4 → chatup-0.2.6}/LICENSE +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/setup.cfg +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/chrome_for_testing.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/chromedriver.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/cli.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/commands/__init__.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/const.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/interaction/__init__.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/interaction/warnings.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/playwright.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/runtime/__init__.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/runtime/_managed_artifact.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/runtime/chrome_for_testing.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/runtime/chromedriver.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/runtime/playwright.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/__init__.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/assets/hermes/install.sh +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/assets/nvm.sh +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/cc_connect.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/claude.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/cli.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/codex.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/crs.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/docker.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/frp.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/gitea.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/hermes.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/lark_cli.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/mode_prompt.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/mysql.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/nginx.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/nodejs.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/opencode.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/shell_rc.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/uv.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/workspace/__init__.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/workspace/cli.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/workspace/core.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/workspace/options.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/workspace/render.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/workspace/templates/default/en/AGENTS.md +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/workspace/templates/default/en/discussion/README.md +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/workspace/templates/default/en/projects/README.md +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/workspace/templates/default/zh/AGENTS.md +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/workspace/templates/default/zh/discussion/README.md +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/workspace/templates/default/zh/projects/README.md +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/workspace.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/zsh.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/utils/__init__.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/utils/custom_logger.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup/utils/pathing.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup.egg-info/dependency_links.txt +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup.egg-info/requires.txt +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/src/chatup.egg-info/top_level.txt +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/tests/test_browser_backend_cli.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/tests/test_chrome_for_testing.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/tests/test_chromedriver.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/tests/test_cli_tree_docs.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/tests/test_crs_setup.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/tests/test_playwright.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/tests/test_playwright_cli.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/tests/test_publish_workflow.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/tests/test_setup_cli.py +0 -0
- {chatup-0.2.4 → chatup-0.2.6}/tests/test_workspace_setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: chatup
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
4
4
|
Summary: ChatArch setup CLI extracted from ChatTool
|
|
5
5
|
Author-email: rexwzh <1073853456@qq.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -71,9 +71,9 @@ chatup crs --install-dir ~/.chatarch/crs/local --port 12392 --redis-port 6379
|
|
|
71
71
|
| 基础运行环境 | `doctor`、`uv`、`nodejs`、`docker`、`zsh`、`chrome-for-testing`、`chromedriver`、`playwright`、`frp` |
|
|
72
72
|
| 工作区脚手架 | `workspace` |
|
|
73
73
|
| 本地服务安装 | `gitea`、`mysql`、`nginx`、`crs` |
|
|
74
|
-
| Agent 工具链 | `cc-connect`、`claude`、`codex`、`opencode`、`hermes`、`lark-cli` |
|
|
74
|
+
| Agent 工具链 | `cc-connect`、`claude`、`codex`、`cursor-agent`、`opencode`、`hermes`、`lark-cli` |
|
|
75
75
|
|
|
76
|
-
所有新增安装项默认目录都收敛到 `~/.chatarch/...`,例如 `~/.chatarch/chrome-for-testing`、`~/.chatarch/chromedriver`、`~/.chatarch/playwright`、`~/.chatarch/chattea`、`~/.chatarch/chatdata`、`~/.chatarch/nginx` 和 `~/.chatarch/crs/local
|
|
76
|
+
所有新增安装项默认目录都收敛到 `~/.chatarch/...`,例如 `~/.chatarch/chrome-for-testing`、`~/.chatarch/chromedriver`、`~/.chatarch/playwright`、`~/.chatarch/chattea`、`~/.chatarch/chatdata`、`~/.chatarch/nginx` 和 `~/.chatarch/crs/local`。`cursor-agent` 额外注册 ChatEnv `CursorAgent` profile,并支持 `-e/--env` 从 env 文件或 profile 快速配置迁移来的 Cursor token。更完整的能力边界见 `docs/capability-map.md`。
|
|
77
77
|
|
|
78
78
|
## 开发
|
|
79
79
|
|
|
@@ -45,9 +45,9 @@ chatup crs --install-dir ~/.chatarch/crs/local --port 12392 --redis-port 6379
|
|
|
45
45
|
| 基础运行环境 | `doctor`、`uv`、`nodejs`、`docker`、`zsh`、`chrome-for-testing`、`chromedriver`、`playwright`、`frp` |
|
|
46
46
|
| 工作区脚手架 | `workspace` |
|
|
47
47
|
| 本地服务安装 | `gitea`、`mysql`、`nginx`、`crs` |
|
|
48
|
-
| Agent 工具链 | `cc-connect`、`claude`、`codex`、`opencode`、`hermes`、`lark-cli` |
|
|
48
|
+
| Agent 工具链 | `cc-connect`、`claude`、`codex`、`cursor-agent`、`opencode`、`hermes`、`lark-cli` |
|
|
49
49
|
|
|
50
|
-
所有新增安装项默认目录都收敛到 `~/.chatarch/...`,例如 `~/.chatarch/chrome-for-testing`、`~/.chatarch/chromedriver`、`~/.chatarch/playwright`、`~/.chatarch/chattea`、`~/.chatarch/chatdata`、`~/.chatarch/nginx` 和 `~/.chatarch/crs/local
|
|
50
|
+
所有新增安装项默认目录都收敛到 `~/.chatarch/...`,例如 `~/.chatarch/chrome-for-testing`、`~/.chatarch/chromedriver`、`~/.chatarch/playwright`、`~/.chatarch/chattea`、`~/.chatarch/chatdata`、`~/.chatarch/nginx` 和 `~/.chatarch/crs/local`。`cursor-agent` 额外注册 ChatEnv `CursorAgent` profile,并支持 `-e/--env` 从 env 文件或 profile 快速配置迁移来的 Cursor token。更完整的能力边界见 `docs/capability-map.md`。
|
|
51
51
|
|
|
52
52
|
## 开发
|
|
53
53
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from chatenv.fields import BaseEnvConfig, EnvField
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CursorAgentConfig(BaseEnvConfig):
|
|
7
|
+
"""ChatEnv schema for Cursor Agent credentials used by ChatUp setup."""
|
|
8
|
+
|
|
9
|
+
_title = "Cursor Agent"
|
|
10
|
+
_aliases = ["cursor-agent", "cursor_agent", "cursor"]
|
|
11
|
+
_storage_dir = "CursorAgent"
|
|
12
|
+
|
|
13
|
+
CURSOR_ACCESS_TOKEN = EnvField(
|
|
14
|
+
"CURSOR_ACCESS_TOKEN",
|
|
15
|
+
desc="Cursor Agent access token copied into Cursor auth.json by chatup cursor-agent.",
|
|
16
|
+
is_sensitive=True,
|
|
17
|
+
)
|
|
18
|
+
CURSOR_REFRESH_TOKEN = EnvField(
|
|
19
|
+
"CURSOR_REFRESH_TOKEN",
|
|
20
|
+
desc="Cursor Agent refresh token copied into Cursor auth.json by chatup cursor-agent.",
|
|
21
|
+
is_sensitive=True,
|
|
22
|
+
)
|
|
23
|
+
CURSOR_CREDENTIAL_STORE = EnvField(
|
|
24
|
+
"CURSOR_CREDENTIAL_STORE",
|
|
25
|
+
default="native",
|
|
26
|
+
desc="Cursor Agent credential store mode: native or file-wrapper.",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def test(cls) -> None:
|
|
31
|
+
"""Run a non-network schema smoke for chatenv test."""
|
|
32
|
+
|
|
33
|
+
print(f"Testing {cls._title}...")
|
|
34
|
+
fields = cls.get_fields()
|
|
35
|
+
expected = {
|
|
36
|
+
"CURSOR_ACCESS_TOKEN",
|
|
37
|
+
"CURSOR_REFRESH_TOKEN",
|
|
38
|
+
"CURSOR_CREDENTIAL_STORE",
|
|
39
|
+
}
|
|
40
|
+
missing = expected.difference(fields)
|
|
41
|
+
if missing:
|
|
42
|
+
raise RuntimeError(f"Missing Cursor Agent config field(s): {', '.join(sorted(missing))}")
|
|
43
|
+
if not fields["CURSOR_ACCESS_TOKEN"].is_sensitive or not fields["CURSOR_REFRESH_TOKEN"].is_sensitive:
|
|
44
|
+
raise RuntimeError("Cursor Agent token fields must be marked sensitive")
|
|
45
|
+
print("✓ Cursor Agent ChatEnv schema is registered.")
|
|
@@ -8,11 +8,27 @@ from chatstyle import (
|
|
|
8
8
|
add_interactive_option,
|
|
9
9
|
)
|
|
10
10
|
from chatstyle import resolve_command_inputs as _resolve_command_inputs
|
|
11
|
-
|
|
11
|
+
import chatstyle.tui.prompt as prompt_module
|
|
12
12
|
|
|
13
13
|
from .policy import resolve_interactive_mode
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
def ask_confirm(*args, **kwargs):
|
|
17
|
+
return prompt_module.ask_confirm(*args, **kwargs)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def ask_path(*args, **kwargs):
|
|
21
|
+
return prompt_module.ask_path(*args, **kwargs)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def ask_select(*args, **kwargs):
|
|
25
|
+
return prompt_module.ask_select(*args, **kwargs)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def ask_text(*args, **kwargs):
|
|
29
|
+
return prompt_module.ask_text(*args, **kwargs)
|
|
30
|
+
|
|
31
|
+
|
|
16
32
|
def resolve_command_inputs(*, schema, provided, interactive, usage):
|
|
17
33
|
"""Resolve command inputs through ChatStyle while preserving old patch points."""
|
|
18
34
|
|
|
@@ -27,10 +43,10 @@ def resolve_command_inputs(*, schema, provided, interactive, usage):
|
|
|
27
43
|
|
|
28
44
|
|
|
29
45
|
class _PromptRuntime:
|
|
30
|
-
ask_confirm = staticmethod(
|
|
31
|
-
ask_path = staticmethod(
|
|
32
|
-
ask_select = staticmethod(
|
|
33
|
-
ask_text = staticmethod(
|
|
46
|
+
ask_confirm = staticmethod(ask_confirm)
|
|
47
|
+
ask_path = staticmethod(ask_path)
|
|
48
|
+
ask_select = staticmethod(ask_select)
|
|
49
|
+
ask_text = staticmethod(ask_text)
|
|
34
50
|
|
|
35
51
|
|
|
36
52
|
def _resolve_interactive_for_chatstyle(*, interactive, auto_prompt_condition):
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
"""Interactive policy helpers shared across CLI commands."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
4
|
+
|
|
3
5
|
import click
|
|
6
|
+
import chatstyle.core.interactive as interactive_module
|
|
4
7
|
|
|
5
8
|
from chatstyle import FORCE_INTERACTIVE_NO_TTY_MESSAGE
|
|
6
|
-
from chatstyle import is_interactive_available as _is_interactive_available
|
|
7
9
|
|
|
8
10
|
|
|
9
11
|
def is_interactive_available():
|
|
10
|
-
return
|
|
12
|
+
return interactive_module.is_interactive_available()
|
|
11
13
|
|
|
12
14
|
|
|
13
15
|
def normalize_interactive(interactive):
|
|
@@ -24,11 +26,23 @@ def normalize_interactive(interactive):
|
|
|
24
26
|
return interactive
|
|
25
27
|
|
|
26
28
|
|
|
29
|
+
def _auto_prompt_enabled() -> bool:
|
|
30
|
+
value = os.environ.get("CHATARCH_AUTO_PROMPT")
|
|
31
|
+
if value is None:
|
|
32
|
+
return True
|
|
33
|
+
return value.strip().lower() not in {"0", "false", "no", "off"}
|
|
34
|
+
|
|
35
|
+
|
|
27
36
|
def resolve_interactive_mode(interactive, auto_prompt_condition):
|
|
28
37
|
interactive = normalize_interactive(interactive)
|
|
29
38
|
can_prompt = is_interactive_available()
|
|
30
39
|
force_interactive = interactive is True
|
|
31
|
-
auto_interactive =
|
|
40
|
+
auto_interactive = (
|
|
41
|
+
interactive is None
|
|
42
|
+
and can_prompt
|
|
43
|
+
and auto_prompt_condition
|
|
44
|
+
and _auto_prompt_enabled()
|
|
45
|
+
)
|
|
32
46
|
need_prompt = force_interactive or auto_interactive
|
|
33
47
|
return interactive, can_prompt, force_interactive, auto_interactive, need_prompt
|
|
34
48
|
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import shutil
|
|
7
|
+
import shlex
|
|
8
|
+
import stat
|
|
9
|
+
import subprocess
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
from chatenv.paths import get_paths
|
|
14
|
+
from chatenv.store import EnvStore
|
|
15
|
+
|
|
16
|
+
from chatup.config import CursorAgentConfig
|
|
17
|
+
from chatup.utils.custom_logger import setup_logger
|
|
18
|
+
|
|
19
|
+
DEFAULT_INSTALL_URL = "https://cursor.com/install"
|
|
20
|
+
CREDENTIAL_STORE_CHOICES = ("native", "file-wrapper")
|
|
21
|
+
CURSOR_ACCESS_TOKEN_KEY = "CURSOR_ACCESS_TOKEN"
|
|
22
|
+
CURSOR_REFRESH_TOKEN_KEY = "CURSOR_REFRESH_TOKEN"
|
|
23
|
+
CURSOR_CREDENTIAL_STORE_KEY = "CURSOR_CREDENTIAL_STORE"
|
|
24
|
+
WRAPPER_MARKER = "Managed by ChatUp cursor-agent setup"
|
|
25
|
+
logger = setup_logger("setup_cursor_agent")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _configure_logger(log_level: str = "INFO"):
|
|
29
|
+
global logger
|
|
30
|
+
logger = setup_logger("setup_cursor_agent", log_level=str(log_level).upper())
|
|
31
|
+
return logger
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _cursor_auth_path() -> Path:
|
|
35
|
+
return Path.home() / ".config" / "cursor" / "auth.json"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _cursor_cli_config_path() -> Path:
|
|
39
|
+
return Path.home() / ".cursor" / "cli-config.json"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _cursor_agent_state_path() -> Path:
|
|
43
|
+
return Path.home() / ".cursor" / "agent-cli-state.json"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _chmod_private(path: Path) -> None:
|
|
47
|
+
try:
|
|
48
|
+
path.chmod(0o600)
|
|
49
|
+
except PermissionError:
|
|
50
|
+
logger.warning(f"Could not chmod private file: {path}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _read_json_file(path: Path) -> dict[str, Any]:
|
|
54
|
+
try:
|
|
55
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
56
|
+
except Exception as exc: # pragma: no cover - ClickException carries detail
|
|
57
|
+
raise click.ClickException(f"Invalid JSON file: {path}: {exc}") from exc
|
|
58
|
+
if not isinstance(data, dict):
|
|
59
|
+
raise click.ClickException(f"Expected JSON object in {path}")
|
|
60
|
+
return data
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _validate_auth_json(path: Path) -> dict[str, Any]:
|
|
64
|
+
data = _read_json_file(path)
|
|
65
|
+
missing = [key for key in ("accessToken", "refreshToken") if not data.get(key)]
|
|
66
|
+
if missing:
|
|
67
|
+
raise click.ClickException(
|
|
68
|
+
f"Cursor auth JSON missing required key(s): {', '.join(missing)}"
|
|
69
|
+
)
|
|
70
|
+
return data
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _parse_env_file(path: Path) -> dict[str, str]:
|
|
74
|
+
values: dict[str, str] = {}
|
|
75
|
+
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
|
76
|
+
line = raw_line.strip()
|
|
77
|
+
if not line or line.startswith("#"):
|
|
78
|
+
continue
|
|
79
|
+
if line.startswith("export "):
|
|
80
|
+
line = line[len("export ") :].strip()
|
|
81
|
+
if "=" not in line:
|
|
82
|
+
continue
|
|
83
|
+
key, value = line.split("=", 1)
|
|
84
|
+
key = key.strip()
|
|
85
|
+
value = value.strip()
|
|
86
|
+
if (value.startswith("'") and value.endswith("'")) or (
|
|
87
|
+
value.startswith('"') and value.endswith('"')
|
|
88
|
+
):
|
|
89
|
+
value = value[1:-1]
|
|
90
|
+
values[key] = value
|
|
91
|
+
return values
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _write_json_private(path: Path, data: dict[str, Any]) -> None:
|
|
95
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
97
|
+
_chmod_private(path)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _cursor_env_store() -> EnvStore:
|
|
101
|
+
return EnvStore(get_paths().envs_dir)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _auth_data_from_tokens(
|
|
105
|
+
access_token: str | None,
|
|
106
|
+
refresh_token: str | None,
|
|
107
|
+
*,
|
|
108
|
+
source_label: str,
|
|
109
|
+
) -> dict[str, str]:
|
|
110
|
+
missing = [
|
|
111
|
+
name
|
|
112
|
+
for name, value in (
|
|
113
|
+
(CURSOR_ACCESS_TOKEN_KEY, access_token),
|
|
114
|
+
(CURSOR_REFRESH_TOKEN_KEY, refresh_token),
|
|
115
|
+
)
|
|
116
|
+
if not value
|
|
117
|
+
]
|
|
118
|
+
if missing:
|
|
119
|
+
raise click.ClickException(
|
|
120
|
+
f"Cursor auth {source_label} missing required key(s): {', '.join(missing)}"
|
|
121
|
+
)
|
|
122
|
+
return {"accessToken": str(access_token), "refreshToken": str(refresh_token)}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _load_auth_from_env_file(path: Path) -> dict[str, str]:
|
|
126
|
+
values = _parse_env_file(path)
|
|
127
|
+
return _auth_data_from_tokens(
|
|
128
|
+
values.get(CURSOR_ACCESS_TOKEN_KEY),
|
|
129
|
+
values.get(CURSOR_REFRESH_TOKEN_KEY),
|
|
130
|
+
source_label=f"env file {path}",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _load_auth_from_profile(profile_name: str) -> tuple[dict[str, str], dict[str, str]]:
|
|
135
|
+
values = _cursor_env_store().load_profile(CursorAgentConfig, profile_name)
|
|
136
|
+
auth_data = _auth_data_from_tokens(
|
|
137
|
+
values.get(CURSOR_ACCESS_TOKEN_KEY),
|
|
138
|
+
values.get(CURSOR_REFRESH_TOKEN_KEY),
|
|
139
|
+
source_label=f"ChatEnv profile {profile_name}",
|
|
140
|
+
)
|
|
141
|
+
return auth_data, values
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _save_auth_to_profile(
|
|
145
|
+
profile_name: str,
|
|
146
|
+
auth_data: dict[str, Any],
|
|
147
|
+
*,
|
|
148
|
+
credential_store: str,
|
|
149
|
+
) -> Path:
|
|
150
|
+
store = _cursor_env_store()
|
|
151
|
+
values = store.load_profile(CursorAgentConfig, profile_name)
|
|
152
|
+
values[CURSOR_ACCESS_TOKEN_KEY] = str(auth_data["accessToken"])
|
|
153
|
+
values[CURSOR_REFRESH_TOKEN_KEY] = str(auth_data["refreshToken"])
|
|
154
|
+
values[CURSOR_CREDENTIAL_STORE_KEY] = credential_store
|
|
155
|
+
return store.save_profile(CursorAgentConfig, profile_name, values)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _copy_json_private(source: Path, target: Path) -> dict[str, Any]:
|
|
159
|
+
data = _read_json_file(source)
|
|
160
|
+
_write_json_private(target, data)
|
|
161
|
+
return data
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _resolve_command_or_user_bin(name: str) -> Path | None:
|
|
165
|
+
"""Resolve a Cursor Agent command from PATH or its standard user-bin install path."""
|
|
166
|
+
|
|
167
|
+
candidates: list[Path] = []
|
|
168
|
+
found = shutil.which(name)
|
|
169
|
+
if found:
|
|
170
|
+
candidates.append(Path(found).expanduser())
|
|
171
|
+
candidates.append(Path.home() / ".local" / "bin" / name)
|
|
172
|
+
for candidate in candidates:
|
|
173
|
+
if candidate.exists() and os.access(candidate, os.X_OK):
|
|
174
|
+
return candidate
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _resolve_cursor_agent_binary() -> str | None:
|
|
179
|
+
found = _resolve_command_or_user_bin("cursor-agent") or _resolve_command_or_user_bin("agent")
|
|
180
|
+
return str(found) if found else None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _managed_wrapper_official_target(entrypoint: Path) -> Path | None:
|
|
184
|
+
if not entrypoint.exists() or entrypoint.is_symlink():
|
|
185
|
+
return None
|
|
186
|
+
try:
|
|
187
|
+
text = entrypoint.read_text(encoding="utf-8")
|
|
188
|
+
except UnicodeDecodeError:
|
|
189
|
+
return None
|
|
190
|
+
if WRAPPER_MARKER not in text:
|
|
191
|
+
return None
|
|
192
|
+
for line in text.splitlines():
|
|
193
|
+
if line.startswith("CHATUP_CURSOR_AGENT_OFFICIAL="):
|
|
194
|
+
value = line.split("=", 1)[1]
|
|
195
|
+
try:
|
|
196
|
+
parts = shlex.split(value)
|
|
197
|
+
except ValueError:
|
|
198
|
+
return None
|
|
199
|
+
if parts:
|
|
200
|
+
return Path(parts[0])
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _resolve_official_cursor_agent_binary(binary: str) -> Path:
|
|
205
|
+
entrypoint = Path(binary).expanduser()
|
|
206
|
+
managed_target = _managed_wrapper_official_target(entrypoint)
|
|
207
|
+
if managed_target:
|
|
208
|
+
return managed_target
|
|
209
|
+
try:
|
|
210
|
+
return entrypoint.resolve(strict=True)
|
|
211
|
+
except FileNotFoundError:
|
|
212
|
+
return entrypoint
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _entrypoint_for(name: str) -> Path | None:
|
|
216
|
+
return _resolve_command_or_user_bin(name)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _write_file_credential_wrapper(entrypoint: Path, official_binary: Path, auth_path: Path) -> bool:
|
|
220
|
+
entrypoint.parent.mkdir(parents=True, exist_ok=True)
|
|
221
|
+
existing_target = None
|
|
222
|
+
if entrypoint.exists() or entrypoint.is_symlink():
|
|
223
|
+
if entrypoint.is_symlink():
|
|
224
|
+
existing_target = entrypoint.resolve(strict=False)
|
|
225
|
+
entrypoint.unlink()
|
|
226
|
+
else:
|
|
227
|
+
managed_target = _managed_wrapper_official_target(entrypoint)
|
|
228
|
+
if not managed_target:
|
|
229
|
+
raise click.ClickException(f"Refusing to overwrite non-ChatUp Cursor Agent entrypoint: {entrypoint}")
|
|
230
|
+
existing_target = managed_target
|
|
231
|
+
if existing_target and existing_target == entrypoint:
|
|
232
|
+
raise click.ClickException(f"Refusing to wrap Cursor Agent entrypoint that resolves to itself: {entrypoint}")
|
|
233
|
+
official = official_binary.expanduser()
|
|
234
|
+
script = f'''#!/usr/bin/env bash
|
|
235
|
+
set -euo pipefail
|
|
236
|
+
# {WRAPPER_MARKER}.
|
|
237
|
+
# This wrapper does not store token values; it reads Cursor auth.json at runtime.
|
|
238
|
+
CHATUP_CURSOR_AGENT_OFFICIAL={shlex.quote(str(official))}
|
|
239
|
+
CHATUP_CURSOR_AGENT_AUTH_JSON="${{CURSOR_AGENT_AUTH_JSON:-{shlex.quote(str(auth_path))}}}"
|
|
240
|
+
export AGENT_CLI_CREDENTIAL_STORE="${{AGENT_CLI_CREDENTIAL_STORE:-file}}"
|
|
241
|
+
if [ -z "${{CURSOR_AUTH_TOKEN:-}}" ] && [ -r "$CHATUP_CURSOR_AGENT_AUTH_JSON" ]; then
|
|
242
|
+
CHATUP_CURSOR_AGENT_TOKEN="$(python3 - "$CHATUP_CURSOR_AGENT_AUTH_JSON" <<'PY'
|
|
243
|
+
import json
|
|
244
|
+
import sys
|
|
245
|
+
path = sys.argv[1]
|
|
246
|
+
with open(path, encoding="utf-8") as fh:
|
|
247
|
+
data = json.load(fh)
|
|
248
|
+
print(data.get("accessToken", ""), end="")
|
|
249
|
+
PY
|
|
250
|
+
)"
|
|
251
|
+
if [ -n "$CHATUP_CURSOR_AGENT_TOKEN" ]; then
|
|
252
|
+
export CURSOR_AUTH_TOKEN="$CHATUP_CURSOR_AGENT_TOKEN"
|
|
253
|
+
fi
|
|
254
|
+
unset CHATUP_CURSOR_AGENT_TOKEN
|
|
255
|
+
fi
|
|
256
|
+
exec -a "$0" "$CHATUP_CURSOR_AGENT_OFFICIAL" "$@"
|
|
257
|
+
'''
|
|
258
|
+
entrypoint.write_text(script, encoding="utf-8")
|
|
259
|
+
entrypoint.chmod(0o755)
|
|
260
|
+
return True
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _write_file_credential_wrappers(binary: str) -> list[str]:
|
|
264
|
+
official = _resolve_official_cursor_agent_binary(binary)
|
|
265
|
+
written: list[str] = []
|
|
266
|
+
for name in ("cursor-agent", "agent"):
|
|
267
|
+
entrypoint = _entrypoint_for(name)
|
|
268
|
+
if not entrypoint:
|
|
269
|
+
continue
|
|
270
|
+
current_official = _resolve_official_cursor_agent_binary(str(entrypoint))
|
|
271
|
+
if current_official != official:
|
|
272
|
+
continue
|
|
273
|
+
if _write_file_credential_wrapper(entrypoint, official, _cursor_auth_path()):
|
|
274
|
+
written.append(str(entrypoint))
|
|
275
|
+
return written
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _install_cursor_agent_if_needed(*, install_url: str = DEFAULT_INSTALL_URL) -> bool:
|
|
279
|
+
if _resolve_cursor_agent_binary():
|
|
280
|
+
return False
|
|
281
|
+
script = subprocess.run(
|
|
282
|
+
["bash", "-lc", f"curl -fsSL {install_url!r} | bash"],
|
|
283
|
+
text=True,
|
|
284
|
+
capture_output=True,
|
|
285
|
+
timeout=300,
|
|
286
|
+
)
|
|
287
|
+
if script.returncode != 0:
|
|
288
|
+
detail = (script.stderr or script.stdout).strip()
|
|
289
|
+
raise click.ClickException(f"Cursor Agent installer failed: {detail}")
|
|
290
|
+
if not _resolve_cursor_agent_binary():
|
|
291
|
+
raise click.ClickException("Cursor Agent installer finished but cursor-agent is still not on PATH")
|
|
292
|
+
return True
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _command_summary(cmd: list[str], *, env: dict[str, str] | None = None) -> dict[str, Any]:
|
|
296
|
+
proc = subprocess.run(
|
|
297
|
+
cmd,
|
|
298
|
+
text=True,
|
|
299
|
+
capture_output=True,
|
|
300
|
+
timeout=120,
|
|
301
|
+
env=env,
|
|
302
|
+
)
|
|
303
|
+
stdout = proc.stdout.strip()
|
|
304
|
+
stderr = proc.stderr.strip()
|
|
305
|
+
return {
|
|
306
|
+
"command": cmd[0],
|
|
307
|
+
"returncode": proc.returncode,
|
|
308
|
+
"stdout_lines": len(stdout.splitlines()) if stdout else 0,
|
|
309
|
+
"stderr_lines": len(stderr.splitlines()) if stderr else 0,
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def setup_cursor_agent(
|
|
314
|
+
*,
|
|
315
|
+
auth_json: str | Path | None = None,
|
|
316
|
+
auth_env: str | Path | None = None,
|
|
317
|
+
env_ref: str | Path | None = None,
|
|
318
|
+
env_profile: str | None = None,
|
|
319
|
+
save_profile: str | None = None,
|
|
320
|
+
cli_config: str | Path | None = None,
|
|
321
|
+
agent_state: str | Path | None = None,
|
|
322
|
+
api_key_env: str | None = None,
|
|
323
|
+
credential_store: str = "native",
|
|
324
|
+
install_only: bool = False,
|
|
325
|
+
verify: bool = True,
|
|
326
|
+
interactive: bool | None = None,
|
|
327
|
+
log_level: str = "INFO",
|
|
328
|
+
) -> dict[str, Any]:
|
|
329
|
+
"""Install/verify Cursor Agent and write safe local login/config files.
|
|
330
|
+
|
|
331
|
+
Returned data is intentionally redacted: it reports file paths, booleans, modes,
|
|
332
|
+
and command success metadata only, never token values.
|
|
333
|
+
"""
|
|
334
|
+
|
|
335
|
+
_configure_logger(log_level)
|
|
336
|
+
if credential_store not in CREDENTIAL_STORE_CHOICES:
|
|
337
|
+
raise click.ClickException(
|
|
338
|
+
f"Unsupported credential store: {credential_store}. Expected one of: {', '.join(CREDENTIAL_STORE_CHOICES)}"
|
|
339
|
+
)
|
|
340
|
+
result: dict[str, Any] = {
|
|
341
|
+
"installed_binary": False,
|
|
342
|
+
"binary": None,
|
|
343
|
+
"auth_json_written": False,
|
|
344
|
+
"cli_config_written": False,
|
|
345
|
+
"agent_state_written": False,
|
|
346
|
+
"env_profile_loaded": False,
|
|
347
|
+
"profile_saved": None,
|
|
348
|
+
"credential_store": None,
|
|
349
|
+
"wrapper_written": False,
|
|
350
|
+
"wrappers": [],
|
|
351
|
+
"verification": None,
|
|
352
|
+
}
|
|
353
|
+
result["credential_store"] = credential_store
|
|
354
|
+
|
|
355
|
+
installed = _install_cursor_agent_if_needed()
|
|
356
|
+
result["installed_binary"] = installed
|
|
357
|
+
result["binary"] = _resolve_cursor_agent_binary()
|
|
358
|
+
|
|
359
|
+
if install_only:
|
|
360
|
+
click.echo(f"Cursor Agent binary: {result['binary']}")
|
|
361
|
+
return result
|
|
362
|
+
|
|
363
|
+
auth_sources = [
|
|
364
|
+
name
|
|
365
|
+
for name, value in (
|
|
366
|
+
("--auth-json", auth_json),
|
|
367
|
+
("--auth-env", auth_env),
|
|
368
|
+
("-e/--env", env_ref),
|
|
369
|
+
("--env-profile", env_profile),
|
|
370
|
+
)
|
|
371
|
+
if value
|
|
372
|
+
]
|
|
373
|
+
if len(auth_sources) > 1:
|
|
374
|
+
raise click.ClickException(f"Use only one auth source: {', '.join(auth_sources)}")
|
|
375
|
+
if env_ref:
|
|
376
|
+
candidate = Path(env_ref).expanduser()
|
|
377
|
+
if candidate.is_file():
|
|
378
|
+
auth_env = candidate
|
|
379
|
+
else:
|
|
380
|
+
env_profile = str(env_ref)
|
|
381
|
+
|
|
382
|
+
auth_data: dict[str, Any] | None = None
|
|
383
|
+
if auth_json:
|
|
384
|
+
source = Path(auth_json).expanduser()
|
|
385
|
+
auth_data = _validate_auth_json(source)
|
|
386
|
+
_write_json_private(_cursor_auth_path(), auth_data)
|
|
387
|
+
result["auth_json_written"] = True
|
|
388
|
+
elif auth_env:
|
|
389
|
+
source = Path(auth_env).expanduser()
|
|
390
|
+
auth_data = _load_auth_from_env_file(source)
|
|
391
|
+
_write_json_private(_cursor_auth_path(), auth_data)
|
|
392
|
+
result["auth_json_written"] = True
|
|
393
|
+
elif env_profile:
|
|
394
|
+
auth_data, profile_values = _load_auth_from_profile(env_profile)
|
|
395
|
+
profile_credential_store = profile_values.get(CURSOR_CREDENTIAL_STORE_KEY)
|
|
396
|
+
if profile_credential_store and credential_store == "native":
|
|
397
|
+
credential_store = profile_credential_store
|
|
398
|
+
if credential_store not in CREDENTIAL_STORE_CHOICES:
|
|
399
|
+
raise click.ClickException(
|
|
400
|
+
f"Unsupported credential store in ChatEnv profile {env_profile}: {credential_store}"
|
|
401
|
+
)
|
|
402
|
+
result["credential_store"] = credential_store
|
|
403
|
+
_write_json_private(_cursor_auth_path(), auth_data)
|
|
404
|
+
result["auth_json_written"] = True
|
|
405
|
+
result["env_profile_loaded"] = True
|
|
406
|
+
|
|
407
|
+
if save_profile:
|
|
408
|
+
if auth_data is None:
|
|
409
|
+
raise click.ClickException("--save-profile requires --auth-json, --auth-env, or --env-profile")
|
|
410
|
+
profile_path = _save_auth_to_profile(save_profile, auth_data, credential_store=credential_store)
|
|
411
|
+
result["profile_saved"] = str(profile_path)
|
|
412
|
+
|
|
413
|
+
if cli_config:
|
|
414
|
+
_copy_json_private(Path(cli_config).expanduser(), _cursor_cli_config_path())
|
|
415
|
+
result["cli_config_written"] = True
|
|
416
|
+
|
|
417
|
+
if agent_state:
|
|
418
|
+
_copy_json_private(Path(agent_state).expanduser(), _cursor_agent_state_path())
|
|
419
|
+
result["agent_state_written"] = True
|
|
420
|
+
|
|
421
|
+
for path in (_cursor_auth_path(), _cursor_cli_config_path(), _cursor_agent_state_path()):
|
|
422
|
+
if path.exists():
|
|
423
|
+
_chmod_private(path)
|
|
424
|
+
|
|
425
|
+
if credential_store == "file-wrapper":
|
|
426
|
+
if not _cursor_auth_path().exists():
|
|
427
|
+
raise click.ClickException("--credential-store file-wrapper requires Cursor auth.json")
|
|
428
|
+
binary = result["binary"] or _resolve_cursor_agent_binary()
|
|
429
|
+
if not binary:
|
|
430
|
+
raise click.ClickException("cursor-agent is not available on PATH")
|
|
431
|
+
wrappers = _write_file_credential_wrappers(str(binary))
|
|
432
|
+
result["wrappers"] = wrappers
|
|
433
|
+
result["wrapper_written"] = bool(wrappers)
|
|
434
|
+
result["binary"] = _resolve_cursor_agent_binary()
|
|
435
|
+
|
|
436
|
+
if verify:
|
|
437
|
+
binary = _resolve_cursor_agent_binary()
|
|
438
|
+
if not binary:
|
|
439
|
+
raise click.ClickException("cursor-agent is not available on PATH")
|
|
440
|
+
env = os.environ.copy()
|
|
441
|
+
if api_key_env:
|
|
442
|
+
api_key = os.environ.get(api_key_env)
|
|
443
|
+
if not api_key:
|
|
444
|
+
raise click.ClickException(f"Environment variable is not set: {api_key_env}")
|
|
445
|
+
env["CURSOR_API_KEY"] = api_key
|
|
446
|
+
result["verification"] = _command_summary([binary, "--version"], env=env)
|
|
447
|
+
|
|
448
|
+
click.echo("Cursor Agent setup complete")
|
|
449
|
+
click.echo(f" binary: {result['binary']}")
|
|
450
|
+
click.echo(f" auth_json_written: {result['auth_json_written']}")
|
|
451
|
+
click.echo(f" cli_config_written: {result['cli_config_written']}")
|
|
452
|
+
click.echo(f" agent_state_written: {result['agent_state_written']}")
|
|
453
|
+
click.echo(f" env_profile_loaded: {result['env_profile_loaded']}")
|
|
454
|
+
click.echo(f" profile_saved: {bool(result['profile_saved'])}")
|
|
455
|
+
click.echo(f" credential_store: {result['credential_store']}")
|
|
456
|
+
click.echo(f" wrapper_written: {result['wrapper_written']}")
|
|
457
|
+
return result
|