keep-skill 0.1.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

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.
keep/cli.py CHANGED
@@ -16,24 +16,140 @@ from typing import Optional
16
16
  import typer
17
17
  from typing_extensions import Annotated
18
18
 
19
- from .api import Keeper
19
+ from .api import Keeper, _text_content_id
20
+ from .document_store import VersionInfo
20
21
  from .types import Item
21
- from .logging_config import configure_quiet_mode
22
+ from .logging_config import configure_quiet_mode, enable_debug_mode
22
23
 
23
24
 
24
25
  # Configure quiet mode by default (suppress verbose library output)
25
- # Set KEEP_VERBOSE=1 to enable verbose mode for debugging
26
- _verbose = sys.argv and "--verbose" in sys.argv or os.environ.get("KEEP_VERBOSE") == "1"
27
- configure_quiet_mode(quiet=not _verbose)
26
+ # Set KEEP_VERBOSE=1 to enable debug mode via environment
27
+ if os.environ.get("KEEP_VERBOSE") == "1":
28
+ enable_debug_mode()
29
+ else:
30
+ configure_quiet_mode(quiet=True)
31
+
32
+
33
+ def _verbose_callback(value: bool):
34
+ if value:
35
+ enable_debug_mode()
36
+
37
+
38
+ # Global state for CLI options
39
+ _json_output = False
40
+ _ids_output = False
41
+
42
+
43
+ def _json_callback(value: bool):
44
+ global _json_output
45
+ _json_output = value
46
+
47
+
48
+ def _get_json_output() -> bool:
49
+ return _json_output
50
+
51
+
52
+ def _ids_callback(value: bool):
53
+ global _ids_output
54
+ _ids_output = value
55
+
56
+
57
+ def _get_ids_output() -> bool:
58
+ return _ids_output
28
59
 
29
60
 
30
61
  app = typer.Typer(
31
62
  name="keep",
32
63
  help="Associative memory with semantic search.",
33
- no_args_is_help=True,
64
+ no_args_is_help=False,
65
+ invoke_without_command=True,
34
66
  )
35
67
 
36
68
 
69
+ def _format_yaml_frontmatter(
70
+ item: Item,
71
+ version_nav: Optional[dict[str, list[VersionInfo]]] = None,
72
+ viewing_version: Optional[int] = None,
73
+ ) -> str:
74
+ """
75
+ Format item as YAML frontmatter with summary as content.
76
+
77
+ Args:
78
+ item: The item to format
79
+ version_nav: Optional version navigation info (prev/next lists)
80
+ viewing_version: If viewing an old version, the version number
81
+ """
82
+ lines = ["---", f"id: {item.id}"]
83
+ if viewing_version is not None:
84
+ lines.append(f"version: {viewing_version}")
85
+ if item.tags:
86
+ lines.append("tags:")
87
+ for k, v in sorted(item.tags.items()):
88
+ lines.append(f" {k}: {v}")
89
+ if item.score is not None:
90
+ lines.append(f"score: {item.score:.3f}")
91
+
92
+ # Add version navigation if available
93
+ if version_nav:
94
+ if version_nav.get("prev"):
95
+ lines.append("prev:")
96
+ for v in version_nav["prev"]:
97
+ # Show version number, date portion, and truncated summary
98
+ date_part = v.created_at[:10] if v.created_at else "unknown"
99
+ summary_preview = v.summary[:40].replace("\n", " ")
100
+ if len(v.summary) > 40:
101
+ summary_preview += "..."
102
+ lines.append(f" - {v.version}: {date_part} {summary_preview}")
103
+ if version_nav.get("next"):
104
+ lines.append("next:")
105
+ for v in version_nav["next"]:
106
+ date_part = v.created_at[:10] if v.created_at else "unknown"
107
+ summary_preview = v.summary[:40].replace("\n", " ")
108
+ if len(v.summary) > 40:
109
+ summary_preview += "..."
110
+ lines.append(f" - {v.version}: {date_part} {summary_preview}")
111
+ elif viewing_version is not None:
112
+ # Viewing old version and next is empty means current is next
113
+ lines.append("next:")
114
+ lines.append(" - current")
115
+
116
+ lines.append("---")
117
+ lines.append(item.summary) # Summary IS the content
118
+ return "\n".join(lines)
119
+
120
+
121
+ @app.callback(invoke_without_command=True)
122
+ def main_callback(
123
+ ctx: typer.Context,
124
+ verbose: Annotated[bool, typer.Option(
125
+ "--verbose", "-v",
126
+ help="Enable debug-level logging to stderr",
127
+ callback=_verbose_callback,
128
+ is_eager=True,
129
+ )] = False,
130
+ output_json: Annotated[bool, typer.Option(
131
+ "--json", "-j",
132
+ help="Output as JSON",
133
+ callback=_json_callback,
134
+ is_eager=True,
135
+ )] = False,
136
+ ids_only: Annotated[bool, typer.Option(
137
+ "--ids", "-I",
138
+ help="Output only IDs (for piping to xargs)",
139
+ callback=_ids_callback,
140
+ is_eager=True,
141
+ )] = False,
142
+ ):
143
+ """Associative memory with semantic search."""
144
+ # If no subcommand provided, show the current context (now)
145
+ if ctx.invoked_subcommand is None:
146
+ kp = _get_keeper(None, "default")
147
+ item = kp.get_now()
148
+ typer.echo(_format_item(item, as_json=_get_json_output()))
149
+ if not _get_json_output():
150
+ typer.echo("\nUse --help for commands.", err=True)
151
+
152
+
37
153
  # -----------------------------------------------------------------------------
