spb/zyquo-agent Public MIT
The autonomous agent that actually operates your Mac — plans, runs real commands, verifies its own work.
Swift 94.7%
Shell 4.1%
Python 0.7%
Makefile 0.5%
1//2// FileTools.swift3// Zyquo Agent4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//8// The five workspace file tools: read_file, write_file, edit_file,9// list_dir, search_files. All paths resolve relative to the task workspace.10// Escaping the workspace (absolute paths or `..`) routes through the11// PolicyEngine: reads outside ask (`.fileReadOutsideWorkspace`), writes12// outside always ask (`.fileWriteOutsideWorkspace`); writes inside go13// through `.fileWrite` (auto-allowed in Guarded/Autonomous). In-workspace14// READS deliberately skip the gate — asking the user before the agent can15// look at its own scratch files would make Manual mode unusable (decision16// documented in PolicyEngine's header). Every operation is audited.17//1819import Foundation2021// MARK: - Shared path resolution2223/// Resolves tool-supplied paths against the workspace and detects escapes.24enum WorkspacePath {25 struct Resolved {26 /// Fully resolved file URL.27 var url: URL28 /// True when the path (symlinks resolved) stays inside the workspace.29 var isInsideWorkspace: Bool30 }3132 /// Relative paths resolve under the workspace; `~` and absolute paths are33 /// honored but flagged when they escape. Symlinks are resolved on the34 /// deepest existing ancestor so a link cannot smuggle a write outside.35 static func resolve(_ raw: String, workspace: URL) -> Resolved {36 var expanded = raw37 if expanded == "~" || expanded.hasPrefix("~/") {38 expanded = (expanded as NSString).expandingTildeInPath39 }40 let url: URL41 if expanded.hasPrefix("/") {42 url = URL(fileURLWithPath: (expanded as NSString).standardizingPath)43 } else {44 url = URL(fileURLWithPath: (workspace.appendingPathComponent(expanded).path as NSString).standardizingPath)45 }46 return Resolved(url: url, isInsideWorkspace: isInside(url, workspace: workspace))47 }4849 private static func isInside(_ url: URL, workspace: URL) -> Bool {50 let root = URL(fileURLWithPath: workspace.path).resolvingSymlinksInPath().path51 // Resolve symlinks on the deepest existing ancestor of the target.52 var probe = url53 var suffix: [String] = []54 while !FileManager.default.fileExists(atPath: probe.path), probe.pathComponents.count > 1 {55 suffix.append(probe.lastPathComponent)56 probe = probe.deletingLastPathComponent()57 }58 var resolved = probe.resolvingSymlinksInPath()59 for component in suffix.reversed() {60 resolved.appendPathComponent(component)61 }62 let path = resolved.path63 return path == root || path.hasPrefix(root + "/")64 }6566 /// Path shown to the model/user: workspace-relative when inside.67 static func display(_ url: URL, workspace: URL) -> String {68 let root = workspace.path69 if url.path == root { return "." }70 if url.path.hasPrefix(root + "/") {71 return String(url.path.dropFirst(root.count + 1))72 }73 return url.path74 }75}7677// MARK: - Shared gate/audit plumbing7879private enum FileToolSupport {80 /// Clears a write with the policy engine (inside → `.fileWrite`,81 /// outside → `.fileWriteOutsideWorkspace`). Returns the decision for the82 /// audit line, or the denial message.83 static func clearWrite(84 of resolved: WorkspacePath.Resolved,85 context: ToolExecutionContext,86 explanation: String87 ) async -> Result<PolicyDecisionRecord, PolicyDenied> {88 do {89 let cleared = try await context.policy.clear(ActionRequest(90 kind: resolved.isInsideWorkspace ? .fileWrite : .fileWriteOutsideWorkspace,91 payload: resolved.url.path,92 cwd: context.workspaceURL,93 explanation: explanation94 ))95 return .success(cleared.decision)96 } catch let denial as PolicyDenied {97 return .failure(denial)98 } catch {99 return .failure(PolicyDenied(reason: error.localizedDescription))100 }101 }102103 /// Clears an out-of-workspace read (in-workspace reads skip the gate).104 static func clearRead(105 of resolved: WorkspacePath.Resolved,106 context: ToolExecutionContext,107 explanation: String108 ) async -> Result<PolicyDecisionRecord, PolicyDenied> {109 guard !resolved.isInsideWorkspace else {110 return .success(PolicyDecisionRecord(ruling: .autoAllowed, riskLabel: RiskAssessment.Level.safe.rawValue, rationale: "Read inside the task workspace."))111 }112 do {113 let cleared = try await context.policy.clear(ActionRequest(114 kind: .fileReadOutsideWorkspace,115 payload: resolved.url.path,116 cwd: context.workspaceURL,117 explanation: explanation118 ))119 return .success(cleared.decision)120 } catch let denial as PolicyDenied {121 return .failure(denial)122 } catch {123 return .failure(PolicyDenied(reason: error.localizedDescription))124 }125 }126127 static func audit(128 _ context: ToolExecutionContext,129 tool: String,130 path: String,131 decision: PolicyDecisionRecord?,132 note: String133 ) async {134 await context.audit.append(AuditEntry(135 actionKind: tool,136 payload: path,137 cwd: context.workspaceURL.path,138 ruling: decision?.ruling.rawValue ?? PolicyDecisionRecord.Ruling.denied.rawValue,139 exitCode: nil,140 outputExcerpt: note141 ))142 }143144 static func string(_ key: String, in object: [String: JSONValue]) -> String? {145 if case .string(let value)? = object[key] { return value }146 return nil147 }148149 static func integer(_ key: String, in object: [String: JSONValue]) -> Int? {150 if case .number(let value)? = object[key] { return Int(value) }151 return nil152 }153154 static func boolean(_ key: String, in object: [String: JSONValue]) -> Bool? {155 if case .bool(let value)? = object[key] { return value }156 return nil157 }158}159160// MARK: - read_file161162struct ReadFileTool: Tool {163 /// Max bytes returned per call — page with offset/limit for bigger files.164 static let byteLimit = 50_000165166 let name = "read_file"167168 let description = """169 Read a text file. Paths are relative to the task workspace; reading \170 outside the workspace requires user approval. Returns numbered lines. \171 Output is capped at ~50KB per call — for larger files, page through \172 with `offset` (1-based first line) and `limit` (line count). Always \173 read a file before editing it with edit_file.174 """175176 var parametersSchema: JSONValue {177 .object([178 "type": .string("object"),179 "properties": .object([180 "path": .object([181 "type": .string("string"),182 "description": .string("File path, relative to the workspace (or absolute)."),183 ]),184 "offset": .object([185 "type": .string("integer"),186 "description": .string("1-based line number to start reading from."),187 ]),188 "limit": .object([189 "type": .string("integer"),190 "description": .string("Maximum number of lines to return."),191 ]),192 ]),193 "required": .array([.string("path")]),194 ])195 }196197 func execute(arguments: JSONValue, context: ToolExecutionContext) async throws -> ToolExecutionResult {198 guard case .object(let object) = arguments,199 let path = FileToolSupport.string("path", in: object) else {200 return .failure("read_file: missing required parameter `path`.")201 }202 let resolved = WorkspacePath.resolve(path, workspace: context.workspaceURL)203204 if case .failure(let denial) = await FileToolSupport.clearRead(of: resolved, context: context, explanation: "Read \(resolved.url.path)") {205 let reason = denial.reason206 await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: nil, note: "denied: \(reason)")207 return .failure("Read not permitted: \(reason)")208 }209210 guard FileManager.default.fileExists(atPath: resolved.url.path) else {211 return .failure("read_file: no file at \(WorkspacePath.display(resolved.url, workspace: context.workspaceURL)).")212 }213 guard let data = FileManager.default.contents(atPath: resolved.url.path) else {214 return .failure("read_file: could not read \(resolved.url.path).")215 }216 guard let text = String(data: data, encoding: .utf8) else {217 return .failure("read_file: \(resolved.url.lastPathComponent) is not UTF-8 text (\(data.count) bytes).")218 }219220 let allLines = text.components(separatedBy: "\n")221 let offset = max(1, FileToolSupport.integer("offset", in: object) ?? 1)222 let limit = FileToolSupport.integer("limit", in: object) ?? allLines.count223 guard offset <= allLines.count else {224 return .failure("read_file: offset \(offset) is past the end of the file (\(allLines.count) lines).")225 }226227 var out = ""228 var emittedLines = 0229 var truncatedByBytes = false230 var lineNumber = offset231 for line in allLines.dropFirst(offset - 1) {232 if emittedLines >= limit { break }233 let numbered = "\(lineNumber)\t\(line)\n"234 if out.utf8.count + numbered.utf8.count > Self.byteLimit {235 truncatedByBytes = true236 break237 }238 out += numbered239 emittedLines += 1240 lineNumber += 1241 }242 let linesRemaining = allLines.count - (lineNumber - 1)243 if truncatedByBytes || (emittedLines >= limit && linesRemaining > 0) {244 out += "… [truncated — file has \(allLines.count) lines; continue with offset=\(lineNumber)]\n"245 }246247 await FileToolSupport.audit(248 context, tool: name, path: resolved.url.path,249 decision: PolicyDecisionRecord(ruling: .autoAllowed, riskLabel: RiskAssessment.Level.safe.rawValue, rationale: nil),250 note: "read \(emittedLines) lines from line \(offset)"251 )252 context.onOutput(.note("read \(WorkspacePath.display(resolved.url, workspace: context.workspaceURL)) (\(emittedLines) lines)"))253 return .success(out.isEmpty ? "[empty file]" : out)254 }255}256257// MARK: - write_file258259struct WriteFileTool: Tool {260 let name = "write_file"261262 let description = """263 Create or overwrite a text file with the given content. Paths are \264 relative to the task workspace; parent directories are created \265 automatically. Writing outside the workspace always requires user \266 approval. To change part of an existing file, prefer edit_file — it \267 is safer than rewriting the whole file.268 """269270 var parametersSchema: JSONValue {271 .object([272 "type": .string("object"),273 "properties": .object([274 "path": .object([275 "type": .string("string"),276 "description": .string("File path, relative to the workspace (or absolute)."),277 ]),278 "content": .object([279 "type": .string("string"),280 "description": .string("The complete file content to write."),281 ]),282 ]),283 "required": .array([.string("path"), .string("content")]),284 ])285 }286287 func execute(arguments: JSONValue, context: ToolExecutionContext) async throws -> ToolExecutionResult {288 guard case .object(let object) = arguments,289 let path = FileToolSupport.string("path", in: object),290 let content = FileToolSupport.string("content", in: object) else {291 return .failure("write_file: `path` and `content` are both required.")292 }293 let resolved = WorkspacePath.resolve(path, workspace: context.workspaceURL)294 let existed = FileManager.default.fileExists(atPath: resolved.url.path)295296 let decision: PolicyDecisionRecord297 switch await FileToolSupport.clearWrite(of: resolved, context: context, explanation: "\(existed ? "Overwrite" : "Create") \(resolved.url.path) (\(content.utf8.count) bytes)") {298 case .failure(let denial):299 await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: nil, note: "denied: \(denial.reason)")300 return .failure("Write not permitted: \(denial.reason)")301 case .success(let record):302 decision = record303 }304305 do {306 try FileManager.default.createDirectory(at: resolved.url.deletingLastPathComponent(), withIntermediateDirectories: true)307 try content.write(to: resolved.url, atomically: true, encoding: .utf8)308 } catch {309 await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: decision, note: "failed: \(error.localizedDescription)")310 return .failure("write_file failed: \(error.localizedDescription)")311 }312313 await context.workspaceManager?.noteFileTouched(resolved.url, existedBefore: existed)314 await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: decision, note: "\(existed ? "overwrote" : "created") \(content.utf8.count) bytes")315 let display = WorkspacePath.display(resolved.url, workspace: context.workspaceURL)316 context.onOutput(.note("\(existed ? "overwrote" : "created") \(display) (\(content.utf8.count) bytes)"))317 return .success("\(existed ? "Overwrote" : "Created") \(display) (\(content.utf8.count) bytes).")318 }319}320321// MARK: - edit_file322323struct EditFileTool: Tool {324 let name = "edit_file"325326 let description = """327 Replace text in an existing file. `old_string` must match the current \328 file content EXACTLY and UNIQUELY — if it matches zero or multiple \329 places the edit fails and reports the match count; include more \330 surrounding lines to make it unique, or set `replace_all` to change \331 every occurrence. Read the file first with read_file so you know its \332 exact current content. Paths resolve relative to the workspace; \333 editing outside the workspace always requires user approval.334 """335336 var parametersSchema: JSONValue {337 .object([338 "type": .string("object"),339 "properties": .object([340 "path": .object([341 "type": .string("string"),342 "description": .string("File path, relative to the workspace (or absolute)."),343 ]),344 "old_string": .object([345 "type": .string("string"),346 "description": .string("Exact text to find (must be unique unless replace_all)."),347 ]),348 "new_string": .object([349 "type": .string("string"),350 "description": .string("Text to replace it with."),351 ]),352 "replace_all": .object([353 "type": .string("boolean"),354 "description": .string("Replace every occurrence instead of requiring a unique match (default false)."),355 ]),356 ]),357 "required": .array([.string("path"), .string("old_string"), .string("new_string")]),358 ])359 }360361 func execute(arguments: JSONValue, context: ToolExecutionContext) async throws -> ToolExecutionResult {362 guard case .object(let object) = arguments,363 let path = FileToolSupport.string("path", in: object),364 let oldString = FileToolSupport.string("old_string", in: object),365 let newString = FileToolSupport.string("new_string", in: object) else {366 return .failure("edit_file: `path`, `old_string`, and `new_string` are all required.")367 }368 guard !oldString.isEmpty else {369 return .failure("edit_file: `old_string` must not be empty.")370 }371 guard oldString != newString else {372 return .failure("edit_file: `old_string` and `new_string` are identical — nothing to change.")373 }374 let replaceAll = FileToolSupport.boolean("replace_all", in: object) ?? false375 let resolved = WorkspacePath.resolve(path, workspace: context.workspaceURL)376 let display = WorkspacePath.display(resolved.url, workspace: context.workspaceURL)377378 guard FileManager.default.fileExists(atPath: resolved.url.path) else {379 return .failure("edit_file: no file at \(display). Use write_file to create new files.")380 }381 guard let data = FileManager.default.contents(atPath: resolved.url.path),382 let text = String(data: data, encoding: .utf8) else {383 return .failure("edit_file: could not read \(display) as UTF-8 text.")384 }385386 // Exact-unique match contract: fail loudly with the match count.387 let matches = text.components(separatedBy: oldString).count - 1388 if matches == 0 {389 return .failure("edit_file: `old_string` was not found in \(display) (0 matches). Read the file again — the content may differ in whitespace or have changed.")390 }391 if matches > 1 && !replaceAll {392 return .failure("edit_file: `old_string` matches \(matches) places in \(display) — it must be unique. Include more surrounding context, or set replace_all=true to replace all \(matches).")393 }394395 let decision: PolicyDecisionRecord396 switch await FileToolSupport.clearWrite(of: resolved, context: context, explanation: "Edit \(resolved.url.path) (\(matches) replacement\(matches == 1 ? "" : "s"))") {397 case .failure(let denial):398 await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: nil, note: "denied: \(denial.reason)")399 return .failure("Edit not permitted: \(denial.reason)")400 case .success(let record):401 decision = record402 }403404 let updated = text.replacingOccurrences(of: oldString, with: newString)405 do {406 try updated.write(to: resolved.url, atomically: true, encoding: .utf8)407 } catch {408 await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: decision, note: "failed: \(error.localizedDescription)")409 return .failure("edit_file failed: \(error.localizedDescription)")410 }411412 await context.workspaceManager?.noteFileTouched(resolved.url, existedBefore: true)413 await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: decision, note: "replaced \(matches) occurrence\(matches == 1 ? "" : "s")")414 context.onOutput(.note("edited \(display) (\(matches) replacement\(matches == 1 ? "" : "s"))"))415 return .success("Edited \(display): replaced \(matches) occurrence\(matches == 1 ? "" : "s").")416 }417}418419// MARK: - list_dir420421struct ListDirTool: Tool {422 static let entryLimit = 500423424 let name = "list_dir"425426 let description = """427 List files and folders. Defaults to the workspace root; pass `path` \428 for a subfolder and `depth` (default 2) to control recursion. \429 Directories end with '/'; files show their size. Listing outside the \430 workspace requires user approval. The internal `.zyquo/` folder and \431 `.git/` contents are omitted.432 """433434 var parametersSchema: JSONValue {435 .object([436 "type": .string("object"),437 "properties": .object([438 "path": .object([439 "type": .string("string"),440 "description": .string("Directory to list, relative to the workspace (default: workspace root)."),441 ]),442 "depth": .object([443 "type": .string("integer"),444 "description": .string("How many directory levels to descend (default 2)."),445 ]),446 ]),447 "required": .array([]),448 ])449 }450451 func execute(arguments: JSONValue, context: ToolExecutionContext) async throws -> ToolExecutionResult {452 var path = "."453 var depth = 2454 if case .object(let object) = arguments {455 if let p = FileToolSupport.string("path", in: object) { path = p }456 if let d = FileToolSupport.integer("depth", in: object) { depth = max(1, min(d, 8)) }457 }458 let resolved = WorkspacePath.resolve(path, workspace: context.workspaceURL)459 let display = WorkspacePath.display(resolved.url, workspace: context.workspaceURL)460461 if case .failure(let denial) = await FileToolSupport.clearRead(of: resolved, context: context, explanation: "List \(resolved.url.path)") {462 let reason = denial.reason463 await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: nil, note: "denied: \(reason)")464 return .failure("Listing not permitted: \(reason)")465 }466467 var isDirectory: ObjCBool = false468 guard FileManager.default.fileExists(atPath: resolved.url.path, isDirectory: &isDirectory), isDirectory.boolValue else {469 return .failure("list_dir: \(display) is not a directory.")470 }471472 var lines: [String] = ["\(display == "." ? "workspace root" : display)/"]473 var count = 0474 var truncated = false475 listRecursively(resolved.url, indent: " ", remainingDepth: depth, lines: &lines, count: &count, truncated: &truncated)476 if truncated {477 lines.append("… [listing truncated at \(Self.entryLimit) entries — list a subfolder or lower the depth]")478 }479 if count == 0 {480 lines.append(" [empty]")481 }482483 await FileToolSupport.audit(484 context, tool: name, path: resolved.url.path,485 decision: PolicyDecisionRecord(ruling: .autoAllowed, riskLabel: RiskAssessment.Level.safe.rawValue, rationale: nil),486 note: "listed \(count) entries (depth \(depth))"487 )488 return .success(lines.joined(separator: "\n"))489 }490491 private func listRecursively(_ directory: URL, indent: String, remainingDepth: Int, lines: inout [String], count: inout Int, truncated: inout Bool) {492 guard remainingDepth > 0, !truncated else { return }493 let contents = (try? FileManager.default.contentsOfDirectory(494 at: directory,495 includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey],496 options: []497 )) ?? []498 for entry in contents.sorted(by: { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending }) {499 if count >= Self.entryLimit { truncated = true; return }500 let entryName = entry.lastPathComponent501 if entryName == ".zyquo" || entryName == ".git" { continue }502 let values = try? entry.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey])503 if values?.isDirectory == true {504 lines.append("\(indent)\(entryName)/")505 count += 1506 listRecursively(entry, indent: indent + " ", remainingDepth: remainingDepth - 1, lines: &lines, count: &count, truncated: &truncated)507 } else {508 let size = values?.fileSize ?? 0509 lines.append("\(indent)\(entryName) (\(Self.format(bytes: size)))")510 count += 1511 }512 }513 }514515 private static func format(bytes: Int) -> String {516 if bytes < 1000 { return "\(bytes) B" }517 if bytes < 1_000_000 { return String(format: "%.1f KB", Double(bytes) / 1000) }518 return String(format: "%.1f MB", Double(bytes) / 1_000_000)519 }520}521522// MARK: - search_files523524struct SearchFilesTool: Tool {525 static let matchLimit = 200526 static let scannedFileByteLimit = 2_000_000527 static let resultByteLimit = 50_000528529 let name = "search_files"530531 let description = """532 Search file contents like grep. `pattern` is tried as a regular \533 expression first, then as a literal substring if the regex is \534 invalid. Searches the whole workspace by default; narrow with `path` \535 (subfolder) and `glob` (filename filter like *.swift). Returns \536 file:line: matches, capped at 200. Binary files, `.git/` and \537 `.zyquo/` are skipped. Searching outside the workspace requires user \538 approval.539 """540541 var parametersSchema: JSONValue {542 .object([543 "type": .string("object"),544 "properties": .object([545 "pattern": .object([546 "type": .string("string"),547 "description": .string("Regex (or literal substring) to search for."),548 ]),549 "path": .object([550 "type": .string("string"),551 "description": .string("Directory to search, relative to the workspace (default: workspace root)."),552 ]),553 "glob": .object([554 "type": .string("string"),555 "description": .string("Filename glob filter, e.g. *.swift or *.md."),556 ]),557 ]),558 "required": .array([.string("pattern")]),559 ])560 }561562 func execute(arguments: JSONValue, context: ToolExecutionContext) async throws -> ToolExecutionResult {563 guard case .object(let object) = arguments,564 let pattern = FileToolSupport.string("pattern", in: object),565 !pattern.isEmpty else {566 return .failure("search_files: missing required parameter `pattern`.")567 }568 let path = FileToolSupport.string("path", in: object) ?? "."569 let glob = FileToolSupport.string("glob", in: object)570 let resolved = WorkspacePath.resolve(path, workspace: context.workspaceURL)571572 if case .failure(let denial) = await FileToolSupport.clearRead(of: resolved, context: context, explanation: "Search \(resolved.url.path) for \(pattern)") {573 let reason = denial.reason574 await FileToolSupport.audit(context, tool: name, path: resolved.url.path, decision: nil, note: "denied: \(reason)")575 return .failure("Search not permitted: \(reason)")576 }577578 let regex = try? NSRegularExpression(pattern: pattern)579 var matches: [String] = []580 var bytes = 0581 var filesScanned = 0582 var capped = false583584 let enumerator = FileManager.default.enumerator(585 at: resolved.url,586 includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey],587 options: [.skipsPackageDescendants]588 )589 while let entry = enumerator?.nextObject() as? URL {590 try Task.checkCancellation()591 if capped { break }592 let entryName = entry.lastPathComponent593 if entryName == ".git" || entryName == ".zyquo" {594 enumerator?.skipDescendants()595 continue596 }597 let values = try? entry.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey])598 guard values?.isRegularFile == true else { continue }599 if let glob, !Self.matchGlob(glob, name: entryName) { continue }600 if (values?.fileSize ?? 0) > Self.scannedFileByteLimit { continue }601 guard let data = FileManager.default.contents(atPath: entry.path),602 !data.contains(0),603 let text = String(data: data, encoding: .utf8) else { continue }604 filesScanned += 1605606 let relative = WorkspacePath.display(entry, workspace: context.workspaceURL)607 for (index, line) in text.components(separatedBy: "\n").enumerated() {608 let hit: Bool609 if let regex {610 hit = regex.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) != nil611 } else {612 hit = line.contains(pattern)613 }614 guard hit else { continue }615 let trimmed = line.count > 250 ? String(line.prefix(250)) + "…" : line616 let record = "\(relative):\(index + 1): \(trimmed)"617 matches.append(record)618 bytes += record.utf8.count619 if matches.count >= Self.matchLimit || bytes >= Self.resultByteLimit {620 capped = true621 break622 }623 }624 }625626 await FileToolSupport.audit(627 context, tool: name, path: resolved.url.path,628 decision: PolicyDecisionRecord(ruling: .autoAllowed, riskLabel: RiskAssessment.Level.safe.rawValue, rationale: nil),629 note: "pattern \(pattern): \(matches.count) matches in \(filesScanned) files"630 )631632 if matches.isEmpty {633 return .success("No matches for \(regex != nil ? "regex" : "substring") “\(pattern)” (\(filesScanned) files scanned).")634 }635 var output = matches.joined(separator: "\n")636 if capped {637 output += "\n… [capped at \(matches.count) matches — refine the pattern, path, or glob]"638 }639 return .success(output)640 }641642 /// Minimal glob: `*` and `?` on the filename (fnmatch-style, no slashes).643 static func matchGlob(_ glob: String, name: String) -> Bool {644 var regexPattern = "^"645 for ch in glob {646 switch ch {647 case "*": regexPattern += ".*"648 case "?": regexPattern += "."649 default: regexPattern += NSRegularExpression.escapedPattern(for: String(ch))650 }651 }652 regexPattern += "$"653 guard let regex = try? NSRegularExpression(pattern: regexPattern, options: [.caseInsensitive]) else { return false }654 return regex.firstMatch(in: name, range: NSRange(name.startIndex..., in: name)) != nil655 }656}657