dsh-task-ledger 0.0.100 → 0.0.102
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.
- package/dist/client.js +2 -2
- package/dist/host/repositories/runner.d.ts +1 -1
- package/dist/host/repositories/runner.js +10 -7
- package/dist/host/skill/registration.d.ts +2 -1
- package/dist/host/skill/registration.js +4 -3
- package/package.json +1 -1
- package/skill/README.md +4 -2
- package/skill/SKILL.md +17 -14
- package/skill/assets/worklog.md +3 -3
- package/skill/package.json +1 -1
- package/skill/references/init-identity.md +3 -3
- package/skill/references/log-note.md +1 -1
- package/skill/references/state-model.md +1 -1
- package/skill/references/troubleshooting.md +1 -1
- package/skill/scripts/bin/ledger-darwin-arm64 +0 -0
- package/skill/scripts/bin/ledger-linux-amd64 +0 -0
- package/skill/scripts/bin/ledger-windows-amd64.exe +0 -0
- package/skill/scripts/progress +44 -0
- package/skill/scripts/ledgerkit/__init__.py +0 -0
- package/skill/scripts/ledgerkit/actions/__init__.py +0 -0
- package/skill/scripts/ledgerkit/actions/atom.py +0 -154
- package/skill/scripts/ledgerkit/actions/bootstrap.py +0 -37
- package/skill/scripts/ledgerkit/actions/claim.py +0 -88
- package/skill/scripts/ledgerkit/actions/close_task.py +0 -193
- package/skill/scripts/ledgerkit/actions/guard.py +0 -158
- package/skill/scripts/ledgerkit/actions/handoff.py +0 -93
- package/skill/scripts/ledgerkit/actions/init_identity.py +0 -90
- package/skill/scripts/ledgerkit/actions/list_tasks.py +0 -74
- package/skill/scripts/ledgerkit/actions/log_progress.py +0 -67
- package/skill/scripts/ledgerkit/actions/new_task.py +0 -130
- package/skill/scripts/ledgerkit/actions/query.py +0 -35
- package/skill/scripts/ledgerkit/actions/query_history.py +0 -220
- package/skill/scripts/ledgerkit/actions/registry.py +0 -82
- package/skill/scripts/ledgerkit/actions/relabel.py +0 -105
- package/skill/scripts/ledgerkit/actions/release.py +0 -76
- package/skill/scripts/ledgerkit/actions/result.py +0 -90
- package/skill/scripts/ledgerkit/actions/set_task.py +0 -73
- package/skill/scripts/ledgerkit/actions/show_task.py +0 -55
- package/skill/scripts/ledgerkit/actions/validate.py +0 -189
- package/skill/scripts/ledgerkit/core/__init__.py +0 -0
- package/skill/scripts/ledgerkit/core/history_md.py +0 -152
- package/skill/scripts/ledgerkit/core/identity.py +0 -54
- package/skill/scripts/ledgerkit/core/ids.py +0 -58
- package/skill/scripts/ledgerkit/core/labels.py +0 -90
- package/skill/scripts/ledgerkit/core/paths.py +0 -92
- package/skill/scripts/ledgerkit/core/roots.py +0 -74
- package/skill/scripts/ledgerkit/core/time.py +0 -103
- package/skill/scripts/ledgerkit/core/worklog_md.py +0 -437
- package/skill/scripts/ledgerkit/env.py +0 -94
- package/skill/scripts/ledgerkit/errors.py +0 -15
- package/skill/scripts/ledgerkit/notices.py +0 -28
- package/skill/scripts/ledgerkit/shell/__init__.py +0 -0
- package/skill/scripts/ledgerkit/shell/cli.py +0 -536
- package/skill/scripts/ledgerkit/shell/gitops.py +0 -668
- package/skill/scripts/ledgerkit/shell/merge.py +0 -266
- package/skill/scripts/ledgerkit/shell/presync.py +0 -23
- package/skill/scripts/ledgerkit/shell/render.py +0 -216
- package/skill/scripts/ledgerkit/shell/runner.py +0 -232
- package/skill/scripts/ledgerkit/store/__init__.py +0 -0
- package/skill/scripts/ledgerkit/store/atomic.py +0 -130
- package/skill/scripts/ledgerkit/store/dirty.py +0 -108
- package/skill/scripts/ledgerkit/store/history_store.py +0 -134
- package/skill/scripts/ledgerkit/store/json_store.py +0 -166
- package/skill/scripts/ledgerkit/store/locks.py +0 -64
- package/skill/scripts/ledgerkit/store/owners_index.py +0 -127
- package/skill/scripts/ledgerkit/store/task_store.py +0 -28
- package/skill/scripts/ledgerkit/store/worklog_store.py +0 -180
- package/skill/scripts/progress.py +0 -20
|
@@ -1,536 +0,0 @@
|
|
|
1
|
-
"""命令行:只做解析与装配,不碰文件。
|
|
2
|
-
|
|
3
|
-
- `--json` 与 `--count-only` 互斥(退出码 2)。
|
|
4
|
-
- `TASK_LEDGER_NO_SYNC` **只在这里读一次**,折成 `args.no_sync` 往下传。
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
import argparse
|
|
10
|
-
import os
|
|
11
|
-
import sys
|
|
12
|
-
from pathlib import Path
|
|
13
|
-
|
|
14
|
-
from ..actions.query import HistoryQuery, ListQuery
|
|
15
|
-
from ..core.roots import as_root, root_of
|
|
16
|
-
from ..core.time import to_utc
|
|
17
|
-
from ..errors import BUSINESS, OK, USAGE, Fail
|
|
18
|
-
from . import render, runner
|
|
19
|
-
from .gitops import repo_sync_lock
|
|
20
|
-
|
|
21
|
-
NO_SYNC_ENV = "TASK_LEDGER_NO_SYNC"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def build_parser() -> argparse.ArgumentParser:
|
|
25
|
-
"""动作名与只读名单从 `actions.registry` 取,不在 cli 里另写一份。"""
|
|
26
|
-
parser = argparse.ArgumentParser(
|
|
27
|
-
prog="progress.py", description="任务台账(本地文件 + git 同步)"
|
|
28
|
-
)
|
|
29
|
-
parser.add_argument(
|
|
30
|
-
"--repo", help="工作区根(直接含有 ledger/ 的那个目录;默认按当前目录向上找)"
|
|
31
|
-
)
|
|
32
|
-
parser.add_argument(
|
|
33
|
-
"--no-sync", action="store_true", help="只写本地,不取最新 / 不提交 / 不推送"
|
|
34
|
-
)
|
|
35
|
-
sub = parser.add_subparsers(dest="command", required=True)
|
|
36
|
-
|
|
37
|
-
new = sub.add_parser("new", help="建任务")
|
|
38
|
-
new.add_argument("--title", required=True)
|
|
39
|
-
new.add_argument("--id", dest="tid")
|
|
40
|
-
new.add_argument("--created")
|
|
41
|
-
new.add_argument("--category")
|
|
42
|
-
new.add_argument("--tags")
|
|
43
|
-
new.add_argument("--self", action="store_true", dest="mine")
|
|
44
|
-
|
|
45
|
-
claim = sub.add_parser("claim", help="认领公共池里的任务")
|
|
46
|
-
claim.add_argument("--id", required=True, dest="tid")
|
|
47
|
-
|
|
48
|
-
setp = sub.add_parser("set", help="释放 / 交接 / 改分类标签")
|
|
49
|
-
setp.add_argument("--id", required=True, dest="tid")
|
|
50
|
-
setp.add_argument("--owner", help="交给别人的 uuid(交接)")
|
|
51
|
-
setp.add_argument("--release", action="store_true", help="释放回公共池")
|
|
52
|
-
setp.add_argument(
|
|
53
|
-
"--category", help="改分类(与 --tags 一起改过程文档头部分类/标签行)"
|
|
54
|
-
)
|
|
55
|
-
setp.add_argument("--tags", help="改标签,顿号 / 逗号分隔")
|
|
56
|
-
|
|
57
|
-
logp = sub.add_parser("log", help="记一条进展")
|
|
58
|
-
logp.add_argument("--id", required=True, dest="tid")
|
|
59
|
-
logp.add_argument("--note", required=True)
|
|
60
|
-
|
|
61
|
-
close = sub.add_parser("close", help="结案")
|
|
62
|
-
close.add_argument("--id", required=True, dest="tid")
|
|
63
|
-
close.add_argument("--summary", required=True)
|
|
64
|
-
close.add_argument("--finished")
|
|
65
|
-
close.add_argument("--category")
|
|
66
|
-
close.add_argument("--tags")
|
|
67
|
-
|
|
68
|
-
listp = sub.add_parser("list", help="查在办任务")
|
|
69
|
-
listp.add_argument("--pool", action="store_true")
|
|
70
|
-
listp.add_argument("--mine", action="store_true")
|
|
71
|
-
listp.add_argument(
|
|
72
|
-
"--owner", help="某个人的 uuid(精确匹配;名字请用 --owner-name)"
|
|
73
|
-
)
|
|
74
|
-
listp.add_argument(
|
|
75
|
-
"--owner-name", dest="owner_name", help="按显示名模糊筛(子串、不分大小写)"
|
|
76
|
-
)
|
|
77
|
-
listp.add_argument("--status")
|
|
78
|
-
listp.add_argument("--category")
|
|
79
|
-
listp.add_argument("--tag")
|
|
80
|
-
listp.add_argument("--since")
|
|
81
|
-
listp.add_argument("--until")
|
|
82
|
-
listp.add_argument("--json", action="store_true")
|
|
83
|
-
|
|
84
|
-
showp = sub.add_parser("show", help="看一条任务")
|
|
85
|
-
showp.add_argument("--id", required=True, dest="tid")
|
|
86
|
-
showp.add_argument("--json", action="store_true")
|
|
87
|
-
|
|
88
|
-
hist = sub.add_parser("history", help="查已结案")
|
|
89
|
-
hist.add_argument(
|
|
90
|
-
"--owner", help="某个人的 uuid(精确匹配;名字请用 --owner-name)"
|
|
91
|
-
)
|
|
92
|
-
hist.add_argument(
|
|
93
|
-
"--owner-name", dest="owner_name", help="按显示名模糊筛(子串、不分大小写)"
|
|
94
|
-
)
|
|
95
|
-
hist.add_argument("--mine", action="store_true")
|
|
96
|
-
hist.add_argument("--category")
|
|
97
|
-
hist.add_argument("--tag")
|
|
98
|
-
hist.add_argument("--since")
|
|
99
|
-
hist.add_argument("--until")
|
|
100
|
-
hist.add_argument("--limit", type=int)
|
|
101
|
-
hist.add_argument("--months", type=int)
|
|
102
|
-
hist.add_argument("--count-only", action="store_true")
|
|
103
|
-
hist.add_argument("--json", action="store_true")
|
|
104
|
-
|
|
105
|
-
valp = sub.add_parser(
|
|
106
|
-
"validate",
|
|
107
|
-
help="台账体检(默认浅度;--deep 历史读全部月份;两档都不读过程文档)",
|
|
108
|
-
)
|
|
109
|
-
valp.add_argument("--deep", action="store_true")
|
|
110
|
-
valp.add_argument("--json", action="store_true")
|
|
111
|
-
|
|
112
|
-
who = sub.add_parser("whoami", help="我是谁(本机身份;未初始化也照常回答)")
|
|
113
|
-
who.add_argument("--json", action="store_true")
|
|
114
|
-
|
|
115
|
-
initp = sub.add_parser("init", help="初始化本机身份(一台机器只一次)")
|
|
116
|
-
initp.add_argument("--claim", help="认领名册里已有的 uuid")
|
|
117
|
-
initp.add_argument("--new", action="store_true", dest="new_id", help="新建身份")
|
|
118
|
-
initp.add_argument("--name", help="新建身份时的显示名字(必填)")
|
|
119
|
-
initp.add_argument(
|
|
120
|
-
"--yes",
|
|
121
|
-
action="store_true",
|
|
122
|
-
help="已禁用:身份写入只能在真终端交互完成",
|
|
123
|
-
)
|
|
124
|
-
|
|
125
|
-
# 宿主(插件 / SKILL.md)的调用写法是 `progress.py list --repo <仓库>`:
|
|
126
|
-
# `--repo` 挂在**子命令后面**,所以每个子命令都要认;子命令给的值覆盖顶层那个。
|
|
127
|
-
for parser_ in sub.choices.values():
|
|
128
|
-
# default=SUPPRESS:子命令没给就不写回 namespace,否则会把顶层的 --repo 覆盖成 None
|
|
129
|
-
parser_.add_argument(
|
|
130
|
-
"--repo", default=argparse.SUPPRESS, help=argparse.SUPPRESS
|
|
131
|
-
)
|
|
132
|
-
|
|
133
|
-
return parser
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def to_query(args: argparse.Namespace) -> None:
|
|
137
|
-
"""装配查询条件并挂到 `args.q`(参数 → 查询对象的装配只此一处)。"""
|
|
138
|
-
command = getattr(args, "command", "")
|
|
139
|
-
if command == "list":
|
|
140
|
-
if getattr(args, "mine", False):
|
|
141
|
-
scope = "mine"
|
|
142
|
-
elif getattr(args, "pool", False):
|
|
143
|
-
scope = "pool"
|
|
144
|
-
elif getattr(args, "owner", None):
|
|
145
|
-
scope = "owner"
|
|
146
|
-
else:
|
|
147
|
-
scope = "all"
|
|
148
|
-
args.q = ListQuery(
|
|
149
|
-
scope=scope,
|
|
150
|
-
owner=getattr(args, "owner", None),
|
|
151
|
-
owner_name=getattr(args, "owner_name", None),
|
|
152
|
-
statuses=tuple(
|
|
153
|
-
s.strip()
|
|
154
|
-
for s in (getattr(args, "status", "") or "").split(",")
|
|
155
|
-
if s.strip()
|
|
156
|
-
),
|
|
157
|
-
category=getattr(args, "category", None),
|
|
158
|
-
tag=getattr(args, "tag", None),
|
|
159
|
-
since=_utc_of(getattr(args, "since", None), "--since"),
|
|
160
|
-
until=_utc_of(
|
|
161
|
-
getattr(args, "until", None), "--until", date_only_ref="23:59:59"
|
|
162
|
-
),
|
|
163
|
-
)
|
|
164
|
-
elif command == "history":
|
|
165
|
-
args.q = HistoryQuery(
|
|
166
|
-
owner=getattr(args, "owner", None),
|
|
167
|
-
owner_name=getattr(args, "owner_name", None),
|
|
168
|
-
mine=bool(getattr(args, "mine", False)),
|
|
169
|
-
category=getattr(args, "category", None),
|
|
170
|
-
tag=getattr(args, "tag", None),
|
|
171
|
-
since=_utc_of(getattr(args, "since", None), "--since"),
|
|
172
|
-
until=_utc_of(
|
|
173
|
-
getattr(args, "until", None), "--until", date_only_ref="23:59:59"
|
|
174
|
-
),
|
|
175
|
-
limit=getattr(args, "limit", None),
|
|
176
|
-
months=getattr(args, "months", None),
|
|
177
|
-
count_only=bool(getattr(args, "count_only", False)),
|
|
178
|
-
)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
def _utc_of(
|
|
182
|
-
value: str | None, field: str, *, date_only_ref: str | None = None
|
|
183
|
-
) -> str | None:
|
|
184
|
-
return to_utc(value, field=field, date_only_ref=date_only_ref) if value else None
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
def command_of(args: argparse.Namespace) -> str:
|
|
188
|
-
"""命令行 → 名册的键(命令写法)。"""
|
|
189
|
-
return str(getattr(args, "command", ""))
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
def _split_tags(value: str | None) -> tuple[str, ...]:
|
|
193
|
-
if not value:
|
|
194
|
-
return ()
|
|
195
|
-
# 分隔符与 core/labels._SPLIT_RE 保持一致:少一个「、」就会把
|
|
196
|
-
# 「甲、乙」静默合成一个标签(2026-10-24 修)
|
|
197
|
-
for sep in (",", "、"):
|
|
198
|
-
value = value.replace(sep, ",")
|
|
199
|
-
return tuple(t.strip() for t in value.split(",") if t.strip())
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
def resolve_repo(arg: str | None, start: Path | None = None) -> Path:
|
|
203
|
-
"""`--repo` 给了就校验后用;没给就从 `start`(默认 cwd)往上找。
|
|
204
|
-
|
|
205
|
-
找不到 ⇒ Fail:绝不在当前目录自作主张建台账(2026-10-22 设计定稿)。
|
|
206
|
-
建台账是第一条写命令的事,不需要、也没有 `init` 子命令。
|
|
207
|
-
"""
|
|
208
|
-
if arg:
|
|
209
|
-
return as_root(arg, source="--repo")
|
|
210
|
-
here = (start or Path.cwd()).resolve()
|
|
211
|
-
root = root_of(here)
|
|
212
|
-
if root is None:
|
|
213
|
-
raise Fail(
|
|
214
|
-
"找不到台账:从当前目录往上都没有 ledger/ 目录。\n"
|
|
215
|
-
" 处理:用 --repo <工作区根> 指定(根是直接含 ledger/ 的那个目录,不是 ledger/ 本身);\n"
|
|
216
|
-
" 或先 cd 到项目根再跑。第一条写命令会自动把 ledger/ 建出来,不用先初始化。"
|
|
217
|
-
)
|
|
218
|
-
return root
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
def _repo_of(args: argparse.Namespace) -> Path:
|
|
222
|
-
"""本次命令的工作区根(runner 与锁都用它;`main` 里已解析过一次)。"""
|
|
223
|
-
return resolve_repo(getattr(args, "repo", None))
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
def main(argv: list[str] | None = None) -> int:
|
|
227
|
-
parser = build_parser()
|
|
228
|
-
args = parser.parse_args(argv)
|
|
229
|
-
if os.environ.get(NO_SYNC_ENV):
|
|
230
|
-
args.no_sync = True
|
|
231
|
-
if getattr(args, "json", False) and getattr(args, "count_only", False):
|
|
232
|
-
parser.error("--json 与 --count-only 不能同时用")
|
|
233
|
-
return USAGE
|
|
234
|
-
for field in ("tags",):
|
|
235
|
-
if hasattr(args, field):
|
|
236
|
-
setattr(args, field, _split_tags(getattr(args, field)))
|
|
237
|
-
to_query(args)
|
|
238
|
-
|
|
239
|
-
command = command_of(args)
|
|
240
|
-
if command == "whoami":
|
|
241
|
-
return _run_whoami(args)
|
|
242
|
-
if command == "init":
|
|
243
|
-
return _run_init(args)
|
|
244
|
-
try:
|
|
245
|
-
# 定位只做一次:解析好的工作区根挂回 args,runner 与整命令锁都拿它
|
|
246
|
-
args.repo = str(resolve_repo(getattr(args, "repo", None)))
|
|
247
|
-
if _readonly(command):
|
|
248
|
-
return runner.run(command, args) # 只读不碰 git ⇒ 不拿同步锁
|
|
249
|
-
with repo_sync_lock(_lock_dir(Path(args.repo))):
|
|
250
|
-
return runner.run(command, args)
|
|
251
|
-
except Fail as exc:
|
|
252
|
-
render.fail_message(exc)
|
|
253
|
-
return BUSINESS
|
|
254
|
-
finally:
|
|
255
|
-
render.flush_notices()
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
def _lock_dir(repo: Path) -> Path:
|
|
259
|
-
"""整命令锁放 **git 公共目录**里(不进仓库、不被 git status 看见;worktree 之间共享)。
|
|
260
|
-
|
|
261
|
-
`repo/.git` 是目录 ⇒ 就是它;是文件 ⇒ 链接 worktree,按 `gitdir:` 指针反推公共目录
|
|
262
|
-
(`<common>/worktrees/<name>` 的上级的上级);两者都不成 ⇒ 不是 git 仓库,
|
|
263
|
-
锁文件放系统临时目录(按仓库绝对路径哈希)—— 落用户工作区会被日后
|
|
264
|
-
`git init && git add -A` 误提交(2026-10-24 修)。
|
|
265
|
-
"""
|
|
266
|
-
dot = Path(repo) / ".git"
|
|
267
|
-
if dot.is_dir():
|
|
268
|
-
return dot
|
|
269
|
-
if dot.is_file():
|
|
270
|
-
try:
|
|
271
|
-
text = dot.read_text(encoding="utf-8").strip()
|
|
272
|
-
except OSError:
|
|
273
|
-
text = ""
|
|
274
|
-
if text.startswith("gitdir:"):
|
|
275
|
-
common = Path(text.split(":", 1)[1].strip()).parent.parent
|
|
276
|
-
if common.is_dir():
|
|
277
|
-
return common
|
|
278
|
-
import hashlib
|
|
279
|
-
import tempfile
|
|
280
|
-
|
|
281
|
-
key = hashlib.sha256(str(Path(repo).resolve()).encode("utf-8")).hexdigest()[:16]
|
|
282
|
-
lock_dir = Path(tempfile.gettempdir()) / f"dsh-task-ledger-{key}"
|
|
283
|
-
lock_dir.mkdir(parents=True, exist_ok=True)
|
|
284
|
-
return lock_dir
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
def _print_candidates(opts: list[dict]) -> None:
|
|
288
|
-
print("这台机器还没初始化。当前仓库里已有的用户:")
|
|
289
|
-
for i, opt in enumerate(opts, 1):
|
|
290
|
-
print(f" {i}. @{opt['name']} {opt['uuid']}")
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
def _print_init_result(res: dict) -> None:
|
|
294
|
-
name_disp = res.get("name") or res["uuid"][:8]
|
|
295
|
-
if res.get("already"):
|
|
296
|
-
print(f"已经初始化过,未做改动:@{name_disp} {res['uuid']}")
|
|
297
|
-
print(f"文件:{res['path']}")
|
|
298
|
-
return
|
|
299
|
-
print(f"\x1b[32m✓ 已初始化:\x1b[0m@{name_disp} {res['uuid']}")
|
|
300
|
-
print(f"文件:{res['path']}")
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
def _select_identity(opts: list[dict], *, title: str) -> tuple[str, int | None]:
|
|
304
|
-
"""交互式选身份:↑/↓ 移动、Enter 确认、Ctrl-C 取消,末尾「+ 新建身份」。
|
|
305
|
-
|
|
306
|
-
返回 (kind, index):kind∈{claim,new,cancel};claim/new 时 index 为候选项下标。
|
|
307
|
-
TTY 下用 raw 键盘 + 原地重绘(不整屏清屏、不动滚动条);不支持时退回编号输入。
|
|
308
|
-
仅在 TTY 调用。
|
|
309
|
-
"""
|
|
310
|
-
entries = list(opts) + [{"new": True}]
|
|
311
|
-
n = len(entries)
|
|
312
|
-
height = n + 4 # 标题 + 空行 + n 个选项 + 空行 + 提示
|
|
313
|
-
|
|
314
|
-
def _frame(sel: int) -> list[str]:
|
|
315
|
-
rows = ["\x1b[1;36m" + title + "\x1b[0m", ""]
|
|
316
|
-
for i, e in enumerate(entries):
|
|
317
|
-
label = (
|
|
318
|
-
"+ 新建身份(自己输入名字)"
|
|
319
|
-
if e.get("new")
|
|
320
|
-
else f"@{e['name']} {e['uuid']}"
|
|
321
|
-
)
|
|
322
|
-
rows.append(
|
|
323
|
-
"\x1b[7m ❯ " + label + " \x1b[0m" if i == sel else " " + label
|
|
324
|
-
)
|
|
325
|
-
rows += ["", "\x1b[2m ↑/↓ 移动 Enter 确认 ESC / Ctrl-C 退出\x1b[0m"]
|
|
326
|
-
return rows
|
|
327
|
-
|
|
328
|
-
try:
|
|
329
|
-
fd = sys.stdin.fileno()
|
|
330
|
-
if not sys.stdin.isatty():
|
|
331
|
-
raise OSError("not a tty")
|
|
332
|
-
import os
|
|
333
|
-
import select
|
|
334
|
-
import termios
|
|
335
|
-
import tty
|
|
336
|
-
|
|
337
|
-
old = termios.tcgetattr(fd)
|
|
338
|
-
sel = 0
|
|
339
|
-
drew = False
|
|
340
|
-
result: tuple[str, int | None] = ("cancel", None)
|
|
341
|
-
try:
|
|
342
|
-
tty.setraw(fd)
|
|
343
|
-
sys.stdout.write("\x1b[?25l") # 隐藏光标
|
|
344
|
-
while True:
|
|
345
|
-
if drew:
|
|
346
|
-
sys.stdout.write("\x1b[1A" * height) # 相对上移回块顶
|
|
347
|
-
else:
|
|
348
|
-
sys.stdout.write("\n") # 另起一行,不覆盖 shell 提示
|
|
349
|
-
drew = True
|
|
350
|
-
# promptui 同款:每行「清整行 + 回车 + 内容 + LF」
|
|
351
|
-
for row in _frame(sel):
|
|
352
|
-
sys.stdout.write("\x1b[2K\r" + row + "\n")
|
|
353
|
-
sys.stdout.flush()
|
|
354
|
-
|
|
355
|
-
ch = os.read(fd, 1)
|
|
356
|
-
if ch == b"\x1b":
|
|
357
|
-
# 区分「单独 ESC(退出)」与「方向键 ESC [ A/B」:短暂等待后续字节
|
|
358
|
-
if not select.select([fd], [], [], 0.05)[0]:
|
|
359
|
-
result = ("cancel", None)
|
|
360
|
-
break
|
|
361
|
-
nxt = os.read(fd, 1)
|
|
362
|
-
if nxt in (b"[", b"O"):
|
|
363
|
-
k = os.read(fd, 1)
|
|
364
|
-
if k == b"A":
|
|
365
|
-
sel = (sel - 1) % n
|
|
366
|
-
elif k == b"B":
|
|
367
|
-
sel = (sel + 1) % n
|
|
368
|
-
elif ch in (b"\r", b"\n"):
|
|
369
|
-
result = (
|
|
370
|
-
("new", None) if entries[sel].get("new") else ("claim", sel)
|
|
371
|
-
)
|
|
372
|
-
break
|
|
373
|
-
elif ch == b"\x03": # Ctrl-C
|
|
374
|
-
result = ("cancel", None)
|
|
375
|
-
break
|
|
376
|
-
finally:
|
|
377
|
-
sys.stdout.write("\x1b[?25h") # 显示光标
|
|
378
|
-
if drew:
|
|
379
|
-
# promptui 同款擦除:逐行「上移 + 清整行 + 回车」,不动主屏滚动
|
|
380
|
-
sys.stdout.write("\x1b[1A\x1b[2K\r" * height)
|
|
381
|
-
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
382
|
-
sys.stdout.flush()
|
|
383
|
-
return result
|
|
384
|
-
except (OSError, ImportError):
|
|
385
|
-
print(title)
|
|
386
|
-
for i, o in enumerate(opts, 1):
|
|
387
|
-
print(f" {i}. @{o['name']} {o['uuid']}")
|
|
388
|
-
print(f" {n}. + 新建身份")
|
|
389
|
-
while True:
|
|
390
|
-
try:
|
|
391
|
-
c = input(f"选编号(1-{n}):").strip()
|
|
392
|
-
except (EOFError, KeyboardInterrupt):
|
|
393
|
-
return ("cancel", None)
|
|
394
|
-
if c.lower() in ("n", "new"):
|
|
395
|
-
return ("new", None)
|
|
396
|
-
try:
|
|
397
|
-
idx = int(c) - 1
|
|
398
|
-
except ValueError:
|
|
399
|
-
continue
|
|
400
|
-
if idx == len(opts):
|
|
401
|
-
return ("new", None)
|
|
402
|
-
if 0 <= idx < len(opts):
|
|
403
|
-
return ("claim", idx)
|
|
404
|
-
return ("cancel", None)
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
def _confirm(question: str) -> bool:
|
|
408
|
-
"""Y/N 确认:Y 返回 True,N 返回 False;Ctrl-C/Ctrl-D 向上抛,当作取消退出。"""
|
|
409
|
-
while True:
|
|
410
|
-
ans = input(question + " [Y/N]:").strip().lower()
|
|
411
|
-
if ans in ("y", "yes"):
|
|
412
|
-
return True
|
|
413
|
-
if ans in ("n", "no"):
|
|
414
|
-
return False
|
|
415
|
-
print("请输入 Y 或 N。")
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
def _run_whoami(args: argparse.Namespace) -> int:
|
|
419
|
-
"""只读机器身份:不解析台账、不碰 git、不看 `--repo`。
|
|
420
|
-
|
|
421
|
-
未初始化**不是错误**:照常回答(`uuid` / `name` 双 null),由调用方据此引导 init。
|
|
422
|
-
"""
|
|
423
|
-
from .. import env
|
|
424
|
-
|
|
425
|
-
try:
|
|
426
|
-
identity = env.read_identity()
|
|
427
|
-
except Fail as exc:
|
|
428
|
-
render.fail_message(exc)
|
|
429
|
-
return BUSINESS
|
|
430
|
-
render.whoami_view(identity, as_json=bool(getattr(args, "json", False)))
|
|
431
|
-
return OK
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
def _run_init(args: argparse.Namespace) -> int:
|
|
435
|
-
"""init 不进 runner、不解析台账、不碰 git:只写机器身份文件。
|
|
436
|
-
|
|
437
|
-
交互式:裸 init 在真终端读名册、让用户箭头选 / 新建;无终端(agent)只报候选、不写。
|
|
438
|
-
"""
|
|
439
|
-
from .. import env
|
|
440
|
-
from ..actions.init_identity import init_identity
|
|
441
|
-
|
|
442
|
-
repo: Path | None = None
|
|
443
|
-
raw = getattr(args, "repo", None)
|
|
444
|
-
try:
|
|
445
|
-
repo = as_root(raw, source="--repo") if raw else root_of(Path.cwd().resolve())
|
|
446
|
-
except Fail:
|
|
447
|
-
repo = None
|
|
448
|
-
claim = getattr(args, "claim", None)
|
|
449
|
-
new = bool(getattr(args, "new_id", False))
|
|
450
|
-
name = (getattr(args, "name", "") or "").strip()
|
|
451
|
-
yes = bool(getattr(args, "yes", False))
|
|
452
|
-
tty = sys.stdin.isatty()
|
|
453
|
-
|
|
454
|
-
try:
|
|
455
|
-
if yes:
|
|
456
|
-
raise Fail(
|
|
457
|
-
"--yes 已禁用:身份写入只能在真终端交互完成,Agent 不得代写或重建身份。\n"
|
|
458
|
-
" 处理:请用户在自己的终端运行 `python3 progress.py init --repo <工作区根>`。"
|
|
459
|
-
)
|
|
460
|
-
existing = env.read_identity()
|
|
461
|
-
if existing is not None:
|
|
462
|
-
# 已初始化:幂等空转(带不带 flag 都一样,零写)
|
|
463
|
-
res = init_identity(repo, claim=claim, new=new, name=name)
|
|
464
|
-
elif claim or new or name:
|
|
465
|
-
# 身份写入只能由用户在真终端交互完成;非终端(agent)一律拒绝(2026-10-26 事故)
|
|
466
|
-
if not tty:
|
|
467
|
-
raise Fail(
|
|
468
|
-
"身份写入只能在真终端交互完成;Agent 不得代写或代为重建身份。\n"
|
|
469
|
-
" 处理:请用户在自己的终端运行 `python3 progress.py init --repo <工作区根>`,\n"
|
|
470
|
-
" 由 ta 亲自选择/新建并确认。"
|
|
471
|
-
)
|
|
472
|
-
res = init_identity(repo, claim=claim, new=new, name=name)
|
|
473
|
-
else:
|
|
474
|
-
# 裸 init:先看名册
|
|
475
|
-
res = init_identity(repo, claim=None, new=False, name="")
|
|
476
|
-
if res.get("need_choose"):
|
|
477
|
-
opts = res.get("options", [])
|
|
478
|
-
if not tty:
|
|
479
|
-
_print_candidates(opts)
|
|
480
|
-
print(
|
|
481
|
-
"请重跑:init --claim=<uuid> 认领,或 init --new --name=<名字> 新建"
|
|
482
|
-
)
|
|
483
|
-
return 0
|
|
484
|
-
while True:
|
|
485
|
-
kind, idx = _select_identity(
|
|
486
|
-
opts, title="这台机器还没初始化。选择你的身份,或新建一个:"
|
|
487
|
-
)
|
|
488
|
-
if kind == "cancel":
|
|
489
|
-
print(
|
|
490
|
-
"\x1b[2m✕ 已取消初始化。随时可重跑 init 重新开始。\x1b[0m"
|
|
491
|
-
)
|
|
492
|
-
return 0
|
|
493
|
-
if kind == "new":
|
|
494
|
-
prompt_name = input("输入用户名:").strip()
|
|
495
|
-
if not prompt_name:
|
|
496
|
-
print("\x1b[2m✕ 未输入用户名,已取消。\x1b[0m")
|
|
497
|
-
return 0
|
|
498
|
-
if not _confirm(
|
|
499
|
-
f"您确认要用「{prompt_name}」这个名称新建账户吗?"
|
|
500
|
-
):
|
|
501
|
-
continue # N:回到选择菜单重来
|
|
502
|
-
res = init_identity(
|
|
503
|
-
repo, claim=None, new=True, name=prompt_name
|
|
504
|
-
)
|
|
505
|
-
break
|
|
506
|
-
chosen = opts[idx]
|
|
507
|
-
if not _confirm(f"您确认是要使用「@{chosen['name']}」这个身份吗?"):
|
|
508
|
-
continue # N:回到选择菜单重来
|
|
509
|
-
res = init_identity(repo, claim=chosen["uuid"], new=False, name="")
|
|
510
|
-
break
|
|
511
|
-
else:
|
|
512
|
-
# 名册空:交互输入名字;非终端交给用户去终端跑(init_identity 会报需要名字)
|
|
513
|
-
prompt_name = input("输入用户名:").strip() if tty else ""
|
|
514
|
-
res = init_identity(repo, claim=None, new=tty, name=prompt_name)
|
|
515
|
-
except Fail as exc:
|
|
516
|
-
render.fail_message(exc)
|
|
517
|
-
return BUSINESS
|
|
518
|
-
except (ValueError, IndexError):
|
|
519
|
-
render.fail_message(Fail("输入无效,初始化已取消。"))
|
|
520
|
-
return BUSINESS
|
|
521
|
-
except (KeyboardInterrupt, EOFError):
|
|
522
|
-
# 中途 Ctrl-C / Ctrl-D:平静取消,不抛栈、干净退出
|
|
523
|
-
print("\x1b[2m✕ 已取消初始化。随时可重跑 init 重新开始。\x1b[0m")
|
|
524
|
-
return 0
|
|
525
|
-
_print_init_result(res)
|
|
526
|
-
return 0
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
def _readonly(command: str) -> bool:
|
|
530
|
-
from ..actions.registry import spec_of
|
|
531
|
-
|
|
532
|
-
return spec_of(command).readonly
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
if __name__ == "__main__": # pragma: no cover
|
|
536
|
-
sys.exit(main())
|