38
154
  # Common Options
39
155
  # -----------------------------------------------------------------------------
@@ -63,11 +179,12 @@ LimitOption = Annotated[
63
179
  )
64
180
  ]
65
181
 
66
- JsonOption = Annotated[
67
- bool,
182
+
183
+ SinceOption = Annotated[
184
+ Optional[str],
68
185
  typer.Option(
69
- "--json", "-j",
70
- help="Output as JSON"
186
+ "--since",
187
+ help="Only items updated since (ISO duration: P3D, P1W, PT1H; or date: 2026-01-15)"
71
188
  )
72
189
  ]
73
190
 
@@ -76,20 +193,47 @@ JsonOption = Annotated[
76
193
  # Output Helpers
77
194
  # -----------------------------------------------------------------------------
78
195
 
79
- def _format_item(item: Item, as_json: bool = False) -> str:
196
+ def _format_item(
197
+ item: Item,
198
+ as_json: bool = False,
199
+ version_nav: Optional[dict[str, list[VersionInfo]]] = None,
200
+ viewing_version: Optional[int] = None,
201
+ ) -> str:
202
+ """
203
+ Format an item for display.
204
+
205
+ Text format: YAML frontmatter (matches docs/system format)
206
+ With --ids: just the ID (for piping)
207
+ """
208
+ if _get_ids_output():
209
+ return json.dumps(item.id) if as_json else item.id
210
+
80
211
  if as_json:
81
- return json.dumps({
212
+ result = {
82
213
  "id": item.id,
83
214
  "summary": item.summary,
84
215
  "tags": item.tags,
85
216
  "score": item.score,
86
- })
87
- else:
88
- score = f"[{item.score:.3f}] " if item.score is not None else ""
89
- return f"{score}{item.id}\n {item.summary}"
217
+ }
218
+ if viewing_version is not None:
219
+ result["version"] = viewing_version
220
+ if version_nav:
221
+ result["version_nav"] = {
222
+ k: [{"version": v.version, "created_at": v.created_at, "summary": v.summary[:60]}
223
+ for v in versions]
224
+ for k, versions in version_nav.items()
225
+ }
226
+ return json.dumps(result)
227
+
228
+ return _format_yaml_frontmatter(item, version_nav, viewing_version)
90
229
 
91
230
 
92
231
  def _format_items(items: list[Item], as_json: bool = False) -> str:
232
+ """Format multiple items for display."""
233
+ if _get_ids_output():
234
+ ids = [item.id for item in items]
235
+ return json.dumps(ids) if as_json else "\n".join(ids)
236
+
93
237
  if as_json:
94
238
  return json.dumps([
95
239
  {
@@ -116,172 +260,548 @@ def _get_keeper(store: Optional[Path], collection: str) -> Keeper:
116
260
  raise typer.Exit(1)
117
261
 
118
262
 
263
+ def _parse_tags(tags: Optional[list[str]]) -> dict[str, str]:
264
+ """Parse key=value tag list to dict."""
265
+ if not tags:
266
+ return {}
267
+ parsed = {}
268
+ for tag in tags:
269
+ if "=" not in tag:
270
+ typer.echo(f"Error: Invalid tag format '{tag}'. Use key=value", err=True)
271
+ raise typer.Exit(1)
272
+ k, v = tag.split("=", 1)
273
+ parsed[k] = v
274
+ return parsed
275
+
276
+
277
+ def _timestamp() -> str:
278
+ """Generate timestamp for auto-generated IDs."""
279
+ from datetime import datetime, timezone
280
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")
281
+
282
+
283
+ def _parse_frontmatter(text: str) -> tuple[str, dict[str, str]]:
284
+ """Parse YAML frontmatter from text, return (content, tags)."""
285
+ if text.startswith("---"):
286
+ parts = text.split("---", 2)
287
+ if len(parts) >= 3:
288
+ import yaml
289
+ frontmatter = yaml.safe_load(parts[1])
290
+ content = parts[2].lstrip("\n")
291
+ tags = frontmatter.get("tags", {}) if frontmatter else {}
292
+ return content, {k: str(v) for k, v in tags.items()}
293
+ return text, {}
294
+
295
+
119
296
  # -----------------------------------------------------------------------------
120
297
  # Commands
121
298
  # -----------------------------------------------------------------------------
122
299
 
123
300
  @app.command()
124
301
  def find(
125
- query: Annotated[str, typer.Argument(help="Search query text")],
302
+ query: Annotated[Optional[str], typer.Argument(help="Search query text")] = None,
303
+ id: Annotated[Optional[str], typer.Option(
304
+ "--id",
305
+ help="Find items similar to this ID (instead of text search)"
306
+ )] = None,
307
+ include_self: Annotated[bool, typer.Option(
308
+ help="Include the queried item (only with --id)"
309
+ )] = False,
126
310
  store: StoreOption = None,
127
311
  collection: CollectionOption = "default",
128
312
  limit: LimitOption = 10,
129
- output_json: JsonOption = False,
313
+ since: SinceOption = None,
130
314
  ):
131
315
  """
132
316
  Find items using semantic similarity search.
317
+
318
+ Examples:
319
+ keep find "authentication" # Search by text
320
+ keep find --id file:///path/to/doc.md # Find similar to item
133
321
  """
322
+ if id and query:
323
+ typer.echo("Error: Specify either a query or --id, not both", err=True)
324
+ raise typer.Exit(1)
325
+ if not id and not query:
326
+ typer.echo("Error: Specify a query or --id", err=True)
327
+ raise typer.Exit(1)
328
+
134
329
  kp = _get_keeper(store, collection)
135
- results = kp.find(query, limit=limit)
136
- typer.echo(_format_items(results, as_json=output_json))
330
+
331
+ if id:
332
+ results = kp.find_similar(id, limit=limit, since=since, include_self=include_self)
333
+ else:
334
+ results = kp.find(query, limit=limit, since=since)
335
+
336
+ typer.echo(_format_items(results, as_json=_get_json_output()))
137
337
 
138
338
 
139
339
  @app.command()
140
- def similar(
141
- id: Annotated[str, typer.Argument(help="URI of item to find similar items for")],
340
+ def search(
341
+ query: Annotated[str, typer.Argument(help="Full-text search query")],
142
342
  store: StoreOption = None,
143
343
  collection: CollectionOption = "default",
144
344
  limit: LimitOption = 10,
145
- include_self: Annotated[bool, typer.Option(help="Include the queried item")] = False,
146
- output_json: JsonOption = False,
345
+ since: SinceOption = None,
147
346
  ):
148
347
  """
149
- Find items similar to an existing item.
348
+ Search item summaries using full-text search.
150
349
  """
151
350
  kp = _get_keeper(store, collection)
152
- results = kp.find_similar(id, limit=limit, include_self=include_self)
153
- typer.echo(_format_items(results, as_json=output_json))
351
+ results = kp.query_fulltext(query, limit=limit, since=since)
352
+ typer.echo(_format_items(results, as_json=_get_json_output()))
154
353
 
155
354
 
156
- @app.command()
157
- def search(
158
- query: Annotated[str, typer.Argument(help="Full-text search query")],
355
+ @app.command("list")
356
+ def list_recent(
159
357
  store: StoreOption = None,
160
358
  collection: CollectionOption = "default",
161
- limit: LimitOption = 10,
162
- output_json: JsonOption = False,
359
+ limit: Annotated[int, typer.Option(
360
+ "--limit", "-n",
361
+ help="Number of items to show"
362
+ )] = 10,
163
363
  ):
164
364
  """
165
- Search item summaries using full-text search.
365
+ List recent items by update time.
366
+
367
+ Shows the most recently updated items, newest first.
166
368
  """
167
369
  kp = _get_keeper(store, collection)
168
- results = kp.query_fulltext(query, limit=limit)
169
- typer.echo(_format_items(results, as_json=output_json))
370
+ results = kp.list_recent(limit=limit)
371
+ typer.echo(_format_items(results, as_json=_get_json_output()))
170
372
 
171
373
 
172
374
  @app.command()
173
375
  def tag(
174
- key: Annotated[str, typer.Argument(help="Tag key to search for")],
175
- value: Annotated[Optional[str], typer.Argument(help="Tag value (optional)")] = None,
376
+ query: Annotated[Optional[str], typer.Argument(
377
+ help="Tag key to list values, or key=value to find docs"
378
+ )] = None,
379
+ list_keys: Annotated[bool, typer.Option(
380
+ "--list", "-l",
381
+ help="List all distinct tag keys"
382
+ )] = False,
176
383
  store: StoreOption = None,
177
384
  collection: CollectionOption = "default",
178
385
  limit: LimitOption = 100,
179
- output_json: JsonOption = False,
386
+ since: SinceOption = None,
180
387
  ):
181
388
  """
182
- Find items by tag.
389
+ List tag values or find items by tag.
390
+
391
+ Examples:
392
+ keep tag --list # List all tag keys
393
+ keep tag project # List values for 'project' tag
394
+ keep tag project=myapp # Find docs with project=myapp
183
395
  """
184
396
  kp = _get_keeper(store, collection)
185
- results = kp.query_tag(key, value, limit=limit)
186
- typer.echo(_format_items(results, as_json=output_json))
187
397
 
398
+ # List all keys mode
399
+ if list_keys or query is None:
400
+ tags = kp.list_tags(None, collection=collection)
401
+ if _get_json_output():
402
+ typer.echo(json.dumps(tags))
403
+ else:
404
+ if not tags:
405
+ typer.echo("No tags found.")
406
+ else:
407
+ for t in tags:
408
+ typer.echo(t)
409
+ return
188
410
 
189
- @app.command()
190
- def update(
191
- id: Annotated[str, typer.Argument(help="URI of document to index")],
192
- store: StoreOption = None,
193
- collection: CollectionOption = "default",
411
+ # Check if query is key=value or just key
412
+ if "=" in query:
413
+ # key=value find documents
414
+ key, value = query.split("=", 1)
415
+ results = kp.query_tag(key, value, limit=limit, since=since)
416
+ typer.echo(_format_items(results, as_json=_get_json_output()))
417
+ else:
418
+ # key only → list values
419
+ values = kp.list_tags(query, collection=collection)
420
+ if _get_json_output():
421
+ typer.echo(json.dumps(values))
422
+ else:
423
+ if not values:
424
+ typer.echo(f"No values for tag '{query}'.")
425
+ else:
426
+ for v in values:
427
+ typer.echo(v)
428
+
429
+
430
+ @app.command("tag-update")
431
+ def tag_update(
432
+ ids: Annotated[list[str], typer.Argument(help="Document IDs to tag")],
194
433
  tags: Annotated[Optional[list[str]], typer.Option(
195
434
  "--tag", "-t",
196
- help="Source tag as key=value (can be repeated)"
435
+ help="Tag as key=value (empty value removes: key=)"
197
436
  )] = None,
198
- lazy: Annotated[bool, typer.Option(
199
- "--lazy", "-l",
200
- help="Fast mode: use truncated summary, queue for later processing"
201
- )] = False,
202
- output_json: JsonOption = False,
437
+ remove: Annotated[Optional[list[str]], typer.Option(
438
+ "--remove", "-r",
439
+ help="Tag keys to remove"
440
+ )] = None,
441
+ store: StoreOption = None,
442
+ collection: CollectionOption = "default",
203
443
  ):
204
444
  """
205
- Add or update a document in the store.
445
+ Add, update, or remove tags on existing documents.
206
446
 
207
- Use --lazy for fast indexing when summarization is slow.
208
- Run 'keep process-pending' later to generate real summaries.
447
+ Does not re-process the document - only updates tags.
448
+
449
+ Examples:
450
+ keep tag-update doc:1 --tag project=myapp
451
+ keep tag-update doc:1 doc:2 --tag status=reviewed
452
+ keep tag-update doc:1 --remove obsolete_tag
453
+ keep tag-update doc:1 --tag temp= # Remove via empty value
209
454
  """
210
455
  kp = _get_keeper(store, collection)
211
456
 
212
457
  # Parse tags from key=value format
213
- source_tags = {}
458
+ tag_changes: dict[str, str] = {}
214
459
  if tags:
215
460
  for tag in tags:
216
461
  if "=" not in tag:
217
- typer.echo(f"Error: Invalid tag format '{tag}'. Use key=value", err=True)
462
+ typer.echo(f"Error: Invalid tag format '{tag}'. Use key=value (or key= to remove)", err=True)
218
463
  raise typer.Exit(1)
219
464
  k, v = tag.split("=", 1)
220
- source_tags[k] = v
465
+ tag_changes[k] = v # Empty v means delete
466
+
467
+ # Add explicit removals as empty strings
468
+ if remove:
469
+ for key in remove:
470
+ tag_changes[key] = ""
471
+
472
+ if not tag_changes:
473
+ typer.echo("Error: Specify at least one --tag or --remove", err=True)
474
+ raise typer.Exit(1)
221
475
 
222
- item = kp.update(id, source_tags=source_tags or None, lazy=lazy)
223
- typer.echo(_format_item(item, as_json=output_json))
476
+ # Process each document
477
+ results = []
478
+ for doc_id in ids:
479
+ item = kp.tag(doc_id, tags=tag_changes, collection=collection)
480
+ if item is None:
481
+ typer.echo(f"Not found: {doc_id}", err=True)
482
+ else:
483
+ results.append(item)
484
+
485
+ if _get_json_output():
486
+ typer.echo(_format_items(results, as_json=True))
487
+ else:
488
+ for item in results:
489
+ typer.echo(_format_item(item, as_json=False))
224
490
 
225
491
 
226
492
  @app.command()
227
- def remember(
228
- content: Annotated[str, typer.Argument(help="Content to remember")],
229
- store: StoreOption = None,
230
- collection: CollectionOption = "default",
493
+ def update(
494
+ source: Annotated[Optional[str], typer.Argument(
495
+ help="URI to fetch, text content, or '-' for stdin"
496
+ )] = None,
231
497
  id: Annotated[Optional[str], typer.Option(
232
498
  "--id", "-i",
233
- help="Custom identifier (default: auto-generated)"
499
+ help="Document ID (auto-generated for text/stdin modes)"
234
500
  )] = None,
501
+ store: StoreOption = None,
502
+ collection: CollectionOption = "default",
235
503
  tags: Annotated[Optional[list[str]], typer.Option(
236
504
  "--tag", "-t",
237
- help="Source tag as key=value (can be repeated)"
505
+ help="Tag as key=value (can be repeated)"
506
+ )] = None,
507
+ summary: Annotated[Optional[str], typer.Option(
508
+ "--summary",
509
+ help="User-provided summary (skips auto-summarization)"
238
510
  )] = None,
239
511
  lazy: Annotated[bool, typer.Option(
240
- "--lazy", "-l",
512
+ "--lazy",
241
513
  help="Fast mode: use truncated summary, queue for later processing"
242
514
  )] = False,
243
- output_json: JsonOption = False,
244
515
  ):
245
516
  """
246
- Remember inline content (conversations, notes, insights).
517
+ Add or update a document in the store.
518
+
519
+ Three input modes (auto-detected):
520
+ keep update file:///path # URI mode: has ://
521
+ keep update "my note" # Text mode: content-addressed ID
522
+ keep update - # Stdin mode: explicit -
523
+ echo "pipe" | keep update # Stdin mode: piped input
247
524
 
248
- Use --lazy for fast indexing when summarization is slow.
249
- Run 'keep process-pending' later to generate real summaries.
525
+ Text mode uses content-addressed IDs for versioning:
526
+ keep update "my note" # Creates _text:{hash}
527
+ keep update "my note" -t done # Same ID, new version (tag change)
528
+ keep update "different note" # Different ID (new doc)
250
529
  """
251
530
  kp = _get_keeper(store, collection)
531
+ parsed_tags = _parse_tags(tags)
532
+
533
+ # Determine mode based on source content
534
+ if source == "-" or (source is None and not sys.stdin.isatty()):
535
+ # Stdin mode: explicit '-' or piped input
536
+ content = sys.stdin.read()
537
+ content, frontmatter_tags = _parse_frontmatter(content)
538
+ parsed_tags = {**frontmatter_tags, **parsed_tags} # CLI tags override
539
+ # Use content-addressed ID for stdin text (enables versioning)
540
+ doc_id = id or _text_content_id(content)
541
+ item = kp.remember(content, id=doc_id, summary=summary, tags=parsed_tags or None, lazy=lazy)
542
+ elif source and "://" in source:
543
+ # URI mode: fetch from URI (ID is the URI itself)
544
+ item = kp.update(source, tags=parsed_tags or None, summary=summary, lazy=lazy)
545
+ elif source:
546
+ # Text mode: inline content (no :// in source)
547
+ # Use content-addressed ID for text (enables versioning)
548
+ doc_id = id or _text_content_id(source)
549
+ item = kp.remember(source, id=doc_id, summary=summary, tags=parsed_tags or None, lazy=lazy)
550
+ else:
551
+ typer.echo("Error: Provide content, URI, or '-' for stdin", err=True)
552
+ raise typer.Exit(1)
252
553
 
253
- # Parse tags from key=value format
254
- source_tags = {}
255
- if tags:
256
- for tag in tags:
257
- if "=" not in tag:
258
- typer.echo(f"Error: Invalid tag format '{tag}'. Use key=value", err=True)
259
- raise typer.Exit(1)
260
- k, v = tag.split("=", 1)
261
- source_tags[k] = v
554
+ typer.echo(_format_item(item, as_json=_get_json_output()))
555
+
556
+
557
+ @app.command()
558
+ def now(
559
+ content: Annotated[Optional[str], typer.Argument(
560
+ help="Content to set (omit to show current)"
561
+ )] = None,
562
+ file: Annotated[Optional[Path], typer.Option(
563
+ "--file", "-f",
564
+ help="Read content from file"
565
+ )] = None,
566
+ reset: Annotated[bool, typer.Option(
567
+ "--reset",
568
+ help="Reset to default from system"
569
+ )] = False,
570
+ version: Annotated[Optional[int], typer.Option(
571
+ "--version", "-V",
572
+ help="Get specific version (0=current, 1=previous, etc.)"
573
+ )] = None,
574
+ history: Annotated[bool, typer.Option(
575
+ "--history", "-H",
576
+ help="List all versions"
577
+ )] = False,
578
+ store: StoreOption = None,
579
+ collection: CollectionOption = "default",
580
+ tags: Annotated[Optional[list[str]], typer.Option(
581
+ "--tag", "-t",
582
+ help="Tag as key=value (can be repeated)"
583
+ )] = None,
584
+ ):
585
+ """
586
+ Get or set the current working context.
587
+
588
+ With no arguments, displays the current context.
589
+ With content, replaces it.
590
+
591
+ Examples:
592
+ keep now # Show current context
593
+ keep now "What's important now" # Update context
594
+ keep now -f context.md # Read content from file
595
+ keep now --reset # Reset to default from system
596
+ keep now -V 1 # Previous version
597
+ keep now --history # List all versions
598
+ """
599
+ from .api import NOWDOC_ID
262
600
 
263
- item = kp.remember(content, id=id, source_tags=source_tags or None, lazy=lazy)
264
- typer.echo(_format_item(item, as_json=output_json))
601
+ kp = _get_keeper(store, collection)
602
+
603
+ # Handle history listing
604
+ if history:
605
+ versions = kp.list_versions(NOWDOC_ID, limit=50, collection=collection)
606
+ current = kp.get(NOWDOC_ID, collection=collection)
607
+
608
+ if _get_json_output():
609
+ result = {
610
+ "id": NOWDOC_ID,
611
+ "current": {
612
+ "summary": current.summary if current else None,
613
+ } if current else None,
614
+ "versions": [
615
+ {
616
+ "version": v.version,
617
+ "summary": v.summary[:60],
618
+ "created_at": v.created_at,
619
+ }
620
+ for v in versions
621
+ ],
622
+ }
623
+ typer.echo(json.dumps(result, indent=2))
624
+ else:
625
+ if current:
626
+ summary_preview = current.summary[:60].replace("\n", " ")
627
+ typer.echo(f"Current: {summary_preview}...")
628
+ if versions:
629
+ typer.echo(f"\nVersion history ({len(versions)} archived):")
630
+ for v in versions:
631
+ date_part = v.created_at[:10] if v.created_at else "unknown"
632
+ summary_preview = v.summary[:50].replace("\n", " ")
633
+ if len(v.summary) > 50:
634
+ summary_preview += "..."
635
+ typer.echo(f" v{v.version} ({date_part}): {summary_preview}")
636
+ else:
637
+ typer.echo("No version history.")
638
+ return
639
+
640
+ # Handle version retrieval
641
+ if version is not None:
642
+ offset = version
643
+ if offset == 0:
644
+ item = kp.get_now()
645
+ viewing_version = None
646
+ else:
647
+ item = kp.get_version(NOWDOC_ID, offset, collection=collection)
648
+ versions = kp.list_versions(NOWDOC_ID, limit=1, collection=collection)
649
+ if versions:
650
+ viewing_version = versions[0].version - (offset - 1)
651
+ else:
652
+ viewing_version = None
653
+
654
+ if item is None:
655
+ typer.echo(f"Version not found (offset {offset})", err=True)
656
+ raise typer.Exit(1)
657
+
658
+ version_nav = kp.get_version_nav(NOWDOC_ID, viewing_version, collection=collection)
659
+ typer.echo(_format_item(
660
+ item,
661
+ as_json=_get_json_output(),
662
+ version_nav=version_nav,
663
+ viewing_version=viewing_version,
664
+ ))
665
+ return
666
+
667
+ # Determine if we're getting or setting
668
+ setting = content is not None or file is not None or reset
669
+
670
+ if setting:
671
+ if reset:
672
+ # Reset to default from system (delete first to clear old tags)
673
+ from .api import _load_frontmatter, SYSTEM_DOC_DIR
674
+ kp.delete(NOWDOC_ID)
675
+ try:
676
+ new_content, default_tags = _load_frontmatter(SYSTEM_DOC_DIR / "now.md")
677
+ parsed_tags = default_tags
678
+ except FileNotFoundError:
679
+ typer.echo("Error: Builtin now.md not found", err=True)
680
+ raise typer.Exit(1)
681
+ elif file is not None:
682
+ if not file.exists():
683
+ typer.echo(f"Error: File not found: {file}", err=True)
684
+ raise typer.Exit(1)
685
+ new_content = file.read_text()
686
+ parsed_tags = {}
687
+ else:
688
+ new_content = content
689
+ parsed_tags = {}
690
+
691
+ # Parse user-provided tags (merge with default if reset)
692
+ if tags:
693
+ for tag in tags:
694
+ if "=" not in tag:
695
+ typer.echo(f"Error: Invalid tag format '{tag}'. Use key=value", err=True)
696
+ raise typer.Exit(1)
697
+ k, v = tag.split("=", 1)
698
+ parsed_tags[k] = v
699
+
700
+ item = kp.set_now(new_content, tags=parsed_tags or None)
701
+ typer.echo(_format_item(item, as_json=_get_json_output()))
702
+ else:
703
+ # Get current context with version navigation
704
+ item = kp.get_now()
705
+ version_nav = kp.get_version_nav(NOWDOC_ID, None, collection=collection)
706
+ typer.echo(_format_item(
707
+ item,
708
+ as_json=_get_json_output(),
709
+ version_nav=version_nav,
710
+ ))
265
711
 
266
712
 
267
713
  @app.command()
268
714
  def get(
269
715
  id: Annotated[str, typer.Argument(help="URI of item to retrieve")],
716
+ version: Annotated[Optional[int], typer.Option(
717
+ "--version", "-V",
718
+ help="Get specific version (0=current, 1=previous, etc.)"
719
+ )] = None,
720
+ history: Annotated[bool, typer.Option(
721
+ "--history", "-H",
722
+ help="List all versions"
723
+ )] = False,
270
724
  store: StoreOption = None,
271
725
  collection: CollectionOption = "default",
272
- output_json: JsonOption = False,
273
726
  ):
274
727
  """
275
728
  Retrieve a specific item by ID.
729
+
730
+ Examples:
731
+ keep get doc:1 # Current version with prev nav
732
+ keep get doc:1 -V 1 # Previous version with prev/next nav
733
+ keep get doc:1 --history # List all versions
276
734
  """
277
735
  kp = _get_keeper(store, collection)
278
- item = kp.get(id)
279
-
736
+
737
+ if history:
738
+ # List all versions
739
+ versions = kp.list_versions(id, limit=50, collection=collection)
740
+ current = kp.get(id, collection=collection)
741
+
742
+ if _get_json_output():
743
+ result = {
744
+ "id": id,
745
+ "current": {
746
+ "summary": current.summary if current else None,
747
+ "tags": current.tags if current else {},
748
+ } if current else None,
749
+ "versions": [
750
+ {
751
+ "version": v.version,
752
+ "summary": v.summary,
753
+ "created_at": v.created_at,
754
+ }
755
+ for v in versions
756
+ ],
757
+ }
758
+ typer.echo(json.dumps(result, indent=2))
759
+ else:
760
+ if current:
761
+ typer.echo(f"Current: {current.summary[:60]}...")
762
+ if versions:
763
+ typer.echo(f"\nVersion history ({len(versions)} archived):")
764
+ for v in versions:
765
+ date_part = v.created_at[:10] if v.created_at else "unknown"
766
+ summary_preview = v.summary[:50].replace("\n", " ")
767
+ if len(v.summary) > 50:
768
+ summary_preview += "..."
769
+ typer.echo(f" v{v.version} ({date_part}): {summary_preview}")
770
+ else:
771
+ typer.echo("No version history.")
772
+ return
773
+
774
+ # Get specific version or current
775
+ offset = version if version is not None else 0
776
+
777
+ if offset == 0:
778
+ item = kp.get(id, collection=collection)
779
+ viewing_version = None
780
+ else:
781
+ item = kp.get_version(id, offset, collection=collection)
782
+ # Calculate actual version number for display
783
+ versions = kp.list_versions(id, limit=1, collection=collection)
784
+ if versions:
785
+ viewing_version = versions[0].version - (offset - 1)
786
+ else:
787
+ viewing_version = None
788
+
280
789
  if item is None:
281
- typer.echo(f"Not found: {id}", err=True)
790
+ if offset > 0:
791
+ typer.echo(f"Version not found: {id} (offset {offset})", err=True)
792
+ else:
793
+ typer.echo(f"Not found: {id}", err=True)
282
794
  raise typer.Exit(1)
283
-
284
- typer.echo(_format_item(item, as_json=output_json))
795
+
796
+ # Get version navigation
797
+ version_nav = kp.get_version_nav(id, viewing_version, collection=collection)
798
+
799
+ typer.echo(_format_item(
800
+ item,
801
+ as_json=_get_json_output(),
802
+ version_nav=version_nav,
803
+ viewing_version=viewing_version,
804
+ ))
285
805
 
286
806
 
287
807
  @app.command()
@@ -306,15 +826,14 @@ def exists(
306
826
  @app.command("collections")
307
827
  def list_collections(
308
828
  store: StoreOption = None,
309
- output_json: JsonOption = False,
310
829
  ):
311
830
  """
312
831
  List all collections in the store.
313
832
  """
314
833
  kp = _get_keeper(store, "default")
315
834
  collections = kp.list_collections()
316
-
317
- if output_json:
835
+
836
+ if _get_json_output():
318
837
  typer.echo(json.dumps(collections))
319
838
  else:
320
839
  if not collections:
@@ -333,58 +852,101 @@ def init(
333
852
  Initialize or verify the store is ready.
334
853
  """
335
854
  kp = _get_keeper(store, collection)
336
-
337
- # Show actual store path
338
- actual_path = kp._store_path if hasattr(kp, '_store_path') else Path(store or ".keep")
339
- typer.echo(f"✓ Store ready: {actual_path}")
340
- typer.echo(f"✓ Collections: {kp.list_collections()}")
341
-
855
+
856
+ # Show config and store paths
857
+ config = kp._config
858
+ config_path = config.config_path if config else None
859
+ store_path = kp._store_path
860
+
861
+ # Show paths
862
+ typer.echo(f"Config: {config_path}")
863
+ if config and config.config_dir and config.config_dir.resolve() != store_path.resolve():
864
+ typer.echo(f"Store: {store_path}")
865
+
866
+ typer.echo(f"Collections: {kp.list_collections()}")
867
+
342
868
  # Show detected providers
343
- try:
344
- if hasattr(kp, '_config'):
345
- config = kp._config
346
- typer.echo(f"\n✓ Detected providers:")
347
- typer.echo(f" Embedding: {config.embedding.name}")
348
- typer.echo(f" Summarization: {config.summarization.name}")
349
- typer.echo(f"\nTo customize, edit {actual_path}/keep.toml")
350
- except Exception:
351
- pass # Don't fail if provider detection doesn't work
352
-
353
- # .gitignore reminder
354
- typer.echo(f"\n⚠️ Remember to add .keep/ to .gitignore")
869
+ if config:
870
+ typer.echo(f"\nProviders:")
871
+ typer.echo(f" Embedding: {config.embedding.name}")
872
+ typer.echo(f" Summarization: {config.summarization.name}")
355
873
 
356
874
 
357
- @app.command("system")
358
- def list_system(
875
+
876
+ @app.command()
877
+ def config(
359
878
  store: StoreOption = None,
360
- output_json: JsonOption = False,
361
879
  ):
362
880
  """
363
- List all system documents (schema as data).
881
+ Show current configuration and store location.
364
882
  """
365
883
  kp = _get_keeper(store, "default")
366
- docs = kp.list_system_documents()
367
- typer.echo(_format_items(docs, as_json=output_json))
884
+
885
+ cfg = kp._config
886
+ config_path = cfg.config_path if cfg else None
887
+ store_path = kp._store_path
888
+
889
+ if _get_json_output():
890
+ result = {
891
+ "store": str(store_path),
892
+ "config": str(config_path) if config_path else None,
893
+ "collections": kp.list_collections(),
894
+ }
895
+ if cfg:
896
+ result["embedding"] = cfg.embedding.name
897
+ result["summarization"] = cfg.summarization.name
898
+ typer.echo(json.dumps(result, indent=2))
899
+ else:
900
+ # Show paths
901
+ typer.echo(f"Config: {config_path}")
902
+ if cfg and cfg.config_dir and cfg.config_dir.resolve() != store_path.resolve():
903
+ typer.echo(f"Store: {store_path}")
904
+
905
+ typer.echo(f"Collections: {kp.list_collections()}")
906
+
907
+ if cfg:
908
+ typer.echo(f"\nProviders:")
909
+ typer.echo(f" Embedding: {cfg.embedding.name}")
910
+ typer.echo(f" Summarization: {cfg.summarization.name}")
368
911
 
369
912
 
370
- @app.command("routing")
371
- def show_routing(
913
+ @app.command("system")
914
+ def list_system(
372
915
  store: StoreOption = None,
373
- output_json: JsonOption = False,
374
916
  ):
375
917
  """
376
- Show the current routing configuration.
918
+ List the system documents.
919
+
920
+ Shows ID and summary for each. Use `keep get ID` for full details.
377
921
  """
378
922
  kp = _get_keeper(store, "default")
379
- routing = kp.get_routing()
923
+ docs = kp.list_system_documents()
380
924
 
381
- if output_json:
382
- from dataclasses import asdict
383
- typer.echo(json.dumps(asdict(routing), indent=2))
925
+ # Use --ids flag for pipe-friendly output
926
+ if _get_ids_output():
927
+ ids = [doc.id for doc in docs]
928
+ if _get_json_output():
929
+ typer.echo(json.dumps(ids))
930
+ else:
931
+ for doc_id in ids:
932
+ typer.echo(doc_id)
933
+ return
934
+
935
+ if _get_json_output():
936
+ typer.echo(json.dumps([
937
+ {"id": doc.id, "summary": doc.summary}
938
+ for doc in docs
939
+ ], indent=2))
384
940
  else:
385
- typer.echo(f"Summary: {routing.summary}")
386
- typer.echo(f"Private patterns: {routing.private_patterns}")
387
- typer.echo(f"Updated: {routing.updated}")
941
+ if not docs:
942
+ typer.echo("No system documents.")
943
+ else:
944
+ for doc in docs:
945
+ # Compact summary: collapse whitespace, truncate to 70 chars
946
+ summary = " ".join(doc.summary.split())[:70]
947
+ if len(doc.summary) > 70:
948
+ summary += "..."
949
+ typer.echo(f"{doc.id}: {summary}")
388
950
 
389
951
 
390
952
  @app.command("process-pending")
@@ -403,7 +965,6 @@ def process_pending(
403
965
  hidden=True,
404
966
  help="Run as background daemon (used internally)"
405
967
  )] = False,
406
- output_json: JsonOption = False,
407
968
  ):
408
969
  """
409
970
  Process pending summaries from lazy indexing.
@@ -452,7 +1013,7 @@ def process_pending(
452
1013
  pending_before = kp.pending_count()
453
1014
 
454
1015
  if pending_before == 0:
455
- if output_json:
1016
+ if _get_json_output():
456
1017
  typer.echo(json.dumps({"processed": 0, "remaining": 0}))
457
1018
  else:
458
1019
  typer.echo("No pending summaries.")
@@ -466,11 +1027,11 @@ def process_pending(
466
1027
  total_processed += processed
467
1028
  if processed == 0:
468
1029
  break
469
- if not output_json:
1030
+ if not _get_json_output():
470
1031
  typer.echo(f" Processed {total_processed}...")
471
1032
 
472
1033
  remaining = kp.pending_count()
473
- if output_json:
1034
+ if _get_json_output():
474
1035
  typer.echo(json.dumps({
475
1036
  "processed": total_processed,
476
1037
  "remaining": remaining
@@ -482,7 +1043,7 @@ def process_pending(
482
1043
  processed = kp.process_pending(limit=limit)
483
1044
  remaining = kp.pending_count()
484
1045
 
485
- if output_json:
1046
+ if _get_json_output():
486
1047
  typer.echo(json.dumps({
487
1048
  "processed": processed,
488
1049
  "remaining": remaining
@@ -496,7 +1057,19 @@ def process_pending(
496
1057
  # -----------------------------------------------------------------------------
497
1058
 
498
1059
  def main():
499
- app()
1060
+ try:
1061
+ app()
1062
+ except SystemExit:
1063
+ raise # Let typer handle exit codes
1064
+ except KeyboardInterrupt:
1065
+ raise SystemExit(130) # Standard exit code for Ctrl+C
1066
+ except Exception as e:
1067
+ # Log full traceback to file, show clean message to user
1068
+ from .errors import log_exception, ERROR_LOG_PATH
1069
+ log_exception(e, context="keep CLI")
1070
+ typer.echo(f"Error: {e}", err=True)
1071
+ typer.echo(f"Details logged to {ERROR_LOG_PATH}", err=True)
1072
+ raise SystemExit(1)
500
1073
 
501
1074
 
502
1075
  if __name__ == "__main__":