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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ icloud-cli safari cloud-tabs probe
icloud-cli safari bookmarks
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
```

The initial implementation reads local Safari session and metadata property lists from `~/Library/Safari`. 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` 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.

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
83 changes: 81 additions & 2 deletions Sources/ICloudCLICore/CommandLine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,32 @@ public struct SafariFrequentlyVisitedOptions: Equatable, Sendable {
}
}

public struct DriveListOptions: Equatable, Sendable {
public var format: OutputFormat
public var rootDirectory: URL
public var path: String?
public var depth: Int

public init(format: OutputFormat = .json, rootDirectory: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Mobile Documents"), path: String? = nil, depth: Int = 2) {
self.format = format
self.rootDirectory = rootDirectory
self.path = path
self.depth = depth
}
}

public struct DriveContainersOptions: Equatable, Sendable {
public var format: OutputFormat
public var rootDirectory: URL
public var sortBy: DriveSortKey

public init(format: OutputFormat = .json, rootDirectory: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Mobile Documents"), sortBy: DriveSortKey = .name) {
self.format = format
self.rootDirectory = rootDirectory
self.sortBy = sortBy
}
}

public struct CloudTabsProbeOptions: Equatable, Sendable {
public var format: OutputFormat
public var safariDirectory: URL
Expand All @@ -51,6 +77,8 @@ public struct CloudTabsProbeOptions: Equatable, Sendable {

public enum CLICommand: Equatable, Sendable {
case cloudTabsProbe(CloudTabsProbeOptions)
case driveContainers(DriveContainersOptions)
case driveList(DriveListOptions)
case safariBookmarks(SafariBookmarksOptions)
case safariFrequentlyVisited(SafariFrequentlyVisitedOptions)
case safariReadingList(SafariBookmarksOptions)
Expand Down Expand Up @@ -83,8 +111,17 @@ public struct CLIParser: Sendable {
if tokens.isEmpty || tokens.contains("--help") || tokens.contains("-h") { return .help }
if tokens == ["--version"] || tokens == ["-V"] { return .version }

guard tokens.first == "safari" else { throw CLIParseError.unknownCommand(tokens.first ?? "") }
tokens.removeFirst()
let topCommand = tokens.removeFirst()
if topCommand == "drive" {
guard let driveCommand = tokens.first else { throw CLIParseError.unknownCommand("drive") }
tokens.removeFirst()
switch driveCommand {
case "list": return .driveList(try parseDriveListOptions(tokens))
case "containers": return .driveContainers(try parseDriveContainersOptions(tokens))
default: throw CLIParseError.unknownCommand((["drive", driveCommand] + tokens).joined(separator: " "))
}
}
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 @@ -152,6 +189,43 @@ public struct CLIParser: Sendable {
return options
}

private func parseDriveListOptions(_ tokens: [String]) throws -> DriveListOptions {
var options = DriveListOptions(); 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 "--icloud-root": options.rootDirectory = try parseURL(after: token, in: tokens, at: &index)
case "--path": options.path = try value(after: token, in: tokens, at: &index)
case "--depth":
let rawValue = try value(after: token, in: tokens, at: &index)
guard let depth = Int(rawValue), depth >= 0 else { throw CLIParseError.missingValue(token) }
options.depth = depth
default: throw CLIParseError.unknownCommand(token)
}
index += 1
}
return options
}

private func parseDriveContainersOptions(_ tokens: [String]) throws -> DriveContainersOptions {
var options = DriveContainersOptions(); 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 "--icloud-root": options.rootDirectory = try parseURL(after: token, in: tokens, at: &index)
case "--sort-by":
let rawValue = try value(after: token, in: tokens, at: &index)
guard let sortBy = DriveSortKey(rawValue: rawValue) else { throw CLIParseError.missingValue(token) }
options.sortBy = sortBy
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 @@ -192,13 +266,18 @@ public enum CLIHelp {
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 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]
icloud-cli safari frequently-visited [--limit N] [--format json|text] [--safari-dir PATH]
icloud-cli safari cloud-tabs probe [--format json|text] [--safari-dir PATH]

Commands:
drive list List files under the local iCloud Drive root without reading file contents.
drive containers
List top-level iCloud app containers.
safari tabs Read Safari open tabs from local Safari session files.
safari bookmarks
Read Safari bookmarks from Bookmarks.plist.
Expand Down
38 changes: 38 additions & 0 deletions Sources/ICloudCLICore/CommandRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ public struct CommandRunner: Sendable {
let report = CloudTabsProbe(safariDirectory: options.safariDirectory).probe()
output(try render(report, format: options.format))
return 0
case .driveContainers(let options):
let containers = try ICloudDriveInventoryReader(rootDirectory: options.rootDirectory).listContainers(sortBy: options.sortBy)
output(try render(containers, format: options.format))
return 0
case .driveList(let options):
let files = try ICloudDriveInventoryReader(rootDirectory: options.rootDirectory).listFiles(path: options.path, depth: options.depth)
output(try render(files, format: options.format))
return 0
case .safariBookmarks(let options):
let bookmarks = try SafariBookmarksReader(safariDirectory: options.safariDirectory).readBookmarks()
output(try render(bookmarks, format: options.format))
Expand Down Expand Up @@ -114,6 +122,36 @@ public struct CommandRunner: Sendable {
}
}

public func render(_ files: [ICloudDriveFile], format: OutputFormat) throws -> String {
switch format {
case .json:
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = .iso8601
return String(decoding: try encoder.encode(files), as: UTF8.self)
case .text:
return files.map { file in
let size = file.sizeBytes.map { "\($0) bytes" } ?? "evicted"
return "[\(file.iCloudStatus.rawValue)] \(file.path) (\(size))"
}.joined(separator: "\n")
}
}

public func render(_ containers: [ICloudDriveContainer], format: OutputFormat) throws -> String {
switch format {
case .json:
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = .iso8601
return String(decoding: try encoder.encode(containers), as: UTF8.self)
case .text:
return containers.map { container in
let size = container.sizeBytes.map { "\($0) bytes" } ?? "unknown size"
return "\(container.displayName) [\(container.bundleId)] - \(size)"
}.joined(separator: "\n")
}
}

public func render(_ report: CloudTabsProbeReport, format: OutputFormat) throws -> String {
switch format {
case .json:
Expand Down
155 changes: 155 additions & 0 deletions Sources/ICloudCLICore/DriveInventory.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import Foundation

public enum DriveSortKey: String, Sendable {
case name
case size
case modified
}

public enum ICloudFileStatus: String, Codable, Equatable, Sendable {
case downloaded
case evicted
case uploading
case unknown
}

public struct ICloudDriveFile: Codable, Equatable, Sendable {
public let path: String
public let name: String
public let sizeBytes: Int64?
public let modifiedAt: Date?
public let iCloudStatus: ICloudFileStatus
public let appContainer: String
}

public struct ICloudDriveContainer: Codable, Equatable, Sendable {
public let bundleId: String
public let displayName: String
public let sizeBytes: Int64?
public let modifiedAt: Date?
}

public enum DriveInventoryError: Error, LocalizedError, Equatable {
case missingRoot(String)
case invalidPath(String)

public var errorDescription: String? {
switch self {
case .missingRoot(let path): return "iCloud Drive root not available: \(path)"
case .invalidPath(let path): return "Path is outside the iCloud Drive root: \(path)"
}
}
}

public struct ICloudDriveInventoryReader: Sendable {
public let rootDirectory: URL
public init(rootDirectory: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Mobile Documents")) {
self.rootDirectory = rootDirectory.standardizedFileURL
}

public func listFiles(path requestedPath: String? = nil, depth: Int = 2) throws -> [ICloudDriveFile] {
guard FileManager.default.fileExists(atPath: rootDirectory.path) else { throw DriveInventoryError.missingRoot(rootDirectory.path) }
let startURL = try scopedURL(for: requestedPath)
let maxDepth = max(0, depth)
var result: [ICloudDriveFile] = []
try walkFiles(at: startURL, currentDepth: 0, maxDepth: maxDepth, into: &result)
return result.sorted { lhs, rhs in lhs.path.localizedStandardCompare(rhs.path) == .orderedAscending }
}

public func listContainers(sortBy: DriveSortKey = .name) throws -> [ICloudDriveContainer] {
guard FileManager.default.fileExists(atPath: rootDirectory.path) else { throw DriveInventoryError.missingRoot(rootDirectory.path) }
let children = try FileManager.default.contentsOfDirectory(at: rootDirectory, includingPropertiesForKeys: [.isDirectoryKey], options: [])
var containers: [ICloudDriveContainer] = []
for child in children {
let values = try? child.resourceValues(forKeys: [.isDirectoryKey])
guard values?.isDirectory == true else { continue }
let stats = directoryStats(child)
containers.append(ICloudDriveContainer(bundleId: child.lastPathComponent, displayName: displayName(for: child.lastPathComponent), sizeBytes: stats.sizeBytes, modifiedAt: stats.modifiedAt))
}
return sort(containers, by: sortBy)
}

private func scopedURL(for requestedPath: String?) throws -> URL {
guard let requestedPath, !requestedPath.isEmpty else { return rootDirectory }
let url: URL
if requestedPath.hasPrefix("/") { url = URL(fileURLWithPath: requestedPath) }
else { url = rootDirectory.appendingPathComponent(requestedPath) }
let standardized = url.standardizedFileURL
guard standardized.path == rootDirectory.path || standardized.path.hasPrefix(rootDirectory.path + "/") else { throw DriveInventoryError.invalidPath(standardized.path) }
return standardized
}

private func walkFiles(at directory: URL, currentDepth: Int, maxDepth: Int, into result: inout [ICloudDriveFile]) throws {
let children = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey], options: [])
for child in children {
let values = try? child.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey])
if values?.isDirectory == true {
if currentDepth < maxDepth { try walkFiles(at: child, currentDepth: currentDepth + 1, maxDepth: maxDepth, into: &result) }
continue
}
result.append(fileEntry(for: child, values: values))
}
}

private func fileEntry(for url: URL, values: URLResourceValues?) -> ICloudDriveFile {
let status = status(for: url)
return ICloudDriveFile(path: relativePath(for: url), name: displayFileName(for: url), sizeBytes: status == .evicted ? nil : values?.fileSize.map(Int64.init), modifiedAt: values?.contentModificationDate, iCloudStatus: status, appContainer: appContainer(for: url))
}

private func status(for url: URL) -> ICloudFileStatus {
let name = url.lastPathComponent
if name.hasPrefix(".") && name.hasSuffix(".icloud") { return .evicted }
if name.hasSuffix(".icloud") { return .uploading }
return .downloaded
}

private func displayFileName(for url: URL) -> String {
let name = url.lastPathComponent
if name.hasPrefix(".") && name.hasSuffix(".icloud") {
let start = name.index(after: name.startIndex)
let end = name.index(name.endIndex, offsetBy: -".icloud".count)
return String(name[start..<end])
}
return name
}

private func relativePath(for url: URL) -> String {
let path = url.standardizedFileURL.path
if path == rootDirectory.path { return "." }
return String(path.dropFirst(rootDirectory.path.count + 1))
}

private func appContainer(for url: URL) -> String {
let relative = relativePath(for: url)
return relative.split(separator: "/").first.map(String.init) ?? ""
}

private func directoryStats(_ directory: URL) -> (sizeBytes: Int64?, modifiedAt: Date?) {
guard let enumerator = FileManager.default.enumerator(at: directory, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey], options: []) else { return (nil, nil) }
var size: Int64 = 0
var sawFile = false
var latest: Date?
for case let url as URL in enumerator {
let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey])
if let modified = values?.contentModificationDate, latest.map({ modified > $0 }) ?? true { latest = modified }
if values?.isDirectory == true { continue }
if status(for: url) == .evicted { continue }
if let fileSize = values?.fileSize { size += Int64(fileSize); sawFile = true }
}
return (sawFile ? size : nil, latest)
}

