sci-agent 0.2.0__tar.gz → 0.3.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 (53) hide show
  1. {sci_agent-0.2.0 → sci_agent-0.3.0}/PKG-INFO +2 -1
  2. {sci_agent-0.2.0 → sci_agent-0.3.0}/README.md +60 -0
  3. {sci_agent-0.2.0 → sci_agent-0.3.0}/pyproject.toml +3 -2
  4. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/sci_agent.egg-info/PKG-INFO +2 -1
  5. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/sci_agent.egg-info/SOURCES.txt +4 -0
  6. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/sci_agent.egg-info/requires.txt +1 -0
  7. sci_agent-0.3.0/src/simple_ci/__init__.py +1 -0
  8. sci_agent-0.3.0/src/simple_ci/__main__.py +74 -0
  9. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/simple_ci/actions/__init__.py +77 -19
  10. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/simple_ci/actions/base.py +24 -0
  11. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/simple_ci/cli/client.py +46 -0
  12. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/simple_ci/cli/config.py +22 -1
  13. sci_agent-0.3.0/src/simple_ci/cli/tools.py +182 -0
  14. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/simple_ci/models.py +16 -0
  15. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/simple_ci/server/app.py +10 -4
  16. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/simple_ci/server/executor.py +18 -3
  17. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/simple_ci/server/main.py +15 -1
  18. sci_agent-0.3.0/src/simple_ci/server/tools.py +548 -0
  19. {sci_agent-0.2.0 → sci_agent-0.3.0}/tests/test_actions_commands.py +121 -0
  20. {sci_agent-0.2.0 → sci_agent-0.3.0}/tests/test_cli.py +118 -1
  21. {sci_agent-0.2.0 → sci_agent-0.3.0}/tests/test_config_loader.py +106 -0
  22. {sci_agent-0.2.0 → sci_agent-0.3.0}/tests/test_executor.py +38 -0
  23. sci_agent-0.3.0/tests/test_server_api.py +708 -0
  24. sci_agent-0.3.0/tests/test_server_tools.py +350 -0
  25. sci_agent-0.3.0/tests/test_tools_cli.py +594 -0
  26. {sci_agent-0.2.0 → sci_agent-0.3.0}/tests/test_version.py +1 -1
  27. sci_agent-0.2.0/src/simple_ci/__init__.py +0 -1
  28. sci_agent-0.2.0/src/simple_ci/__main__.py +0 -42
  29. sci_agent-0.2.0/tests/test_server_api.py +0 -353
  30. {sci_agent-0.2.0 → sci_agent-0.3.0}/setup.cfg +0 -0
  31. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/sci_agent.egg-info/dependency_links.txt +0 -0
  32. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/sci_agent.egg-info/entry_points.txt +0 -0
  33. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/sci_agent.egg-info/top_level.txt +0 -0
  34. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/simple_ci/actions/build.py +0 -0
  35. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/simple_ci/actions/deploy.py +0 -0
  36. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/simple_ci/actions/hooks.py +0 -0
  37. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/simple_ci/actions/image.py +0 -0
  38. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/simple_ci/actions/source.py +0 -0
  39. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/simple_ci/actions/target.py +0 -0
  40. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/simple_ci/cli/__init__.py +0 -0
  41. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/simple_ci/cli/history.py +0 -0
  42. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/simple_ci/cli/main.py +0 -0
  43. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/simple_ci/cli/render.py +0 -0
  44. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/simple_ci/errors.py +0 -0
  45. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/simple_ci/server/__init__.py +0 -0
  46. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/simple_ci/server/auth.py +0 -0
  47. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/simple_ci/server/store.py +0 -0
  48. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/simple_ci/ui/__init__.py +0 -0
  49. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/simple_ci/ui/server.py +0 -0
  50. {sci_agent-0.2.0 → sci_agent-0.3.0}/src/simple_ci/ui/static/index.html +0 -0
  51. {sci_agent-0.2.0 → sci_agent-0.3.0}/tests/test_history.py +0 -0
  52. {sci_agent-0.2.0 → sci_agent-0.3.0}/tests/test_hooks.py +0 -0
  53. {sci_agent-0.2.0 → sci_agent-0.3.0}/tests/test_render.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sci-agent
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Agent-first lightweight CI/CD tool
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: fastapi>=0.110.0
@@ -8,4 +8,5 @@ Requires-Dist: uvicorn>=0.29.0
8
8
  Requires-Dist: httpx>=0.27.0
