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.
Files changed (83) hide show
  1. {chatup-0.2.4/src/chatup.egg-info → chatup-0.2.6}/PKG-INFO +3 -3
  2. {chatup-0.2.4 → chatup-0.2.6}/README.md +2 -2
  3. {chatup-0.2.4 → chatup-0.2.6}/pyproject.toml +3 -0
  4. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/__init__.py +1 -1
  5. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/commands/chrome_for_testing.py +1 -1
  6. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/commands/chromedriver.py +1 -1
  7. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/commands/playwright.py +1 -1
  8. chatup-0.2.6/src/chatup/config.py +45 -0
  9. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/interaction/command_schema.py +21 -5
  10. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/interaction/policy.py +17 -3
  11. chatup-0.2.6/src/chatup/setup/cursor_agent.py +457 -0
  12. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/elements.py +132 -0
  13. {chatup-0.2.4 → chatup-0.2.6/src/chatup.egg-info}/PKG-INFO +3 -3
  14. {chatup-0.2.4 → chatup-0.2.6}/src/chatup.egg-info/SOURCES.txt +3 -0
  15. chatup-0.2.6/src/chatup.egg-info/entry_points.txt +5 -0
  16. chatup-0.2.6/tests/test_cursor_agent_setup.py +289 -0
  17. {chatup-0.2.4 → chatup-0.2.6}/tests/test_pyproject_metadata.py +19 -6
  18. {chatup-0.2.4 → chatup-0.2.6}/tests/test_version.py +2 -2
  19. chatup-0.2.4/src/chatup.egg-info/entry_points.txt +0 -2
  20. {chatup-0.2.4 → chatup-0.2.6}/LICENSE +0 -0
  21. {chatup-0.2.4 → chatup-0.2.6}/setup.cfg +0 -0
  22. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/chrome_for_testing.py +0 -0
  23. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/chromedriver.py +0 -0
  24. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/cli.py +0 -0
  25. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/commands/__init__.py +0 -0
  26. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/const.py +0 -0
  27. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/interaction/__init__.py +0 -0
  28. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/interaction/warnings.py +0 -0
  29. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/playwright.py +0 -0
  30. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/runtime/__init__.py +0 -0
  31. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/runtime/_managed_artifact.py +0 -0
  32. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/runtime/chrome_for_testing.py +0 -0
  33. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/runtime/chromedriver.py +0 -0
  34. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/runtime/playwright.py +0 -0
  35. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/__init__.py +0 -0
  36. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/assets/hermes/install.sh +0 -0
  37. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/assets/nvm.sh +0 -0
  38. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/cc_connect.py +0 -0
  39. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/claude.py +0 -0
  40. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/cli.py +0 -0
  41. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/codex.py +0 -0
  42. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/crs.py +0 -0
  43. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/docker.py +0 -0
  44. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/frp.py +0 -0
  45. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/gitea.py +0 -0
  46. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/hermes.py +0 -0
  47. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/lark_cli.py +0 -0
  48. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/mode_prompt.py +0 -0
  49. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/mysql.py +0 -0
  50. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/nginx.py +0 -0
  51. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/nodejs.py +0 -0
  52. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/opencode.py +0 -0
  53. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/shell_rc.py +0 -0
  54. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/uv.py +0 -0
  55. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/workspace/__init__.py +0 -0
  56. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/workspace/cli.py +0 -0
  57. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/workspace/core.py +0 -0
  58. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/workspace/options.py +0 -0
  59. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/workspace/render.py +0 -0
  60. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/workspace/templates/default/en/AGENTS.md +0 -0
  61. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/workspace/templates/default/en/discussion/README.md +0 -0
  62. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/workspace/templates/default/en/projects/README.md +0 -0
  63. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/workspace/templates/default/zh/AGENTS.md +0 -0
  64. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/workspace/templates/default/zh/discussion/README.md +0 -0
  65. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/workspace/templates/default/zh/projects/README.md +0 -0
  66. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/workspace.py +0 -0
  67. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/setup/zsh.py +0 -0
  68. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/utils/__init__.py +0 -0
  69. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/utils/custom_logger.py +0 -0
  70. {chatup-0.2.4 → chatup-0.2.6}/src/chatup/utils/pathing.py +0 -0
  71. {chatup-0.2.4 → chatup-0.2.6}/src/chatup.egg-info/dependency_links.txt +0 -0
  72. {chatup-0.2.4 → chatup-0.2.6}/src/chatup.egg-info/requires.txt +0 -0
  73. {chatup-0.2.4 → chatup-0.2.6}/src/chatup.egg-info/top_level.txt +0 -0
  74. {chatup-0.2.4 → chatup-0.2.6}/tests/test_browser_backend_cli.py +0 -0
  75. {chatup-0.2.4 → chatup-0.2.6}/tests/test_chrome_for_testing.py +0 -0
  76. {chatup-0.2.4 → chatup-0.2.6}/tests/test_chromedriver.py +0 -0
  77. {chatup-0.2.4 → chatup-0.2.6}/tests/test_cli_tree_docs.py +0 -0
  78. {chatup-0.2.4 → chatup-0.2.6}/tests/test_crs_setup.py +0 -0
  79. {chatup-0.2.4 → chatup-0.2.6}/tests/test_playwright.py +0 -0
  80. {chatup-0.2.4 → chatup-0.2.6}/tests/test_playwright_cli.py +0 -0
  81. {chatup-0.2.4 → chatup-0.2.6}/tests/test_publish_workflow.py +0 -0
  82. {chatup-0.2.4 → chatup-0.2.6}/tests/test_setup_cli.py +0 -0
  83. {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.4
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`。更完整的能力边界见 `docs/capability-map.md`。
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`。更完整的能力边界见 `docs/capability-map.md`。
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
 
@@ -38,6 +38,9 @@ docs = [
38
38
  [project.scripts]
39
39
  chatup = "chatup.cli:main"
40
40
 
41
+ [project.entry-points."chatenv.configs"]
42
+ cursor-agent = "chatup.config:CursorAgentConfig"
43
+
41
44
  [tool.setuptools.dynamic]
42
45
  version = {attr = "chatup.__version__"}
43
46
 
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.2.4"
5
+ __version__ = "0.2.6"
@@ -6,7 +6,7 @@ from pathlib import Path
6
6
  from typing import Any
7
7
 
8
8
  import click
9
- from chatstyle import (
9
+ from chatup.interaction import (
10
10
  CommandField,
11
11
  CommandSchema,
12
12
  add_interactive_option,
@@ -6,7 +6,7 @@ from pathlib import Path
6
6
  from typing import Any
7
7
 
8
8
  import click
9
- from chatstyle import (
9
+ from chatup.interaction import (
10
10
  CommandField,
11
11
  CommandSchema,
12
12
  add_interactive_option,
@@ -6,7 +6,7 @@ from pathlib import Path
6
6
  from typing import Any
7
7
 
8
8
  import click
9
- from chatstyle import (
9
+ from chatup.interaction import (
10
10
  CommandField,
11
11
  CommandSchema,
12
12
  add_interactive_option,
@@ -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
- from chatstyle.tui.prompt import ask_confirm, ask_path, ask_select, ask_text
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(lambda *args, **kwargs: ask_confirm(*args, **kwargs))
31
- ask_path = staticmethod(lambda *args, **kwargs: ask_path(*args, **kwargs))
32
- ask_select = staticmethod(lambda *args, **kwargs: ask_select(*args, **kwargs))
33
- ask_text = staticmethod(lambda *args, **kwargs: ask_text(*args, **kwargs))
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 _is_interactive_available()
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 = interactive is None and can_prompt and auto_prompt_condition
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