starforge-cli 0.1.6__py3-none-any.whl

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 (55) hide show
  1. starforge_cli/__init__.py +3 -0
  2. starforge_cli/api_client.py +589 -0
  3. starforge_cli/auth.py +349 -0
  4. starforge_cli/catalog.py +124 -0
  5. starforge_cli/cli.py +74 -0
  6. starforge_cli/cli_ui.py +469 -0
  7. starforge_cli/client_device.py +104 -0
  8. starforge_cli/commands/__init__.py +1 -0
  9. starforge_cli/commands/admin.py +140 -0
  10. starforge_cli/commands/bench.py +94 -0
  11. starforge_cli/commands/common.py +178 -0
  12. starforge_cli/commands/dataset.py +150 -0
  13. starforge_cli/commands/exp.py +213 -0
  14. starforge_cli/commands/init.py +52 -0
  15. starforge_cli/commands/jobs.py +223 -0
  16. starforge_cli/commands/login.py +54 -0
  17. starforge_cli/commands/plugin.py +243 -0
  18. starforge_cli/commands/recipe.py +163 -0
  19. starforge_cli/commands/serve.py +79 -0
  20. starforge_cli/commands/submit.py +467 -0
  21. starforge_cli/commands/sweep.py +154 -0
  22. starforge_cli/config_resolve.py +17 -0
  23. starforge_cli/data_prep.py +60 -0
  24. starforge_cli/new_experiment.py +195 -0
  25. starforge_cli/packing.py +179 -0
  26. starforge_cli/plugins_lock.py +73 -0
  27. starforge_cli/project.py +130 -0
  28. starforge_cli/recipe_lock.py +453 -0
  29. starforge_cli/scaffold/agent-run.py.tmpl +146 -0
  30. starforge_cli/scaffold/custom-framework/train.sh +56 -0
  31. starforge_cli/scaffold/experiment-template/.gitkeep +0 -0
  32. starforge_cli/scaffold/experiment-template/README.md +36 -0
  33. starforge_cli/scaffold/experiment-template/config.yaml +44 -0
  34. starforge_cli/scaffold/project/common/README.md +12 -0
  35. starforge_cli/scaffold/project/common/__init__.py +0 -0
  36. starforge_cli/scaffold/project/configs/README.md +103 -0
  37. starforge_cli/scaffold/project/configs/base/README.md +24 -0
  38. starforge_cli/scaffold/project/configs/base/distillation_math.yaml +284 -0
  39. starforge_cli/scaffold/project/configs/base/grpo_lora.yaml +30 -0
  40. starforge_cli/scaffold/project/configs/base/grpo_math_1B.yaml +470 -0
  41. starforge_cli/scaffold/project/configs/base/grpo_megatron.yaml +43 -0
  42. starforge_cli/scaffold/project/configs/base/grpo_noncolocated.yaml +18 -0
  43. starforge_cli/scaffold/project/configs/base/grpo_sliding_puzzle.yaml +81 -0
  44. starforge_cli/scaffold/project/configs/base/ppo_math_1B.yaml +454 -0
  45. starforge_cli/scaffold/project/configs/base/rm.yaml +224 -0
  46. starforge_cli/scaffold/project/configs/base/sft.yaml +294 -0
  47. starforge_cli/scaffold/project/configs/models/README.md +16 -0
  48. starforge_cli/scaffold/project/configs/models/qwen3.5-4b.yaml +12 -0
  49. starforge_cli/scaffold/project/configs/models/qwen3.5-9b.yaml +10 -0
  50. starforge_cli/scaffold/project/gitignore +11 -0
  51. starforge_cli/spec_builder.py +372 -0
  52. starforge_cli-0.1.6.dist-info/METADATA +40 -0
  53. starforge_cli-0.1.6.dist-info/RECORD +55 -0
  54. starforge_cli-0.1.6.dist-info/WHEEL +4 -0
  55. starforge_cli-0.1.6.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,213 @@