9
9
  Requires-Dist: pyyaml>=6.0
10
10
  Requires-Dist: jinja2>=3.1.0
11
+ Requires-Dist: python-multipart>=0.0.20
11
12
  Requires-Dist: python-dotenv>=1.0.0
@@ -23,6 +23,19 @@ uvx --from sci-agent sci-agent server --port 9090 --token xxx
23
23
  ```
24
24
 
25
25
  For production-like usage, prefer `SCI_AGENT_TOKEN` over passing the token on the command line.
26
+ Set the target server workspace base directory with `--base-dir`:
27
+
28
+ ```powershell
29
+ uvx --from sci-agent sci-agent server --port 9090 --token xxx --base-dir /opt/sci-agent
30
+ ```
31
+
32
+ Or with an environment variable:
33
+
34
+ ```bash
35
+ SCI_AGENT_BASE_DIR=/opt/sci-agent sci-agent server --port 9090 --token xxx
36
+ ```
37
+
38
+ The priority is: `--base-dir > SCI_AGENT_BASE_DIR > current working directory`.
26
39
 
27
40
  ## Configuration layout
28
41
 
@@ -83,6 +96,53 @@ branch = "release"
83
96
  credential = "gitlab-ai-threat"
84
97
  ```
85
98
 
99
+ ## Target server tools
100
+
101
+ The target server can store uploaded tool archives, extract them under the server `base_dir`, maintain a manifest, and return stable paths relative to `<base_dir>`. This is useful for tools such as Maven or JDK that pipelines need to reference explicitly.
102
+
103
+ For `base_dir=/opt/sci-agent`, the server-managed layout is:
104
+
105
+ ```text
106
+ /opt/sci-agent/
107
+ └── tools/
108
+ ├── packages/
109
+ │ └── apache-maven-3.9.9-bin.tar.gz
110
+ ├── installed/
111
+ │ ├── maven/
112
+ │ └── jdk/
113
+ └── manifest.json
114
+ ```
115
+
116
+ Tool configuration belongs in the local target TOML. Do not put server `base_dir` in target tool configuration; the server decides `base_dir` only at startup.
117
+
118
+ ```toml
119
+ [tools.maven]
120
+ archive = "packages/apache-maven-3.9.9-bin.tar.gz"
121
+ strip_root = true
122
+
123
+ [tools.maven.bins]
124
+ mvn = "bin/mvn"
125
+
126
+ [tools.jdk]
127
+ archive = "packages/jdk-8u333-linux-x64.tar.gz"
128
+ strip_root = true
129
+
130
+ [tools.jdk.bins]
131
+ java = "bin/java"
132
+ javac = "bin/javac"
133
+ ```
134
+
135
+ Upload, list, and remove tools:
136
+
137
+ ```powershell
138
+ uvx --from sci-agent sci-agent tools list --env 10-50-10-18
139
+ uvx --from sci-agent sci-agent tools upload --env 10-50-10-18 --tool maven
140
+ uvx --from sci-agent sci-agent tools upload --env 10-50-10-18 --all
141
+ uvx --from sci-agent sci-agent tools remove --env 10-50-10-18 --tool maven --purge-package
142
+ ```
143
+
144
+ This feature does not automatically inject `PATH`, `JAVA_HOME`, or `MAVEN_HOME`. Pipelines should explicitly reference returned paths such as `tools/installed/maven/bin/mvn` or configure their own environment variables in pipeline steps.
145
+
86
146
  ## Running pipelines
87
147
 
88
148
  Inspect the resolved plan without executing it:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sci-agent"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Agent-first lightweight CI/CD tool"