private func displayName(for bundleId: String) -> String {
bundleId.replacingOccurrences(of: "com~apple~", with: "Apple ").replacingOccurrences(of: "~", with: ".")
}

private func sort(_ containers: [ICloudDriveContainer], by key: DriveSortKey) -> [ICloudDriveContainer] {
containers.sorted { lhs, rhs in
switch key {
case .name: return lhs.displayName.localizedStandardCompare(rhs.displayName) == .orderedAscending
case .size: return (lhs.sizeBytes ?? -1, lhs.displayName) > (rhs.sizeBytes ?? -1, rhs.displayName)
case .modified: return (lhs.modifiedAt ?? .distantPast, lhs.displayName) > (rhs.modifiedAt ?? .distantPast, rhs.displayName)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
stub
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
hello drive
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
note data
27 changes: 27 additions & 0 deletions Tests/ICloudCLICoreTests/CLIParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,30 @@ import Testing
#expect(options.limit == 5)
#expect(options.format == .text)
}

@Test func parsesDriveListCommand() throws {
let command = try CLIParser().parse(arguments: ["icloud-cli", "drive", "list", "--path", "com~apple~CloudDocs", "--depth", "1", "--format", "text", "--icloud-root", "/tmp/mobile-documents"])

guard case .driveList(let options) = command else {
Issue.record("Expected drive list command")
return
}

#expect(options.path == "com~apple~CloudDocs")
#expect(options.depth == 1)
#expect(options.format == .text)
#expect(options.rootDirectory.path == "/tmp/mobile-documents")
}

@Test func parsesDriveContainersCommand() throws {
let command = try CLIParser().parse(arguments: ["icloud-cli", "drive", "containers", "--sort-by", "size", "--format", "json", "--icloud-root", "/tmp/mobile-documents"])

guard case .driveContainers(let options) = command else {
Issue.record("Expected drive containers command")
return
}

#expect(options.sortBy == .size)
#expect(options.format == .json)
#expect(options.rootDirectory.path == "/tmp/mobile-documents")
}
Loading
Loading