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.
Files changed (56) hide show
  1. package/README.md +240 -17
  2. package/VERSION +1 -1
  3. package/bin/_pip_entry.py +1 -1
  4. package/bin/board +17 -0
  5. package/bin/check-changelog +98 -0
  6. package/bin/check-npm-package +89 -0
  7. package/bin/check-readme-sync +69 -0
  8. package/bin/cnb +95 -9
  9. package/bin/configure-godaddy-pages-dns +203 -0
  10. package/bin/doctor +52 -1
  11. package/bin/init +64 -3
  12. package/bin/notify +59 -5
  13. package/bin/secret-scan +165 -0
  14. package/bin/shutdown +68 -0
  15. package/lib/board_admin.py +15 -31
  16. package/lib/board_bbs.py +11 -17
  17. package/lib/board_bug.py +9 -21
  18. package/lib/board_db.py +19 -0
  19. package/lib/board_lock.py +11 -12
  20. package/lib/board_mail.py +212 -0
  21. package/lib/board_mailbox.py +35 -0
  22. package/lib/board_msg.py +8 -33
  23. package/lib/board_own.py +288 -0
  24. package/lib/board_pending.py +10 -7
  25. package/lib/board_task.py +24 -8
  26. package/lib/board_tui.py +10 -4
  27. package/lib/board_view.py +65 -41
  28. package/lib/board_vote.py +3 -12
  29. package/lib/build_lock.py +2 -2
  30. package/lib/cli.py +3 -3
  31. package/lib/common.py +27 -9
  32. package/lib/concerns/digest_scheduler.py +25 -6
  33. package/lib/concerns/file_watcher.py +19 -9
  34. package/lib/concerns/helpers.py +17 -37
  35. package/lib/concerns/notification_push.py +9 -2
  36. package/lib/digest.py +80 -0
  37. package/lib/github_issues_sync.py +173 -0
  38. package/lib/global_registry.py +183 -0
  39. package/lib/inject.py +21 -13
  40. package/lib/migrate.py +8 -4
  41. package/lib/monitor.py +25 -9
  42. package/lib/notification_config.py +25 -5
  43. package/lib/notification_delivery.py +95 -0
  44. package/lib/panel.py +6 -2
  45. package/lib/resources.py +6 -3
  46. package/lib/shift_report.py +223 -0
  47. package/lib/shutdown.py +198 -0
  48. package/lib/tmux_utils.py +52 -0
  49. package/lib/token_usage.py +148 -0
  50. package/migrations/007_mail.sql +15 -0
  51. package/migrations/008_ownership.sql +10 -0
  52. package/package.json +36 -3
  53. package/pyproject.toml +1 -1
  54. package/registry/0005-ritchie.json +12 -0
  55. package/registry/pubkeys.json +83 -3
  56. 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
- # ---- Export project root so all subprocesses can find .claudes/ ----
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/.claudes/dispatcher.pid"
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/.claudes/logs"
101
- "$CLAUDES_HOME/bin/dispatcher" >> "$CNB_PROJECT/.claudes/logs/dispatcher.log" 2>&1 &
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
- if [ -f .claudes/config.toml ] && [ -f .claudes/board.db ]; then
108
- _EXISTING=$(grep '^sessions' .claudes/config.toml | sed 's/sessions = \[//;s/\]//;s/"//g;s/,/ /g;s/ */ /g' | xargs)
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' .claudes/config.toml | cut -d'"' -f2)
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}初始化 .claudes/ ...${N}\n"
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' .claudes/config.toml 2>/dev/null | cut -d'"' -f2)
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
- "BOARD=$(grep '^claudes_home' \"$CNB_PROJECT/.claudes/config.toml\" 2>/dev/null "
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 / ".claudes"
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
- if not sessions:
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 row in sessions:
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
- print(f" {member}: 通道 {channel} 尚未实现")
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: