Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
35 changes: 35 additions & 0 deletions Sources/ICloudCLICore/CommandLine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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]
Expand All @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions Sources/ICloudCLICore/CommandRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
110 changes: 110 additions & 0 deletions Sources/ICloudCLICore/ShortcutsInventory.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
12 changes: 12 additions & 0 deletions Tests/Fixtures/Shortcuts/Archive Notes.shortcut/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>WFWorkflowName</key><string>Archive Notes</string>
<key>WFWorkflowActions</key>
<array>
<dict><key>WFWorkflowActionIdentifier</key><string>is.workflow.actions.appendnote</string></dict>
</array>
<key>WFWorkflowAcceptsInput</key><false/>
</dict>
</plist>
16 changes: 16 additions & 0 deletions Tests/Fixtures/Shortcuts/Daily Check.shortcut/Shortcut.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>WFWorkflowName</key><string>Daily Check</string>
<key>WFWorkflowCreationDate</key><date>2026-01-02T03:04:05Z</date>
<key>WFWorkflowModificationDate</key><date>2026-01-03T04:05:06Z</date>
<key>WFWorkflowActions</key>
<array>
<dict><key>WFWorkflowActionIdentifier</key><string>is.workflow.actions.gettext</string></dict>
<dict><key>WFWorkflowActionIdentifier</key><string>is.workflow.actions.showresult</string></dict>
</array>
<key>WFWorkflowInputContentItemClasses</key>
<array><string>WFStringContentItem</string></array>
</dict>
</plist>
13 changes: 13 additions & 0 deletions Tests/ICloudCLICoreTests/CLIParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
24 changes: 24 additions & 0 deletions Tests/ICloudCLICoreTests/ShortcutsInventoryTests.swift
Original file line number Diff line number Diff line change
@@ -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"])
}
12 changes: 6 additions & 6 deletions docs/issue-work-plan.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 6 additions & 10 deletions docs/issue-work-result.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
```
6 changes: 6 additions & 0 deletions docs/privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Loading