yswj-internal-cli 0.1.2__tar.gz → 0.2.0__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 (22) hide show
  1. {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/PKG-INFO +28 -3
  2. {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/README.md +27 -2
  3. {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/pyproject.toml +1 -1
  4. {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli/AI_ONE_CLICK.md +3 -3
  5. {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli/AI_SETUP.md +19 -5
  6. {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli/__init__.py +1 -1
  7. {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli/cli.py +76 -2
  8. {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli/client.py +28 -8
  9. {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli.egg-info/PKG-INFO +28 -3
  10. {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/tests/test_cli.py +64 -0
  11. {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/tests/test_client.py +30 -4
  12. {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/setup.cfg +0 -0
  13. {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli/__main__.py +0 -0
  14. {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli/config.py +0 -0
  15. {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli/output.py +0 -0
  16. {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli.egg-info/SOURCES.txt +0 -0
  17. {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli.egg-info/dependency_links.txt +0 -0
  18. {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli.egg-info/entry_points.txt +0 -0
  19. {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli.egg-info/requires.txt +0 -0
  20. {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/src/yswj_internal_cli.egg-info/top_level.txt +0 -0
  21. {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/tests/test_config.py +0 -0
  22. {yswj_internal_cli-0.1.2 → yswj_internal_cli-0.2.0}/tests/test_output.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yswj-internal-cli
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Command-line client for 元生无界内部管理系统
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -8,7 +8,7 @@ Requires-Dist: keyring<26,>=25.7
8
8
 
9
9
  # 元生无界内部管理系统 CLI
10
10
 
11
- 命令名:`yswj-internal`。CLI 通过系统开放 API 读取数据,权限仍由 API Key 和服务端账号权限控制。
11
+ 命令名:`yswj-internal`。CLI 通过系统开放 API 读取和写入数据,权限由 API Key 和服务端账号权限共同控制。
12
12
 
13
13
  ## 安装
14
14
 
@@ -35,7 +35,7 @@ pipx install ./cli
35
35
  macOS 或 Windows 用户在系统页面复制自己的 API Key 后,可让 AI 在本机执行一条命令完成安装和配置:
36
36
 
37
37
  ```bash
38
- uvx --from yswj-internal-cli==0.1.2 yswj-internal setup --from-clipboard
38
+ uvx --from yswj-internal-cli==0.2.0 yswj-internal setup --from-clipboard
39
39
  ```
40
40
 
41
41
  需要验证已授权模块时,可加 `--verify projects` 等参数。验证最多请求一条记录,不打印业务数据。其他系统可运行 `yswj-internal setup`,在本地终端的隐藏提示中输入 Key。
@@ -72,6 +72,31 @@ yswj-internal activities list --created-since 2026-11-01T00:00:00+08:00 --limit
72
72
 
73
73
  `export` 会顺序读取所有页并写入指定文件;CSV 按页流式写盘,避免大批数据占满内存,交互终端会显示读取进度。大数据集建议使用 CSV 和增量时间筛选。联系人私密字段、项目财务字段以及各数据范围仍遵循该 Key 的权限;CLI 不会扩大 Key 权限。
74
74
 
75
+ ## 写入命令
76
+
77
+ API Key 默认只读。在系统页面为具体数据模块分别授予新建、修改或删除权限后,CLI 才可执行对应命令;当前账号的网页端业务规则仍然生效。写入内容使用 UTF-8 JSON 文件,`--input -` 可从标准输入读取,但不要将业务数据放进命令参数。
78
+
79
+ ```bash
80
+ # leads.json 内容示例:{"name":"新客户商机","company":"示例公司"}
81
+ yswj-internal leads create --input leads.json
82
+
83
+ # change.json 内容示例:{"name":"更新后的商机名"}
84
+ yswj-internal leads update 101 --input change.json
85
+
86
+ # 项目经理提交修改审批,change.json 可包含 status 和 reason
87
+ yswj-internal projects request-change 501 --input change.json
88
+
89
+ # 管理员或项目经理上级可按网页规则直接修改项目
90
+ yswj-internal projects update 501 --input change.json
91
+
92
+ # 删除需要输入记录 ID 确认;非交互脚本需显式指定 --yes
93
+ yswj-internal leads delete 101
94
+ ```
95
+
96
+ 所有资源均可 `create`、`update`。只有商机、渠道、联系人和跟进记录可 `delete`;项目和待跟进任务通过 `update` 修改 `status`。项目经理不能直接修改项目,须使用 `request-change` 提交审批。联系人电话、微信、邮箱和项目合同编号的写入还需单独的敏感字段权限;写入权限不自动授予读取这些字段的权限。删除商机或渠道会影响关联跟进记录和联系人关联,执行前请核对目标。
97
+
98
+ 写入接口的字段、状态码和副作用见 [开放 API 规范](../docs/api-v1.md)。
99
+
75
100
  ## 命令帮助
76
101
 
77
102
  ```bash
@@ -1,6 +1,6 @@
1
1
  # 元生无界内部管理系统 CLI
2
2
 
3
- 命令名:`yswj-internal`。CLI 通过系统开放 API 读取数据,权限仍由 API Key 和服务端账号权限控制。
3
+ 命令名:`yswj-internal`。CLI 通过系统开放 API 读取和写入数据,权限由 API Key 和服务端账号权限共同控制。
4
4
 
5
5
  ## 安装
6
6
 
@@ -27,7 +27,7 @@ pipx install ./cli
27
27
  macOS 或 Windows 用户在系统页面复制自己的 API Key 后,可让 AI 在本机执行一条命令完成安装和配置:
28
28
 
29
29
  ```bash
30
- uvx --from yswj-internal-cli==0.1.2 yswj-internal setup --from-clipboard
30
+ uvx --from yswj-internal-cli==0.2.0 yswj-internal setup --from-clipboard
31
31
  ```
32
32
 
33
33
  需要验证已授权模块时,可加 `--verify projects` 等参数。验证最多请求一条记录,不打印业务数据。其他系统可运行 `yswj-internal setup`,在本地终端的隐藏提示中输入 Key。
@@ -64,6 +64,31 @@ yswj-internal activities list --created-since 2026-11-01T00:00:00+08:00 --limit
64
64
 
65
65
  `export` 会顺序读取所有页并写入指定文件;CSV 按页流式写盘,避免大批数据占满内存,交互终端会显示读取进度。大数据集建议使用 CSV 和增量时间筛选。联系人私密字段、项目财务字段以及各数据范围仍遵循该 Key 的权限;CLI 不会扩大 Key 权限。
66
66
 
67
+ ## 写入命令
68
+
69
+ API Key 默认只读。在系统页面为具体数据模块分别授予新建、修改或删除权限后,CLI 才可执行对应命令;当前账号的网页端业务规则仍然生效。写入内容使用 UTF-8 JSON 文件,`--input -` 可从标准输入读取,但不要将业务数据放进命令参数。
70
+
71
+ ```bash
72
+ # leads.json 内容示例:{"name":"新客户商机","company":"示例公司"}
73
+ yswj-internal leads create --input leads.json
74
+
75
+ # change.json 内容示例:{"name":"更新后的商机名"}
76
+ yswj-internal leads update 101 --input change.json
77
+
78
+ # 项目经理提交修改审批,change.json 可包含 status 和 reason
79
+ yswj-internal projects request-change 501 --input change.json
80
+
81
+ # 管理员或项目经理上级可按网页规则直接修改项目
82
+ yswj-internal projects update 501 --input change.json
83
+
84
+ # 删除需要输入记录 ID 确认;非交互脚本需显式指定 --yes
85
+ yswj-internal leads delete 101
86
+ ```
87
+
88
+ 所有资源均可 `create`、`update`。只有商机、渠道、联系人和跟进记录可 `delete`;项目和待跟进任务通过 `update` 修改 `status`。项目经理不能直接修改项目,须使用 `request-change` 提交审批。联系人电话、微信、邮箱和项目合同编号的写入还需单独的敏感字段权限;写入权限不自动授予读取这些字段的权限。删除商机或渠道会影响关联跟进记录和联系人关联,执行前请核对目标。
89
+
90
+ 写入接口的字段、状态码和副作用见 [开放 API 规范](../docs/api-v1.md)。
91
+
67
92
  ## 命令帮助
68
93
 
69
94
  ```bash
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "yswj-internal-cli"
7
- version = "0.1.2"
7
+ version = "0.2.0"
8
8
  description = "Command-line client for 元生无界内部管理系统"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -10,20 +10,20 @@
10
10
  4. 用户说“已复制”后,在同一台 **macOS 或 Windows** 电脑的终端运行下面这一条命令:
11
11
 
12
12
  ```bash
13
- uvx --from yswj-internal-cli==0.1.2 yswj-internal setup --from-clipboard
13
+ uvx --from yswj-internal-cli==0.2.0 yswj-internal setup --from-clipboard
14
14
  ```
15
15
 
16
16
  这条命令会按需从 PyPI 安装 CLI、检查当前系统 API Key 的完整格式、配置生产环境地址、把 Key 保存到系统密钥库并读回校验;密钥库不可用时退回本机用户配置文件。保存成功后,CLI 会清空仍包含该 Key 的剪贴板。命令参数和输出都不包含 Key。Windows 通过系统 PowerShell 剪贴板接口读取 Key,无需额外剪贴板工具。
17
17
 
18
18
  5. 只有当用户明确知道 Key 获准访问的模块时,才可在同一条命令末尾加 `--verify MODULE`,例如 `--verify projects`。它只请求最多一条记录且不打印业务数据。模块可选:`leads`、`channels`、`contacts`、`activities`、`follow-up-tasks`、`projects`。不要逐个模块试探权限。
19
- 6. 配置完成后可运行 `uvx --from yswj-internal-cli==0.1.2 yswj-internal config show` 核对地址与 Key 保存状态;此命令不显示 Key。向用户只报告安装版本、配置状态、可选的验证结果和必要的错误信息。
19
+ 6. 配置完成后可运行 `uvx --from yswj-internal-cli==0.2.0 yswj-internal config show` 核对地址与 Key 保存状态;此命令不显示 Key。向用户只报告安装版本、配置状态、可选的验证结果和必要的错误信息。配置 CLI 本身不授权修改业务数据;若用户以后明确要求写入,请先阅读同包 `AI_SETUP.md` 的写入规则。
20
20
 
21
21
  如果 `uvx` 不在本机,请按 [uv 官方安装说明](https://docs.astral.sh/uv/getting-started/installation/)安装,或使用已有的 `yswj-internal` 命令运行 `yswj-internal setup --from-clipboard`。不要从同名的非官方包或不明地址下载脚本。
22
22
 
23
23
  Linux 或无法读取剪贴板的环境中,用户可在自己的交互终端运行:
24
24
 
25
25
  ```bash
26
- uvx --from yswj-internal-cli==0.1.2 yswj-internal setup
26
+ uvx --from yswj-internal-cli==0.2.0 yswj-internal setup
27
27
  ```
28
28
 
29
29
  CLI 会用隐藏输入提示让用户输入并确认 Key。AI 无法安全交接该提示时,让用户在自己的终端完成输入。
@@ -2,19 +2,19 @@
2
2
 
3
3
  本文供 AI 助手读取并按步骤配置 `yswj-internal-cli`。CLI 包名是 `yswj-internal-cli`,实际命令名是 `yswj-internal`。
4
4
 
5
- 若只需快速配置,请先读同包的 `AI_ONE_CLICK.md`。macOS 或 Windows 用户复制系统 API Key 到本机剪贴板后,AI 可运行 `uvx --from yswj-internal-cli==0.1.2 yswj-internal setup --from-clipboard`,一次完成安装、地址与密钥配置。
5
+ 若只需快速配置,请先读同包的 `AI_ONE_CLICK.md`。macOS 或 Windows 用户复制系统 API Key 到本机剪贴板后,AI 可运行 `uvx --from yswj-internal-cli==0.2.0 yswj-internal setup --from-clipboard`,一次完成安装、地址与密钥配置。
6
6
 
7
7
  ## 目标和边界
8
8
 
9
9
  - 配置 CLI 连接用户有权访问的元生无界内部管理系统,并做最小化连通性验证。
10
- - CLI 仅通过开放 API 读取数据。API Key 的数据范围和权限由系统服务端执行,CLI 不会增加账号权限。
10
+ - CLI 通过开放 API 读取或写入数据。API Key 默认只读;服务端同时检查 Key 权限和账号权限。
11
11
  - 默认只验证配置和认证,不读取、导出或汇总业务数据。只有用户另行提出数据任务时,才读取所需数据。
12
12
  - API Key 是凭据。不得让用户把完整 Key 发在 AI 对话中;不得把 Key 放在命令参数、URL、代码、日志、仓库或普通输出中,也不得读取配置文件来查看 Key。
13
13
 
14
14
  ## 需要用户完成的事项
15
15
 
16
16
  1. 线上系统地址为 `http://39.107.72.34:6611`,当前可以直连。安装和本机配置不要求 OpenVPN;若所在网络无法访问,再检查 VPN、网络路由和公司网络策略。
17
- 2. 用户在系统的“API Key 管理”页面创建自己的 Key,选择实际需要的数据范围、只读权限和有效期。完整 Key 创建后只显示一次。
17
+ 2. 用户在系统的“API Key 管理”页面创建自己的 Key,选择实际需要的数据范围、操作权限和有效期。写入权限应只授予确需操作的模块;完整 Key 创建后只显示一次。
18
18
  3. 用户在 AI 能访问的本地终端中直接输入 Key。优先使用 CLI 的隐藏输入提示,不要在 AI 聊天窗口粘贴 Key。
19
19
 
20
20
  若用户使用的是测试或其他环境,使用用户明确给出的站点根地址。站点地址只包含协议、主机和可选端口;不要附加 `/api`、`/api/open/v1`、路径、查询参数或片段。
@@ -59,7 +59,7 @@ python3 -m pip install --user yswj-internal-cli
59
59
  yswj-internal --profile prod config set-server http://39.107.72.34:6611 --activate
60
60
  ```
61
61
 
62
- 当前线上入口使用 HTTP,因此 CLI 会提示传输未加密。`setup` 默认只配置本机,不发送 Key;验证或读取数据会通过 HTTP 明文传输 Key。直连可达不代表传输安全;发送含 Key 的请求时应使用受信任的 VPN,或改用用户提供的 HTTPS 站点地址。
62
+ 当前线上入口使用 HTTP,因此 CLI 会提示传输未加密。`setup` 默认只配置本机,不发送 Key;验证、读取和写入数据都会通过 HTTP 明文传输 Key。直连可达不代表传输安全;发送含 Key 的请求时应使用受信任的 VPN,或改用用户提供的 HTTPS 站点地址。
63
63
 
64
64
  ### 3. 安全保存 API Key
65
65
 
@@ -125,13 +125,22 @@ yswj-internal --profile prod projects list --limit 1 --columns id
125
125
  - JSON 适合后续程序处理;表格输出适合人工查看。只请求任务所需字段时可使用 `--columns`(仅适用于表格列表)。
126
126
  - 对 401、403 或网络错误进行诊断时,不要重试密钥猜测或扩大数据范围。
127
127
 
128
+ ## 写入规则
129
+
130
+ - 只有用户明确要求创建、修改、删除某条业务记录时才执行写入。先确认资源、目标 ID、要改的字段及删除影响;不要把“配置 CLI”理解为授权写入业务数据。
131
+ - 在用户授权的 Key 范围内使用 `create --input FILE`、`update ID --input FILE`。可用 `--input -` 从安全的标准输入读取 JSON;不要把 API Key 或敏感业务数据放到命令参数。
132
+ - 项目经理修改项目使用 `projects request-change ID --input FILE` 提交审批;直接 `projects update` 只适用于网页端允许直接修改的账号。项目和待跟进任务不能删除,可用 `update` 修改 `status`,例如 `cancelled`。
133
+ - `delete ID` 仅适用于商机、渠道、联系人和跟进记录。交互终端会要求再次输入 ID;脚本在用户明确指定目标后才可使用 `--yes`。删除商机或渠道会影响关联记录,删除跟进记录可能删除自动生成的待办。
134
+ - 联系人电话、微信、邮箱需要 `write_contact_private`,项目合同编号需要 `write_project_financial`。写入后返回值仍按读取权限遮蔽敏感字段。
135
+ - 写入错误尤其是超时后,不要自动重试 `POST` 或 `DELETE`,应先按 ID 或列表核对服务端结果,避免重复创建或误删。
136
+
128
137
  ## 常见错误处理
129
138
 
130
139
  | CLI 结果 | AI 应采取的动作 |
131
140
  | --- | --- |
132
141
  | 无法连接、超时、连接被拒绝 | 先确认服务地址为站点根地址,检查直连、VPN 路由和公司网络策略;必要时做一次重试。不要修改 API Key。 |
133
142
  | HTTP 401 | Key 无效、已撤销或过期。让用户在系统页面检查或重新签发,然后通过隐藏提示更新。不要要求用户把 Key 发到聊天中。 |
134
- | HTTP 403 | Key 未获准访问当前模块或字段。确认测试模块确实在 Key 范围内;请用户通过系统页面调整授权,不要尝试其他模块来枚举数据。 |
143
+ | HTTP 403 | Key 未获准当前模块、字段或操作,或账号不具备网页端相同业务权限。检查对应权限;项目经理直接修改项目时应改用 `request-change`。 |
135
144
  | HTTP 404 | 确认地址没有多余路径,ID 和资源名称正确;API 路径由 CLI 自动添加。 |
136
145
  | HTTP 500 或非 JSON 响应 | 可能是服务端异常或站点地址不正确。保留状态码和不含密钥的错误内容,提示用户联系系统维护人员。 |
137
146
  | 配置未设置 | 先设置 profile 的站点地址,再通过隐藏提示保存 Key。 |
@@ -156,6 +165,11 @@ yswj-internal --profile prod projects export \
156
165
  --updated-since 2026-11-01T00:00:00+08:00 \
157
166
  --format csv --output projects.csv
158
167
 
168
+ # 仅在用户明确授权写入时,新建、修改或提交项目审批
169
+ yswj-internal --profile prod leads create --input lead.json
170
+ yswj-internal --profile prod leads update 101 --input change.json
171
+ yswj-internal --profile prod projects request-change 501 --input project-change.json
172
+
159
173
  # 更换或删除当前 profile 的密钥
160
174
  yswj-internal --profile prod config set-key
161
175
  yswj-internal --profile prod config remove-key
@@ -1,3 +1,3 @@
1
1
  """CLI client for 元生无界内部管理系统."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.2.0"
@@ -9,6 +9,7 @@ import subprocess
9
9
  import sys
10
10
  from datetime import datetime
11
11
  from getpass import getpass
12
+ from pathlib import Path
12
13
 
13
14
  from . import __version__
14
15
  from .client import APIClient, APIError
@@ -25,7 +26,9 @@ RESOURCE_FILTERS = {
25
26
  "projects": "updated_since",
26
27
  }
27
28
  RESOURCES = tuple(RESOURCE_FILTERS)
29
+ DELETABLE_RESOURCES = {"leads", "channels", "contacts", "activities"}
28
30
  MAX_PAGE_SIZE = 500
31
+ MAX_WRITE_JSON_SIZE = 1_000_000
29
32
  DEFAULT_SERVER_URL = "http://39.107.72.34:6611"
30
33
 
31
34
 
@@ -173,7 +176,7 @@ def _add_list_options(parser: argparse.ArgumentParser, resource: str, *, export:
173
176
  def create_parser() -> argparse.ArgumentParser:
174
177
  parser = argparse.ArgumentParser(
175
178
  prog="yswj-internal",
176
- description="元生无界内部管理系统只读数据 CLI。",
179
+ description="元生无界内部管理系统 CLI;按 API Key 和账号权限读写数据。",
177
180
  )
178
181
  parser.add_argument("--version", action="version", version=f"yswj-internal {__version__}")
179
182
  parser.add_argument(
@@ -215,7 +218,7 @@ def create_parser() -> argparse.ArgumentParser:
215
218
  config_commands.add_parser("path", help="显示配置文件路径")
216
219
 
217
220
  for resource in RESOURCES:
218
- resource_parser = root.add_parser(resource, help=f"读取 {resource} 数据")
221
+ resource_parser = root.add_parser(resource, help=f"读取或修改 {resource} 数据")
219
222
  actions = resource_parser.add_subparsers(dest="action", required=True)
220
223
 
221
224
  list_parser = actions.add_parser("list", help="读取列表")
@@ -229,6 +232,26 @@ def create_parser() -> argparse.ArgumentParser:
229
232
  export_parser = actions.add_parser("export", help="读取所有分页并导出")
230
233
  _add_list_options(export_parser, resource, export=True)
231
234
 
235
+ create_action_parser = actions.add_parser("create", help="新建记录(需 API Key 新建权限)")
236
+ create_action_parser.add_argument("--input", required=True, help="JSON 文件路径;- 表示从标准输入读取")
237
+ create_action_parser.add_argument("--output", help="将返回的 JSON 写入文件")
238
+
239
+ update_parser = actions.add_parser("update", help="修改记录(需 API Key 修改权限)")
240
+ update_parser.add_argument("id", type=_record_id, help="记录 ID")
241
+ update_parser.add_argument("--input", required=True, help="只包含需要修改字段的 JSON 文件;- 表示标准输入")
242
+ update_parser.add_argument("--output", help="将返回的 JSON 写入文件")
243
+
244
+ if resource == "projects":
245
+ request_parser = actions.add_parser("request-change", help="提交项目修改审批(项目经理使用)")
246
+ request_parser.add_argument("id", type=_record_id, help="项目 ID")
247
+ request_parser.add_argument("--input", required=True, help="含拟修改字段及可选 reason 的 JSON 文件;- 表示标准输入")
248
+ request_parser.add_argument("--output", help="将审批单摘要写入文件")
249
+
250
+ if resource in DELETABLE_RESOURCES:
251
+ delete_parser = actions.add_parser("delete", help="删除记录(需 API Key 删除权限)")
252
+ delete_parser.add_argument("id", type=_record_id, help="记录 ID")
253
+ delete_parser.add_argument("--yes", action="store_true", help="确认删除,供已明确授权的非交互操作使用")
254
+
232
255
  return parser
233
256
 
234
257
 
@@ -361,10 +384,61 @@ def _query_params(args: argparse.Namespace, *, export: bool = False) -> dict:
361
384
  return params
362
385
 
363
386
 
387
+ def _read_write_payload(source: str) -> dict:
388
+ if source == "-":
389
+ if sys.stdin.isatty():
390
+ raise ConfigError("从标准输入读取 JSON 时,请通过管道或重定向提供内容。")
391
+ stream = getattr(sys.stdin, "buffer", None)
392
+ if stream is not None:
393
+ data = stream.read(MAX_WRITE_JSON_SIZE + 1)
394
+ else:
395
+ data = sys.stdin.read(MAX_WRITE_JSON_SIZE + 1).encode("utf-8")
396
+ else:
397
+ path = Path(source).expanduser()
398
+ with path.open("rb") as stream:
399
+ data = stream.read(MAX_WRITE_JSON_SIZE + 1)
400
+ if len(data) > MAX_WRITE_JSON_SIZE:
401
+ raise ConfigError("写入数据超过 1 MB,请缩小单次请求。")
402
+ try:
403
+ raw = data.decode("utf-8")
404
+ except UnicodeDecodeError as exc:
405
+ raise ConfigError("输入文件必须使用 UTF-8 编码。") from exc
406
+ try:
407
+ payload = json.loads(raw)
408
+ except json.JSONDecodeError as exc:
409
+ raise ConfigError(f"输入不是有效 JSON:第 {exc.lineno} 行第 {exc.colno} 列。") from exc
410
+ if not isinstance(payload, dict) or not payload:
411
+ raise ConfigError("写入数据必须是非空 JSON 对象。")
412
+ return payload
413
+
414
+
364
415
  def _data_command(args: argparse.Namespace, store: ConfigStore) -> int:
365
416
  client, profile = _api_client(args, store)
366
417
  resource = getattr(args, "resource", args.command)
367
418
 
419
+ if args.action in {"create", "update", "request-change"}:
420
+ payload = _read_write_payload(args.input)
421
+ if args.action == "create":
422
+ result = client.post_json(resource, payload)
423
+ elif args.action == "update":
424
+ result = client.patch_json(f"{resource}/{args.id}", payload)
425
+ else:
426
+ result = client.post_json(f"projects/{args.id}/change-requests", payload)
427
+ write_result(render_result(result, "json", resource, is_list=False), args.output, "json")
428
+ return 0
429
+
430
+ if args.action == "delete":
431
+ if not args.yes:
432
+ if not sys.stdin.isatty():
433
+ raise ConfigError("删除需要确认;在用户明确指定记录后添加 --yes。")
434
+ answer = input(f"即将删除 {resource} #{args.id},可能影响关联记录。请输入记录 ID 确认:")
435
+ if answer.strip() != str(args.id):
436
+ print("已取消删除。")
437
+ return 0
438
+ client.delete(f"{resource}/{args.id}")
439
+ print(f"已删除 {resource} #{args.id}。")
440
+ return 0
441
+
368
442
  if args.action == "get":
369
443
  payload = client.get_json(f"{resource}/{args.id}")
370
444
  content = render_result(payload, args.format, resource, is_list=False)
@@ -31,15 +31,21 @@ class APIClient:
31
31
  url = f"{url}?{query}"
32
32
  return url
33
33
 
34
- def get_json(self, path: str, params: dict | None = None):
34
+ def _request_json(self, method: str, path: str, params: dict | None = None, payload: dict | None = None):
35
+ headers = {
36
+ "Accept": "application/json",
37
+ "X-API-Key": self.api_key,
38
+ "User-Agent": f"yswj-internal-cli/{__version__}",
39
+ }
40
+ body_data = None
41
+ if payload is not None:
42
+ headers["Content-Type"] = "application/json; charset=utf-8"
43
+ body_data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
35
44
  request = Request(
36
45
  self._url(path, params),
37
- headers={
38
- "Accept": "application/json",
39
- "X-API-Key": self.api_key,
40
- "User-Agent": f"yswj-internal-cli/{__version__}",
41
- },
42
- method="GET",
46
+ data=body_data,
47
+ headers=headers,
48
+ method=method,
43
49
  )
44
50
  try:
45
51
  with urlopen(request, timeout=self.timeout) as response:
@@ -49,7 +55,7 @@ class APIClient:
49
55
  detail = self._error_detail(exc.read())
50
56
  messages = {
51
57
  401: "API Key 无效、已撤销或已过期。请检查当前环境的 API Key。",
52
- 403: "API Key 没有访问此数据范围或字段的权限。",
58
+ 403: "API Key 或账号没有此操作的数据范围、字段或写入权限。",
53
59
  404: "记录不存在,或当前账号无权查看该记录。",
54
60
  }
55
61
  message = messages.get(exc.code, detail or exc.reason or "请求失败")
@@ -60,6 +66,8 @@ class APIClient:
60
66
  except TimeoutError as exc:
61
67
  raise APIError("连接系统服务超时,请稍后重试。") from exc
62
68
 
69
+ if not body and method == "DELETE":
70
+ return None
63
71
  if "application/json" not in content_type.lower():
64
72
  raise APIError("后端未返回 JSON。请检查服务地址是否为系统站点根地址。")
65
73
  try:
@@ -67,6 +75,18 @@ class APIClient:
67
75
  except (UnicodeDecodeError, json.JSONDecodeError) as exc:
68
76
  raise APIError("后端返回的 JSON 格式无效。") from exc
69
77
 
78
+ def get_json(self, path: str, params: dict | None = None):
79
+ return self._request_json("GET", path, params=params)
80
+
81
+ def post_json(self, path: str, payload: dict):
82
+ return self._request_json("POST", path, payload=payload)
83
+
84
+ def patch_json(self, path: str, payload: dict):
85
+ return self._request_json("PATCH", path, payload=payload)
86
+
87
+ def delete(self, path: str) -> None:
88
+ self._request_json("DELETE", path)
89
+
70
90
  @staticmethod
71
91
  def _error_detail(body: bytes) -> str:
72
92
  if not body:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yswj-internal-cli
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Command-line client for 元生无界内部管理系统
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -8,7 +8,7 @@ Requires-Dist: keyring<26,>=25.7
8
8
 
9
9
  # 元生无界内部管理系统 CLI
10
10
 
11
- 命令名:`yswj-internal`。CLI 通过系统开放 API 读取数据,权限仍由 API Key 和服务端账号权限控制。
11
+ 命令名:`yswj-internal`。CLI 通过系统开放 API 读取和写入数据,权限由 API Key 和服务端账号权限共同控制。
12
12
 
13
13
  ## 安装
14
14
 
@@ -35,7 +35,7 @@ pipx install ./cli
35
35
  macOS 或 Windows 用户在系统页面复制自己的 API Key 后,可让 AI 在本机执行一条命令完成安装和配置:
36
36
 
37
37
  ```bash
38
- uvx --from yswj-internal-cli==0.1.2 yswj-internal setup --from-clipboard
38
+ uvx --from yswj-internal-cli==0.2.0 yswj-internal setup --from-clipboard
39
39
  ```
40
40
 
41
41
  需要验证已授权模块时,可加 `--verify projects` 等参数。验证最多请求一条记录,不打印业务数据。其他系统可运行 `yswj-internal setup`,在本地终端的隐藏提示中输入 Key。
@@ -72,6 +72,31 @@ yswj-internal activities list --created-since 2026-11-01T00:00:00+08:00 --limit
72
72
 
73
73
  `export` 会顺序读取所有页并写入指定文件;CSV 按页流式写盘,避免大批数据占满内存,交互终端会显示读取进度。大数据集建议使用 CSV 和增量时间筛选。联系人私密字段、项目财务字段以及各数据范围仍遵循该 Key 的权限;CLI 不会扩大 Key 权限。
74
74
 
75
+ ## 写入命令
76
+
77
+ API Key 默认只读。在系统页面为具体数据模块分别授予新建、修改或删除权限后,CLI 才可执行对应命令;当前账号的网页端业务规则仍然生效。写入内容使用 UTF-8 JSON 文件,`--input -` 可从标准输入读取,但不要将业务数据放进命令参数。
78
+
79
+ ```bash
80
+ # leads.json 内容示例:{"name":"新客户商机","company":"示例公司"}
81
+ yswj-internal leads create --input leads.json
82
+
83
+ # change.json 内容示例:{"name":"更新后的商机名"}
84
+ yswj-internal leads update 101 --input change.json
85
+
86
+ # 项目经理提交修改审批,change.json 可包含 status 和 reason
87
+ yswj-internal projects request-change 501 --input change.json
88
+
89
+ # 管理员或项目经理上级可按网页规则直接修改项目
90
+ yswj-internal projects update 501 --input change.json
91
+
92
+ # 删除需要输入记录 ID 确认;非交互脚本需显式指定 --yes
93
+ yswj-internal leads delete 101
94
+ ```
95
+
96
+ 所有资源均可 `create`、`update`。只有商机、渠道、联系人和跟进记录可 `delete`;项目和待跟进任务通过 `update` 修改 `status`。项目经理不能直接修改项目,须使用 `request-change` 提交审批。联系人电话、微信、邮箱和项目合同编号的写入还需单独的敏感字段权限;写入权限不自动授予读取这些字段的权限。删除商机或渠道会影响关联跟进记录和联系人关联,执行前请核对目标。
97
+
98
+ 写入接口的字段、状态码和副作用见 [开放 API 规范](../docs/api-v1.md)。
99
+
75
100
  ## 命令帮助
76
101
 
77
102
  ```bash
@@ -177,3 +177,67 @@ def test_setup_detects_truncated_key_after_saving():
177
177
 
178
178
  with pytest.raises(config_module.ConfigError, match="保存后校验失败"):
179
179
  cli_module._save_and_check_key(TruncatingStore(), "prod", secret)
180
+
181
+
182
+ def test_write_parser_matches_web_delete_abilities():
183
+ parser = create_parser()
184
+ for resource in cli_module.RESOURCES:
185
+ create = parser.parse_args([resource, "create", "--input", "record.json"])
186
+ update = parser.parse_args([resource, "update", "7", "--input", "-"])
187
+ assert (create.action, create.input) == ("create", "record.json")
188
+ assert (update.action, update.id, update.input) == ("update", 7, "-")
189
+ if resource in cli_module.DELETABLE_RESOURCES:
190
+ assert parser.parse_args([resource, "delete", "7", "--yes"]).yes
191
+ else:
192
+ with pytest.raises(SystemExit):
193
+ parser.parse_args([resource, "delete", "7"])
194
+ assert parser.parse_args(["projects", "request-change", "7", "--input", "change.json"]).id == 7
195
+
196
+
197
+ def test_cli_create_update_and_delete_use_explicit_payload_and_confirmation(tmp_path, monkeypatch, capsys):
198
+ calls = []
199
+
200
+ class FakeClient:
201
+ def post_json(self, path, payload):
202
+ calls.append(("POST", path, payload))
203
+ return {"id": 7, **payload}
204
+
205
+ def patch_json(self, path, payload):
206
+ calls.append(("PATCH", path, payload))
207
+ return {"id": 7, **payload}
208
+
209
+ def delete(self, path):
210
+ calls.append(("DELETE", path))
211
+
212
+ monkeypatch.setattr(cli_module, "_api_client", lambda args, store: (FakeClient(), "prod"))
213
+ payload_path = tmp_path / "record.json"
214
+ payload_path.write_text('{"name":"新建商机"}', encoding="utf-8")
215
+ assert cli_module.main(["leads", "create", "--input", str(payload_path)]) == 0
216
+ monkeypatch.setattr(cli_module.sys, "stdin", io.StringIO('{"name":"更新商机"}'))
217
+ assert cli_module.main(["leads", "update", "7", "--input", "-"]) == 0
218
+ payload_path.write_text('{"status":"active","reason":"客户已启动"}', encoding="utf-8")
219
+ assert cli_module.main(["projects", "request-change", "501", "--input", str(payload_path)]) == 0
220
+ assert cli_module.main(["leads", "delete", "7"]) == 1
221
+ assert cli_module.main(["leads", "delete", "7", "--yes"]) == 0
222
+ assert calls == [
223
+ ("POST", "leads", {"name": "新建商机"}),
224
+ ("PATCH", "leads/7", {"name": "更新商机"}),
225
+ ("POST", "projects/501/change-requests", {"status": "active", "reason": "客户已启动"}),
226
+ ("DELETE", "leads/7"),
227
+ ]
228
+ out = capsys.readouterr()
229
+ assert "新建商机" in out.out and "更新商机" in out.out
230
+ assert "删除需要确认" in out.err
231
+
232
+
233
+ def test_write_payload_rejects_invalid_and_oversized_input(tmp_path):
234
+ payload_path = tmp_path / "record.json"
235
+ payload_path.write_text('[]', encoding="utf-8")
236
+ with pytest.raises(config_module.ConfigError, match="非空 JSON 对象"):
237
+ cli_module._read_write_payload(str(payload_path))
238
+ payload_path.write_text('{"name":', encoding="utf-8")
239
+ with pytest.raises(config_module.ConfigError, match="有效 JSON"):
240
+ cli_module._read_write_payload(str(payload_path))
241
+ payload_path.write_text("x" * (cli_module.MAX_WRITE_JSON_SIZE + 1), encoding="utf-8")
242
+ with pytest.raises(config_module.ConfigError, match="超过 1 MB"):
243
+ cli_module._read_write_payload(str(payload_path))
@@ -9,9 +9,9 @@ from yswj_internal_cli.client import APIClient, APIError
9
9
 
10
10
 
11
11
  class FakeResponse:
12
- def __init__(self, payload):
13
- self.body = json.dumps(payload).encode("utf-8")
14
- self.headers = {"Content-Type": "application/json"}
12
+ def __init__(self, payload=None, *, empty=False):
13
+ self.body = b"" if empty else json.dumps(payload).encode("utf-8")
14
+ self.headers = {"Content-Type": "" if empty else "application/json"}
15
15
 
16
16
  def __enter__(self):
17
17
  return self
@@ -78,7 +78,7 @@ def test_single_page_list_does_not_fetch_the_next_page(monkeypatch):
78
78
  ("status", "expected"),
79
79
  [
80
80
  (401, "API Key 无效、已撤销或已过期"),
81
- (403, "没有访问此数据范围或字段的权限"),
81
+ (403, "没有此操作的数据范围、字段或写入权限"),
82
82
  ],
83
83
  )
84
84
  def test_auth_errors_are_actionable_and_do_not_expose_the_key(monkeypatch, status, expected):
@@ -95,3 +95,29 @@ def test_auth_errors_are_actionable_and_do_not_expose_the_key(monkeypatch, statu
95
95
 
96
96
  assert expected in str(error.value)
97
97
  assert "crm_live_sensitive-secret" not in str(error.value)
98
+
99
+
100
+ def test_write_methods_send_json_and_delete_accepts_empty_204(monkeypatch):
101
+ requests = []
102
+ responses = [FakeResponse({"id": 8}), FakeResponse({"id": 8, "name": "更新"}), FakeResponse(empty=True)]
103
+
104
+ def fake_urlopen(request, timeout):
105
+ requests.append(request)
106
+ assert timeout == 30.0
107
+ return responses.pop(0)
108
+
109
+ monkeypatch.setattr(client_module, "urlopen", fake_urlopen)
110
+ api = APIClient("https://example.com", "crm_live_sensitive-secret")
111
+ assert api.post_json("leads", {"name": "新建"}) == {"id": 8}
112
+ assert api.patch_json("leads/8", {"name": "更新"})["name"] == "更新"
113
+ assert api.delete("leads/8") is None
114
+
115
+ assert [request.get_method() for request in requests] == ["POST", "PATCH", "DELETE"]
116
+ assert [request.full_url for request in requests] == [
117
+ "https://example.com/api/open/v1/leads",
118
+ "https://example.com/api/open/v1/leads/8",
119
+ "https://example.com/api/open/v1/leads/8",
120
+ ]
121
+ assert [json.loads(request.data) for request in requests[:2]] == [{"name": "新建"}, {"name": "更新"}]
122
+ assert all(request.get_header("X-api-key") == "crm_live_sensitive-secret" for request in requests)
123
+ assert all("crm_live_sensitive-secret" not in request.full_url for request in requests)