keep-skill 0.1.0__py3-none-any.whl → 0.2.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
@@ -18,22 +18,99 @@ from typing_extensions import Annotated
18
18
 
19
19
  from .api import Keeper
20
20
  from .types import Item
21
- from .logging_config import configure_quiet_mode
21
+ from .logging_config import configure_quiet_mode, enable_debug_mode
22
22
 
23
23
 
24
24
  # 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)
25
+ # Set KEEP_VERBOSE=1 to enable debug mode via environment
26
+ if os.environ.get("KEEP_VERBOSE") == "1":
27
+ enable_debug_mode()
28
+ else:
29
+ configure_quiet_mode(quiet=True)
30
+
31
+
32
+ def _verbose_callback(value: bool):
33
+ if value:
34
+ enable_debug_mode()
35
+
36
+
37
+ # Global state for CLI options
38
+ _json_output = False
39
+ _ids_output = False
40
+
41
+
42
+ def _json_callback(value: bool):
43
+ global _json_output
44
+ _json_output = value
45
+
46
+
47
+ def _get_json_output() -> bool:
48
+ return _json_output
49
+
50
+
51
+ def _ids_callback(value: bool):
52
+ global _ids_output
53
+ _ids_output = value
54
+
55
+
56
+ def _get_ids_output() -> bool:
57
+ return _ids_output
28
58
 
29
59
 
30
60
  app = typer.Typer(
31
61
  name="keep",
32
62
  help="Associative memory with semantic search.",
33
- no_args_is_help=True,
63
+ no_args_is_help=False,
64
+ invoke_without_command=True,
34
65
  )
35
66
 
36
67
 
68
+ def _format_yaml_frontmatter(item: Item) -> str:
69
+ """Format item as YAML frontmatter with summary as content."""
70
+ lines = ["---", f"id: {item.id}"]
71
+ if item.tags:
72
+ lines.append("tags:")
73
+ for k, v in sorted(item.tags.items()):
74
+ lines.append(f" {k}: {v}")
75
+ if item.score is not None:
76
+ lines.append(f"score: {item.score:.3f}")
77
+ lines.append("---")
78
+ lines.append(item.summary) # Summary IS the content
79
+ return "\n".join(lines)
80
+
81
+
82
+ @app.callback(invoke_without_command=True)
83
+ def main_callback(
84
+ ctx: typer.Context,
85
+ verbose: Annotated[bool, typer.Option(
86
+ "--verbose", "-v",
87
+ help="Enable debug-level logging to stderr",
88
+ callback=_verbose_callback,
89
+ is_eager=True,
90
+ )] = False,
91
+ output_json: Annotated[bool, typer.Option(
92
+ "--json", "-j",
93
+ help="Output as JSON",
94
+ callback=_json_callback,
95
+ is_eager=True,
96
+ )] = False,
97
+ ids_only: Annotated[bool, typer.Option(
98
+ "--ids", "-I",
99
+ help="Output only IDs (for piping to xargs)",
100
+ callback=_ids_callback,
101
+ is_eager=True,
102
+ )] = False,
103
+ ):
104
+ """Associative memory with semantic search."""
105
+ # If no subcommand provided, show the current context (now)
106
+ if ctx.invoked_subcommand is None:
107
+ kp = _get_keeper(None, "default")
108
+ item = kp.get_now()
109
+ typer.echo(_format_item(item, as_json=_get_json_output()))
110
+ if not _get_json_output():
111
+ typer.echo("\nUse --help for commands.", err=True)
112
+
113
+
37
114
  # -----------------------------------------------------------------------------
38
115
  # Common Options
39
116
  # -----------------------------------------------------------------------------
@@ -63,11 +140,12 @@ LimitOption = Annotated[
63
140
  )
64
141
  ]
65
142
 
66
- JsonOption = Annotated[
67
- bool,
143
+
144
+ SinceOption = Annotated[
145
+ Optional[str],
68
146
  typer.Option(
69
- "--json", "-j",
70
- help="Output as JSON"
147
+ "--since",
148
+ help="Only items updated since (ISO duration: P3D, P1W, PT1H; or date: 2026-01-15)"
71
149
  )
72
150
  ]
73
151
 
@@ -77,6 +155,15 @@ JsonOption = Annotated[
77
155
  # -----------------------------------------------------------------------------
78
156
 
79
157
  def _format_item(item: Item, as_json: bool = False) -> str:
158
+ """
159
+ Format an item for display.
160
+
161
+ Text format: YAML frontmatter (matches docs/system format)
162
+ With --ids: just the ID (for piping)
163
+ """
164
+ if _get_ids_output():
165
+ return json.dumps(item.id) if as_json else item.id
166
+
80
167
  if as_json:
81
168
  return json.dumps({
82
169
  "id": item.id,
@@ -84,12 +171,16 @@ def _format_item(item: Item, as_json: bool = False) -> str:
84
171
  "tags": item.tags,
85
172
  "score": item.score,
86
173
  })
87
- else:
88
- score = f"[{item.score:.3f}] " if item.score is not None else ""
89
- return f"{score}{item.id}\n {item.summary}"
174
+
175
+ return _format_yaml_frontmatter(item)
90
176
 
91
177
 
92
178
  def _format_items(items: list[Item], as_json: bool = False) -> str:
179
+ """Format multiple items for display."""
180
+ if _get_ids_output():
181
+ ids = [item.id for item in items]
182
+ return json.dumps(ids) if as_json else "\n".join(ids)
183
+
93
184
  if as_json:
94
185
  return json.dumps([
95
186
  {
@@ -116,41 +207,80 @@ def _get_keeper(store: Optional[Path], collection: str) -> Keeper:
116
207
  raise typer.Exit(1)
117
208
 
118
209
 
210
+ def _parse_tags(tags: Optional[list[str]]) -> dict[str, str]:
211
+ """Parse key=value tag list to dict."""
212
+ if not tags:
213
+ return {}
214
+ parsed = {}
215
+ for tag in tags:
216
+ if "=" not in tag:
217
+ typer.echo(f"Error: Invalid tag format '{tag}'. Use key=value", err=True)
218
+ raise typer.Exit(1)
219
+ k, v = tag.split("=", 1)
220
+ parsed[k] = v
221
+ return parsed
222
+
223
+
224
+ def _timestamp() -> str:
225
+ """Generate timestamp for auto-generated IDs."""
226
+ from datetime import datetime, timezone
227
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")
228
+
229
+
230
+ def _parse_frontmatter(text: str) -> tuple[str, dict[str, str]]:
231
+ """Parse YAML frontmatter from text, return (content, tags)."""
232
+ if text.startswith("---"):
233
+ parts = text.split("---", 2)
234
+ if len(parts) >= 3:
235
+ import yaml
236
+ frontmatter = yaml.safe_load(parts[1])
237
+ content = parts[2].lstrip("\n")
238
+ tags = frontmatter.get("tags", {}) if frontmatter else {}
239
+ return content, {k: str(v) for k, v in tags.items()}
240
+ return text, {}
241
+
242
+
119
243
  # -----------------------------------------------------------------------------
120
244
  # Commands
121
245
  # -----------------------------------------------------------------------------
122
246
 
123
247
  @app.command()
124
248
  def find(
125
- query: Annotated[str, typer.Argument(help="Search query text")],
249
+ query: Annotated[Optional[str], typer.Argument(help="Search query text")] = None,
250
+ id: Annotated[Optional[str], typer.Option(
251
+ "--id",
252
+ help="Find items similar to this ID (instead of text search)"
253
+ )] = None,
254
+ include_self: Annotated[bool, typer.Option(
255
+ help="Include the queried item (only with --id)"
256
+ )] = False,
126
257
  store: StoreOption = None,
127
258
  collection: CollectionOption = "default",
128
259
  limit: LimitOption = 10,
129
- output_json: JsonOption = False,
260
+ since: SinceOption = None,
130
261
  ):
131
262
  """
132
263
  Find items using semantic similarity search.
264
+
265
+ Examples:
266
+ keep find "authentication" # Search by text
267
+ keep find --id file:///path/to/doc.md # Find similar to item
133
268
  """
269
+ if id and query:
270
+ typer.echo("Error: Specify either a query or --id, not both", err=True)
271
+ raise typer.Exit(1)
272
+ if not id and not query:
273
+ typer.echo("Error: Specify a query or --id", err=True)
274
+ raise typer.Exit(1)
275
+
134
276
  kp = _get_keeper(store, collection)
135
- results = kp.find(query, limit=limit)
136
- typer.echo(_format_items(results, as_json=output_json))
137
277
 
278
+ if id:
279
+ results = kp.find_similar(id, limit=limit, since=since, include_self=include_self)
280
+ else:
281
+ results = kp.find(query, limit=limit, since=since)
138
282
 
139
- @app.command()
140
- def similar(
141
- id: Annotated[str, typer.Argument(help="URI of item to find similar items for")],
142
- store: StoreOption = None,
143
- collection: CollectionOption = "default",
144
- limit: LimitOption = 10,
145
- include_self: Annotated[bool, typer.Option(help="Include the queried item")] = False,
146
- output_json: JsonOption = False,
147
- ):
148
- """
149
- Find items similar to an existing item.
150
- """
151
- 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))
283
+ typer.echo(_format_items(results, as_json=_get_json_output()))
154
284
 
155
285
 
156
286
  @app.command()
@@ -159,109 +289,265 @@ def search(
159
289
  store: StoreOption = None,
160
290
  collection: CollectionOption = "default",
161
291
  limit: LimitOption = 10,
162
- output_json: JsonOption = False,
292
+ since: SinceOption = None,
163
293
  ):
164
294
  """
165
295
  Search item summaries using full-text search.
166
296
  """
167
297
  kp = _get_keeper(store, collection)
168
- results = kp.query_fulltext(query, limit=limit)
169
- typer.echo(_format_items(results, as_json=output_json))
298
+ results = kp.query_fulltext(query, limit=limit, since=since)
299
+ typer.echo(_format_items(results, as_json=_get_json_output()))
170
300
 
171
301
 
172
302
  @app.command()
173
303
  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,
304
+ query: Annotated[Optional[str], typer.Argument(
305
+ help="Tag key to list values, or key=value to find docs"
306
+ )] = None,
307
+ list_keys: Annotated[bool, typer.Option(
308
+ "--list", "-l",
309
+ help="List all distinct tag keys"
310
+ )] = False,
176
311
  store: StoreOption = None,
177
312
  collection: CollectionOption = "default",
178
313
  limit: LimitOption = 100,
179
- output_json: JsonOption = False,
314
+ since: SinceOption = None,
180
315
  ):
181
316
  """
182
- Find items by tag.
317
+ List tag values or find items by tag.
318
+
319
+ Examples:
320
+ keep tag --list # List all tag keys
321
+ keep tag project # List values for 'project' tag
322
+ keep tag project=myapp # Find docs with project=myapp
183
323
  """
184
324
  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
325
 
326
+ # List all keys mode
327
+ if list_keys or query is None:
328
+ tags = kp.list_tags(None, collection=collection)
329
+ if _get_json_output():
330
+ typer.echo(json.dumps(tags))
331
+ else:
332
+ if not tags:
333
+ typer.echo("No tags found.")
334
+ else:
335
+ for t in tags:
336
+ typer.echo(t)
337
+ return
188
338
 
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",
339
+ # Check if query is key=value or just key
340
+ if "=" in query:
341
+ # key=value find documents
342
+ key, value = query.split("=", 1)
343
+ results = kp.query_tag(key, value, limit=limit, since=since)
344
+ typer.echo(_format_items(results, as_json=_get_json_output()))
345
+ else:
346
+ # key only → list values
347
+ values = kp.list_tags(query, collection=collection)
348
+ if _get_json_output():
349
+ typer.echo(json.dumps(values))
350
+ else:
351
+ if not values:
352
+ typer.echo(f"No values for tag '{query}'.")
353
+ else:
354
+ for v in values:
355
+ typer.echo(v)
356
+
357
+
358
+ @app.command("tag-update")
359
+ def tag_update(
360
+ ids: Annotated[list[str], typer.Argument(help="Document IDs to tag")],
194
361
  tags: Annotated[Optional[list[str]], typer.Option(
195
362
  "--tag", "-t",
196
- help="Source tag as key=value (can be repeated)"
363
+ help="Tag as key=value (empty value removes: key=)"
197
364
  )] = 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,
365
+ remove: Annotated[Optional[list[str]], typer.Option(
366
+ "--remove", "-r",
367
+ help="Tag keys to remove"
368
+ )] = None,
369
+ store: StoreOption = None,
370
+ collection: CollectionOption = "default",
203
371
  ):
204
372
  """
205
- Add or update a document in the store.
373
+ Add, update, or remove tags on existing documents.
374
+
375
+ Does not re-process the document - only updates tags.
206
376
 
207
- Use --lazy for fast indexing when summarization is slow.
208
- Run 'keep process-pending' later to generate real summaries.
377
+ Examples:
378
+ keep tag-update doc:1 --tag project=myapp
379
+ keep tag-update doc:1 doc:2 --tag status=reviewed
380
+ keep tag-update doc:1 --remove obsolete_tag
381
+ keep tag-update doc:1 --tag temp= # Remove via empty value
209
382
  """
210
383
  kp = _get_keeper(store, collection)
211
384
 
212
385
  # Parse tags from key=value format
213
- source_tags = {}
386
+ tag_changes: dict[str, str] = {}
214
387
  if tags:
215
388
  for tag in tags:
216
389
  if "=" not in tag:
217
- typer.echo(f"Error: Invalid tag format '{tag}'. Use key=value", err=True)
390
+ typer.echo(f"Error: Invalid tag format '{tag}'. Use key=value (or key= to remove)", err=True)
218
391
  raise typer.Exit(1)
219
392
  k, v = tag.split("=", 1)
220
- source_tags[k] = v
393
+ tag_changes[k] = v # Empty v means delete
394
+
395
+ # Add explicit removals as empty strings
396
+ if remove:
397
+ for key in remove:
398
+ tag_changes[key] = ""
399
+
400
+ if not tag_changes:
401
+ typer.echo("Error: Specify at least one --tag or --remove", err=True)
402
+ raise typer.Exit(1)
403
+
404
+ # Process each document
405
+ results = []
406
+ for doc_id in ids:
407
+ item = kp.tag(doc_id, tags=tag_changes, collection=collection)
408
+ if item is None:
409
+ typer.echo(f"Not found: {doc_id}", err=True)
410
+ else:
411
+ results.append(item)
221
412
 
222
- item = kp.update(id, source_tags=source_tags or None, lazy=lazy)
223
- typer.echo(_format_item(item, as_json=output_json))
413
+ if _get_json_output():
414
+ typer.echo(_format_items(results, as_json=True))
415
+ else:
416
+ for item in results:
417
+ typer.echo(_format_item(item, as_json=False))
224
418
 
225
419
 
226
420
  @app.command()
227
- def remember(
228
- content: Annotated[str, typer.Argument(help="Content to remember")],
229
- store: StoreOption = None,
230
- collection: CollectionOption = "default",
421
+ def update(
422
+ source: Annotated[Optional[str], typer.Argument(
423
+ help="URI to fetch, text content, or '-' for stdin"
424
+ )] = None,
231
425
  id: Annotated[Optional[str], typer.Option(
232
426
  "--id", "-i",
233
- help="Custom identifier (default: auto-generated)"
427
+ help="Document ID (auto-generated for text/stdin modes)"
234
428
  )] = None,
429
+ store: StoreOption = None,
430
+ collection: CollectionOption = "default",
235
431
  tags: Annotated[Optional[list[str]], typer.Option(
236
432
  "--tag", "-t",
237
- help="Source tag as key=value (can be repeated)"
433
+ help="Tag as key=value (can be repeated)"
434
+ )] = None,
435
+ summary: Annotated[Optional[str], typer.Option(
436
+ "--summary",
437
+ help="User-provided summary (skips auto-summarization)"
238
438
  )] = None,
239
439
  lazy: Annotated[bool, typer.Option(
240
- "--lazy", "-l",
440
+ "--lazy",
241
441
  help="Fast mode: use truncated summary, queue for later processing"
242
442
  )] = False,
243
- output_json: JsonOption = False,
244
443
  ):
245
444
  """
246
- Remember inline content (conversations, notes, insights).
445
+ Add or update a document in the store.
247
446
 
248
- Use --lazy for fast indexing when summarization is slow.
249
- Run 'keep process-pending' later to generate real summaries.
447
+ Three input modes (auto-detected):
448
+ keep update file:///path # URI mode: has ://
449
+ keep update "my note" # Text mode: no ://
450
+ keep update - # Stdin mode: explicit -
451
+ echo "pipe" | keep update # Stdin mode: piped input
250
452
  """
251
453
  kp = _get_keeper(store, collection)
454
+ parsed_tags = _parse_tags(tags)
455
+
456
+ # Determine mode based on source content
457
+ if source == "-" or (source is None and not sys.stdin.isatty()):
458
+ # Stdin mode: explicit '-' or piped input
459
+ content = sys.stdin.read()
460
+ content, frontmatter_tags = _parse_frontmatter(content)
461
+ parsed_tags = {**frontmatter_tags, **parsed_tags} # CLI tags override
462
+ doc_id = id or f"mem:{_timestamp()}"
463
+ item = kp.remember(content, id=doc_id, summary=summary, tags=parsed_tags or None, lazy=lazy)
464
+ elif source and "://" in source:
465
+ # URI mode: fetch from URI (ID is the URI itself)
466
+ item = kp.update(source, tags=parsed_tags or None, summary=summary, lazy=lazy)
467
+ elif source:
468
+ # Text mode: inline content (no :// in source)
469
+ doc_id = id or f"mem:{_timestamp()}"
470
+ item = kp.remember(source, id=doc_id, summary=summary, tags=parsed_tags or None, lazy=lazy)
471
+ else:
472
+ typer.echo("Error: Provide content, URI, or '-' for stdin", err=True)
473
+ raise typer.Exit(1)
474
+
475
+ typer.echo(_format_item(item, as_json=_get_json_output()))
252
476
 
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
262
477
 
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))
478
+ @app.command()
479
+ def now(
480
+ content: Annotated[Optional[str], typer.Argument(
481
+ help="Content to set (omit to show current)"
482
+ )] = None,
483
+ file: Annotated[Optional[Path], typer.Option(
484
+ "--file", "-f",
485
+ help="Read content from file"
486
+ )] = None,
487
+ reset: Annotated[bool, typer.Option(
488
+ "--reset",
489
+ help="Reset to default from system"
490
+ )] = False,
491
+ store: StoreOption = None,
492
+ collection: CollectionOption = "default",
493
+ tags: Annotated[Optional[list[str]], typer.Option(
494
+ "--tag", "-t",
495
+ help="Tag as key=value (can be repeated)"
496
+ )] = None,
497
+ ):
498
+ """
499
+ Get or set the current working context.
500
+
501
+ With no arguments, displays the current context.
502
+ With content, replaces it.
503
+
504
+ Examples:
505
+ keep now # Show current context
506
+ keep now "Working on auth flow" # Set context
507
+ keep now -f context.md # Set from file
508
+ keep now --reset # Reset to default
509
+ """
510
+ kp = _get_keeper(store, collection)
511
+
512
+ # Determine if we're getting or setting
513
+ setting = content is not None or file is not None or reset
514
+
515
+ if setting:
516
+ if reset:
517
+ # Reset to default from system (delete first to clear old tags)
518
+ from .api import _load_frontmatter, NOWDOC_ID, SYSTEM_DOC_DIR
519
+ kp.delete(NOWDOC_ID)
520
+ try:
521
+ new_content, default_tags = _load_frontmatter(SYSTEM_DOC_DIR / "now.md")
522
+ parsed_tags = default_tags
523
+ except FileNotFoundError:
524
+ typer.echo("Error: Builtin now.md not found", err=True)
525
+ raise typer.Exit(1)
526
+ elif file is not None:
527
+ if not file.exists():
528
+ typer.echo(f"Error: File not found: {file}", err=True)
529
+ raise typer.Exit(1)
530
+ new_content = file.read_text()
531
+ parsed_tags = {}
532
+ else:
533
+ new_content = content
534
+ parsed_tags = {}
535
+
536
+ # Parse user-provided tags (merge with default if reset)
537
+ if tags:
538
+ for tag in tags:
539
+ if "=" not in tag:
540
+ typer.echo(f"Error: Invalid tag format '{tag}'. Use key=value", err=True)
541
+ raise typer.Exit(1)
542
+ k, v = tag.split("=", 1)
543
+ parsed_tags[k] = v
544
+
545
+ item = kp.set_now(new_content, tags=parsed_tags or None)
546
+ typer.echo(_format_item(item, as_json=_get_json_output()))
547
+ else:
548
+ # Get current context
549
+ item = kp.get_now()
550
+ typer.echo(_format_item(item, as_json=_get_json_output()))
265
551
 
266
552
 
267
553
  @app.command()
@@ -269,19 +555,18 @@ def get(
269
555
  id: Annotated[str, typer.Argument(help="URI of item to retrieve")],
270
556
  store: StoreOption = None,
271
557
  collection: CollectionOption = "default",
272
- output_json: JsonOption = False,
273
558
  ):
274
559
  """
275
560
  Retrieve a specific item by ID.
276
561
  """
277
562
  kp = _get_keeper(store, collection)
278
563
  item = kp.get(id)
279
-
564
+
280
565
  if item is None:
281
566
  typer.echo(f"Not found: {id}", err=True)
282
567
  raise typer.Exit(1)
283
-
284
- typer.echo(_format_item(item, as_json=output_json))
568
+
569
+ typer.echo(_format_item(item, as_json=_get_json_output()))
285
570
 
286
571
 
287
572
  @app.command()
@@ -306,15 +591,14 @@ def exists(
306
591
  @app.command("collections")
307
592
  def list_collections(
308
593
  store: StoreOption = None,
309
- output_json: JsonOption = False,
310
594
  ):
311
595
  """
312
596
  List all collections in the store.
313
597
  """
314
598
  kp = _get_keeper(store, "default")
315
599
  collections = kp.list_collections()
316
-
317
- if output_json:
600
+
601
+ if _get_json_output():
318
602
  typer.echo(json.dumps(collections))
319
603
  else:
320
604
  if not collections:
@@ -333,58 +617,101 @@ def init(
333
617
  Initialize or verify the store is ready.
334
618
  """
335
619
  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
-
620
+
621
+ # Show config and store paths
622
+ config = kp._config
623
+ config_path = config.config_path if config else None
624
+ store_path = kp._store_path
625
+
626
+ # Show paths
627
+ typer.echo(f"Config: {config_path}")
628
+ if config and config.config_dir and config.config_dir.resolve() != store_path.resolve():
629
+ typer.echo(f"Store: {store_path}")
630
+
631
+ typer.echo(f"Collections: {kp.list_collections()}")
632
+
342
633
  # 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")
634
+ if config:
635
+ typer.echo(f"\nProviders:")
636
+ typer.echo(f" Embedding: {config.embedding.name}")
637
+ typer.echo(f" Summarization: {config.summarization.name}")
355
638
 
356
639
 
357
- @app.command("system")
358
- def list_system(
640
+
641
+ @app.command()
642
+ def config(
359
643
  store: StoreOption = None,
360
- output_json: JsonOption = False,
361
644
  ):
362
645
  """
363
- List all system documents (schema as data).
646
+ Show current configuration and store location.
364
647
  """
365
648
  kp = _get_keeper(store, "default")
366
- docs = kp.list_system_documents()
367
- typer.echo(_format_items(docs, as_json=output_json))
368
649
 
650
+ cfg = kp._config
651
+ config_path = cfg.config_path if cfg else None
652
+ store_path = kp._store_path
653
+
654
+ if _get_json_output():
655
+ result = {
656
+ "store": str(store_path),
657
+ "config": str(config_path) if config_path else None,
658
+ "collections": kp.list_collections(),
659
+ }
660
+ if cfg:
661
+ result["embedding"] = cfg.embedding.name
662
+ result["summarization"] = cfg.summarization.name
663
+ typer.echo(json.dumps(result, indent=2))
664
+ else:
665
+ # Show paths
666
+ typer.echo(f"Config: {config_path}")
667
+ if cfg and cfg.config_dir and cfg.config_dir.resolve() != store_path.resolve():
668
+ typer.echo(f"Store: {store_path}")
669
+
670
+ typer.echo(f"Collections: {kp.list_collections()}")
369
671
 
370
- @app.command("routing")
371
- def show_routing(
672
+ if cfg:
673
+ typer.echo(f"\nProviders:")
674
+ typer.echo(f" Embedding: {cfg.embedding.name}")
675
+ typer.echo(f" Summarization: {cfg.summarization.name}")
676
+
677
+
678
+ @app.command("system")
679
+ def list_system(
372
680
  store: StoreOption = None,
373
- output_json: JsonOption = False,
374
681
  ):
375
682
  """
376
- Show the current routing configuration.
683
+ List the system documents.
684
+
685
+ Shows ID and summary for each. Use `keep get ID` for full details.
377
686
  """
378
687
  kp = _get_keeper(store, "default")
379
- routing = kp.get_routing()
688
+ docs = kp.list_system_documents()
689
+
690
+ # Use --ids flag for pipe-friendly output
691
+ if _get_ids_output():
692
+ ids = [doc.id for doc in docs]
693
+ if _get_json_output():
694
+ typer.echo(json.dumps(ids))
695
+ else:
696
+ for doc_id in ids:
697
+ typer.echo(doc_id)
698
+ return
380
699
 
381
- if output_json:
382
- from dataclasses import asdict
383
- typer.echo(json.dumps(asdict(routing), indent=2))
700
+ if _get_json_output():
701
+ typer.echo(json.dumps([
702
+ {"id": doc.id, "summary": doc.summary}
703
+ for doc in docs
704
+ ], indent=2))
384
705
  else:
385
- typer.echo(f"Summary: {routing.summary}")
386
- typer.echo(f"Private patterns: {routing.private_patterns}")
387
- typer.echo(f"Updated: {routing.updated}")
706
+ if not docs:
707
+ typer.echo("No system documents.")
708
+ else:
709
+ for doc in docs:
710
+ # Compact summary: collapse whitespace, truncate to 70 chars
711
+ summary = " ".join(doc.summary.split())[:70]
712
+ if len(doc.summary) > 70:
713
+ summary += "..."
714
+ typer.echo(f"{doc.id}: {summary}")
388
715
 
389
716
 
390
717
  @app.command("process-pending")
@@ -403,7 +730,6 @@ def process_pending(
403
730
  hidden=True,
404
731
  help="Run as background daemon (used internally)"
405
732
  )] = False,
406
- output_json: JsonOption = False,
407
733
  ):
408
734
  """
409
735
  Process pending summaries from lazy indexing.
@@ -452,7 +778,7 @@ def process_pending(
452
778
  pending_before = kp.pending_count()
453
779
 
454
780
  if pending_before == 0:
455
- if output_json:
781
+ if _get_json_output():
456
782
  typer.echo(json.dumps({"processed": 0, "remaining": 0}))
457
783
  else:
458
784
  typer.echo("No pending summaries.")
@@ -466,11 +792,11 @@ def process_pending(
466
792
  total_processed += processed
467
793
  if processed == 0:
468
794
  break
469
- if not output_json:
795
+ if not _get_json_output():
470
796
  typer.echo(f" Processed {total_processed}...")
471
797
 
472
798
  remaining = kp.pending_count()
473
- if output_json:
799
+ if _get_json_output():
474
800
  typer.echo(json.dumps({
475
801
  "processed": total_processed,
476
802
  "remaining": remaining
@@ -482,7 +808,7 @@ def process_pending(
482
808
  processed = kp.process_pending(limit=limit)
483
809
  remaining = kp.pending_count()
484
810
 
485
- if output_json:
811
+ if _get_json_output():
486
812
  typer.echo(json.dumps({
487
813
  "processed": processed,
488
814
  "remaining": remaining
@@ -496,7 +822,19 @@ def process_pending(
496
822
  # -----------------------------------------------------------------------------
497
823
 
498
824
  def main():
499
- app()
825
+ try:
826
+ app()
827
+ except SystemExit:
828
+ raise # Let typer handle exit codes
829
+ except KeyboardInterrupt:
830
+ raise SystemExit(130) # Standard exit code for Ctrl+C
831
+ except Exception as e:
832
+ # Log full traceback to file, show clean message to user
833
+ from .errors import log_exception, ERROR_LOG_PATH
834
+ log_exception(e, context="keep CLI")
835
+ typer.echo(f"Error: {e}", err=True)
836
+ typer.echo(f"Details logged to {ERROR_LOG_PATH}", err=True)
837
+ raise SystemExit(1)
500
838
 
501
839
 
502
840
  if __name__ == "__main__":