## 岛上能直接作答 Claude 的交互请求
AskUserQuestion / ExitPlanMode 的 tool_input 里没有 command/path,
RiskClassifier 提不出 summary,风险判定为 normal 直接放行 ——
岛上从来不弹任何东西。这是这次要修的核心 bug。
现在按 toolName 在风险判定**之前**分流,岛上弹可作答的卡:
- 选择题:单题单选点选即答;多题表单逐题作答,多选勾完提交,
最后合成一条回传。答案借 deny 的 reason 送回模型(PreToolUse
没有「替用户作答」的通道,这是唯一的口子)。
- 计划审批:显式 allow 压不掉 plan 状态机自己的确认框(实测
2.1.220),所以「批准」= 放行后替用户在终端框里按 1/2,带重试
等对话框渲染出来;驳回走 hook deny 有效。
- 进入计划模式(EnterPlanMode)同样接管。
- 终端权限框("Do you want to...")走 Notification hook 弹卡,
允许=注入 1、拒绝=Esc,复用 TerminalReply 的全套护栏。
超时方向和审批卡**相反**:审批超时必须 deny(唯一的安全刹车),
交互卡超时 allow(不输出决策 = 交还终端原生对话框,用户零损失)。
这条镜像关系有专门的测试钉住。
## 项目列表
- 置顶:岛和主窗口菜单里都能置顶,落盘 pinned.json。
- 排序统一成 置顶 > 正在开发 > 最近提交 > 名字。GitInfo 加
lastCommitAt(38 个仓库合计约 300ms,后台队列)—— 三十多个项目
按 yaml 顺序排,找一个要翻半屏。
- 岛上无会话的行点击弹启动菜单,不再直接起 claude(误触成本太高)。
用原生 NSMenu:SwiftUI Menu 的两种整行方案都翻过车(label 被
压扁 / 透明盖层收不到点击)。
## 建仓与克隆
- scripts/new-project.sh:目录 + git + GitHub 远程 + 防污染
settings.json + 注册 projects.yaml,一条命令。app、/new-project
skill、命令行三个入口共用同一份逻辑。
- scripts/clone-project.sh + 克隆表单:列出账号可见的全部仓库
(含组织仓,之前 gh repo list 只列个人仓),按 owner 分组筛选,
多账号可切换(gh 只用活跃账号的凭据,不切就拉不了另一个账号的
私有仓)。列表按账号分开缓存 + 后台静默刷新。
- 防污染 deny 刻意**不禁** ~/.claude/plans/:plan mode 的计划文件
写在那里,禁了所有 plan 会话都会报 Error writing file(真踩过)。
## 顺带修掉
add-project.sh 最后一行是 `[ ${#ALIASES[@]} -gt 0 ] && echo ...`,
没有别名时测试为假,整个脚本退出码变成 1 —— 明明写进去了,调用方
却报「注册 projects.yaml 失败」。改成显式 exit 0。
Co-authored-by: wuguangan <anjaihd0206@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
229 lines
8.3 KiB
Swift
229 lines
8.3 KiB
Swift
import Foundation
|
|
|
|
/// projects.yaml 里的一个项目。
|
|
public struct Project: Identifiable, Hashable, Sendable {
|
|
public let name: String
|
|
public let path: String
|
|
public let aliases: [String]
|
|
public let description: String?
|
|
public let tags: [String]
|
|
|
|
public var id: String { path }
|
|
|
|
public init(
|
|
name: String, path: String, aliases: [String] = [],
|
|
description: String? = nil, tags: [String] = []
|
|
) {
|
|
self.name = name
|
|
self.path = path
|
|
self.aliases = aliases
|
|
self.description = description
|
|
self.tags = tags
|
|
}
|
|
|
|
/// 展开 `~/`。yaml 里普遍写成 `~/Documents/code/xxx`。
|
|
public var expandedPath: String {
|
|
guard path.hasPrefix("~") else { return path }
|
|
return NSString(string: path).expandingTildeInPath
|
|
}
|
|
}
|
|
|
|
/// git 状态。
|
|
/// 一条提交记录。
|
|
public struct GitCommit: Hashable, Sendable, Identifiable {
|
|
public let hash: String
|
|
public let subject: String
|
|
/// git 自己给的相对时间("3 minutes ago")—— 不自己算,省一次时间戳解析,
|
|
/// 而且 git 的措辞已经本地化过。
|
|
public let relativeDate: String
|
|
public let author: String
|
|
|
|
public var id: String { hash }
|
|
|
|
public init(hash: String, subject: String, relativeDate: String, author: String) {
|
|
self.hash = hash
|
|
self.subject = subject
|
|
self.relativeDate = relativeDate
|
|
self.author = author
|
|
}
|
|
}
|
|
|
|
/// 一个仓库工作区的快照:状态 + 最近提交。
|
|
///
|
|
/// 按**会话的 cwd** 采集而不是按 projects.yaml 里的项目 —— 会话可能开在
|
|
/// 任意目录、任意 worktree,甚至根本不在已登记的项目里。同一个项目多开时,
|
|
/// 这份快照是区分它们的主要依据。
|
|
public struct WorkspaceGit: Hashable, Sendable {
|
|
public let root: String
|
|
public let info: GitInfo
|
|
public let recent: [GitCommit]
|
|
|
|
public init(root: String, info: GitInfo, recent: [GitCommit]) {
|
|
self.root = root
|
|
self.info = info
|
|
self.recent = recent
|
|
}
|
|
}
|
|
|
|
public struct GitInfo: Hashable, Sendable {
|
|
public let branch: String?
|
|
public let changeCount: Int
|
|
public let ahead: Int
|
|
public let behind: Int
|
|
public let hasUpstream: Bool
|
|
/// 最后一次提交的时间。项目列表按它排"最近在做的"。
|
|
public let lastCommitAt: Date?
|
|
|
|
public var isClean: Bool { changeCount == 0 }
|
|
|
|
public init(
|
|
branch: String?, changeCount: Int, ahead: Int, behind: Int, hasUpstream: Bool,
|
|
lastCommitAt: Date? = nil
|
|
) {
|
|
self.branch = branch
|
|
self.changeCount = changeCount
|
|
self.ahead = ahead
|
|
self.behind = behind
|
|
self.hasUpstream = hasUpstream
|
|
self.lastCommitAt = lastCommitAt
|
|
}
|
|
}
|
|
|
|
/// 极简 YAML 解析。
|
|
///
|
|
/// 只认 projects.yaml 这一种结构,不引 Yams。理由是常驻 app 的长期维护成本:
|
|
/// 多一个依赖就多一次跟着 Swift 版本升级的负担,而这里需要的表达力极其有限。
|
|
///
|
|
/// 沿用现有 Rust 版 `parse_yaml` 的宽松行解析契约(trim 后判前缀),
|
|
/// 这样两边对同一个文件的理解一致,迁移期间不会打架。
|
|
public enum ProjectYAML {
|
|
|
|
/// 解析结果 + 诊断。
|
|
///
|
|
/// 存在的理由是一次真实事故:文件里明明有 117 条项目,解析器却返回空数组,
|
|
/// UI 老老实实显示"0 个项目",**没有任何地方提示出了问题**。
|
|
/// 排查它花的时间远超写这个类型的成本 —— 解析失败必须能被看见。
|
|
public struct Outcome: Sendable {
|
|
public let projects: [Project]
|
|
/// 文件里出现过多少个 `- name:`(不管有没有被成功解析)。
|
|
public let entryMarkers: Int
|
|
|
|
/// 文件里有条目但一条都没解析出来 —— 格式坏了。
|
|
public var looksBroken: Bool { projects.isEmpty && entryMarkers > 0 }
|
|
|
|
public var diagnostic: String? {
|
|
guard looksBroken else { return nil }
|
|
return "文件里有 \(entryMarkers) 条项目,但一条都没能解析出来 —— 检查 `projects:` 这一行的格式"
|
|
}
|
|
}
|
|
|
|
public static func parse(_ text: String) -> [Project] {
|
|
analyze(text).projects
|
|
}
|
|
|
|
public static func analyze(_ text: String) -> Outcome {
|
|
var projects: [Project] = []
|
|
var entryMarkers = 0
|
|
|
|
var name: String?
|
|
var path: String?
|
|
var description: String?
|
|
var aliases: [String] = []
|
|
var tags: [String] = []
|
|
var inProjectsSection = false
|
|
|
|
func flush() {
|
|
if let name, let path {
|
|
projects.append(
|
|
Project(
|
|
name: name, path: path, aliases: aliases,
|
|
description: description, tags: tags
|
|
)
|
|
)
|
|
}
|
|
name = nil
|
|
path = nil
|
|
description = nil
|
|
aliases = []
|
|
tags = []
|
|
}
|
|
|
|
for rawLine in text.split(separator: "\n", omittingEmptySubsequences: false) {
|
|
let line = String(rawLine)
|
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
|
|
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
|
|
|
if trimmed.hasPrefix("- name:") || trimmed.hasPrefix("-name:") {
|
|
entryMarkers += 1
|
|
}
|
|
|
|
// 顶层键。scan_dirs 之类的段落要跳过,只处理 projects。
|
|
//
|
|
// 判定必须按**键名**而不是整行字面量。踩过的坑:原来写的是
|
|
// `trimmed == "projects:"`,于是 `projects: []` 完全不匹配 ——
|
|
// 旧版留下的空数组标记后面被追加了 117 条项目,全部被静默忽略。
|
|
// 行内值(`[]`、`~`、`null`)只是"暂时没有内联条目",
|
|
// 不代表下面缩进的列表不算数。
|
|
if !line.hasPrefix(" ") && !line.hasPrefix("\t"),
|
|
let key = topLevelKey(of: trimmed) {
|
|
flush()
|
|
inProjectsSection = key == "projects"
|
|
continue
|
|
}
|
|
guard inProjectsSection else { continue }
|
|
|
|
if trimmed.hasPrefix("- ") {
|
|
flush()
|
|
let rest = String(trimmed.dropFirst(2))
|
|
if let value = value(of: "name", in: rest) { name = value }
|
|
continue
|
|
}
|
|
|
|
if let value = value(of: "name", in: trimmed) { name = value }
|
|
else if let value = value(of: "path", in: trimmed) { path = value }
|
|
else if let value = value(of: "description", in: trimmed) { description = value }
|
|
else if let value = value(of: "aliases", in: trimmed) { aliases = list(value) }
|
|
else if let value = value(of: "tags", in: trimmed) { tags = list(value) }
|
|
}
|
|
flush()
|
|
return Outcome(projects: projects, entryMarkers: entryMarkers)
|
|
}
|
|
|
|
/// 顶层键名。`projects:` / `projects: []` / `scan_dirs:` 都能取到键名;
|
|
/// 列表项(`- xxx`)和纯文本行返回 nil。
|
|
private static func topLevelKey(of trimmed: String) -> String? {
|
|
guard !trimmed.hasPrefix("-"), let colon = trimmed.firstIndex(of: ":") else {
|
|
return nil
|
|
}
|
|
let key = String(trimmed[trimmed.startIndex..<colon])
|
|
guard !key.isEmpty, !key.contains(" ") else { return nil }
|
|
return key
|
|
}
|
|
|
|
private static func value(of key: String, in line: String) -> String? {
|
|
let prefix = key + ":"
|
|
guard line.hasPrefix(prefix) else { return nil }
|
|
return unquote(String(line.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces))
|
|
}
|
|
|
|
private static func list(_ raw: String) -> [String] {
|
|
var body = raw
|
|
if body.hasPrefix("["), body.hasSuffix("]") {
|
|
body = String(body.dropFirst().dropLast())
|
|
}
|
|
return body
|
|
.split(separator: ",")
|
|
.map { unquote($0.trimmingCharacters(in: .whitespaces)) }
|
|
.filter { !$0.isEmpty }
|
|
}
|
|
|
|
private static func unquote(_ raw: String) -> String {
|
|
var value = raw
|
|
for quote in ["\"", "'"] where value.hasPrefix(quote) && value.hasSuffix(quote) && value.count >= 2 {
|
|
value = String(value.dropFirst().dropLast())
|
|
}
|
|
return value
|
|
}
|
|
}
|