aethel 0.4.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,416 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createHash } from "node:crypto";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { Readable } from "node:stream";
8
+ import { fileURLToPath } from "node:url";
9
+ import { initWorkspace } from "../src/core/config.js";
10
+ import { ensureFolder, resetFolderLookupCache } from "../src/core/drive-api.js";
11
+ import { Repository } from "../src/core/repository.js";
12
+
13
+ const FOLDER_MIME = "application/vnd.google-apps.folder";
14
+
15
+ function md5(buffer) {
16
+ return createHash("md5").update(buffer).digest("hex");
17
+ }
18
+
19
+ function clone(value) {
20
+ return JSON.parse(JSON.stringify(value));
21
+ }
22
+
23
+ function folder(id, name, parentId, createdTime) {
24
+ return {
25
+ id,
26
+ name,
27
+ mimeType: FOLDER_MIME,
28
+ parents: parentId ? [parentId] : [],
29
+ createdTime,
30
+ modifiedTime: createdTime,
31
+ md5Checksum: null,
32
+ size: null,
33
+ trashed: false,
34
+ };
35
+ }
36
+
37
+ function file(id, name, parentId, createdTime, content) {
38
+ const body = Buffer.from(content);
39
+ return {
40
+ id,
41
+ name,
42
+ mimeType: "application/octet-stream",
43
+ parents: [parentId],
44
+ createdTime,
45
+ modifiedTime: createdTime,
46
+ md5Checksum: md5(body),
47
+ size: body.length,
48
+ trashed: false,
49
+ body,
50
+ };
51
+ }
52
+
53
+ function createFakeDrive(initialItems = []) {
54
+ const items = new Map();
55
+ const contentById = new Map();
56
+ let sequence = 0;
57
+ let idCounter = 1000;
58
+
59
+ for (const item of initialItems) {
60
+ const next = clone(item);
61
+ delete next.body;
62
+ items.set(next.id, next);
63
+ if (item.body) {
64
+ contentById.set(next.id, Buffer.from(item.body));
65
+ }
66
+ }
67
+
68
+ function decodeQueryValue(value) {
69
+ return value.replace(/\\\\/g, "\\").replace(/\\'/g, "'");
70
+ }
71
+
72
+ function matches(item, query) {
73
+ if (!query) {
74
+ return true;
75
+ }
76
+
77
+ return query.split(" and ").every((part) => {
78
+ if (part === "trashed = false") {
79
+ return !item.trashed;
80
+ }
81
+
82
+ const nameMatch = part.match(/^name = '(.+)'$/);
83
+ if (nameMatch) {
84
+ return item.name === decodeQueryValue(nameMatch[1]);
85
+ }
86
+
87
+ const mimeMatch = part.match(/^mimeType = '(.+)'$/);
88
+ if (mimeMatch) {
89
+ return item.mimeType === decodeQueryValue(mimeMatch[1]);
90
+ }
91
+
92
+ const parentMatch = part.match(/^'(.+)' in parents$/);
93
+ if (parentMatch) {
94
+ return (item.parents || []).includes(parentMatch[1]);
95
+ }
96
+
97
+ return true;
98
+ });
99
+ }
100
+
101
+ async function drain(stream) {
102
+ if (!stream) {
103
+ return Buffer.alloc(0);
104
+ }
105
+
106
+ const chunks = [];
107
+ for await (const chunk of stream) {
108
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
109
+ }
110
+ return Buffer.concat(chunks);
111
+ }
112
+
113
+ function touch(item) {
114
+ item.modifiedTime = new Date(1710000000000 + sequence++).toISOString();
115
+ }
116
+
117
+ return {
118
+ files: {
119
+ async list({ q, pageSize = 1000, pageToken, orderBy }) {
120
+ const matchesQuery = [...items.values()].filter((item) => matches(item, q));
121
+ matchesQuery.sort((left, right) => {
122
+ if (orderBy === "createdTime desc") {
123
+ return Date.parse(right.createdTime) - Date.parse(left.createdTime);
124
+ }
125
+ return String(left.id).localeCompare(String(right.id));
126
+ });
127
+
128
+ const start = Number(pageToken || 0);
129
+ const slice = matchesQuery.slice(start, start + pageSize).map(clone);
130
+ const nextPageToken =
131
+ start + pageSize < matchesQuery.length ? String(start + pageSize) : undefined;
132
+
133
+ return {
134
+ data: {
135
+ files: slice,
136
+ nextPageToken,
137
+ },
138
+ };
139
+ },
140
+ async create({ requestBody, media }) {
141
+ const body = await drain(media?.body);
142
+ const id = `id-${++idCounter}`;
143
+ const createdTime = new Date(1710000000000 + sequence++).toISOString();
144
+ const item = {
145
+ id,
146
+ name: requestBody.name,
147
+ mimeType: requestBody.mimeType || "application/octet-stream",
148
+ parents: requestBody.parents || [],
149
+ createdTime,
150
+ modifiedTime: createdTime,
151
+ md5Checksum: requestBody.mimeType === FOLDER_MIME ? null : md5(body),
152
+ size: requestBody.mimeType === FOLDER_MIME ? null : body.length,
153
+ trashed: false,
154
+ };
155
+ items.set(id, item);
156
+ if (item.mimeType !== FOLDER_MIME) {
157
+ contentById.set(id, body);
158
+ }
159
+ return { data: clone(item) };
160
+ },
161
+ async update({ fileId, requestBody = {}, addParents, removeParents, media }) {
162
+ const body = await drain(media?.body);
163
+ const item = items.get(fileId);
164
+
165
+ if (!item) {
166
+ throw new Error(`Missing file: ${fileId}`);
167
+ }
168
+
169
+ if (requestBody.name) {
170
+ item.name = requestBody.name;
171
+ }
172
+
173
+ if (Object.hasOwn(requestBody, "trashed")) {
174
+ item.trashed = Boolean(requestBody.trashed);
175
+ }
176
+
177
+ if (addParents || removeParents) {
178
+ const nextParents = new Set(item.parents || []);
179
+ for (const parentId of String(removeParents || "").split(",").filter(Boolean)) {
180
+ nextParents.delete(parentId);
181
+ }
182
+ if (addParents) {
183
+ nextParents.add(addParents);
184
+ }
185
+ item.parents = [...nextParents];
186
+ }
187
+
188
+ if (body.length && item.mimeType !== FOLDER_MIME) {
189
+ item.md5Checksum = md5(body);
190
+ item.size = body.length;
191
+ contentById.set(fileId, body);
192
+ }
193
+
194
+ touch(item);
195
+ return { data: clone(item) };
196
+ },
197
+ async delete({ fileId }) {
198
+ items.delete(fileId);
199
+ contentById.delete(fileId);
200
+ return { data: {} };
201
+ },
202
+ async get({ fileId, alt }) {
203
+ if (fileId === "root") {
204
+ return {
205
+ data: {
206
+ id: "root",
207
+ name: "My Drive",
208
+ mimeType: FOLDER_MIME,
209
+ parents: [],
210
+ createdTime: "2026-01-01T00:00:00.000Z",
211
+ },
212
+ };
213
+ }
214
+
215
+ const item = items.get(fileId);
216
+ if (!item) {
217
+ throw new Error(`Missing file: ${fileId}`);
218
+ }
219
+
220
+ if (alt === "media") {
221
+ return {
222
+ data: Readable.from([contentById.get(fileId) || Buffer.alloc(0)]),
223
+ };
224
+ }
225
+
226
+ return { data: clone(item) };
227
+ },
228
+ },
229
+ };
230
+ }
231
+
232
+ function writeLocal(root, relativePath, content) {
233
+ const absolutePath = path.join(root, relativePath);
234
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
235
+ fs.writeFileSync(absolutePath, content);
236
+ }
237
+
238
+ function formatStatus(diff, staged) {
239
+ const lines = [];
240
+
241
+ if (diff.isClean && staged.length === 0) {
242
+ lines.push("Everything up to date.");
243
+ return lines;
244
+ }
245
+
246
+ if (staged.length) {
247
+ lines.push(`Staged changes (${staged.length}):`);
248
+ for (const entry of staged) {
249
+ lines.push(` ${entry.action.padStart(15, " ")} ${entry.path}`);
250
+ }
251
+ }
252
+
253
+ const stagedPaths = new Set(staged.map((e) => e.path));
254
+ const unstagedRemote = diff.remoteChanges.filter((c) => !stagedPaths.has(c.path));
255
+ const unstagedLocal = diff.localChanges.filter((c) => !stagedPaths.has(c.path));
256
+ const unstagedConflicts = diff.conflicts.filter((c) => !stagedPaths.has(c.path));
257
+
258
+ if (unstagedRemote.length) {
259
+ lines.push(`Remote changes (${unstagedRemote.length}):`);
260
+ for (const change of unstagedRemote) {
261
+ lines.push(` ${change.shortStatus} ${change.path} (${change.description})`);
262
+ }
263
+ }
264
+
265
+ if (unstagedLocal.length) {
266
+ lines.push(`Local changes (${unstagedLocal.length}):`);
267
+ for (const change of unstagedLocal) {
268
+ lines.push(` ${change.shortStatus} ${change.path} (${change.description})`);
269
+ }
270
+ }
271
+
272
+ if (unstagedConflicts.length) {
273
+ lines.push(`Conflicts (${unstagedConflicts.length}):`);
274
+ for (const change of unstagedConflicts) {
275
+ lines.push(` ${change.shortStatus} ${change.path} (${change.description})`);
276
+ }
277
+ }
278
+
279
+ return lines;
280
+ }
281
+
282
+ function formatDiff(diff) {
283
+ if (diff.isClean) {
284
+ return ["No changes detected."];
285
+ }
286
+
287
+ const lines = [];
288
+ const sections = [
289
+ ["Remote changes", diff.remoteChanges],
290
+ ["Local changes", diff.localChanges],
291
+ ["Conflicts", diff.conflicts],
292
+ ];
293
+
294
+ for (const [title, changes] of sections) {
295
+ if (!changes.length) {
296
+ continue;
297
+ }
298
+
299
+ lines.push(`${title}:`);
300
+ for (const change of changes) {
301
+ lines.push(` ${change.shortStatus} ${change.path}`);
302
+ lines.push(` ${change.description}`);
303
+ }
304
+ }
305
+
306
+ return lines;
307
+ }
308
+
309
+ async function commit(repo, message, snapshotHint) {
310
+ const result = await repo.executeStaged();
311
+ await repo.saveSnapshot(message, snapshotHint);
312
+ return result.summary;
313
+ }
314
+
315
+ function renderCommand(lines, command, output) {
316
+ if (lines.length) {
317
+ lines.push("");
318
+ }
319
+
320
+ lines.push(`$ ${command}`);
321
+ lines.push(...output);
322
+ }
323
+
324
+ export async function generateDemoTranscript({ redactWorkspace = false } = {}) {
325
+ const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "aethel-demo-"));
326
+ const visibleWorkspace = redactWorkspace ? "/tmp/aethel-demo-XXXXXX" : workspace;
327
+ resetFolderLookupCache();
328
+
329
+ try {
330
+ initWorkspace(workspace, "root", "My Drive");
331
+
332
+ const drive = createFakeDrive([
333
+ folder("fld-docs", "docs", "root", "2026-04-01T10:00:00.000Z"),
334
+ folder("fld-notes", "notes", "root", "2026-04-01T10:01:00.000Z"),
335
+ file("file-spec", "spec.txt", "fld-docs", "2026-04-01T10:02:00.000Z", "Spec v1\n"),
336
+ file("file-ideas", "ideas.txt", "fld-notes", "2026-04-01T10:03:00.000Z", "Idea v1\n"),
337
+ ]);
338
+
339
+ const repo = new Repository(workspace, { drive });
340
+
341
+ writeLocal(workspace, "docs/spec.txt", "Spec v1\n");
342
+ writeLocal(workspace, "notes/ideas.txt", "Idea v1\n");
343
+ await repo.saveSnapshot("initial sync");
344
+
345
+ await drive.files.update({
346
+ fileId: "file-spec",
347
+ media: { body: Readable.from(["Spec v2 from Drive\n"]) },
348
+ });
349
+ const designFolderId = await ensureFolder(drive, "design");
350
+ await drive.files.create({
351
+ requestBody: {
352
+ name: "roadmap.txt",
353
+ parents: [designFolderId],
354
+ },
355
+ media: { body: Readable.from(["Roadmap from Drive\n"]) },
356
+ });
357
+ writeLocal(workspace, "notes/ideas.txt", "Idea v2 from local\n");
358
+ writeLocal(workspace, "drafts/todo.txt", "Local draft\n");
359
+ repo.invalidateRemoteCache();
360
+
361
+ const lines = [
362
+ "Aethel demo",
363
+ `Workspace: ${visibleWorkspace}`,
364
+ "Backend: fake Google Drive",
365
+ "",
366
+ "Scenario:",
367
+ " Drive changed docs/spec.txt and added design/roadmap.txt",
368
+ " Local changed notes/ideas.txt and added drafts/todo.txt",
369
+ ];
370
+
371
+ let state = await repo.loadState({ useCache: false });
372
+
373
+ renderCommand(lines, "aethel status", formatStatus(state.diff, repo.getStagedEntries()));
374
+ renderCommand(lines, "aethel diff --side all", formatDiff(state.diff));
375
+
376
+ const stagedCount = repo.stageChanges(
377
+ state.diff.changes.filter((change) => change.suggestedAction !== "conflict")
378
+ );
379
+ renderCommand(lines, "aethel add --all", [`Staged ${stagedCount} change(s).`]);
380
+ renderCommand(lines, "aethel status", formatStatus(state.diff, repo.getStagedEntries()));
381
+
382
+ const summary = await commit(repo, "demo sync");
383
+ renderCommand(lines, 'aethel commit -m "demo sync"', [`Commit complete: ${summary}`]);
384
+
385
+ state = await repo.loadState({ useCache: false });
386
+ renderCommand(lines, "aethel status", formatStatus(state.diff, repo.getStagedEntries()));
387
+
388
+ lines.push("");
389
+ lines.push(`Inspect the demo workspace at: ${visibleWorkspace}`);
390
+
391
+ return { workspace, lines };
392
+ } finally {
393
+ resetFolderLookupCache();
394
+ }
395
+ }
396
+
397
+ export async function runDemo({ cleanup = false, redactWorkspace = false } = {}) {
398
+ const { workspace, lines } = await generateDemoTranscript({ redactWorkspace });
399
+ process.stdout.write(`${lines.join("\n")}\n`);
400
+
401
+ if (cleanup) {
402
+ fs.rmSync(workspace, { recursive: true, force: true });
403
+ }
404
+ }
405
+
406
+ const entryPath = fileURLToPath(import.meta.url);
407
+
408
+ if (process.argv[1] && path.resolve(process.argv[1]) === entryPath) {
409
+ const cleanup = process.argv.includes("--cleanup");
410
+ const redactWorkspace = process.argv.includes("--redact-workspace");
411
+
412
+ runDemo({ cleanup, redactWorkspace }).catch((error) => {
413
+ console.error(error?.stack || String(error));
414
+ process.exitCode = 1;
415
+ });
416
+ }
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from pathlib import Path
4
+ from subprocess import run
5
+ from PIL import Image, ImageDraw, ImageFont
6
+
7
+ ROOT = Path(__file__).resolve().parent.parent
8
+ OUTPUT = ROOT / "docs" / "demo.gif"
9
+ DISPLAY_WIDTH = 1320
10
+ DISPLAY_MIN_HEIGHT = 760
11
+ LINE_HEIGHT = 30
12
+ SCALE = 2
13
+
14
+
15
+ def load_lines():
16
+ result = run(
17
+ ["node", "scripts/demo.js", "--cleanup", "--redact-workspace"],
18
+ cwd=ROOT,
19
+ capture_output=True,
20
+ text=True,
21
+ check=True,
22
+ )
23
+ return result.stdout.rstrip("\n").splitlines()
24
+
25
+
26
+ def load_font(size):
27
+ for candidate in [
28
+ "/System/Library/Fonts/Supplemental/Times New Roman.ttf",
29
+ "/System/Library/Fonts/Supplemental/Times New Roman Bold.ttf",
30
+ ]:
31
+ path = Path(candidate)
32
+ if path.exists():
33
+ return ImageFont.truetype(str(path), size)
34
+ return ImageFont.load_default()
35
+
36
+
37
+ def load_title_font(size):
38
+ for candidate in [
39
+ "/System/Library/Fonts/Supplemental/Times New Roman Bold.ttf",
40
+ "/System/Library/Fonts/Supplemental/Times New Roman.ttf",
41
+ ]:
42
+ path = Path(candidate)
43
+ if path.exists():
44
+ return ImageFont.truetype(str(path), size)
45
+ return ImageFont.load_default()
46
+
47
+
48
+ def build_frame(lines, font, title_font, visible):
49
+ shown = lines[:visible]
50
+ width = DISPLAY_WIDTH * SCALE
51
+ height = max(DISPLAY_MIN_HEIGHT, 180 + len(lines) * LINE_HEIGHT) * SCALE
52
+ image = Image.new("RGB", (width, height), "#0b1324")
53
+ draw = ImageDraw.Draw(image)
54
+ draw.rounded_rectangle((40, 40, width - 40, height - 40), 44, fill="#0a0f1a", outline="#243044", width=4)
55
+ draw.rounded_rectangle((40, 40, width - 40, 108), 44, fill="#10192a")
56
+ draw.ellipse((84, 62, 108, 86), fill="#fb7185")
57
+ draw.ellipse((124, 62, 148, 86), fill="#f59e0b")
58
+ draw.ellipse((164, 62, 188, 86), fill="#22c55e")
59
+ draw.text((236, 52), "Aethel demo", font=title_font, fill="#dbe4f0")
60
+ y = 176
61
+ for line in shown:
62
+ draw.text((104, y), line, font=font, fill="#dbe4f0")
63
+ y += LINE_HEIGHT * SCALE
64
+ if visible < len(lines):
65
+ draw.rounded_rectangle((104, y + 8, 134, y + 48), 6, fill="#38bdf8")
66
+ return image.resize((DISPLAY_WIDTH, height // SCALE), Image.Resampling.LANCZOS)
67
+
68
+
69
+ def main():
70
+ lines = load_lines()
71
+ font = load_font(24 * SCALE)
72
+ title_font = load_title_font(22 * SCALE)
73
+ checkpoints = [3, 7, 13, 21, 27, len(lines)]
74
+ frames = [build_frame(lines, font, title_font, count) for count in checkpoints]
75
+ durations = [500, 600, 700, 800, 900, 1800]
76
+ OUTPUT.parent.mkdir(parents=True, exist_ok=True)
77
+ frames[0].save(
78
+ OUTPUT,
79
+ save_all=True,
80
+ append_images=frames[1:],
81
+ duration=durations,
82
+ loop=0,
83
+ optimize=False,
84
+ disposal=2,
85
+ )
86
+ print(OUTPUT)
87
+
88
+
89
+ if __name__ == "__main__":
90
+ main()
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { generateDemoTranscript } from "./demo.js";
7
+
8
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
9
+ const outputPath = path.join(root, "docs", "demo-screenshot.svg");
10
+
11
+ function escapeXml(value) {
12
+ return value
13
+ .replaceAll("&", "&amp;")
14
+ .replaceAll("<", "&lt;")
15
+ .replaceAll(">", "&gt;")
16
+ .replaceAll('"', "&quot;");
17
+ }
18
+
19
+ const { workspace, lines } = await generateDemoTranscript({ redactWorkspace: true });
20
+
21
+ try {
22
+ const longest = lines.reduce((max, line) => Math.max(max, line.length), 0);
23
+ const charWidth = 12.4;
24
+ const lineHeight = 32;
25
+ const width = Math.max(1120, Math.ceil(longest * charWidth) + 160);
26
+ const height = lines.length * lineHeight + 168;
27
+ const text = lines
28
+ .map(
29
+ (line, index) =>
30
+ `<text x="64" y="${100 + index * lineHeight}" xml:space="preserve">${escapeXml(line)}</text>`
31
+ )
32
+ .join("\n");
33
+
34
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="Aethel demo screenshot">
35
+ <defs>
36
+ <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
37
+ <stop offset="0%" stop-color="#0b1324"/>
38
+ <stop offset="55%" stop-color="#111827"/>
39
+ <stop offset="100%" stop-color="#1f2937"/>
40
+ </linearGradient>
41
+ <linearGradient id="glow" x1="0" y1="0" x2="1" y2="0">
42
+ <stop offset="0%" stop-color="#22c55e"/>
43
+ <stop offset="50%" stop-color="#38bdf8"/>
44
+ <stop offset="100%" stop-color="#f59e0b"/>
45
+ </linearGradient>
46
+ </defs>
47
+ <rect width="${width}" height="${height}" rx="28" fill="url(#bg)"/>
48
+ <rect x="24" y="24" width="${width - 48}" height="${height - 48}" rx="20" fill="#0a0f1a" stroke="#243044"/>
49
+ <rect x="24" y="24" width="${width - 48}" height="36" rx="20" fill="#10192a"/>
50
+ <circle cx="52" cy="42" r="7" fill="#fb7185"/>
51
+ <circle cx="74" cy="42" r="7" fill="#f59e0b"/>
52
+ <circle cx="96" cy="42" r="7" fill="#22c55e"/>
53
+ <rect x="132" y="35" width="${Math.min(320, width - 220)}" height="12" rx="6" fill="url(#glow)" opacity="0.8"/>
54
+ <g fill="#dbe4f0" font-family="'Times New Roman', Times, serif" font-size="22">
55
+ ${text}
56
+ </g>
57
+ </svg>
58
+ `;
59
+
60
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
61
+ fs.writeFileSync(outputPath, svg);
62
+ process.stdout.write(`${outputPath}\n`);
63
+ } finally {
64
+ fs.rmSync(workspace, { recursive: true, force: true });
65
+ }
package/src/cli.js CHANGED
@@ -289,7 +289,9 @@ async function handleStatus(options) {
289
289
  const { diff } = await loadStateWithProgress(repo);
290
290
  const staged = repo.getStagedEntries();
291
291
 
292
- if (diff.isClean && staged.length === 0) {
292
+ const hasPackChanges = diff.hasPackChanges || (options.verbose && diff.syncedPacks?.length > 0);
293
+
294
+ if (diff.isClean && staged.length === 0 && !hasPackChanges) {
293
295
  console.log("Everything up to date.");
294
296
  return;
295
297
  }
@@ -301,23 +303,54 @@ async function handleStatus(options) {
301
303
  }
302
304
  }
303
305
 
304
- if (diff.remoteChanges.length) {
305
- console.log(`\nRemote changes (${diff.remoteChanges.length}):`);
306
- for (const change of diff.remoteChanges) {
306
+ const stagedPaths = new Set(staged.map((e) => e.path));
307
+ const unstagedRemote = diff.remoteChanges.filter((c) => !stagedPaths.has(c.path));
308
+ const unstagedLocal = diff.localChanges.filter((c) => !stagedPaths.has(c.path));
309
+ const unstagedConflicts = diff.conflicts.filter((c) => !stagedPaths.has(c.path));
310
+
311
+ if (unstagedRemote.length) {
312
+ console.log(`\nRemote changes (${unstagedRemote.length}):`);
313
+ for (const change of unstagedRemote) {
307
314
  console.log(` ${change.shortStatus} ${change.path} (${change.description})`);
308
315
  }
309
316
  }
310
317
 
311
- if (diff.localChanges.length) {
312
- console.log(`\nLocal changes (${diff.localChanges.length}):`);
313
- for (const change of diff.localChanges) {
318
+ if (unstagedLocal.length) {
319
+ console.log(`\nLocal changes (${unstagedLocal.length}):`);
320
+ for (const change of unstagedLocal) {
314
321
  console.log(` ${change.shortStatus} ${change.path} (${change.description})`);
315
322
  }
316
323
  }
317
324
 
318
- if (diff.conflicts.length) {
319
- console.log(`\nConflicts (${diff.conflicts.length}):`);
320
- for (const change of diff.conflicts) {
325
+ if (unstagedConflicts.length) {
326
+ console.log(`\nConflicts (${unstagedConflicts.length}):`);
327
+ for (const change of unstagedConflicts) {
328
+ console.log(` ${change.shortStatus} ${change.path} (${change.description})`);
329
+ }
330
+ }
331
+
332
+ // Display pack changes
333
+ const pendingPacks = diff.pendingPackChanges || [];
334
+ const packConflicts = diff.packConflicts || [];
335
+ const syncedPacks = diff.syncedPacks || [];
336
+
337
+ if (pendingPacks.length) {
338
+ console.log(`\nPack changes (${pendingPacks.length}):`);
339
+ for (const change of pendingPacks) {
340
+ console.log(` ${change.shortStatus} ${change.path} (${change.description})`);
341
+ }
342
+ }
343
+
344
+ if (packConflicts.length) {
345
+ console.log(`\nPack conflicts (${packConflicts.length}):`);
346
+ for (const change of packConflicts) {
347
+ console.log(` ${change.shortStatus} ${change.path} (${change.description})`);
348
+ }
349
+ }
350
+
351
+ if (options.verbose && syncedPacks.length) {
352
+ console.log(`\nSynced packs (${syncedPacks.length}):`);
353
+ for (const change of syncedPacks) {
321
354
  console.log(` ${change.shortStatus} ${change.path} (${change.description})`);
322
355
  }
323
356
  }
@@ -1076,9 +1109,10 @@ async function main() {
1076
1109
  .option("--drive-folder-name <name>", "Display name for the Drive folder")
1077
1110
  ).action(handleInit);
1078
1111
 
1079
- addAuthOptions(program.command("status").description("Show sync status")).action(
1080
- handleStatus
1081
- );
1112
+ addAuthOptions(
1113
+ program.command("status").description("Show sync status")
1114
+ .option("-v, --verbose", "Show all pack states including synced")
1115
+ ).action(handleStatus);
1082
1116
 
1083
1117
  addAuthOptions(
1084
1118
  program