1
+ """实验资产命令:ls / new / validate / methods(纯本地 + SDK catalog,不联网)。"""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ import typer
7
+
8
+ from starforge_cli import cli_ui
9
+ from starforge_cli.commands import common
10
+ from starforge_cli.new_experiment import NewExperimentError, create_experiment
11
+
12
+
13
+ def ls() -> None:
14
+ """列出实验 / 项目。"""
15
+ for kind in ("experiments", "projects"):
16
+ base = common.ROOT / kind
17
+ if not base.is_dir():
18
+ continue
19
+ exps = sorted(p.name for p in base.iterdir() if p.is_dir())
20
+ typer.echo(f"\n[{kind}] ({len(exps)})")
21
+ for e in exps:
22
+ typer.echo(f" - {e}")
23
+
24
+
25
+ def new(
26
+ name: str = typer.Argument(..., help="实验名"),
27
+ from_exp: Optional[str] = typer.Option(
28
+ None, "--from", autocompletion=common.complete_exp,
29
+ help="从已有实验 fork",
30
+ ),
31
+ method: str = typer.Option(
32
+ "nemo-rl/grpo", "--method", "-m", autocompletion=common.complete_method,
33
+ help="方法标识 <framework>/<method>;默认 nemo-rl/grpo,`sf methods` 查看全部",
34
+ ),
35
+ framework_version: Optional[str] = typer.Option(
36
+ None,
37
+ "--framework-version",
38
+ help="精确框架版本;必须是 recipe catalog 已发布版本",
39
+ ),
40
+ kind: common.Kind = typer.Option(
41
+ common.Kind.experiments, "--kind", help="experiments 或 projects"
42
+ ),
43
+ ) -> None:
44
+ """新建实验(--from fork 现成实验;--method 来自 SDK recipe catalog)。"""
45
+ if from_exp and method != "nemo-rl/grpo":
46
+ typer.secho("fork 会继承来源实验配置,--method 已忽略。", fg=typer.colors.YELLOW)
47
+ src = ""
48
+ if from_exp:
49
+ from pathlib import Path
50
+
51
+ src = Path(common.resolve_exp(from_exp)).name
52
+ try:
53
+ create_experiment(
54
+ common.ROOT,
55
+ kind.value,
56
+ name,
57
+ src=src,
58
+ method=method,
59
+ framework_version=framework_version or "",
60
+ )
61
+ except NewExperimentError as e:
62
+ cli_ui.fail(str(e))
63
+
64
+
65
+ def validate_exp_config(exp_dir, recipe, *, repo_root=None) -> list[str]:
66
+ """只校验实验内容与当前 recipe,不要求锁已经是最新。"""
67
+ errors, _ = _validate_exp_contents(exp_dir, recipe, repo_root=repo_root)
68
+ return errors
69
+
70
+
71
+ def _validate_exp_contents(exp_dir, recipe, *, repo_root=None) -> tuple[list[str], list[str]]:
72
+ import yaml
73
+
74
+ from starforge_cli.config_resolve import resolve, validate_framework_config
75
+
76
+ root = repo_root or common.ROOT
77
+ if recipe.entrypoint.kind == "experiment":
78
+ entry = (exp_dir / recipe.entrypoint.value).resolve()
79
+ if not entry.is_relative_to(exp_dir.resolve()) or not entry.is_file():
80
+ return [f"{recipe.framework} 实验缺少入口: {recipe.entrypoint.value}"], []
81
+ if recipe.framework == "custom":
82
+ return [], []
83
+
84
+ entry_warns: list[str] = []
85
+ if recipe.entrypoint.kind != "experiment" and not recipe.entrypoint.experiment_override:
86
+ for candidate in ("run.py", "main.py"):
87
+ if (exp_dir / candidate).is_file():
88
+ entry_warns.append(
89
+ f"实验目录有 {candidate},但 recipe {recipe.id} 未声明 entrypoint.experiment_override,"
90
+ f"提交后将执行默认入口 {recipe.entrypoint.value},{candidate} 不会被执行。"
91
+ )
92
+
93
+ cfg_file = exp_dir / "config.yaml"
94
+ if not cfg_file.is_file():
95
+ return [f"{recipe.framework} 实验缺少 config.yaml: {exp_dir.name}"], []
96
+ try:
97
+ if recipe.framework == "nemo-rl":
98
+ cfg = resolve(cfg_file)
99
+ else:
100
+ cfg = yaml.safe_load(cfg_file.read_text(encoding="utf-8"))
101
+ except Exception as exc: # noqa: BLE001
102
+ return [f"解析 {recipe.framework} config 失败: {exc}"], []
103
+ if not isinstance(cfg, dict):
104
+ return [f"{recipe.framework} config 根节点必须是对象"], []
105
+
106
+ try:
107
+ issues = validate_framework_config(
108
+ recipe.framework, cfg, repo_root=root, recipe=recipe
109
+ )
110
+ except ValueError as exc:
111
+ return [str(exc)], entry_warns
112
+ return (
113
+ [message for level, message in issues if level == "error"],
114
+ entry_warns + [message for level, message in issues if level == "warn"],
115
+ )
116
+
117
+
118
+ def _validate_exp(exp_path: str, recipe_override: str = "") -> tuple[list[str], list[str]]:
119
+ """锁必须是当前 bundle,再跑 recipe 所属框架的 validator。"""
120
+ from starforge_core.contract import SpecError
121
+ from starforge_core.recipes import get_recipe
122
+
123
+ from starforge_cli.recipe_lock import validate_recipe_lock
124
+ from starforge_cli.spec_builder import infer_recipe
125
+
126
+ exp_dir = common.ROOT / exp_path
127
+ recipe_name = recipe_override.strip() or infer_recipe(exp_dir)
128
+ if not recipe_name:
129
+ return [f"实验缺少 recipe 声明(recipe.lock.json): {exp_path}"], []
130
+ try:
131
+ recipe = get_recipe(recipe_name)
132
+ validate_recipe_lock(exp_dir, recipe_name)
133
+ except (SpecError, ValueError) as exc:
134
+ return [str(exc)], []
135
+ return _validate_exp_contents(exp_dir, recipe)
136
+
137
+
138
+ def validate(
139
+ exp: str = typer.Argument(..., autocompletion=common.complete_exp, help="实验名或路径"),
140
+ ) -> None:
141
+ """校验实验 config(提交前本地检查)。"""
142
+ exp_path = common.resolve_exp(exp)
143
+ errors, warns = _validate_exp(exp_path)
144
+ if errors:
145
+ cli_ui.emit_error(
146
+ f"{exp_path}:{len(errors)} 处错误" + (f",{len(warns)} 处告警" if warns else ""),
147
+ items=errors,
148
+ )
149
+ raise typer.Exit(1)
150
+ if warns:
151
+ cli_ui.emit_warning(f"{exp_path}:{len(warns)} 处告警", body="\n".join(f"• {w}" for w in warns))
152
+ suffix = f"({len(warns)} 个告警)" if warns else ""
153
+ typer.secho(f"✓ {exp_path}:通过{suffix}", fg=typer.colors.GREEN)
154
+
155
+
156
+ def methods(
157
+ name: Optional[str] = typer.Argument(None, help="方法名;不传则列出全部"),
158
+ ) -> None:
159
+ """列出可用的后训练方法与它们的超参。
160
+
161
+ 方法目录来自 starforge-core,与服务端是同一份 —— 这里看到的就是提交时会被校验的。
162
+ """
163
+ from starforge_core.contract import SpecError
164
+ from starforge_core.recipes import get_recipe, recipe_names
165
+
166
+ if not name:
167
+ for n in recipe_names():
168
+ r = get_recipe(n)
169
+ typer.echo(f"{n:22s} {r.title}")
170
+ typer.echo(
171
+ f"{'':22s} 默认 {r.framework}@{r.runtime.default_version}"
172
+ f" · 支持 {', '.join(r.runtime.supported_versions)}"
173
+ )
174
+ typer.echo(f"{'':22s} {r.summary.strip()}")
175
+ typer.echo("\n用 `sf methods <方法名>` 看它的可调超参。")
176
+ return
177
+
178
+ try:
179
+ r = get_recipe(name)
180
+ except SpecError as e:
181
+ cli_ui.emit_error(str(e))
182
+ raise typer.Exit(1) from e
183
+
184
+ typer.echo(f"{r.id} v{r.version} —— {r.title}")
185
+ typer.echo(f" {r.summary.strip()}\n")
186
+ typer.echo(f" 框架 : {r.framework}@{r.runtime.default_version}(默认)")
187
+ typer.echo(f" 支持版本 : {', '.join(r.runtime.supported_versions)}")
188
+ typer.echo(f" 角色 : {', '.join(r.roles)}")
189
+ typer.echo(f" 训练后动作: {', '.join(r.lifecycle) or '(无)'}")
190
+ if r.plugins:
191
+ typer.echo(f" 算法插件 : {', '.join(r.plugins)}")
192
+ if r.runtime.requires:
193
+ typer.echo(f" 默认依赖 : {', '.join(r.runtime.requires)}")
194
+ typer.echo(f" 核心指标 : {', '.join(r.primary_metrics)}\n")
195
+ typer.echo(" 可调超参(--set KEY=VALUE):")
196
+ current_group = None
197
+ for p in r.params.values():
198
+ group = p.group or "其他"
199
+ if group != current_group:
200
+ typer.secho(f" ── {group} ──", fg=typer.colors.CYAN)
201
+ current_group = group
202
+ rng = []
203
+ if p.minimum is not None:
204
+ rng.append(f"≥{p.minimum}" if not p.exclusive_minimum else f">{p.minimum}")
205
+ if p.maximum is not None:
206
+ rng.append(f"≤{p.maximum}")
207
+ if p.choices:
208
+ rng.append("|".join(str(c) for c in p.choices))
209
+ meta = f"{p.type}{' ' + ','.join(rng) if rng else ''}"
210
+ default = f" (默认 {p.default})" if p.default is not None else ""
211
+ typer.echo(f" {p.name:32s} {meta}{default}")
212
+ if p.doc:
213
+ typer.echo(f" {'':32s} {p.doc.strip()}")
@@ -0,0 +1,52 @@
1
+ """`sf init`:交互式创建 StarForge 微调项目。"""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from starforge_cli import cli_ui
10
+ from starforge_cli.project import InitError, init_project
11
+
12
+
13
+ def init(
14
+ directory: Optional[str] = typer.Argument(
15
+ None, help="项目目录(省略则交互询问;. 表示当前目录)"
16
+ ),
17
+ name: Optional[str] = typer.Option(None, "--name", help="项目名(默认目录名)"),
18
+ no_git: bool = typer.Option(False, "--no-git", help="不初始化 git 仓库"),
19
+ yes: bool = typer.Option(False, "--yes", "-y", help="非交互:全部取默认值"),
20
+ ) -> None:
21
+ """创建 StarForge 项目:experiments/ + configs/(官方基底)+ common/ 骨架。
22
+
23
+ 项目是独立的 git 仓库——实验配置与共享代码归你和团队所有,
24
+ 与 CLI 工具的版本解耦(升级 CLI 只需 pip install -U starforge-cli)。
25
+ """
26
+ target = (directory or "").strip()
27
+ if not target and not yes:
28
+ target = typer.prompt("项目目录", default="my-starforge-lab")
29
+ if not target:
30
+ target = "my-starforge-lab"
31
+ dest = Path(target).expanduser()
32
+
33
+ project_name = (name or "").strip() or (dest.resolve().name if target != "." else Path.cwd().name)
34
+ if not yes and not name:
35
+ project_name = typer.prompt("项目名", default=project_name)
36
+
37
+ use_git = not no_git
38
+ if not yes and not no_git:
39
+ use_git = typer.confirm("初始化 git 仓库?", default=True)
40
+
41
+ try:
42
+ root = init_project(dest if target != "." else Path.cwd(), name=project_name, git=use_git)
43
+ except InitError as e:
44
+ cli_ui.fail(str(e))
45
+
46
+ typer.secho(f"✓ StarForge 项目已创建:{root}", fg=typer.colors.GREEN)
47
+ typer.echo("下一步:")
48
+ if target not in (".",):
49
+ typer.echo(f" cd {target}")
50
+ typer.echo(" sf login --server https://<你的 StarForge 域名> # 首次")
51
+ typer.echo(" sf new my-exp --method nemo-rl/grpo")
52
+ typer.echo(" sf submit my-exp --profile <卡型:卡数>")
@@ -0,0 +1,223 @@
1
+ """作业观测与控制:status + job 子命令组(全部经 Console)。"""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ import typer
7
+
8
+ from starforge_cli import api_client, cli_ui
9
+ from starforge_cli.auth import gate
10
+ from starforge_cli.commands import common
11
+
12
+ job_app = typer.Typer(
13
+ no_args_is_help=True,
14
+ help="作业管理",
15
+ context_settings={"help_option_names": ["-h", "--help"]},
16
+ )
17
+
18
+
19
+ def _format_user_label(user: dict) -> str:
20
+ """把 /api/whoami 的 user 格式化为单行展示。"""
21
+ username = user.get("username") or "?"
22
+ role = user.get("role") or "?"
23
+ parts = [f"用户:{username}", f"角色:{role}"]
24
+ if user.get("email"):
25
+ parts.append(f"邮箱:{user['email']}")
26
+ return " ".join(parts)
27
+
28
+
29
+ def status() -> None:
30
+ """账号、配额、用量与活跃作业。"""
31
+ gate()
32
+ who = api_client.whoami_via_server()
33
+ user = who.get("user") or {}
34
+ typer.echo(_format_user_label(user))
35
+ typer.echo("")
36
+
37
+ data = api_client.usage_via_server()
38
+ q, u = data.get("quota") or {}, data.get("usage") or {}
39
+ cap = q.get("max_concurrent_gpus")
40
+ typer.echo("我的用量")
41
+ typer.echo(f" 并发 GPU : {u.get('active_gpus', 0)} / {'不限' if cap is None else cap}")
42
+ typer.echo(f" 并发作业 : {u.get('active_jobs', 0)} / {q.get('max_concurrent_jobs') or '不限'}")
43
+ typer.echo(f" 今日/累计 GPU-hours : {u.get('gpu_hours_today', 0):.1f} / {u.get('gpu_hours_total', 0):.1f}")
44
+ running = u.get("running") or []
45
+ typer.echo("\n我的活跃作业")
46
+ if not running:
47
+ typer.echo(" (无)")
48
+ else:
49
+ for r in running:
50
+ jid = (r.get("job_ref") or r.get("lab_run_id") or "-")[:26]
51
+ typer.echo(f" {jid:<26} {r.get('status','-'):<10} GPU={r.get('gpus') or '-'} {r.get('exp','-')}")
52
+
53
+ cluster = api_client.cluster_status_via_server()
54
+ gpu = (cluster or {}).get("gpu") or {}
55
+ if gpu:
56
+ accel = "/".join(gpu.get("accel") or []) or "GPU"
57
+ typer.echo("\n集群 GPU")
58
+ typer.echo(
59
+ f" {accel} : 空闲 {gpu.get('gpu_free', 0):g} / 共 {gpu.get('gpu_total', 0):g}"
60
+ f"(占用 {gpu.get('gpu_used', 0):g})"
61
+ )
62
+ typer.echo(f" 活跃作业 : {cluster.get('active_count', 0)}")
63
+ typer.echo("\n查看日志:sf job logs [作业 ID]")
64
+
65
+
66
+ def _server_jobs_table(jobs: list[dict]) -> None:
67
+ if not jobs:
68
+ typer.echo("(无作业)")
69
+ return
70
+ typer.echo(f"{'TIME':<20} {'JOB ID':<26} {'状态':<10} {'GPU':>4} 实验")
71
+ for j in jobs:
72
+ jid = (j.get("job_ref") or j.get("lab_run_id") or "-")[:26]
73
+ typer.echo(
74
+ f"{str(j.get('submitted_at', '-'))[:19]:<20} {jid:<26} "
75
+ f"{str(j.get('status','-')):<10} {str(j.get('requested_gpus') or '-'):>4} "
76
+ f"{j.get('exp','-')}"
77
+ )
78
+
79
+
80
+ @job_app.command("ls", help="作业列表(含提交历史)")
81
+ def job_ls(
82
+ all_jobs: bool = typer.Option(False, "--all", help="显示全部(默认最近 15 条)"),
83
+ exp: Optional[str] = typer.Option(
84
+ None, "--exp", autocompletion=common.complete_exp, help="只看某实验(接受全名或末段名)"
85
+ ),
86
+ limit: int = typer.Option(15, "-n", "--limit", help="显示条数(--all 时忽略)"),
87
+ ) -> None:
88
+ gate()
89
+ jobs = api_client.list_my_jobs(limit=200 if all_jobs else limit)
90
+ if exp:
91
+ jobs = [j for j in jobs if exp in (j.get("exp") or "")]
92
+ _server_jobs_table(jobs)
93
+
94
+
95
+ @job_app.command("logs", help="跟随作业日志(省略作业 ID 则跟最近一个)")
96
+ def job_logs(
97
+ job_id: Optional[str] = typer.Argument(None, help="作业 ID(见 sf job ls);省略=最近一个"),
98
+ tail: Optional[int] = typer.Option(
99
+ 2000, "-n", "--tail", help="只回放最后 N 行历史日志再跟随(默认 2000;-n 0 看全量)"
100
+ ),
101
+ ) -> None:
102
+ gate()
103
+ jid = job_id or api_client.latest_job_via_server()
104
+ if not jid:
105
+ cli_ui.emit_warning("还没有作业", hint="运行 sf submit 提交训练")
106
+ raise typer.Exit(1)
107
+ api_client.stream_logs_via_server(jid, tail=tail)
108
+
109
+
110
+ @job_app.command("status", help="查看作业状态")
111
+ def job_status(
112
+ job_id: str = typer.Argument(..., help="作业 ID"),
113
+ ) -> None:
114
+ gate()
115
+ match = [j for j in api_client.list_my_jobs(limit=200)
116
+ if job_id in (j.get("job_ref") or "", j.get("lab_run_id") or "")]
117
+ if not match:
118
+ cli_ui.fail(f"未找到作业 {job_id}")
119
+ _server_jobs_table(match)
120
+
121
+
122
+ @job_app.command("samples", help="查看某次验证的多轮对话轨迹(默认最近一次验证)")
123
+ def job_samples(
124
+ job_id: str = typer.Argument(..., help="作业 ID(见 sf job ls)"),
125
+ vidx: int = typer.Option(-1, "--vidx", help="验证轮次下标(默认 -1=最近一次)"),
126
+ n: int = typer.Option(6, "-n", "--limit", help="显示样本条数"),
127
+ ) -> None:
128
+ gate()
129
+ overview = api_client.job_overview_via_server(job_id)
130
+ vals = overview.get("validations") or []
131
+ if not vals:
132
+ typer.secho("该作业暂无验证样本。", fg=typer.colors.YELLOW)
133
+ raise typer.Exit(1)
134
+ idx = vidx if vidx >= 0 else len(vals) + vidx
135
+ if idx < 0 or idx >= len(vals):
136
+ typer.secho(f"验证下标越界:vidx={vidx},共 {len(vals)} 轮。", fg=typer.colors.RED)
137
+ raise typer.Exit(1)
138
+ page = api_client.samples_via_server(job_id, idx, 0, n)
139
+ samples = page.get("samples") or []
140
+ typer.echo(
141
+ f"验证 step={page.get('step', '?')}(第 {idx + 1}/{len(vals)} 轮) "
142
+ f"样本 {len(samples)}/{page.get('total', len(samples))}"
143
+ )
144
+ for s in samples:
145
+ typer.echo("")
146
+ typer.secho(f"── Sample {s.get('idx', '?')} | reward={s.get('reward', '?')} ──", fg=typer.colors.CYAN)
147
+ if s.get("user"):
148
+ typer.secho("USER:", fg=typer.colors.GREEN)
149
+ typer.echo(s["user"])
150
+ if s.get("assistant"):
151
+ typer.secho("ASSISTANT:", fg=typer.colors.BLUE)
152
+ typer.echo(s["assistant"])
153
+ if s.get("env"):
154
+ typer.secho("ENVIRONMENT:", fg=typer.colors.MAGENTA)
155
+ typer.echo(s["env"])
156
+
157
+
158
+ @job_app.command("stop", help="停止作业(运行中 → 终止)")
159
+ def job_stop(
160
+ job_id: str = typer.Argument(..., help="作业 ID"),
161
+ ) -> None:
162
+ gate()
163
+ api_client.job_control_via_server("stop", job_id)
164
+ typer.secho("✓ 已停止作业", fg=typer.colors.GREEN)
165
+
166
+
167
+ @job_app.command("pause", help="暂停作业(保留 checkpoint,可继续)")
168
+ def job_pause(
169
+ job_id: str = typer.Argument(..., help="作业 ID"),
170
+ ) -> None:
171
+ gate()
172
+ api_client.job_control_via_server("pause", job_id)
173
+ typer.secho("✓ 已暂停作业(恢复后从最近 checkpoint 继续,最多丢最近 save_period 步)", fg=typer.colors.GREEN)
174
+
175
+
176
+ @job_app.command("resume", help="继续已暂停的作业(自动从最近 checkpoint 续训)")
177
+ def job_resume(
178
+ job_id: str = typer.Argument(..., help="作业 ID"),
179
+ ) -> None:
180
+ gate()
181
+ res = api_client.job_control_via_server("resume", job_id)
182
+ typer.secho(f"✓ {res.get('message') or '已加入恢复队列'}", fg=typer.colors.GREEN)
183
+
184
+
185
+ @job_app.command("delete", help="删除某个已结束的作业记录(运行中需先 stop)")
186
+ def job_delete(
187
+ job_id: str = typer.Argument(..., help="作业 ID"),
188
+ ) -> None:
189
+ gate()
190
+ api_client.job_control_via_server("delete", job_id)
191
+ typer.secho("✓ 已删除记录", fg=typer.colors.GREEN)
192
+
193
+
194
+ @job_app.command("cancel-all", help="停止我所有运行中 / 排队中的作业")
195
+ def job_cancel_all(
196
+ yes: bool = typer.Option(False, "-y", "--yes", help="跳过确认"),
197
+ ) -> None:
198
+ gate()
199
+ if not yes:
200
+ typer.confirm("将停止你【全部】运行中/排队中的作业,确认?", abort=True)
201
+ res = api_client.batch_via_server("cancel-all")
202
+ typer.secho(f"✓ 已停止 {res.get('stopped', 0)} 个作业", fg=typer.colors.GREEN)
203
+
204
+
205
+ @job_app.command("clean", help="清理已结束作业的显示记录")
206
+ def job_clean() -> None:
207
+ gate()
208
+ res = api_client.batch_via_server("clean")
209
+ typer.secho(f"✓ 已清理 {res.get('deleted', 0)} 个终态作业记录", fg=typer.colors.GREEN)
210
+
211
+
212
+ @job_app.command("stop-sweep", help="一键停止一个超参 sweep 的全部活跃作业")
213
+ def job_stop_sweep(
214
+ sweep_id: str = typer.Argument(..., help="sweep 标识(sf sweep 提交时打印)"),
215
+ yes: bool = typer.Option(False, "-y", "--yes", help="跳过确认"),
216
+ ) -> None:
217
+ gate()
218
+ if not yes:
219
+ typer.confirm(f"将停止 sweep {sweep_id} 的全部活跃作业,确认?", abort=True)
220
+ res = api_client.stop_sweep_via_server(sweep_id)
221
+ typer.secho(f"✓ 已停止 {res.get('stopped', 0)} 个作业", fg=typer.colors.GREEN)
222
+ for item in res.get("failed") or []:
223
+ cli_ui.emit_warning(f"停止失败:{item.get('id')}({item.get('error')})")
@@ -0,0 +1,54 @@
1
+ """身份命令:login / logout(凭据管理核心见 starforge_cli.auth)。"""
2
+ from __future__ import annotations
3
+
4
+ import urllib.error
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from starforge_cli import auth, cli_ui
10
+
11
+
12
+ def login(
13
+ server: Optional[str] = typer.Option(
14
+ None, "--server", "-s",
15
+ help=f"Lab 服务地址(默认 {auth.DEFAULT_FORGE_SERVER})",
16
+ ),
17
+ token: Optional[str] = typer.Option(None, "--token", help="非交互登录:直接用服务令牌(CI 用)"),
18
+ device_flow: bool = typer.Option(False, "--device-flow", help="强制使用设备码登录(SSH / 无浏览器)"),
19
+ no_browser: bool = typer.Option(False, "--no-browser", help="不打开浏览器(等同 --device-flow)"),
20
+ ) -> None:
21
+ """登录 Lab(本机默认浏览器;SSH 环境走验证码)。"""
22
+ srv = auth.current_server(server)
23
+ auth._save_server(srv)
24
+ if token:
25
+ creds = {"access_token": token, "refresh_token": None, "expires_at": None, "user": None}
26
+ try:
27
+ who = auth._api(srv, "GET", "/api/whoami", token=token)
28
+ creds["user"] = who.get("user")
29
+ except urllib.error.HTTPError:
30
+ cli_ui.fail("登录令牌无效,请重新登录。", hint="运行 sf login 重新登录")
31
+ auth._save_creds(srv, creds)
32
+ else:
33
+ creds = auth._interactive_login(srv, device_flow=device_flow, no_browser=no_browser)
34
+ auth._save_creds(srv, creds)
35
+ u = (creds.get("user") or {}).get("username", "?")
36
+ typer.secho(f"✓ 已登录:{u}", fg=typer.colors.GREEN)
37
+
38
+
39
+ def logout(
40
+ server: Optional[str] = typer.Option(None, "--server", "-s", help="指定 Lab 地址(默认当前)"),
41
+ ) -> None:
42
+ """登出当前账号。"""
43
+ srv = auth.current_server(server)
44
+ creds = auth._load_creds(srv)
45
+ if not creds:
46
+ typer.echo("当前未登录。")
47
+ return
48
+ if creds.get("refresh_token"):
49
+ try:
50
+ auth._api(srv, "POST", "/api/auth/logout", body={"refresh_token": creds["refresh_token"]})
51
+ except urllib.error.URLError:
52
+ pass
53
+ auth._clear_creds(srv)
54
+ typer.secho("✓ 已登出", fg=typer.colors.GREEN)