Keep using Photos.app like you always do. Attic quietly backs up your originals and edits to an S3 bucket you control. One-way, append-only.
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add brainstorm and plan for UX/open-source readiness

+597
+84
docs/brainstorms/2026-03-13-edited-assets-backup-brainstorm.md
··· 1 + # Back Up Rendered Edits Alongside Originals 2 + 3 + **Date:** 2026-03-13 4 + **Status:** Ready for planning 5 + 6 + ## What We're Building 7 + 8 + Extend the backup pipeline to detect edited photos/videos and upload the rendered (fullsize JPEG) version alongside the original. Also detect edits made to already-backed-up assets and upload their rendered versions retroactively. 9 + 10 + ## Why This Approach 11 + 12 + The backup should be self-contained and viewable without Apple Photos. Currently only originals are backed up. Apple Photos edits are non-destructive (the original is always preserved), but the "finished" version a user actually wants to see requires either Apple Photos or the adjustment plist to re-render. Backing up the rendered version makes the backup independently useful. 13 + 14 + ## Current State 15 + 16 + | Metric | Value | 17 + |--------|-------| 18 + | Total assets | 37,289 | 19 + | Edited assets | 1,312 (3.5%) | 20 + | Rendered versions (fullsize JPEG, resource type 1) | 1,672 resources | 21 + | Locally available renders | 1,480 | 22 + | Original size (edited subset) | ~6.2 GB | 23 + | Rendered size (edited subset) | ~13.7 GB | 24 + 25 + Edit sources: Apple Photos (1,181), slo-mo (47), Google Photos (42), Markup (11), Adobe Lens (9), Snapseed (3). 26 + 27 + ## Key Decisions 28 + 29 + 1. **Back up rendered versions** (not just adjustment plists). The fullsize JPEG is what users actually see. Plists are Apple-internal and not portable. 30 + 31 + 2. **Sibling key with `_edited` suffix** in S3: 32 + ``` 33 + originals/2024/01/{uuid}.heic # original 34 + originals/2024/01/{uuid}_edited.jpg # rendered edit 35 + metadata/assets/{uuid}.json # includes edit metadata 36 + ``` 37 + 38 + 3. **Same pass as originals**. When processing a batch, detect edits and upload both files together. No separate command needed. 39 + 40 + 4. **Re-scan already-backed-up assets for new edits**. Compare adjustment timestamps against the manifest's `backedUpAt` to detect photos edited after their initial backup. 41 + 42 + ## Data Sources in Photos.sqlite 43 + 44 + ### Edit detection 45 + - `ZUNMANAGEDADJUSTMENT` joined via `ZADDITIONALASSETATTRIBUTES` tells us an asset has been edited 46 + - `ZADJUSTMENTTIMESTAMP` tells us when the edit happened 47 + - `ZADJUSTMENTFORMATIDENTIFIER` tells us which editor (com.apple.photo, com.adobe.lens, etc.) 48 + 49 + ### Rendered file location 50 + - `ZINTERNALRESOURCE` with `ZRESOURCETYPE = 1` (fullsize JPEG) points to the rendered version 51 + - `ZLOCALAVAILABILITY = 1` means the file is on disk 52 + - `ZDATALENGTH` gives the file size 53 + - The actual file lives in the Photos Library package, path derivable from `ZDATASTORECLASSID` + fingerprint 54 + 55 + ### Export via ladder 56 + - The current exporter uses PhotoKit ID `{uuid}/L0/001` for originals 57 + - Rendered versions may need a different resource variant or direct file copy from the library package 58 + 59 + ## Scope 60 + 61 + ### In scope 62 + - Detect which assets have edits (via ZUNMANAGEDADJUSTMENT) 63 + - Add edit metadata to PhotoAsset and the S3 metadata JSON (hasEdit, editedAt, editor) 64 + - Export and upload rendered fullsize JPEG alongside original 65 + - Re-scan manifest for assets edited after backup 66 + - Track edit backup state in manifest (so renders are not re-uploaded) 67 + 68 + ### Out of scope 69 + - Backing up adjustment plists (edit recipes) 70 + - Handling slo-mo video rendering (complex, different pipeline) 71 + - Re-rendering from adjustment data outside Apple Photos 72 + - Backing up thumbnails or other resource types 73 + 74 + ## Resolved Questions 75 + 76 + 1. **Manifest schema**: Extend the existing manifest entry with optional `editS3Key`, `editChecksum`, `editBackedUpAt` fields. No separate entries, no schema break. 77 + 78 + 2. **Re-edit handling**: Always upload the latest render. Compare adjustment timestamp against `editBackedUpAt` to detect re-edits. The backup should reflect the current state of the edit. 79 + 80 + ## Open Questions 81 + 82 + 1. **How does ladder/PhotoKit export the rendered version?** The current `/L0/001` suffix gets the original. Need to investigate what identifier or API call retrieves the fullsize rendered JPEG. May need a ladder change. 83 + 84 + 2. **What about iCloud-only rendered versions?** 1,480 of 1,672 renders are local. The remaining ~200 may need to be downloaded first, same as iCloud-only originals. Is there an existing mechanism for this?
+88
docs/brainstorms/2026-03-13-ux-open-source-readiness-brainstorm.md
··· 1 + # UX and Open-Source Readiness 2 + 3 + **Date:** 2026-03-13 4 + **Status:** Ready for planning 5 + 6 + ## What We're Building 7 + 8 + Make attic friendly to use for technical Mac users and ready to open source. Replace hardcoded Scaleway configuration with a generic S3-compatible config layer, add an interactive `attic init` command, adopt Cliffy for polished CLI output, and improve error messages throughout. 9 + 10 + ## Why This Approach 11 + 12 + Attic currently works well as a personal tool but has Scaleway details baked into the code (endpoint, region, keychain service names, type names). To open source it, the tool needs to work with any S3-compatible provider out of the box. The UX should feel polished — good help text, colored output, and clear error messages that tell you what to do next. 13 + 14 + ## Current State 15 + 16 + | Area | Current | Target | 17 + |------|---------|--------| 18 + | S3 endpoint/region | Hardcoded Scaleway constants | Config file, any S3-compatible provider | 19 + | Bucket name | Hardcoded default, `--bucket` flag | Config file, CLI override | 20 + | Credentials | Keychain with hardcoded service names | Keychain with configurable service names | 21 + | Config file | None | `~/.attic/config.json` | 22 + | First-run setup | Manual (read README, set keychain, run) | `attic init` interactive prompts | 23 + | CLI framework | Hand-rolled arg parsing | Cliffy (subcommands, typed flags, help, color) | 24 + | Error messages | Raw exceptions in some paths | Friendly messages with suggested fixes | 25 + | path style | Hardcoded `true` | Config option, default `true` | 26 + | Provider docs | Scaleway-specific | Provider-neutral with EU-focused examples | 27 + 28 + ## Key Decisions 29 + 30 + 1. **Config file at `~/.attic/config.json`** — primary configuration source. CLI flags override. No env var fallback (keep it simple, macOS-only tool). 31 + 32 + 2. **Interactive `attic init`** — asks for S3 endpoint, region, bucket, and keychain service names step by step. Writes config.json. Can offer provider suggestions (Scaleway, Hetzner, OVH as EU options). 33 + 34 + 3. **Keychain with configurable service names** — stay macOS Keychain-only (security principle from CLAUDE.md), but let config.json specify the service names instead of hardcoding `attic-s3-access-key` / `attic-s3-secret-key`. 35 + 36 + 4. **Cliffy for CLI** — replace hand-rolled arg parsing with Cliffy. Gets us subcommands, typed flags, auto-generated help, colored output, and shell completions. 37 + 38 + 5. **`forcePathStyle` as config option** — default `true` (works with most S3-compatible providers). AWS users can set to `false`. 39 + 40 + 6. **EU-focused provider examples** — highlight Scaleway, Hetzner, OVH as EU data sovereignty options in docs and init prompts. Mention AWS/Backblaze as alternatives. Position attic as a good choice for keeping your photos in the EU. 41 + 42 + 7. **Top-level error boundary** — catch unhandled errors in mod.ts, present friendly messages instead of stack traces. Pattern: detect known error types (keychain missing, network timeout, S3 access denied) and print actionable guidance. 43 + 44 + ## Config File Schema 45 + 46 + ```json 47 + { 48 + "endpoint": "https://s3.fr-par.scw.cloud", 49 + "region": "fr-par", 50 + "bucket": "my-photo-backup", 51 + "pathStyle": true, 52 + "keychain": { 53 + "accessKeyService": "attic-s3-access-key", 54 + "secretKeyService": "attic-s3-secret-key" 55 + } 56 + } 57 + ``` 58 + 59 + ## Scope 60 + 61 + ### In scope 62 + - Config file (`~/.attic/config.json`) with validation 63 + - `attic init` interactive setup command 64 + - Cliffy migration for all commands (scan, status, backup, verify) 65 + - Rename `ScalewayCredentials` to `S3Credentials`, remove `SCALEWAY_*` constants 66 + - `createS3Provider()` accepts endpoint, region, pathStyle as parameters 67 + - Top-level error boundary with friendly messages for known failure modes 68 + - Updated README, CLAUDE.md, and architecture docs 69 + - EU-focused provider examples in docs and init 70 + 71 + ### Out of scope 72 + - Env var credential fallback (keep Keychain-only) 73 + - Non-macOS support 74 + - Provider presets in init (just ask for endpoint/region directly, with examples) 75 + - Web UI or GUI 76 + - Auto-detection of Photos.sqlite path across macOS versions 77 + 78 + ## Resolved Questions 79 + 80 + 1. **Audience**: Technical Mac users comfortable with terminal and S3 setup. 81 + 2. **Config approach**: Config file at `~/.attic/config.json`, CLI flags override. 82 + 3. **Init style**: Interactive prompts, writes config at the end. 83 + 4. **Credentials**: Keychain-only with configurable service names in config. 84 + 5. **Provider presentation**: EU-focused examples (Scaleway, Hetzner, OVH), others mentioned as alternatives. 85 + 6. **Path style**: Config option `pathStyle`, default `true`. 86 + 7. **CLI framework**: Cliffy. 87 + 8. **Init stores credentials directly**: `attic init` prompts for access key and secret key and runs `security add-generic-password` automatically. 88 + 9. **Validate config when S3 is needed**: scan/status only need Photos.sqlite — they work without config. backup/verify validate config and fail fast with a clear message if missing or incomplete.
+425
docs/plans/2026-03-13-feat-ux-open-source-readiness-plan.md
··· 1 + --- 2 + title: "feat: UX and Open-Source Readiness" 3 + type: feat 4 + status: active 5 + date: 2026-03-13 6 + --- 7 + 8 + # UX and Open-Source Readiness 9 + 10 + ## Overview 11 + 12 + Replace hardcoded Scaleway configuration with a generic S3-compatible config layer, add an interactive `attic init` command, migrate CLI to Cliffy, and improve error messages. Makes attic usable with any S3-compatible provider and ready to open source. 13 + 14 + ## Problem Statement 15 + 16 + Attic has Scaleway details baked into the code — endpoint, region, keychain service names, type names. A user who wants to use Hetzner, OVH, or AWS must fork and edit constants. The CLI uses hand-rolled arg parsing (130+ lines in `cli/mod.ts`) with no help generation, no colored output, and no shell completions. Error messages are raw exceptions in some paths. 17 + 18 + ## Proposed Solution 19 + 20 + Five phases, each independently shippable: 21 + 22 + 1. **Config layer** — `~/.attic/config.json` with validation 23 + 2. **Generic S3** — rename types, parameterize `createS3Provider()` 24 + 3. **Cliffy CLI** — replace hand-rolled parsing with Cliffy subcommands 25 + 4. **Interactive init** — `attic init` prompts for config + credentials 26 + 5. **Error boundary** — top-level catch with friendly messages 27 + 28 + ## Steps 29 + 30 + ### Phase 1: Config Layer 31 + 32 + Add config file support at `~/.attic/config.json`. 33 + 34 + **Files to modify:** 35 + - New: `cli/src/config/config.ts` (~80 lines) 36 + - New: `cli/src/config/config.test.ts` (~60 lines) 37 + 38 + **Config schema:** 39 + 40 + ```json 41 + { 42 + "endpoint": "https://s3.fr-par.scw.cloud", 43 + "region": "fr-par", 44 + "bucket": "my-photo-backup", 45 + "pathStyle": true, 46 + "keychain": { 47 + "accessKeyService": "attic-s3-access-key", 48 + "secretKeyService": "attic-s3-secret-key" 49 + } 50 + } 51 + ``` 52 + 53 + **Implementation:** 54 + 55 + ```typescript 56 + // cli/src/config/config.ts 57 + export interface AtticConfig { 58 + endpoint: string; 59 + region: string; 60 + bucket: string; 61 + pathStyle: boolean; 62 + keychain: { 63 + accessKeyService: string; 64 + secretKeyService: string; 65 + }; 66 + } 67 + 68 + const CONFIG_DIR = join(homedir(), ".attic"); 69 + const CONFIG_PATH = join(CONFIG_DIR, "config.json"); 70 + 71 + /** Load and validate config. Returns null if file doesn't exist. */ 72 + export function loadConfig(): AtticConfig | null 73 + 74 + /** Validate config fields, throw with specific message on missing/invalid. */ 75 + export function validateConfig(raw: unknown): AtticConfig 76 + 77 + /** Write config to disk, creating ~/.attic/ if needed. */ 78 + export function writeConfig(config: AtticConfig): void 79 + ``` 80 + 81 + **Validation rules:** 82 + - `endpoint` — required, must start with `https://` 83 + - `region` — required, non-empty string 84 + - `bucket` — required, non-empty string, validated against `/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/` 85 + - `pathStyle` — optional, defaults to `true` 86 + - `keychain.accessKeyService` — optional, defaults to `"attic-s3-access-key"` 87 + - `keychain.secretKeyService` — optional, defaults to `"attic-s3-secret-key"` 88 + 89 + **Tests:** 90 + - Valid config round-trips through write/load 91 + - Missing required fields throw descriptive errors 92 + - Optional fields get defaults 93 + - Config file not found returns null 94 + - Invalid endpoint (no https) rejected 95 + - Invalid bucket name rejected 96 + 97 + ### Phase 2: Generic S3 98 + 99 + Remove Scaleway-specific naming. Parameterize the S3 client. 100 + 101 + **Files to modify:** 102 + - `cli/src/storage/s3-client.ts` (~20 lines changed) 103 + - `cli/mod.ts` (~15 lines changed) 104 + - `deno.json` (~2 lines changed — remove `--allow-net=s3.fr-par.scw.cloud`, use broader net permission) 105 + 106 + **Changes:** 107 + 108 + ```typescript 109 + // s3-client.ts — before 110 + export interface ScalewayCredentials { ... } 111 + const SCALEWAY_ENDPOINT = "https://s3.fr-par.scw.cloud"; 112 + const SCALEWAY_REGION = "fr-par"; 113 + export function createS3Provider(credentials: ScalewayCredentials, bucket: string): S3Provider 114 + 115 + // s3-client.ts — after 116 + export interface S3Credentials { 117 + accessKeyId: string; 118 + secretAccessKey: string; 119 + } 120 + 121 + export interface S3ConnectionConfig { 122 + endpoint: string; 123 + region: string; 124 + pathStyle: boolean; 125 + } 126 + 127 + export function createS3Provider( 128 + credentials: S3Credentials, 129 + bucket: string, 130 + connection: S3ConnectionConfig, 131 + ): S3Provider 132 + ``` 133 + 134 + - `loadKeychainCredentials()` accepts service names as parameters instead of hardcoding them 135 + - Delete `SCALEWAY_ENDPOINT` and `SCALEWAY_REGION` constants 136 + - `cli/mod.ts` reads config and passes connection details to `createS3Provider()` 137 + - `deno.json` tasks: replace `--allow-net=s3.fr-par.scw.cloud` with `--allow-net` (endpoint is now configurable) 138 + 139 + **Migration for existing users:** scan/status continue working without config (they don't need S3). backup/verify check for config and fail fast: 140 + 141 + ``` 142 + Error: No config file found at ~/.attic/config.json 143 + Run "attic init" to set up your S3 connection, or create the file manually. 144 + See: https://github.com/tijs/attic#setup 145 + ``` 146 + 147 + ### Phase 3: Cliffy CLI 148 + 149 + Replace hand-rolled arg parsing with Cliffy. 150 + 151 + **Dependencies to add (JSR):** 152 + - `@cliffy/command@1.0.0` 153 + - `@cliffy/prompt@1.0.0` 154 + - `@cliffy/ansi@1.0.0` 155 + 156 + **Files to modify:** 157 + - `cli/mod.ts` — full rewrite (~120 lines, replaces 252 lines) 158 + - `cli/deno.json` — add Cliffy imports 159 + 160 + **New structure:** 161 + 162 + ```typescript 163 + // cli/mod.ts 164 + import { Command } from "@cliffy/command"; 165 + 166 + const main = new Command() 167 + .name("attic") 168 + .version("0.1.0") 169 + .description("Back up your iCloud Photos library to S3-compatible storage") 170 + .action(() => main.showHelp()); 171 + 172 + // Each command in its own .command() chain 173 + main.command("scan", "Scan Photos library and show statistics") 174 + .option("--db <path:string>", "Path to Photos.sqlite") 175 + .action(async ({ db }) => { ... }); 176 + 177 + main.command("status", "Compare Photos DB vs backup manifest") 178 + .option("--db <path:string>", "Path to Photos.sqlite") 179 + .action(async ({ db }) => { ... }); 180 + 181 + main.command("backup", "Back up pending assets to S3") 182 + .option("--dry-run", "Show what would be uploaded") 183 + .option("--limit <n:integer>", "Back up at most N assets") 184 + .option("--batch-size <n:integer>", "Assets per ladder batch", { default: 50 }) 185 + .option("--type <type:string>", "Only back up photos or videos") 186 + .option("--bucket <name:string>", "Override bucket from config") 187 + .option("--ladder <path:string>", "Path to ladder binary") 188 + .option("--db <path:string>", "Path to Photos.sqlite") 189 + .action(async (options) => { ... }); 190 + 191 + main.command("verify", "Verify backup integrity against S3") 192 + .option("--deep", "Download and re-checksum each object") 193 + .option("--rebuild-manifest", "Reconstruct manifest from S3 metadata") 194 + .option("--bucket <name:string>", "Override bucket from config") 195 + .action(async (options) => { ... }); 196 + 197 + main.command("init", "Set up attic configuration") 198 + .action(async () => { ... }); 199 + 200 + await main.parse(Deno.args); 201 + ``` 202 + 203 + **What this gives us:** 204 + - Auto-generated `--help` for every command 205 + - Typed flags with validation (`:integer`, `:string`) 206 + - Unknown flag detection 207 + - Version flag (`--version`) 208 + - Shell completions via `main.command("completions", ...).action(completeCommand)` 209 + 210 + **What we delete:** 211 + - `parseBackupFlags()` (~55 lines) 212 + - `parseVerifyFlags()` (~30 lines) 213 + - `requireArg()`, `parsePositiveInt()` (~15 lines) 214 + - Manual help text block (~25 lines) 215 + 216 + ### Phase 4: Interactive Init 217 + 218 + Add `attic init` command with interactive prompts. 219 + 220 + **Files to modify:** 221 + - New: `cli/src/commands/init.ts` (~120 lines) 222 + - `cli/mod.ts` — wire up init command 223 + 224 + **Flow:** 225 + 226 + ``` 227 + $ attic init 228 + 229 + attic — iCloud Photos backup to S3-compatible storage 230 + 231 + S3 Connection 232 + ───────────── 233 + 234 + Endpoint URL: https://s3.fr-par.scw.cloud 235 + Examples: 236 + · Scaleway (EU): https://s3.fr-par.scw.cloud 237 + · Hetzner (EU): https://fsn1.your-objectstorage.com 238 + · OVH (EU): https://s3.gra.io.cloud.ovh.net 239 + · AWS: https://s3.eu-west-1.amazonaws.com 240 + 241 + Region: fr-par 242 + 243 + Bucket name: my-photo-backup 244 + 245 + Use path-style URLs? (Y/n): Y 246 + Most S3-compatible providers need this. AWS users: set to No. 247 + 248 + Credentials 249 + ─────────── 250 + 251 + Access key: SCWXXXXXXXXXXXXXXXXX 252 + Secret key: ········································ 253 + 254 + Writing config to ~/.attic/config.json... done 255 + Storing credentials in macOS Keychain... done 256 + 257 + ✓ Setup complete. Run "attic scan" to see your Photos library. 258 + ``` 259 + 260 + **Implementation:** 261 + 262 + ```typescript 263 + // cli/src/commands/init.ts 264 + import { Input, Confirm, Secret } from "@cliffy/prompt"; 265 + import { colors } from "@cliffy/ansi"; 266 + 267 + export async function runInit(): Promise<void> { 268 + // Check for existing config 269 + const existing = loadConfig(); 270 + if (existing) { 271 + const overwrite = await Confirm.prompt("Config already exists. Overwrite?"); 272 + if (!overwrite) return; 273 + } 274 + 275 + const endpoint = await Input.prompt({ message: "Endpoint URL", hint: "..." }); 276 + const region = await Input.prompt({ message: "Region" }); 277 + const bucket = await Input.prompt({ message: "Bucket name" }); 278 + const pathStyle = await Confirm.prompt({ message: "Use path-style URLs?", default: true }); 279 + 280 + const accessKey = await Input.prompt({ message: "Access key" }); 281 + const secretKey = await Secret.prompt({ message: "Secret key" }); 282 + 283 + // Write config 284 + writeConfig({ endpoint, region, bucket, pathStyle, keychain: { 285 + accessKeyService: "attic-s3-access-key", 286 + secretKeyService: "attic-s3-secret-key", 287 + }}); 288 + 289 + // Store credentials with -U flag (update if exists) 290 + await storeKeychainCredential("attic-s3-access-key", accessKey); 291 + await storeKeychainCredential("attic-s3-secret-key", secretKey); 292 + } 293 + 294 + async function storeKeychainCredential(service: string, value: string): Promise<void> { 295 + // Try update first, fall back to add 296 + const update = new Deno.Command("security", { 297 + args: ["add-generic-password", "-U", "-s", service, "-a", "attic", "-w", value], 298 + stderr: "piped", 299 + }); 300 + const { code } = await update.output(); 301 + if (code !== 0) { 302 + throw new Error(`Failed to store credential in Keychain for service "${service}"`); 303 + } 304 + } 305 + ``` 306 + 307 + **Keychain idempotency:** Use `security add-generic-password -U` which updates an existing entry or creates a new one. No need to delete-then-add. 308 + 309 + **No test file for init** — it's pure I/O (prompts + Keychain + file writes). The config validation is tested in Phase 1. Keychain interaction is tested manually. 310 + 311 + ### Phase 5: Error Boundary 312 + 313 + Add a top-level error handler in `cli/mod.ts`. 314 + 315 + **Files to modify:** 316 + - `cli/mod.ts` (~40 lines added) 317 + 318 + **Implementation:** 319 + 320 + ```typescript 321 + // Wrap main.parse() in try/catch 322 + try { 323 + await main.parse(Deno.args); 324 + } catch (error: unknown) { 325 + handleError(error); 326 + Deno.exit(1); 327 + } 328 + 329 + function handleError(error: unknown): void { 330 + if (!(error instanceof Error)) { 331 + console.error("An unexpected error occurred."); 332 + return; 333 + } 334 + 335 + const msg = error.message; 336 + 337 + // Keychain not found 338 + if (msg.includes("find-generic-password") || msg.includes("SecKeychainSearchCopyNext")) { 339 + console.error("Could not read credentials from macOS Keychain."); 340 + console.error('Run "attic init" to set up your credentials.\n'); 341 + return; 342 + } 343 + 344 + // Config missing 345 + if (msg.includes("config.json") && msg.includes("ENOENT")) { 346 + console.error("No config file found at ~/.attic/config.json"); 347 + console.error('Run "attic init" to set up your S3 connection.\n'); 348 + return; 349 + } 350 + 351 + // S3 access denied 352 + if (msg.includes("AccessDenied") || msg.includes("403")) { 353 + console.error("S3 access denied. Check your credentials and bucket permissions."); 354 + console.error("Your credentials are stored in macOS Keychain."); 355 + console.error('Run "attic init" to update them.\n'); 356 + return; 357 + } 358 + 359 + // S3 bucket not found 360 + if (msg.includes("NoSuchBucket") || msg.includes("404")) { 361 + console.error(`S3 bucket not found. Check the bucket name in ~/.attic/config.json`); 362 + return; 363 + } 364 + 365 + // Network error 366 + if (msg.includes("ECONNREFUSED") || msg.includes("ETIMEDOUT") || msg.includes("fetch failed")) { 367 + console.error("Could not connect to S3 endpoint. Check your network and endpoint URL."); 368 + return; 369 + } 370 + 371 + // Photos.sqlite not found 372 + if (msg.includes("Photos.sqlite") || msg.includes("no such file")) { 373 + console.error("Could not open Photos database."); 374 + console.error("Make sure Photos is set up on this Mac and the database exists."); 375 + return; 376 + } 377 + 378 + // Fallback 379 + console.error(`Error: ${msg}`); 380 + } 381 + ``` 382 + 383 + ## Files Summary 384 + 385 + | Phase | File | Change | 386 + |-------|------|--------| 387 + | 1 | `cli/src/config/config.ts` | New — config load/validate/write | 388 + | 1 | `cli/src/config/config.test.ts` | New — config tests | 389 + | 2 | `cli/src/storage/s3-client.ts` | Rename types, parameterize | 390 + | 2 | `cli/mod.ts` | Read config, pass to S3 | 391 + | 2 | `deno.json` | Broader net permission | 392 + | 3 | `cli/mod.ts` | Rewrite with Cliffy | 393 + | 3 | `cli/deno.json` | Add Cliffy imports | 394 + | 4 | `cli/src/commands/init.ts` | New — interactive setup | 395 + | 5 | `cli/mod.ts` | Error boundary wrapper | 396 + 397 + ## Verification 398 + 399 + After each phase: 400 + 1. `deno task check` — type checking 401 + 2. `deno task test` — all tests pass 402 + 3. `deno task lint` — no lint errors 403 + 404 + Integration test (after all phases): 405 + 1. Run `attic init` with a test bucket 406 + 2. Run `attic scan` — works without config 407 + 3. Run `attic backup --dry-run` — reads config, validates, shows plan 408 + 4. Run `attic verify` — reads config, connects to S3 409 + 410 + ## Dependencies 411 + 412 + - `@cliffy/command@1.0.0` (JSR) — subcommands, typed flags, help generation 413 + - `@cliffy/prompt@1.0.0` (JSR) — interactive prompts for init 414 + - `@cliffy/ansi@1.0.0` (JSR) — colored output 415 + 416 + All three are Deno 2+ compatible via JSR. No npm dependencies. 417 + 418 + ## Out of Scope 419 + 420 + - Env var credential fallback (keep Keychain-only) 421 + - Non-macOS support 422 + - Provider presets/auto-detection in init 423 + - Web UI or GUI 424 + - Auto-detection of Photos.sqlite path across macOS versions 425 + - Shell completion generation (Cliffy supports it, but we can add it later)