claude-nb 0.5.1 → 0.5.44
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/README.md +240 -17
- package/VERSION +1 -1
- package/bin/_pip_entry.py +1 -1
- package/bin/board +17 -0
- package/bin/check-changelog +98 -0
- package/bin/check-npm-package +89 -0
- package/bin/check-readme-sync +69 -0
- package/bin/cnb +95 -9
- package/bin/configure-godaddy-pages-dns +203 -0
- package/bin/doctor +52 -1
- package/bin/init +64 -3
- package/bin/notify +59 -5
- package/bin/secret-scan +165 -0
- package/bin/shutdown +68 -0
- package/lib/board_admin.py +15 -31
- package/lib/board_bbs.py +11 -17
- package/lib/board_bug.py +9 -21
- package/lib/board_db.py +19 -0
- package/lib/board_lock.py +11 -12
- package/lib/board_mail.py +212 -0
- package/lib/board_mailbox.py +35 -0
- package/lib/board_msg.py +8 -33
- package/lib/board_own.py +288 -0
- package/lib/board_pending.py +10 -7
- package/lib/board_task.py +24 -8
- package/lib/board_tui.py +10 -4
- package/lib/board_view.py +65 -41
- package/lib/board_vote.py +3 -12
- package/lib/build_lock.py +2 -2
- package/lib/cli.py +3 -3
- package/lib/common.py +27 -9
- package/lib/concerns/digest_scheduler.py +25 -6
- package/lib/concerns/file_watcher.py +19 -9
- package/lib/concerns/helpers.py +17 -37
- package/lib/concerns/notification_push.py +9 -2
- package/lib/digest.py +80 -0
- package/lib/github_issues_sync.py +173 -0
- package/lib/global_registry.py +183 -0
- package/lib/inject.py +21 -13
- package/lib/migrate.py +8 -4
- package/lib/monitor.py +25 -9
- package/lib/notification_config.py +25 -5
- package/lib/notification_delivery.py +95 -0
- package/lib/panel.py +6 -2
- package/lib/resources.py +6 -3
- package/lib/shift_report.py +223 -0
- package/lib/shutdown.py +198 -0
- package/lib/tmux_utils.py +52 -0
- package/lib/token_usage.py +148 -0
- package/migrations/007_mail.sql +15 -0
- package/migrations/008_ownership.sql +10 -0
- package/package.json +36 -3
- package/pyproject.toml +1 -1
- package/registry/0005-ritchie.json +12 -0
- package/registry/pubkeys.json +83 -3
- package/schema.sql +25 -0
package/bin/cnb
CHANGED
|
@@ -10,7 +10,23 @@ else
|
|
|
10
10
|
B='' D='' G='' C='' Y='' N=''
|
|
11
11
|
fi
|
|
12
12
|
|
|
13
|
-
# ----
|
|
13
|
+
# ---- Version update check (non-blocking, shown only at interactive startup) ----
|
|
14
|
+
_CACHE="$HOME/.cnb/latest-version"
|
|
15
|
+
_check_update() {
|
|
16
|
+
mkdir -p "$HOME/.cnb"
|
|
17
|
+
if [ ! -f "$_CACHE" ] || [ "$(find "$_CACHE" -mmin +60 2>/dev/null)" ]; then
|
|
18
|
+
(npm view claude-nb version 2>/dev/null > "$_CACHE.tmp" && mv "$_CACHE.tmp" "$_CACHE" || rm -f "$_CACHE.tmp") &
|
|
19
|
+
fi
|
|
20
|
+
if [ -f "$_CACHE" ]; then
|
|
21
|
+
_LATEST=$(cat "$_CACHE" 2>/dev/null | tr -d '[:space:]')
|
|
22
|
+
_LOCAL=$(echo "$VERSION" | sed 's/-dev$//')
|
|
23
|
+
if [ -n "$_LATEST" ] && [ "$_LOCAL" != "$_LATEST" ]; then
|
|
24
|
+
printf "${Y}⬆ cnb v${_LATEST} 已发布,当前 v${VERSION}。运行 pip install --upgrade claude-nb 更新。${N}\n"
|
|
25
|
+
fi
|
|
26
|
+
fi
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# ---- Export project root so all subprocesses can find .cnb/ ----
|
|
14
30
|
export CNB_PROJECT="${CNB_PROJECT:-$(pwd)}"
|
|
15
31
|
|
|
16
32
|
# ---- Subcommands (exact match, always first) ----
|
|
@@ -23,6 +39,7 @@ if [ $# -gt 0 ]; then
|
|
|
23
39
|
dispatcher) shift; exec "$CLAUDES_HOME/bin/dispatcher" "$@" ;;
|
|
24
40
|
watchdog) shift; exec "$CLAUDES_HOME/bin/dispatcher-watchdog" "$@" ;;
|
|
25
41
|
doctor) shift; exec "$CLAUDES_HOME/bin/doctor" "$@" ;;
|
|
42
|
+
shutdown) shift; exec "$CLAUDES_HOME/bin/shutdown" "$@" ;;
|
|
26
43
|
logs)
|
|
27
44
|
shift
|
|
28
45
|
if [ $# -eq 0 ]; then
|
|
@@ -68,7 +85,67 @@ print(' '.join(names))
|
|
|
68
85
|
echo "OK 团队已启动: $_NAMES"
|
|
69
86
|
fi
|
|
70
87
|
exit 0 ;;
|
|
88
|
+
projects)
|
|
89
|
+
shift
|
|
90
|
+
if [ $# -eq 0 ]; then
|
|
91
|
+
echo "用法: cnb projects <list|cleanup>" >&2; exit 1
|
|
92
|
+
fi
|
|
93
|
+
subcmd="$1"; shift
|
|
94
|
+
case "$subcmd" in
|
|
95
|
+
list)
|
|
96
|
+
python3 -c "
|
|
97
|
+
import sys; sys.path.insert(0, '$CLAUDES_HOME')
|
|
98
|
+
from lib.global_registry import list_projects
|
|
99
|
+
projects = list_projects()
|
|
100
|
+
if not projects:
|
|
101
|
+
print('没有已注册的项目')
|
|
102
|
+
else:
|
|
103
|
+
for p in projects:
|
|
104
|
+
print(f\" {p['name']:20s} {p['path']} (最后活跃: {p.get('last_active', '未知')})\")
|
|
105
|
+
" ;;
|
|
106
|
+
cleanup)
|
|
107
|
+
python3 -c "
|
|
108
|
+
import sys; sys.path.insert(0, '$CLAUDES_HOME')
|
|
109
|
+
from lib.global_registry import cleanup
|
|
110
|
+
removed = cleanup()
|
|
111
|
+
if not removed:
|
|
112
|
+
print('没有需要清理的项目')
|
|
113
|
+
else:
|
|
114
|
+
for p in removed:
|
|
115
|
+
print(f' 已移除: {p}')
|
|
116
|
+
print(f'OK 清理了 {len(removed)} 个失效项目')
|
|
117
|
+
" ;;
|
|
118
|
+
*)
|
|
119
|
+
echo "用法: cnb projects <list|cleanup>" >&2; exit 1 ;;
|
|
120
|
+
esac
|
|
121
|
+
exit 0 ;;
|
|
71
122
|
ui) shift; exec "$CLAUDES_HOME/bin/board" tui ;;
|
|
123
|
+
usage)
|
|
124
|
+
shift
|
|
125
|
+
python3 -c "
|
|
126
|
+
import sys; sys.path.insert(0, '$CLAUDES_HOME')
|
|
127
|
+
from pathlib import Path
|
|
128
|
+
from lib.token_usage import cmd_usage
|
|
129
|
+
cmd_usage(Path('$CNB_PROJECT'), sys.argv[1:])
|
|
130
|
+
" "$@"
|
|
131
|
+
exit 0 ;;
|
|
132
|
+
leaderboard)
|
|
133
|
+
echo "${B}${G}◆ 贡献排行榜${N}"
|
|
134
|
+
echo ""
|
|
135
|
+
git -C "$CNB_PROJECT" log --format='%aN%n%(trailers:key=Co-Authored-By,valueonly,separator=%x0A)' \
|
|
136
|
+
| sed '/^$/d' | sed 's/^ *//' | sed 's/ *<[^>]*>//g' \
|
|
137
|
+
| sort | uniq -c | sort -rn \
|
|
138
|
+
| awk -v b="$B" -v n="$N" -v g="$G" -v y="$Y" '{
|
|
139
|
+
rank++
|
|
140
|
+
if (rank == 1) medal = "🥇"
|
|
141
|
+
else if (rank == 2) medal = "🥈"
|
|
142
|
+
else if (rank == 3) medal = "🥉"
|
|
143
|
+
else medal = " "
|
|
144
|
+
name = ""
|
|
145
|
+
for (i = 2; i <= NF; i++) name = name (i > 2 ? " " : "") $i
|
|
146
|
+
printf " %s %s%-20s%s %s%d commits%s\n", medal, b, name, n, g, $1, n
|
|
147
|
+
}'
|
|
148
|
+
exit 0 ;;
|
|
72
149
|
version|--version|-v) echo "cnb v${VERSION}"; exit 0 ;;
|
|
73
150
|
help|--help|-h)
|
|
74
151
|
printf "${B}${G}◆ cnb${N} ${D}v${VERSION}${N}\n"
|
|
@@ -84,6 +161,10 @@ print(' '.join(names))
|
|
|
84
161
|
printf " cnb logs <name> 查看某个同学的消息历史\n"
|
|
85
162
|
printf " cnb exec <name> \"msg\" 给某个同学发指令\n"
|
|
86
163
|
printf " cnb stop <name> 停止某个同学\n"
|
|
164
|
+
printf " cnb projects list 列出所有已注册的项目\n"
|
|
165
|
+
printf " cnb projects cleanup 清理已失效的项目\n"
|
|
166
|
+
printf " cnb usage token 用量统计\n"
|
|
167
|
+
printf " cnb leaderboard 贡献排行榜\n"
|
|
87
168
|
printf " cnb doctor 健康检查\n\n"
|
|
88
169
|
printf " cnb board [...] 底层消息/任务命令\n"
|
|
89
170
|
printf " cnb swarm [...] 管理后台同学\n"
|
|
@@ -93,23 +174,27 @@ fi
|
|
|
93
174
|
|
|
94
175
|
# ---- Helper: start dispatcher if not already running ----
|
|
95
176
|
_start_dispatcher() {
|
|
96
|
-
local pidfile="$CNB_PROJECT
|
|
177
|
+
local pidfile="$CNB_PROJECT/$_CFG_DIR/dispatcher.pid"
|
|
97
178
|
if [ -f "$pidfile" ] && kill -0 "$(cat "$pidfile")" 2>/dev/null; then
|
|
98
179
|
return
|
|
99
180
|
fi
|
|
100
|
-
mkdir -p "$CNB_PROJECT
|
|
101
|
-
"$CLAUDES_HOME/bin/dispatcher" >> "$CNB_PROJECT
|
|
181
|
+
mkdir -p "$CNB_PROJECT/$_CFG_DIR/logs"
|
|
182
|
+
"$CLAUDES_HOME/bin/dispatcher" >> "$CNB_PROJECT/$_CFG_DIR/logs/dispatcher.log" 2>&1 &
|
|
102
183
|
echo $! > "$pidfile"
|
|
103
184
|
disown
|
|
104
185
|
}
|
|
105
186
|
|
|
106
187
|
# ---- Compose mode: reuse existing team from config.toml ----
|
|
107
|
-
|
|
108
|
-
|
|
188
|
+
# Detect config dir: prefer .cnb/, fall back to .claudes/
|
|
189
|
+
_CFG_DIR=".cnb"
|
|
190
|
+
[ ! -f ".cnb/config.toml" ] && [ -f ".claudes/config.toml" ] && _CFG_DIR=".claudes"
|
|
191
|
+
|
|
192
|
+
if [ -f "$_CFG_DIR/config.toml" ] && [ -f "$_CFG_DIR/board.db" ]; then
|
|
193
|
+
_EXISTING=$(grep '^sessions' "$_CFG_DIR/config.toml" | sed 's/sessions = \[//;s/\]//;s/"//g;s/,/ /g;s/ */ /g' | xargs)
|
|
109
194
|
if [ -n "$_EXISTING" ]; then
|
|
110
195
|
ME=$(echo "$_EXISTING" | cut -d' ' -f1)
|
|
111
196
|
WORKERS=$(echo "$_EXISTING" | cut -d' ' -f2-)
|
|
112
|
-
LABEL=$(grep '^prefix'
|
|
197
|
+
LABEL=$(grep '^prefix' "$_CFG_DIR/config.toml" | cut -d'"' -f2)
|
|
113
198
|
_NUM=$(echo "$WORKERS" | wc -w | tr -d ' ')
|
|
114
199
|
"$CLAUDES_HOME/bin/swarm" start $WORKERS >/dev/null 2>&1 &
|
|
115
200
|
disown
|
|
@@ -160,7 +245,7 @@ else
|
|
|
160
245
|
ME=$(echo "$ALL_NAMES" | cut -d' ' -f1)
|
|
161
246
|
WORKERS=$(echo "$ALL_NAMES" | cut -d' ' -f2-)
|
|
162
247
|
|
|
163
|
-
printf "${D}初始化 .
|
|
248
|
+
printf "${D}初始化 .cnb/ ...${N}\n"
|
|
164
249
|
"$CLAUDES_HOME/bin/init" $ALL_NAMES >/dev/null 2>&1
|
|
165
250
|
"$CLAUDES_HOME/bin/swarm" start $WORKERS >/dev/null 2>&1 &
|
|
166
251
|
disown
|
|
@@ -173,7 +258,7 @@ mkdir -p "$CMD_DIR"
|
|
|
173
258
|
grep -qx 'commands/cnb-*' .claude/.gitignore 2>/dev/null || echo 'commands/cnb-*' >> .claude/.gitignore 2>/dev/null || true
|
|
174
259
|
BOARD="$CLAUDES_HOME/bin/board"
|
|
175
260
|
SWARM="$CLAUDES_HOME/bin/swarm"
|
|
176
|
-
_PREFIX=$(grep '^prefix'
|
|
261
|
+
_PREFIX=$(grep '^prefix' "$_CFG_DIR/config.toml" 2>/dev/null | cut -d'"' -f2)
|
|
177
262
|
cat > "$CMD_DIR/cnb-watch.md" <<EOF
|
|
178
263
|
看某个同学在干什么。解析 \$ARGUMENTS 拿到名字。
|
|
179
264
|
运行 \`tmux capture-pane -t ${_PREFIX}-<名字> -p -S -30 2>/dev/null | tail -20\`,用简洁的话告诉用户这个同学在做什么、进展到哪了。
|
|
@@ -209,6 +294,7 @@ fi
|
|
|
209
294
|
|
|
210
295
|
# ---- Banner + launch ----
|
|
211
296
|
clear
|
|
297
|
+
_check_update
|
|
212
298
|
printf "${B}${G}◆ cnb${N} ${D}v${VERSION}${N}\n"
|
|
213
299
|
printf "${D} 「${LABEL}」你是 ${ME},同学: ${WORKERS}${N}\n\n"
|
|
214
300
|
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Configure GoDaddy DNS records for the cnb GitHub Pages site."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib.request
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
GITHUB_PAGES_A = [
|
|
18
|
+
"185.199.108.153",
|
|
19
|
+
"185.199.109.153",
|
|
20
|
+
"185.199.110.153",
|
|
21
|
+
"185.199.111.153",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
GITHUB_PAGES_AAAA = [
|
|
25
|
+
"2606:50c0:8000::153",
|
|
26
|
+
"2606:50c0:8001::153",
|
|
27
|
+
"2606:50c0:8002::153",
|
|
28
|
+
"2606:50c0:8003::153",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
DEFAULT_DOMAIN = "c-n-b.space"
|
|
32
|
+
DEFAULT_PROFILE = "godaddy-no"
|
|
33
|
+
DEFAULT_GODADDY_BASE = "https://api.godaddy.com"
|
|
34
|
+
DEFAULT_WWW_TARGET = "apollozhangongithub.github.io"
|
|
35
|
+
DEFAULT_TTL = 600
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ConfigError(RuntimeError):
|
|
39
|
+
"""Raised when a required GoDaddy profile cannot be loaded."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _profile_prefix(profile: str) -> str:
|
|
43
|
+
normalized = re.sub(r"[^A-Za-z0-9]+", "_", profile).strip("_").upper()
|
|
44
|
+
if not normalized:
|
|
45
|
+
normalized = "DEFAULT"
|
|
46
|
+
if normalized.startswith("GODADDY_"):
|
|
47
|
+
return normalized
|
|
48
|
+
return f"GODADDY_{normalized}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load_profile_file(path: Path, profile: str) -> dict[str, Any]:
|
|
52
|
+
if not path.exists():
|
|
53
|
+
return {}
|
|
54
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
55
|
+
profiles = data.get("profiles", data)
|
|
56
|
+
value = profiles.get(profile, {})
|
|
57
|
+
if not isinstance(value, dict):
|
|
58
|
+
raise ConfigError(f"profile {profile!r} in {path} must be an object")
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_profile(profile: str, config_path: Path | None = None) -> dict[str, str]:
|
|
63
|
+
prefix = _profile_prefix(profile)
|
|
64
|
+
paths = []
|
|
65
|
+
if config_path is not None:
|
|
66
|
+
paths.append(config_path)
|
|
67
|
+
env_config = os.environ.get("CNB_GODADDY_PROFILES")
|
|
68
|
+
if env_config:
|
|
69
|
+
paths.append(Path(env_config).expanduser())
|
|
70
|
+
paths.append(Path("~/.config/cnb/godaddy-profiles.json").expanduser())
|
|
71
|
+
|
|
72
|
+
file_profile: dict[str, Any] = {}
|
|
73
|
+
for path in paths:
|
|
74
|
+
file_profile.update(_load_profile_file(path, profile))
|
|
75
|
+
|
|
76
|
+
def get_value(key: str, default: str = "") -> str:
|
|
77
|
+
env_key = f"{prefix}_{key.upper()}"
|
|
78
|
+
return os.environ.get(env_key) or os.environ.get(f"GODADDY_{key.upper()}") or str(file_profile.get(key, default))
|
|
79
|
+
|
|
80
|
+
api_key = get_value("api_key")
|
|
81
|
+
api_sec = get_value("api_" + "secret")
|
|
82
|
+
if not api_key or not api_sec:
|
|
83
|
+
raise ConfigError(
|
|
84
|
+
"missing GoDaddy credentials for profile "
|
|
85
|
+
f"{profile!r}; set {prefix}_API_KEY/{prefix}_API_SECRET, "
|
|
86
|
+
"or add the profile to ~/.config/cnb/godaddy-profiles.json"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
"api_key": api_key,
|
|
91
|
+
"api_secret": api_sec,
|
|
92
|
+
"api_base": get_value("api_base", DEFAULT_GODADDY_BASE).rstrip("/"),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def request_json(
|
|
97
|
+
*,
|
|
98
|
+
method: str,
|
|
99
|
+
base_url: str,
|
|
100
|
+
path: str,
|
|
101
|
+
api_key: str,
|
|
102
|
+
api_sec: str,
|
|
103
|
+
body: Any | None = None,
|
|
104
|
+
) -> Any:
|
|
105
|
+
url = f"{base_url}{path}"
|
|
106
|
+
headers = {
|
|
107
|
+
"Accept": "application/json",
|
|
108
|
+
"Authorization": f"sso-key {api_key}:{api_sec}",
|
|
109
|
+
}
|
|
110
|
+
data = None
|
|
111
|
+
if body is not None:
|
|
112
|
+
headers["Content-Type"] = "application/json"
|
|
113
|
+
data = json.dumps(body, separators=(",", ":")).encode("utf-8")
|
|
114
|
+
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
115
|
+
try:
|
|
116
|
+
with urllib.request.urlopen(request, timeout=30) as response:
|
|
117
|
+
payload = response.read().decode("utf-8")
|
|
118
|
+
return json.loads(payload) if payload else None
|
|
119
|
+
except urllib.error.HTTPError as err:
|
|
120
|
+
payload = err.read().decode("utf-8", errors="replace")
|
|
121
|
+
raise RuntimeError(f"GoDaddy API {method} {path} failed: HTTP {err.code} {payload}") from err
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def record_path(domain: str, record_type: str, name: str) -> str:
|
|
125
|
+
return "/v1/domains/{}/records/{}/{}".format(
|
|
126
|
+
urllib.parse.quote(domain, safe=""),
|
|
127
|
+
urllib.parse.quote(record_type, safe=""),
|
|
128
|
+
urllib.parse.quote(name, safe=""),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def put_records(args: argparse.Namespace, profile: dict[str, str], record_type: str, name: str, values: list[str]) -> None:
|
|
133
|
+
body = [{"data": value, "ttl": args.ttl} for value in values]
|
|
134
|
+
path = record_path(args.domain, record_type, name)
|
|
135
|
+
if args.dry_run:
|
|
136
|
+
print(f"DRY-RUN PUT {path} {json.dumps(body, ensure_ascii=False)}")
|
|
137
|
+
return
|
|
138
|
+
request_json(
|
|
139
|
+
method="PUT",
|
|
140
|
+
base_url=profile["api_base"],
|
|
141
|
+
path=path,
|
|
142
|
+
api_key=profile["api_key"],
|
|
143
|
+
api_sec=profile["api_secret"],
|
|
144
|
+
body=body,
|
|
145
|
+
)
|
|
146
|
+
print(f"OK {record_type} {name} -> {', '.join(values)}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def delete_records(args: argparse.Namespace, profile: dict[str, str], record_type: str, name: str) -> None:
|
|
150
|
+
path = record_path(args.domain, record_type, name)
|
|
151
|
+
if args.dry_run:
|
|
152
|
+
print(f"DRY-RUN DELETE {path}")
|
|
153
|
+
return
|
|
154
|
+
try:
|
|
155
|
+
request_json(
|
|
156
|
+
method="DELETE",
|
|
157
|
+
base_url=profile["api_base"],
|
|
158
|
+
path=path,
|
|
159
|
+
api_key=profile["api_key"],
|
|
160
|
+
api_sec=profile["api_secret"],
|
|
161
|
+
)
|
|
162
|
+
print(f"OK deleted {record_type} {name}")
|
|
163
|
+
except RuntimeError as err:
|
|
164
|
+
if "HTTP 404" not in str(err):
|
|
165
|
+
raise
|
|
166
|
+
print(f"OK no existing {record_type} {name}")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main(argv: list[str] | None = None) -> int:
|
|
170
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
171
|
+
parser.add_argument("--domain", default=DEFAULT_DOMAIN)
|
|
172
|
+
parser.add_argument("--profile", default=os.environ.get("GODADDY_PROFILE", DEFAULT_PROFILE))
|
|
173
|
+
parser.add_argument("--config", type=Path, help="Path to a GoDaddy profile JSON file")
|
|
174
|
+
parser.add_argument("--ttl", type=int, default=int(os.environ.get("GODADDY_TTL", DEFAULT_TTL)))
|
|
175
|
+
parser.add_argument("--www-target", default=DEFAULT_WWW_TARGET)
|
|
176
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
177
|
+
args = parser.parse_args(argv)
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
profile = load_profile(args.profile, args.config)
|
|
181
|
+
except ConfigError as err:
|
|
182
|
+
if not args.dry_run:
|
|
183
|
+
print(f"ERROR {err}", file=sys.stderr)
|
|
184
|
+
return 2
|
|
185
|
+
print(f"WARN {err}; using placeholder credentials for dry-run", file=sys.stderr)
|
|
186
|
+
profile = {
|
|
187
|
+
"api_key": "dry-run",
|
|
188
|
+
"api_secret": "dry-run",
|
|
189
|
+
"api_base": DEFAULT_GODADDY_BASE,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
print(f"Using GoDaddy profile {args.profile!r} for {args.domain}")
|
|
193
|
+
delete_records(args, profile, "CNAME", "@")
|
|
194
|
+
delete_records(args, profile, "A", "www")
|
|
195
|
+
delete_records(args, profile, "AAAA", "www")
|
|
196
|
+
put_records(args, profile, "A", "@", GITHUB_PAGES_A)
|
|
197
|
+
put_records(args, profile, "AAAA", "@", GITHUB_PAGES_AAAA)
|
|
198
|
+
put_records(args, profile, "CNAME", "www", [args.www_target])
|
|
199
|
+
return 0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
raise SystemExit(main())
|
package/bin/doctor
CHANGED
|
@@ -269,6 +269,53 @@ def check_sessions(env: ClaudesEnv) -> bool:
|
|
|
269
269
|
return True
|
|
270
270
|
|
|
271
271
|
|
|
272
|
+
# ---------------------------------------------------------------------------
|
|
273
|
+
# Global registry checks
|
|
274
|
+
# ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def check_global_registry() -> bool:
|
|
278
|
+
"""Check global project registry for stale projects and expired credentials."""
|
|
279
|
+
try:
|
|
280
|
+
from lib.global_registry import list_projects
|
|
281
|
+
|
|
282
|
+
ok = True
|
|
283
|
+
|
|
284
|
+
# Check projects
|
|
285
|
+
projects = list_projects()
|
|
286
|
+
if projects:
|
|
287
|
+
_ok(f"已注册 {len(projects)} 个项目")
|
|
288
|
+
else:
|
|
289
|
+
_warn("全局注册表为空 (运行 cnb init 注册当前项目)")
|
|
290
|
+
|
|
291
|
+
# Check for stale projects (without removing)
|
|
292
|
+
stale = []
|
|
293
|
+
for p in projects:
|
|
294
|
+
if not Path(p.get("path", "")).exists():
|
|
295
|
+
stale.append(p.get("path", ""))
|
|
296
|
+
if stale:
|
|
297
|
+
_warn(f"{len(stale)} 个项目路径已失效 (运行 cnb projects cleanup 清理)")
|
|
298
|
+
ok = False
|
|
299
|
+
elif projects:
|
|
300
|
+
_ok("所有项目路径有效")
|
|
301
|
+
|
|
302
|
+
# Check credentials for expired ones
|
|
303
|
+
from lib.global_registry import _read_credentials
|
|
304
|
+
|
|
305
|
+
creds = _read_credentials()
|
|
306
|
+
expired = [name for name, info in creds.items() if isinstance(info, dict) and info.get("status") == "expired"]
|
|
307
|
+
if expired:
|
|
308
|
+
_warn(f"过期凭证: {', '.join(expired)}")
|
|
309
|
+
ok = False
|
|
310
|
+
elif creds:
|
|
311
|
+
_ok(f"{len(creds)} 个凭证状态正常")
|
|
312
|
+
|
|
313
|
+
return ok
|
|
314
|
+
except Exception as e:
|
|
315
|
+
_warn(f"全局注册表检查失败: {e}")
|
|
316
|
+
return True # non-fatal
|
|
317
|
+
|
|
318
|
+
|
|
272
319
|
# ---------------------------------------------------------------------------
|
|
273
320
|
# Main
|
|
274
321
|
# ---------------------------------------------------------------------------
|
|
@@ -312,9 +359,13 @@ def main() -> None:
|
|
|
312
359
|
print("\n── Sessions ──")
|
|
313
360
|
sess_ok = check_sessions(env)
|
|
314
361
|
|
|
362
|
+
# ── Global Registry ──
|
|
363
|
+
print("\n── Global Registry ──")
|
|
364
|
+
reg_ok = check_global_registry()
|
|
365
|
+
|
|
315
366
|
# ── Result ──
|
|
316
367
|
print()
|
|
317
|
-
all_ok = all([env_ok, cfg_ok, db_ok, sess_ok])
|
|
368
|
+
all_ok = all([env_ok, cfg_ok, db_ok, sess_ok, reg_ok])
|
|
318
369
|
if all_ok:
|
|
319
370
|
print("✓ All checks passed.")
|
|
320
371
|
else:
|
package/bin/init
CHANGED
|
@@ -117,7 +117,8 @@ def _update_claude_md(project_dir: Path, snippet: str) -> None:
|
|
|
117
117
|
def _hook_command(claudes_home: Path) -> str:
|
|
118
118
|
return (
|
|
119
119
|
'if [ -n "$CLAUDE_SESSION_NAME" ] && [ -n "$CNB_PROJECT" ]; then '
|
|
120
|
-
"
|
|
120
|
+
'CFG="$CNB_PROJECT/.cnb/config.toml"; [ ! -f "$CFG" ] && CFG="$CNB_PROJECT/.claudes/config.toml"; '
|
|
121
|
+
"BOARD=$(grep '^claudes_home' \"$CFG\" 2>/dev/null "
|
|
121
122
|
"| cut -d'\"' -f2)/bin/board; "
|
|
122
123
|
'[ -x "$BOARD" ] && $BOARD --as $CLAUDE_SESSION_NAME pulse 2>/dev/null; fi'
|
|
123
124
|
)
|
|
@@ -160,6 +161,28 @@ def _update_settings(project_dir: Path, claudes_home: Path) -> None:
|
|
|
160
161
|
settings_path.write_text(json.dumps(settings, indent=2, ensure_ascii=False) + "\n")
|
|
161
162
|
|
|
162
163
|
|
|
164
|
+
def _install_pre_commit_hook(project_dir: Path, claudes_home: Path) -> None:
|
|
165
|
+
"""Install bin/secret-scan as a git pre-commit hook if git repo exists."""
|
|
166
|
+
git_dir = project_dir / ".git"
|
|
167
|
+
if not git_dir.is_dir():
|
|
168
|
+
return
|
|
169
|
+
hooks_dir = git_dir / "hooks"
|
|
170
|
+
hooks_dir.mkdir(exist_ok=True)
|
|
171
|
+
hook_path = hooks_dir / "pre-commit"
|
|
172
|
+
secret_scan = claudes_home / "bin" / "secret-scan"
|
|
173
|
+
if not secret_scan.exists():
|
|
174
|
+
return
|
|
175
|
+
if hook_path.exists():
|
|
176
|
+
content = hook_path.read_text()
|
|
177
|
+
if "secret-scan" in content:
|
|
178
|
+
return
|
|
179
|
+
content = content.rstrip("\n") + f"\n\n# cnb: block sensitive files\n{secret_scan}\n"
|
|
180
|
+
hook_path.write_text(content)
|
|
181
|
+
else:
|
|
182
|
+
hook_path.write_text(f"#!/bin/sh\n{secret_scan}\n")
|
|
183
|
+
hook_path.chmod(0o755)
|
|
184
|
+
|
|
185
|
+
|
|
163
186
|
DEFAULT_SESSIONS = ["s1", "s2", "s3"]
|
|
164
187
|
|
|
165
188
|
|
|
@@ -204,9 +227,14 @@ def main() -> None:
|
|
|
204
227
|
else:
|
|
205
228
|
sessions = list(DEFAULT_SESSIONS)
|
|
206
229
|
project_dir = Path.cwd()
|
|
207
|
-
claudes_dir = project_dir / ".
|
|
230
|
+
claudes_dir = project_dir / ".cnb"
|
|
208
231
|
claudes_home = CLAUDES_HOME
|
|
209
232
|
|
|
233
|
+
# Also accept existing .claudes/ projects
|
|
234
|
+
legacy_dir = project_dir / ".claudes"
|
|
235
|
+
if not claudes_dir.exists() and legacy_dir.exists():
|
|
236
|
+
claudes_dir = legacy_dir
|
|
237
|
+
|
|
210
238
|
if (claudes_dir / "board.db").exists():
|
|
211
239
|
print(f"Already initialized: {claudes_dir}")
|
|
212
240
|
sys.exit(0)
|
|
@@ -264,9 +292,12 @@ def main() -> None:
|
|
|
264
292
|
print(f"Applied {applied} schema migration(s).")
|
|
265
293
|
|
|
266
294
|
# .gitignore for generated stuff
|
|
267
|
-
gitignore_content = "board.db\nboard.db-shm\nboard.db-wal\nlogs/\ndispatcher.pid\n"
|
|
295
|
+
gitignore_content = "board.db\nboard.db-shm\nboard.db-wal\nlogs/\ndispatcher.pid\nkeys/\n"
|
|
268
296
|
(claudes_dir / ".gitignore").write_text(gitignore_content)
|
|
269
297
|
|
|
298
|
+
# Install pre-commit hook for secret scanning
|
|
299
|
+
_install_pre_commit_hook(project_dir, claudes_home)
|
|
300
|
+
|
|
270
301
|
# Update .claude/settings.json (merge hooks if exists, create if not)
|
|
271
302
|
_update_settings(project_dir, claudes_home)
|
|
272
303
|
|
|
@@ -274,6 +305,36 @@ def main() -> None:
|
|
|
274
305
|
snippet = _claude_md_snippet(sessions, claudes_home, personas)
|
|
275
306
|
_update_claude_md(project_dir, snippet)
|
|
276
307
|
|
|
308
|
+
# Register in global project registry
|
|
309
|
+
try:
|
|
310
|
+
from lib.global_registry import register_project
|
|
311
|
+
|
|
312
|
+
register_project(project_dir, project_dir.name)
|
|
313
|
+
except Exception:
|
|
314
|
+
pass # non-fatal — registry is optional
|
|
315
|
+
|
|
316
|
+
# Generate encryption keypairs for all sessions (Issue #22)
|
|
317
|
+
try:
|
|
318
|
+
from lib.board_mailbox import _load_pubkeys, _save_pubkeys
|
|
319
|
+
from lib.crypto import generate_keypair, public_key_to_hex, save_keypair
|
|
320
|
+
|
|
321
|
+
keys_dir = claudes_dir / "keys"
|
|
322
|
+
pubkeys = _load_pubkeys()
|
|
323
|
+
gen_count = 0
|
|
324
|
+
for s in sessions:
|
|
325
|
+
n = s.lower()
|
|
326
|
+
if (keys_dir / f"{n}.pem").exists():
|
|
327
|
+
continue
|
|
328
|
+
private, public = generate_keypair()
|
|
329
|
+
save_keypair(keys_dir, n, private, public)
|
|
330
|
+
pubkeys[n] = public_key_to_hex(public)
|
|
331
|
+
gen_count += 1
|
|
332
|
+
if gen_count:
|
|
333
|
+
_save_pubkeys(pubkeys)
|
|
334
|
+
print(f"Generated {gen_count} encryption keypair(s).")
|
|
335
|
+
except ImportError:
|
|
336
|
+
pass # cryptography not installed — skip keygen
|
|
337
|
+
|
|
277
338
|
sessions_str = " ".join(sessions)
|
|
278
339
|
print(f"Initialized cnb (sessions: {sessions_str})")
|
|
279
340
|
|
package/bin/notify
CHANGED
|
@@ -6,6 +6,7 @@ Usage:
|
|
|
6
6
|
notify subscriptions [member] Show subscriptions for member (or all)
|
|
7
7
|
notify test <member> <type> Send a test notification
|
|
8
8
|
notify digest [--send] Generate daily digest (--send to deliver)
|
|
9
|
+
notify weekly [--send] Generate weekly report (--send to deliver)
|
|
9
10
|
notify log [--limit N] Show recent notification log
|
|
10
11
|
"""
|
|
11
12
|
|
|
@@ -17,8 +18,9 @@ sys.path.insert(0, str(CLAUDES_HOME))
|
|
|
17
18
|
|
|
18
19
|
from lib.board_db import BoardDB
|
|
19
20
|
from lib.common import ClaudesEnv
|
|
20
|
-
from lib.digest import generate_daily_digest
|
|
21
|
+
from lib.digest import generate_daily_digest, generate_weekly_report
|
|
21
22
|
from lib.notification_config import BUILTIN_DEFAULTS, NOTIFICATION_TYPES, load
|
|
23
|
+
from lib.notification_delivery import deliver_external
|
|
22
24
|
|
|
23
25
|
|
|
24
26
|
def _env() -> ClaudesEnv:
|
|
@@ -74,11 +76,13 @@ def cmd_subscriptions(member: str | None = None) -> None:
|
|
|
74
76
|
raise SystemExit(1)
|
|
75
77
|
board = BoardDB(db_path)
|
|
76
78
|
sessions = board.query("SELECT name FROM sessions ORDER BY name")
|
|
77
|
-
|
|
79
|
+
names = [row[0] for row in (sessions or [])]
|
|
80
|
+
if config.human:
|
|
81
|
+
names.append("human")
|
|
82
|
+
if not names:
|
|
78
83
|
print("无会话")
|
|
79
84
|
return
|
|
80
|
-
for
|
|
81
|
-
name = row[0]
|
|
85
|
+
for name in names:
|
|
82
86
|
subs = [t for t in NOTIFICATION_TYPES if config.is_subscribed(name, t)]
|
|
83
87
|
channel = config.channel_for(name)
|
|
84
88
|
subs_str = ", ".join(subs) if subs else "无"
|
|
@@ -152,7 +156,54 @@ def cmd_digest(send: bool = False) -> None:
|
|
|
152
156
|
except Exception:
|
|
153
157
|
print(f"ERROR: 发送到 {member} 失败")
|
|
154
158
|
else:
|
|
155
|
-
|
|
159
|
+
result = deliver_external(config, member, channel, "daily-digest", digest, "manual-digest")
|
|
160
|
+
if result.delivered:
|
|
161
|
+
sent += 1
|
|
162
|
+
print(f" {member}: {result.detail}")
|
|
163
|
+
print(f"\nOK 已发送到 {sent}/{len(subscribers)} 订阅者")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def cmd_weekly(send: bool = False) -> None:
|
|
167
|
+
env = _env()
|
|
168
|
+
db_path = Path(env.claudes_dir) / "board.db"
|
|
169
|
+
if not db_path.exists():
|
|
170
|
+
print("ERROR: board.db 不存在")
|
|
171
|
+
raise SystemExit(1)
|
|
172
|
+
|
|
173
|
+
board = BoardDB(db_path)
|
|
174
|
+
report = generate_weekly_report(board)
|
|
175
|
+
print(report)
|
|
176
|
+
|
|
177
|
+
if send:
|
|
178
|
+
config = load(_config_path(env))
|
|
179
|
+
sessions = board.query("SELECT name FROM sessions ORDER BY name")
|
|
180
|
+
members = [r[0] for r in (sessions or [])]
|
|
181
|
+
subscribers = config.subscribers_for("weekly-report", members)
|
|
182
|
+
if not subscribers:
|
|
183
|
+
print("\n无订阅者")
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
import subprocess
|
|
187
|
+
|
|
188
|
+
board_sh = str(CLAUDES_HOME / "bin" / "board")
|
|
189
|
+
sent = 0
|
|
190
|
+
for member in subscribers:
|
|
191
|
+
channel = config.channel_for(member)
|
|
192
|
+
if channel == "board-inbox":
|
|
193
|
+
try:
|
|
194
|
+
subprocess.run(
|
|
195
|
+
[board_sh, "--as", "dispatcher", "send", member, report],
|
|
196
|
+
capture_output=True,
|
|
197
|
+
timeout=10,
|
|
198
|
+
)
|
|
199
|
+
sent += 1
|
|
200
|
+
except Exception:
|
|
201
|
+
print(f"ERROR: 发送到 {member} 失败")
|
|
202
|
+
else:
|
|
203
|
+
result = deliver_external(config, member, channel, "weekly-report", report, "manual-weekly")
|
|
204
|
+
if result.delivered:
|
|
205
|
+
sent += 1
|
|
206
|
+
print(f" {member}: {result.detail}")
|
|
156
207
|
print(f"\nOK 已发送到 {sent}/{len(subscribers)} 订阅者")
|
|
157
208
|
|
|
158
209
|
|
|
@@ -204,6 +255,9 @@ def main() -> None:
|
|
|
204
255
|
elif cmd == "digest":
|
|
205
256
|
send = "--send" in args
|
|
206
257
|
cmd_digest(send=send)
|
|
258
|
+
elif cmd == "weekly":
|
|
259
|
+
send = "--send" in args
|
|
260
|
+
cmd_weekly(send=send)
|
|
207
261
|
elif cmd == "log":
|
|
208
262
|
limit = 20
|
|
209
263
|
if "--limit" in args:
|