From b9d7faec9da9fb919a54356250cf92757a25b567 Mon Sep 17 00:00:00 2001 From: Hermes Date: Wed, 13 May 2026 20:56:35 -0500 Subject: [PATCH] feat: add Shortcuts inventory command --- README.md | 3 +- Sources/ICloudCLICore/CommandLine.swift | 35 ++++++ Sources/ICloudCLICore/CommandRunner.swift | 19 +++ .../ICloudCLICore/ShortcutsInventory.swift | 110 ++++++++++++++++++ .../Archive Notes.shortcut/Info.plist | 12 ++ .../Daily Check.shortcut/Shortcut.plist | 16 +++ Tests/ICloudCLICoreTests/CLIParserTests.swift | 13 +++ .../ShortcutsInventoryTests.swift | 24 ++++ docs/issue-work-plan.md | 12 +- docs/issue-work-result.md | 16 +-- docs/privacy.md | 6 + 11 files changed, 249 insertions(+), 17 deletions(-) create mode 100644 Sources/ICloudCLICore/ShortcutsInventory.swift create mode 100644 Tests/Fixtures/Shortcuts/Archive Notes.shortcut/Info.plist create mode 100644 Tests/Fixtures/Shortcuts/Daily Check.shortcut/Shortcut.plist create mode 100644 Tests/ICloudCLICoreTests/ShortcutsInventoryTests.swift diff --git a/README.md b/README.md index b9cfcee..b878f46 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,10 @@ icloud-cli safari reading-list icloud-cli safari frequently-visited --limit 10 icloud-cli drive list --depth 2 icloud-cli drive containers --sort-by size +icloud-cli shortcuts list --name Daily ``` -The initial implementation reads local Safari session and metadata property lists from `~/Library/Safari` and iCloud Drive metadata from `~/Library/Mobile Documents`. That keeps the first slice simple and testable while we map the broader iCloud/Safari sync surface. Reading live browser state may require running the terminal with Full Disk Access on macOS. +The initial implementation reads local Safari session and metadata property lists from `~/Library/Safari` iCloud Drive metadata from `~/Library/Mobile Documents`, and Shortcuts metadata from `~/Library/Shortcuts`. That keeps the first slice simple and testable while we map the broader iCloud/Safari sync surface. Reading live browser state may require running the terminal with Full Disk Access on macOS. If Safari session files are unreadable, the command exits with an error naming the file paths it tried. If the files are readable but empty, the error says no tabs were found instead of treating it as a permissions problem. diff --git a/Sources/ICloudCLICore/CommandLine.swift b/Sources/ICloudCLICore/CommandLine.swift index ab33434..8420660 100644 --- a/Sources/ICloudCLICore/CommandLine.swift +++ b/Sources/ICloudCLICore/CommandLine.swift @@ -65,6 +65,18 @@ public struct DriveContainersOptions: Equatable, Sendable { } } +public struct ShortcutsListOptions: Equatable, Sendable { + public var format: OutputFormat + public var shortcutsDirectory: URL + public var namePattern: String? + + public init(format: OutputFormat = .json, shortcutsDirectory: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Shortcuts"), namePattern: String? = nil) { + self.format = format + self.shortcutsDirectory = shortcutsDirectory + self.namePattern = namePattern + } +} + public struct CloudTabsProbeOptions: Equatable, Sendable { public var format: OutputFormat public var safariDirectory: URL @@ -83,6 +95,7 @@ public enum CLICommand: Equatable, Sendable { case safariFrequentlyVisited(SafariFrequentlyVisitedOptions) case safariReadingList(SafariBookmarksOptions) case safariTabs(SafariTabsOptions) + case shortcutsList(ShortcutsListOptions) case help case version } @@ -121,6 +134,11 @@ public struct CLIParser: Sendable { default: throw CLIParseError.unknownCommand((["drive", driveCommand] + tokens).joined(separator: " ")) } } + if topCommand == "shortcuts" { + guard tokens.first == "list" else { throw CLIParseError.unknownCommand((["shortcuts"] + tokens).joined(separator: " ")) } + tokens.removeFirst() + return .shortcutsList(try parseShortcutsListOptions(tokens)) + } guard topCommand == "safari" else { throw CLIParseError.unknownCommand(topCommand) } guard let safariCommand = tokens.first else { throw CLIParseError.unknownCommand((["safari"] + tokens).joined(separator: " ")) } tokens.removeFirst() @@ -226,6 +244,21 @@ public struct CLIParser: Sendable { return options } + private func parseShortcutsListOptions(_ tokens: [String]) throws -> ShortcutsListOptions { + var options = ShortcutsListOptions(); var index = 0 + while index < tokens.count { + let token = tokens[index] + switch token { + case "--format": options.format = try parseFormat(after: token, in: tokens, at: &index) + case "--shortcuts-dir": options.shortcutsDirectory = try parseURL(after: token, in: tokens, at: &index) + case "--name": options.namePattern = try value(after: token, in: tokens, at: &index) + default: throw CLIParseError.unknownCommand(token) + } + index += 1 + } + return options + } + private func parseCloudTabsProbeOptions(_ tokens: [String]) throws -> CloudTabsProbeOptions { var options = CloudTabsProbeOptions(); var index = 0 while index < tokens.count { @@ -268,6 +301,7 @@ icloud-cli \(version) Usage: icloud-cli drive list [--path PATH] [--depth N] [--format json|text] [--icloud-root PATH] icloud-cli drive containers [--sort-by size|modified|name] [--format json|text] [--icloud-root PATH] + icloud-cli shortcuts list [--name PATTERN] [--format json|text] [--shortcuts-dir PATH] icloud-cli safari tabs [--source all|current-session|last-session] [--format json|text] [--safari-dir PATH] icloud-cli safari bookmarks [--format json|text] [--safari-dir PATH] icloud-cli safari reading-list [--format json|text] [--safari-dir PATH] @@ -278,6 +312,7 @@ Commands: drive list List files under the local iCloud Drive root without reading file contents. drive containers List top-level iCloud app containers. + shortcuts list List local Shortcuts metadata without executing shortcuts. safari tabs Read Safari open tabs from local Safari session files. safari bookmarks Read Safari bookmarks from Bookmarks.plist. diff --git a/Sources/ICloudCLICore/CommandRunner.swift b/Sources/ICloudCLICore/CommandRunner.swift index c98af2a..5c6143d 100644 --- a/Sources/ICloudCLICore/CommandRunner.swift +++ b/Sources/ICloudCLICore/CommandRunner.swift @@ -53,6 +53,10 @@ public struct CommandRunner: Sendable { let tabs = try SafariTabsReader(safariDirectory: options.safariDirectory).readTabs(source: options.source) output(try render(tabs, format: options.format)) return 0 + case .shortcutsList(let options): + let shortcuts = try ShortcutsInventoryReader(shortcutsDirectory: options.shortcutsDirectory).listShortcuts(namePattern: options.namePattern) + output(try render(shortcuts, format: options.format)) + return 0 } } catch { errorOutput(error.localizedDescription) @@ -152,6 +156,21 @@ public struct CommandRunner: Sendable { } } + public func render(_ shortcuts: [ShortcutEntry], format: OutputFormat) throws -> String { + switch format { + case .json: + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + return String(decoding: try encoder.encode(shortcuts), as: UTF8.self) + case .text: + return shortcuts.map { shortcut in + let input = shortcut.acceptsInput ? "accepts input" : "no input" + return "\(shortcut.name) - \(shortcut.actionCount) actions, \(input)" + }.joined(separator: "\n") + } + } + public func render(_ report: CloudTabsProbeReport, format: OutputFormat) throws -> String { switch format { case .json: diff --git a/Sources/ICloudCLICore/ShortcutsInventory.swift b/Sources/ICloudCLICore/ShortcutsInventory.swift new file mode 100644 index 0000000..e54e9e1 --- /dev/null +++ b/Sources/ICloudCLICore/ShortcutsInventory.swift @@ -0,0 +1,110 @@ +import Foundation + +public struct ShortcutEntry: Codable, Equatable, Sendable { + public let name: String + public let actionCount: Int + public let createdAt: Date? + public let modifiedAt: Date? + public let acceptsInput: Bool +} + +public enum ShortcutsInventoryError: Error, LocalizedError, Equatable { + case missingDirectory(String) + + public var errorDescription: String? { + switch self { + case .missingDirectory(let path): return "Shortcuts directory not available: \(path)" + } + } +} + +public struct ShortcutsInventoryReader: Sendable { + public let shortcutsDirectory: URL + + public init(shortcutsDirectory: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Shortcuts")) { + self.shortcutsDirectory = shortcutsDirectory.standardizedFileURL + } + + public func listShortcuts(namePattern: String? = nil) throws -> [ShortcutEntry] { + guard FileManager.default.fileExists(atPath: shortcutsDirectory.path) else { + throw ShortcutsInventoryError.missingDirectory(shortcutsDirectory.path) + } + + let entries = try FileManager.default.contentsOfDirectory(at: shortcutsDirectory, includingPropertiesForKeys: [.isDirectoryKey, .creationDateKey, .contentModificationDateKey], options: []) + var shortcuts: [ShortcutEntry] = [] + for entry in entries where entry.pathExtension == "shortcut" { + if let shortcut = try readShortcut(at: entry) { + shortcuts.append(shortcut) + } + } + + if let pattern = namePattern, !pattern.isEmpty { + shortcuts = shortcuts.filter { $0.name.localizedCaseInsensitiveContains(pattern) } + } + + return shortcuts.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + } + + private func readShortcut(at url: URL) throws -> ShortcutEntry? { + let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .creationDateKey, .contentModificationDateKey]) + let plistURL = values?.isDirectory == true ? preferredPlist(in: url) : url + guard let plistURL else { return nil } + let data = try Data(contentsOf: plistURL) + let object = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) + guard let plist = object as? [String: Any] else { return nil } + + let fallbackName = url.deletingPathExtension().lastPathComponent + let name = stringValue(plist, keys: ["WFWorkflowName", "name", "Name"]) ?? fallbackName + let actions = arrayValue(plist, keys: ["WFWorkflowActions", "actions", "Actions"]) + let createdAt = dateValue(plist, keys: ["WFWorkflowCreationDate", "createdAt", "CreatedAt"]) ?? values?.creationDate + let modifiedAt = dateValue(plist, keys: ["WFWorkflowModificationDate", "modifiedAt", "ModifiedAt"]) ?? values?.contentModificationDate + let acceptsInput = boolValue(plist, keys: ["WFWorkflowAcceptsInput", "acceptsInput", "AcceptsInput"]) ?? hasWorkflowInput(plist) + + return ShortcutEntry(name: name, actionCount: actions?.count ?? 0, createdAt: createdAt, modifiedAt: modifiedAt, acceptsInput: acceptsInput) + } + + private func preferredPlist(in directory: URL) -> URL? { + let candidates = ["Shortcut.plist", "Info.plist", "shortcut.plist"] + for candidate in candidates { + let url = directory.appendingPathComponent(candidate) + if FileManager.default.fileExists(atPath: url.path) { return url } + } + return nil + } + + private func stringValue(_ plist: [String: Any], keys: [String]) -> String? { + for key in keys { + if let value = plist[key] as? String, !value.isEmpty { return value } + } + return nil + } + + private func arrayValue(_ plist: [String: Any], keys: [String]) -> [Any]? { + for key in keys { + if let value = plist[key] as? [Any] { return value } + } + return nil + } + + private func dateValue(_ plist: [String: Any], keys: [String]) -> Date? { + for key in keys { + if let value = plist[key] as? Date { return value } + if let value = plist[key] as? String { return ISO8601DateFormatter().date(from: value) } + } + return nil + } + + private func boolValue(_ plist: [String: Any], keys: [String]) -> Bool? { + for key in keys { + if let value = plist[key] as? Bool { return value } + if let value = plist[key] as? NSNumber { return value.boolValue } + } + return nil + } + + private func hasWorkflowInput(_ plist: [String: Any]) -> Bool { + if let classes = plist["WFWorkflowInputContentItemClasses"] as? [Any], !classes.isEmpty { return true } + if let types = plist["WFWorkflowInputTypes"] as? [Any], !types.isEmpty { return true } + return false + } +} diff --git a/Tests/Fixtures/Shortcuts/Archive Notes.shortcut/Info.plist b/Tests/Fixtures/Shortcuts/Archive Notes.shortcut/Info.plist new file mode 100644 index 0000000..b7fac23 --- /dev/null +++ b/Tests/Fixtures/Shortcuts/Archive Notes.shortcut/Info.plist @@ -0,0 +1,12 @@ + + + + + WFWorkflowNameArchive Notes + WFWorkflowActions + + WFWorkflowActionIdentifieris.workflow.actions.appendnote + + WFWorkflowAcceptsInput + + diff --git a/Tests/Fixtures/Shortcuts/Daily Check.shortcut/Shortcut.plist b/Tests/Fixtures/Shortcuts/Daily Check.shortcut/Shortcut.plist new file mode 100644 index 0000000..fb09d53 --- /dev/null +++ b/Tests/Fixtures/Shortcuts/Daily Check.shortcut/Shortcut.plist @@ -0,0 +1,16 @@ + + + + + WFWorkflowNameDaily Check + WFWorkflowCreationDate2026-01-02T03:04:05Z + WFWorkflowModificationDate2026-01-03T04:05:06Z + WFWorkflowActions + + WFWorkflowActionIdentifieris.workflow.actions.gettext + WFWorkflowActionIdentifieris.workflow.actions.showresult + + WFWorkflowInputContentItemClasses + WFStringContentItem + + diff --git a/Tests/ICloudCLICoreTests/CLIParserTests.swift b/Tests/ICloudCLICoreTests/CLIParserTests.swift index 8527776..3b7a5a1 100644 --- a/Tests/ICloudCLICoreTests/CLIParserTests.swift +++ b/Tests/ICloudCLICoreTests/CLIParserTests.swift @@ -132,3 +132,16 @@ import Testing #expect(options.format == .json) #expect(options.rootDirectory.path == "/tmp/mobile-documents") } + +@Test func parsesShortcutsListCommand() throws { + let command = try CLIParser().parse(arguments: ["icloud-cli", "shortcuts", "list", "--name", "Daily", "--format", "text", "--shortcuts-dir", "/tmp/shortcuts"]) + + guard case .shortcutsList(let options) = command else { + Issue.record("Expected shortcuts list command") + return + } + + #expect(options.namePattern == "Daily") + #expect(options.format == .text) + #expect(options.shortcutsDirectory.path == "/tmp/shortcuts") +} diff --git a/Tests/ICloudCLICoreTests/ShortcutsInventoryTests.swift b/Tests/ICloudCLICoreTests/ShortcutsInventoryTests.swift new file mode 100644 index 0000000..1c74e9c --- /dev/null +++ b/Tests/ICloudCLICoreTests/ShortcutsInventoryTests.swift @@ -0,0 +1,24 @@ +import Foundation +import Testing +@testable import ICloudCLICore + +private func shortcutsFixtureURL() throws -> URL { + let fileURL = URL(fileURLWithPath: #filePath) + let testsDirectory = fileURL.deletingLastPathComponent().deletingLastPathComponent() + return testsDirectory.appendingPathComponent("Fixtures/Shortcuts") +} + +@Test func listsSyntheticShortcutsFixture() throws { + let shortcuts = try ShortcutsInventoryReader(shortcutsDirectory: try shortcutsFixtureURL()).listShortcuts() + + #expect(shortcuts.map(\.name) == ["Archive Notes", "Daily Check"]) + #expect(shortcuts.first { $0.name == "Daily Check" }?.actionCount == 2) + #expect(shortcuts.first { $0.name == "Daily Check" }?.acceptsInput == true) + #expect(shortcuts.first { $0.name == "Archive Notes" }?.acceptsInput == false) +} + +@Test func filtersSyntheticShortcutsByName() throws { + let shortcuts = try ShortcutsInventoryReader(shortcutsDirectory: try shortcutsFixtureURL()).listShortcuts(namePattern: "daily") + + #expect(shortcuts.map(\.name) == ["Daily Check"]) +} diff --git a/docs/issue-work-plan.md b/docs/issue-work-plan.md index caa3ef1..6d17e30 100644 --- a/docs/issue-work-plan.md +++ b/docs/issue-work-plan.md @@ -1,14 +1,14 @@ # Issue work plan -Reviewed the remaining open issues after the Safari metadata PR. +Reviewed the remaining open issues after the Drive metadata PR. ## Groups -- iCloud Drive filesystem metadata: #19 Drive file inventory and #32 app iCloud container inventory. -- Local app/private databases: #21 Notes, #24 Reminders, #28 Contacts. -- Device/availability state: #29 Focus, #30 Handoff, #31 devices. -- High-sensitivity activity/content: #25 Safari history, #26 Messages, #33 Maps, #34 News, #35 Wallet. +- Ready medium-risk data commands: #21 Notes titles, #22 storage quota status, #23 cache/watch mode. +- Local automation metadata: #27 Shortcuts inventory. +- High-sensitivity content/activity surfaces: #18 Photos, #25 Safari history, #26 Messages, #33 Maps, #34 News, #35 Wallet. +- Device/status surfaces: #29 Focus, #30 Handoff, #31 connected devices. ## Selected group -Selected the iCloud Drive filesystem metadata group (#19 and #32). It is coherent, testable with synthetic directories, and lower-risk than message/history/location surfaces because it reads metadata only and never file contents. +Selected #27 Shortcuts inventory as the next low/medium-risk pass. It is coherent, read-only, testable with synthetic `.shortcut` bundles, and avoids executing Shortcuts or reading private content databases. diff --git a/docs/issue-work-result.md b/docs/issue-work-result.md index f36fa00..7f3ac3c 100644 --- a/docs/issue-work-result.md +++ b/docs/issue-work-result.md @@ -1,18 +1,14 @@ # Issue work result -## Selected issues +## Selected issue -- Closes #19 -- Closes #32 +- Closes #27 ## Summary -Added read-only iCloud Drive metadata inventory commands: +Added `icloud-cli shortcuts list` for read-only local Shortcuts metadata inventory. Each entry includes `name`, `actionCount`, `createdAt`, `modifiedAt`, and `acceptsInput`. The command supports `--name PATTERN`, `--format json|text`, and `--shortcuts-dir PATH` for fixtures or alternate libraries. -- `icloud-cli drive list` lists files under the local iCloud Drive root with relative path, name, size, modified timestamp, iCloud status, and app container. -- `icloud-cli drive containers` lists top-level iCloud app containers with display name, size, and latest modified timestamp. - -The implementation uses synthetic filesystem fixtures and does not read file contents. +The implementation does not execute shortcuts and does not request Automation permission. ## Validation @@ -23,6 +19,6 @@ bash scripts/ci/run-fast-checks.sh swift test swift build bash scripts/check-privacy-fixtures.sh -.build/debug/icloud-cli drive list --icloud-root Tests/Fixtures/MobileDocuments --format json -.build/debug/icloud-cli drive containers --icloud-root Tests/Fixtures/MobileDocuments --sort-by size --format text +.build/debug/icloud-cli shortcuts list --shortcuts-dir Tests/Fixtures/Shortcuts --format json +.build/debug/icloud-cli shortcuts list --shortcuts-dir Tests/Fixtures/Shortcuts --name Daily --format text ``` diff --git a/docs/privacy.md b/docs/privacy.md index 1a35734..292a8e0 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -36,6 +36,7 @@ OpenClaw integrations should default to local retention. Exporting raw browsing | `icloud-cli safari reading-list` | `~/Library/Safari/Bookmarks.plist` Reading List entries | Terminal or calling process may need Full Disk Access to read Safari metadata files. | | `icloud-cli safari frequently-visited` | `~/Library/Safari/TopSites.plist` or compatible frequently visited site cache | Terminal or calling process may need Full Disk Access to read Safari metadata files. | | Future `icloud-cli safari tabs --include-cloud` | Safari iCloud sync storage, likely under `~/Library/Safari` | Full Disk Access is expected; schema and safety constraints must be documented before implementation. | +| `icloud-cli shortcuts list` | `~/Library/Shortcuts/*.shortcut` metadata including shortcut names, action counts, dates, and input capability | Terminal or calling process may need access to the Shortcuts library. The command is read-only and never executes shortcuts. | | Future iCloud settings commands | Local Apple account or system settings state | Document per-command read surfaces before implementation; do not require Automation unless a command actually controls an app. | Automation permission is not required for the current Safari tab reader because it reads local files. Any future command that controls Safari, System Settings, or another app must document the Automation prompt and failure mode before merge. @@ -59,3 +60,8 @@ This keeps static privacy checks and Swift tests ahead of the standalone build. ## iCloud Drive inventory `icloud-cli drive list` and `icloud-cli drive containers` read filesystem metadata under `~/Library/Mobile Documents` only. They do not read file contents. JSON output includes real paths by design for direct operator use; logs and status summaries should redact the home directory. Evicted `.icloud` stubs are reported as metadata with `sizeBytes: null`. + + +## Shortcuts inventory + +`icloud-cli shortcuts list` reads local Shortcuts metadata only. It does not execute shortcuts, request Automation permission, or read action payload contents beyond counting action entries in the shortcut plist. Shortcut names are operator-sensitive metadata; logs and status summaries should avoid dumping full JSON unless explicitly requested.