9
9
  requires-python = ">=3.12"
10
10
  dependencies = [
@@ -13,6 +13,7 @@ dependencies = [
13
13
  "httpx>=0.27.0",
14
14
  "pyyaml>=6.0",
15
15
  "jinja2>=3.1.0",
16
+ "python-multipart>=0.0.20",
16
17
  "python-dotenv>=1.0.0",
17
18
  ]
18
19
 
@@ -30,4 +31,4 @@ where = ["src"]
30
31
  include = ["simple_ci*"]
31
32
 
32
33
  [tool.setuptools.package-data]
33
- simple_ci = ["ui/static/*.html", "ui/static/*.js", "ui/static/*.css"]
34
+ simple_ci = ["ui/static/*.html", "ui/static/*.js", "ui/static/*.css"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sci-agent
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Agent-first lightweight CI/CD tool
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: fastapi>=0.110.0
@@ -8,4 +8,5 @@ Requires-Dist: uvicorn>=0.29.0
8
8
  Requires-Dist: httpx>=0.27.0
9
9
  Requires-Dist: pyyaml>=6.0
10
10
  Requires-Dist: jinja2>=3.1.0
11
+ Requires-Dist: python-multipart>=0.0.20
11
12
  Requires-Dist: python-dotenv>=1.0.0
@@ -24,12 +24,14 @@ src/simple_ci/cli/config.py
24
24
  src/simple_ci/cli/history.py
25
25
  src/simple_ci/cli/main.py
26
26
  src/simple_ci/cli/render.py
27
+ src/simple_ci/cli/tools.py
27
28
  src/simple_ci/server/__init__.py
28
29
  src/simple_ci/server/app.py
29
30
  src/simple_ci/server/auth.py
30
31
  src/simple_ci/server/executor.py
31
32
  src/simple_ci/server/main.py
32
33
  src/simple_ci/server/store.py
34
+ src/simple_ci/server/tools.py
33
35
  src/simple_ci/ui/__init__.py
34
36
  src/simple_ci/ui/server.py
35
37
  src/simple_ci/ui/static/index.html
@@ -41,4 +43,6 @@ tests/test_history.py
41
43
  tests/test_hooks.py
42
44
  tests/test_render.py
43
45
  tests/test_server_api.py
46
+ tests/test_server_tools.py
47
+ tests/test_tools_cli.py
44
48
  tests/test_version.py
@@ -3,4 +3,5 @@ uvicorn>=0.29.0
3
3
  httpx>=0.27.0
4
4
  pyyaml>=6.0
5
5
  jinja2>=3.1.0
6
+ python-multipart>=0.0.20
6
7
  python-dotenv>=1.0.0
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import ctypes
4
+ import sys
5
+
6
+
7
+ TOP_LEVEL_HELP = """usage: sci-agent [-h] [--root ROOT] [--host HOST] [--port PORT] [--open]
8
+ sci-agent run [-h] [--env ENV] [--project PROJECT] [--pipeline PIPELINE] ...
9
+ sci-agent server [-h] [--host HOST] [--port PORT] [--token TOKEN]
10
+ sci-agent tools [-h] {list,upload,remove} ...
11
+
12
+ Agent-first lightweight CI/CD tool.
13
+
14
+ commands:
15
+ run Run CI pipelines, inspect configuration, and view local history.
16
+ server Run the target execution API server.
17
+ tools Manage target-side tool packages.
18
+
19
+ default:
20
+ Without a command, sci-agent starts the local Web UI.
21
+
22
+ common examples:
23
+ sci-agent
24
+ sci-agent run --targets
25
+ sci-agent run --env 10-50-10-18 --project ai-threat --pipeline server-openapi --yes
26
+ sci-agent server --host 0.0.0.0 --port 9090 --token <token>
27
+ sci-agent tools list --env 10-50-10-18
28
+
29
+ Use "sci-agent run --help", "sci-agent server --help", or "sci-agent tools --help" for command-specific options.
30
+ """
31
+
32
+
33
+ def _configure_stdio_encoding() -> None:
34
+ if sys.platform == "win32":
35
+ try:
36
+ kernel32 = ctypes.windll.kernel32
37
+ kernel32.SetConsoleOutputCP(65001)
38
+ kernel32.SetConsoleCP(65001)
39
+ except Exception:
40
+ pass
41
+
42
+ for stream in (sys.stdout, sys.stderr, sys.stdin):
43
+ try:
44
+ stream.reconfigure(encoding="utf-8", errors="replace")
45
+ except Exception:
46
+ pass
47
+
48
+
49
+ def main() -> None:
50
+ _configure_stdio_encoding()
51
+
52
+ argv = sys.argv[1:]
53
+ if argv and argv[0] in {"-h", "--help"}:
54
+ print(TOP_LEVEL_HELP)
55
+ elif argv and argv[0] == "server":
56
+ from simple_ci.server.main import handle_server
57
+
58
+ handle_server(argv[1:])
59
+ elif argv and argv[0] == "run":
60
+ from simple_ci.cli.main import handle_run
61
+
62
+ handle_run(argv[1:])
63
+ elif argv and argv[0] == "tools":
64
+ from simple_ci.cli.tools import handle_tools
65
+
66
+ handle_tools(argv[1:])
67
+ else:
68
+ from simple_ci.ui.server import run_ui
69
+
70
+ run_ui(argv)
71
+
72
+
73
+ if __name__ == "__main__":
74
+ main()
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from collections.abc import Callable
6
+ from pathlib import Path
6
7
  from typing import Any
7
8
 
8
9
  from simple_ci.actions import base
@@ -12,10 +13,18 @@ from simple_ci.actions.image import build_image_command, build_push_command
12
13
  from simple_ci.actions.source import build_git_pull_command
13
14
  from simple_ci.actions.target import build_target_shell_command
14
15
 
15
- ActionHandler = Callable[[dict[str, Any], dict[str, Any]], base.ActionResult]
16
+ ActionHandler = Callable[
17
+ [dict[str, Any], dict[str, Any], str | Path | None],
18
+ base.ActionResult,
19
+ ]
16
20
 
17
21
 
18
- def dispatch_action(step: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
22
+ def dispatch_action(
23
+ step: dict[str, Any],
24
+ payload: dict[str, Any],
25
+ *,
26
+ base_dir: str | Path | None = None,
27
+ ) -> dict[str, Any]:
19
28
  """根据 step.uses 分发内置动作,并返回可 JSON 序列化的动作结果。"""
20
29
 
21
30
  action_name = str(step.get("uses") or step.get("action") or "")
@@ -26,7 +35,7 @@ def dispatch_action(step: dict[str, Any], payload: dict[str, Any]) -> dict[str,
26
35
  exit_code=1,
27
36
  stderr_tail=f"Unknown action: {action_name or '<missing>'}",
28
37
  ).to_dict()
29
- return handler(step, payload).to_dict()
38
+ return handler(step, payload, base_dir).to_dict()
30
39
 
31
40
 
32
41
  def _params(step: dict[str, Any]) -> dict[str, Any]:
@@ -40,13 +49,15 @@ def _run_built_command(
40
49
  command: list[str] | list[list[str]] | str,
41
50
  params: dict[str, Any],
42
51
  *,
52
+ base_dir: str | Path | None = None,
43
53
  allow_shell: bool = False,
54
+ cwd_keys: tuple[str, ...] = ("cwd", "workdir", "work_dir", "working_dir"),
44
55
  ) -> base.ActionResult:
45
56
  """统一执行命令并注入动作参数中的敏感值用于结果脱敏。"""
46
57
 
47
58
  return base.run_command(
48
59
  command,
49
- cwd=params.get("cwd") or params.get("workdir"),
60
+ cwd=base.resolve_process_cwd(params, base_dir=base_dir, cwd_keys=cwd_keys),
50
61
  env=params.get("env"),
51
62
  secret_values=base.collect_secret_values(params),
52
63
  timeout_seconds=float(params.get("timeout_seconds", 300)),
@@ -54,28 +65,59 @@ def _run_built_command(
54
65
  )
55
66
 
56
67
 
57
- def _source_git_pull(step: dict[str, Any], _payload: dict[str, Any]) -> base.ActionResult:
68
+ def _source_git_pull(
69
+ step: dict[str, Any],
70
+ _payload: dict[str, Any],
71
+ base_dir: str | Path | None,
72
+ ) -> base.ActionResult:
58
73
  params = _params(step)
59
74
  if isinstance(params.get("credential"), str):
60
75
  message = "Unresolved credential id for source.git_pull; CLI must send a credential dict."
61
76
  return base.ActionResult(rendered_command="", exit_code=1, stderr_tail=message, error=message)
62
- return _run_built_command(build_git_pull_command(params), params)
77
+ command_params = dict(params)
78
+ if "work_dir" in command_params and "directory" not in command_params and "checkout_dir" not in command_params:
79
+ command_params["directory"] = command_params["work_dir"]
80
+ process_cwd = base.resolve_process_cwd(
81
+ command_params,
82
+ base_dir=base_dir,
83
+ cwd_keys=("cwd", "workdir", "working_dir"),
84
+ )
85
+ if process_cwd is not None:
86
+ command_params["cwd"] = process_cwd
87
+ return _run_built_command(
88
+ build_git_pull_command(command_params),
89
+ command_params,
90
+ base_dir=base_dir,
91
+ cwd_keys=("cwd", "workdir", "working_dir"),
92
+ )
63
93
 
64
94
 
65
- def _build_maven(step: dict[str, Any], _payload: dict[str, Any]) -> base.ActionResult:
95
+ def _build_maven(
96
+ step: dict[str, Any],
97
+ _payload: dict[str, Any],
98
+ base_dir: str | Path | None,
99
+ ) -> base.ActionResult:
66
100
  params = _params(step)
67
- return _run_built_command(build_maven_command(params), params)
101
+ return _run_built_command(build_maven_command(params), params, base_dir=base_dir)
68
102
 
69
103
 
70
- def _shell_action(step: dict[str, Any], _payload: dict[str, Any]) -> base.ActionResult:
104
+ def _shell_action(
105
+ step: dict[str, Any],
106
+ _payload: dict[str, Any],
107
+ base_dir: str | Path | None,
108
+ ) -> base.ActionResult:
71
109
  params = _params(step)
72
110
  command = params.get("command") or step.get("command")
73
111
  if not isinstance(command, (str, list)):
74
112
  return base.ActionResult(rendered_command="", exit_code=1, stderr_tail="Missing shell command.")
75
- return _run_built_command(command, params, allow_shell=True)
113
+ return _run_built_command(command, params, base_dir=base_dir, allow_shell=True)
76
114
 
77
115
 
78
- def _target_shell(step: dict[str, Any], payload: dict[str, Any]) -> base.ActionResult:
116
+ def _target_shell(
117
+ step: dict[str, Any],
118
+ payload: dict[str, Any],
119
+ base_dir: str | Path | None,
120
+ ) -> base.ActionResult:
79
121
  """执行目标脚本或 inline 命令;script 模式使用 argv,inline 字符串保留 shell 能力。"""
80
122
 
81
123
  params = _params(step)
@@ -83,28 +125,44 @@ def _target_shell(step: dict[str, Any], payload: dict[str, Any]) -> base.ActionR
83
125
  command = build_target_shell_command(params, payload)
84
126
  except ValueError as exc:
85
127
  return base.ActionResult(rendered_command="", exit_code=1, stderr_tail=str(exc), error=str(exc))
86
- return _run_built_command(command, params, allow_shell=isinstance(command, str))
128
+ return _run_built_command(command, params, base_dir=base_dir, allow_shell=isinstance(command, str))
87
129
 
88
130
 
89
- def _image_build(step: dict[str, Any], _payload: dict[str, Any]) -> base.ActionResult:
131
+ def _image_build(
132
+ step: dict[str, Any],
133
+ _payload: dict[str, Any],
134
+ base_dir: str | Path | None,
135
+ ) -> base.ActionResult:
90
136
  params = _params(step)
91
- result = _run_built_command(build_image_command(params), params)
137
+ result = _run_built_command(build_image_command(params), params, base_dir=base_dir)
92
138
  if result.exit_code == 0:
93
139
  result.image_tag = f"{params['image']}:{params.get('tag', 'latest')}"
94
140
  return result
95
141
 
96
142
 
97
- def _image_push(step: dict[str, Any], _payload: dict[str, Any]) -> base.ActionResult:
143
+ def _image_push(
144
+ step: dict[str, Any],
145
+ _payload: dict[str, Any],
146
+ base_dir: str | Path | None,
147
+ ) -> base.ActionResult:
98
148
  params = _params(step)
99
- return _run_built_command(build_push_command(params), params)
149
+ return _run_built_command(build_push_command(params), params, base_dir=base_dir)
100
150
 
101
151
 
102
- def _deploy_compose(step: dict[str, Any], _payload: dict[str, Any]) -> base.ActionResult:
152
+ def _deploy_compose(
153
+ step: dict[str, Any],
154
+ _payload: dict[str, Any],
155
+ base_dir: str | Path | None,
156
+ ) -> base.ActionResult:
103
157
  params = _params(step)
104
- return _run_built_command(build_compose_command(params), params)
158
+ return _run_built_command(build_compose_command(params), params, base_dir=base_dir)
105
159
 
106
160
 
107
- def _deploy_healthcheck(step: dict[str, Any], _payload: dict[str, Any]) -> base.ActionResult:
161
+ def _deploy_healthcheck(
162
+ step: dict[str, Any],
163
+ _payload: dict[str, Any],
164
+ _base_dir: str | Path | None,
165
+ ) -> base.ActionResult:
108
166
  """执行 HTTP 健康检查;失败时以非零退出码进入流水线短路。"""
109
167
 
110
168
  params = _params(step)
@@ -6,6 +6,7 @@ import re
6
6
  import subprocess
7
7
  import time
8
8
  from dataclasses import dataclass
9
+ from pathlib import Path
9
10
  from typing import Any
10
11
  from urllib.parse import quote, urlsplit, urlunsplit
11
12
 
@@ -145,6 +146,29 @@ def run_command(
145
146
  )
146
147
 
147
148
 
149
+ def resolve_process_cwd(
150
+ params: dict[str, Any],
151
+ *,
152
+ base_dir: str | Path | None = None,
153
+ cwd_keys: tuple[str, ...] = ("cwd", "workdir", "work_dir", "working_dir"),
154
+ ) -> str | None:
155
+ """解析动作进程 cwd;未显式配置时默认使用服务端 base_dir。"""
156
+
157
+ configured_cwd = next(
158
+ (params[key] for key in cwd_keys if params.get(key) not in (None, "")),
159
+ None,
160
+ )
161
+ if configured_cwd is None:
162
+ return str(Path(base_dir).resolve()) if base_dir is not None else None
163
+
164
+ cwd_path = Path(str(configured_cwd))
165
+ if cwd_path.is_absolute():
166
+ return str(cwd_path.resolve())
167
+ if base_dir is not None:
168
+ return str((Path(base_dir) / cwd_path).resolve())
169
+ return str(cwd_path)
170
+
171
+
148
172
  def _normalize_commands(command: list[str] | list[list[str]] | str) -> list[list[str] | str]:
149
173
  """把单命令或多命令统一为顺序执行列表。"""
150
174
 
@@ -1,6 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  import time
5
+ from pathlib import Path
4
6
  from typing import Any
5
7
 
6
8
  import httpx
@@ -50,6 +52,50 @@ class SimpleCiClient:
50
52
 
51
53
  return self._request_json("GET", f"/api/v1/runs/{run_id}/logs")
52
54
 
55
+ def list_tools(self) -> dict[str, Any]:
56
+ """查询服务端已安装工具列表。"""
57
+
58
+ return self._request_json("GET", "/api/v1/tools")
59
+
60
+ def get_tool(self, tool_id: str) -> dict[str, Any]:
61
+ """查询指定 tool_id 的工具详情。"""
62
+
63
+ return self._request_json("GET", f"/api/v1/tools/{tool_id}")
64
+
65
+ def remove_tool(self, tool_id: str, purge_package: bool = False) -> dict[str, Any]:
66
+ """删除指定工具;默认保留原始归档包。"""
67
+
68
+ return self._request_json(
69
+ "DELETE",
70
+ f"/api/v1/tools/{tool_id}",
71
+ params={"purge_package": str(purge_package).lower()},
72
+ )
73
+
74
+ def upload_tool(
75
+ self,
76
+ tool_id: str,
77
+ archive_path: str | Path,
78
+ strip_root: bool,
79
+ bins: dict[str, Any],
80
+ force: bool = False,
81
+ ) -> dict[str, Any]:
82
+ """以 multipart 方式上传工具归档包并安装。"""
83
+
84
+ archive = Path(archive_path)
85
+ data = {
86
+ "tool_id": tool_id,
87
+ "strip_root": str(strip_root).lower(),
88
+ "bins": json.dumps(bins, ensure_ascii=False),
89
+ "force": str(force).lower(),
90
+ }
91
+ with archive.open("rb") as archive_file:
92
+ return self._request_json(
93
+ "POST",
94
+ "/api/v1/tools/upload",
95
+ data=data,
96
+ files={"archive": (archive.name, archive_file, "application/octet-stream")},
97
+ )
98
+
53
99
  def run_and_poll(
54
100
  self,
55
101
  payload: dict[str, Any],
@@ -17,7 +17,7 @@ import yaml
17
17
  from dotenv import dotenv_values
18
18
 
19
19
  from simple_ci.errors import ConfigError
20
- from simple_ci.models import Credential, Pipeline, Project, ProjectSource, Step, Target, TargetServer
20
+ from simple_ci.models import Credential, Pipeline, Project, ProjectSource, Step, Target, TargetServer, TargetTool
21
21
 
22
22
 
23
23
  @dataclass
@@ -109,10 +109,31 @@ def _load_targets(targets_dir: Path, env: Mapping[str, str]) -> dict[str, Target
109
109
  vars=_optional_mapping(data, "vars", f"target {target_id}"),
110
110
  description=_optional_str(data, "description"),
111
111
  policy=_optional_mapping(data, "policy", f"target {target_id}"),
112
+ tools=_load_target_tools(data, f"target {target_id}"),
112
113
  )
113
114
  return targets
114
115
 
115
116
 
117
+ def _load_target_tools(data: Mapping[str, Any], context: str) -> dict[str, TargetTool]:
118
+ raw_tools = data.get("tools", {})
119
+ if not isinstance(raw_tools, dict):
120
+ raise ConfigError(f"{context} field tools must be a table")
121
+
122
+ tools: dict[str, TargetTool] = {}
123
+ for tool_id, raw_tool in raw_tools.items():
124
+ section = _require_mapping(raw_tool, f"{context}.tools.{tool_id}")
125
+ archive = _require_str(section, "archive", f"{context}.tools.{tool_id}")
126
+ strip_root = bool(section.get("strip_root", True))
127
+ bins = _optional_mapping(section, "bins", f"{context}.tools.{tool_id}")
128
+ tools[tool_id] = TargetTool(
129
+ id=tool_id,
130
+ archive=archive,
131
+ strip_root=strip_root,
132
+ bins={str(key): str(value) for key, value in bins.items()},
133
+ )
134
+ return tools
135
+
136
+
116
137
  def _build_target_server(target_id: str, server: Mapping[str, Any], env: Mapping[str, str]) -> TargetServer:
117
138
  normalized_id = _normalize_env_id(target_id)
118
139
  url_env = _optional_str(server, "url_env") or f"SERVER_{normalized_id}_URL"