keep-skill 0.1.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/__init__.py +53 -0
- keep/__main__.py +8 -0
- keep/api.py +686 -0
- keep/chunking.py +364 -0
- keep/cli.py +503 -0
- keep/config.py +323 -0
- keep/context.py +127 -0
- keep/indexing.py +208 -0
- keep/logging_config.py +73 -0
- keep/paths.py +67 -0
- keep/pending_summaries.py +166 -0
- keep/providers/__init__.py +40 -0
- keep/providers/base.py +416 -0
- keep/providers/documents.py +250 -0
- keep/providers/embedding_cache.py +260 -0
- keep/providers/embeddings.py +245 -0
- keep/providers/llm.py +371 -0
- keep/providers/mlx.py +256 -0
- keep/providers/summarization.py +107 -0
- keep/store.py +403 -0
- keep/types.py +65 -0
- keep_skill-0.1.0.dist-info/METADATA +290 -0
- keep_skill-0.1.0.dist-info/RECORD +26 -0
- keep_skill-0.1.0.dist-info/WHEEL +4 -0
- keep_skill-0.1.0.dist-info/entry_points.txt +2 -0
- keep_skill-0.1.0.dist-info/licenses/LICENSE +21 -0
keep/cli.py
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI interface for associative memory.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
keepfind "query text"
|
|
6
|
+
keepupdate file:///path/to/doc.md
|
|
7
|
+
keepget file:///path/to/doc.md
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
from typing_extensions import Annotated
|
|
18
|
+
|
|
19
|
+
from .api import Keeper
|
|
20
|
+
from .types import Item
|
|
21
|
+
from .logging_config import configure_quiet_mode
|
|
22
|
+
|
|
23
|
+
|
|
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)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
app = typer.Typer(
|
|
31
|
+
name="keep",
|
|
32
|
+
help="Associative memory with semantic search.",
|
|
33
|
+
no_args_is_help=True,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# -----------------------------------------------------------------------------
|
|
38
|
+
# Common Options
|
|
39
|
+
# -----------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
StoreOption = Annotated[
|
|
42
|
+
Optional[Path],
|
|
43
|
+
typer.Option(
|
|
44
|
+
"--store", "-s",
|
|
45
|
+
envvar="KEEP_STORE_PATH",
|
|
46
|
+
help="Path to the store directory (default: .keep/ at repo root)"
|
|
47
|
+
)
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
CollectionOption = Annotated[
|
|
51
|
+
str,
|
|
52
|
+
typer.Option(
|
|
53
|
+
"--collection", "-c",
|
|
54
|
+
help="Collection name"
|
|
55
|
+
)
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
LimitOption = Annotated[
|
|
59
|
+
int,
|
|
60
|
+
typer.Option(
|
|
61
|
+
"--limit", "-n",
|
|
62
|
+
help="Maximum results to return"
|
|
63
|
+
)
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
JsonOption = Annotated[
|
|
67
|
+
bool,
|
|
68
|
+
typer.Option(
|
|
69
|
+
"--json", "-j",
|
|
70
|
+
help="Output as JSON"
|
|
71
|
+
)
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# -----------------------------------------------------------------------------
|
|
76
|
+
# Output Helpers
|
|
77
|
+
# -----------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def _format_item(item: Item, as_json: bool = False) -> str:
|
|
80
|
+
if as_json:
|
|
81
|
+
return json.dumps({
|
|
82
|
+
"id": item.id,
|
|
83
|
+
"summary": item.summary,
|
|
84
|
+
"tags": item.tags,
|
|
85
|
+
"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}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _format_items(items: list[Item], as_json: bool = False) -> str:
|
|
93
|
+
if as_json:
|
|
94
|
+
return json.dumps([
|
|
95
|
+
{
|
|
96
|
+
"id": item.id,
|
|
97
|
+
"summary": item.summary,
|
|
98
|
+
"tags": item.tags,
|
|
99
|
+
"score": item.score,
|
|
100
|
+
}
|
|
101
|
+
for item in items
|
|
102
|
+
], indent=2)
|
|
103
|
+
else:
|
|
104
|
+
if not items:
|
|
105
|
+
return "No results."
|
|
106
|
+
return "\n\n".join(_format_item(item, as_json=False) for item in items)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _get_keeper(store: Optional[Path], collection: str) -> Keeper:
|
|
110
|
+
"""Initialize memory, handling errors gracefully."""
|
|
111
|
+
# store=None is fine — Keeper will use default (git root/.keep)
|
|
112
|
+
try:
|
|
113
|
+
return Keeper(store, collection=collection)
|
|
114
|
+
except Exception as e:
|
|
115
|
+
typer.echo(f"Error: {e}", err=True)
|
|
116
|
+
raise typer.Exit(1)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# -----------------------------------------------------------------------------
|
|
120
|
+
# Commands
|
|
121
|
+
# -----------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
@app.command()
|
|
124
|
+
def find(
|
|
125
|
+
query: Annotated[str, typer.Argument(help="Search query text")],
|
|
126
|
+
store: StoreOption = None,
|
|
127
|
+
collection: CollectionOption = "default",
|
|
128
|
+
limit: LimitOption = 10,
|
|
129
|
+
output_json: JsonOption = False,
|
|
130
|
+
):
|
|
131
|
+
"""
|
|
132
|
+
Find items using semantic similarity search.
|
|
133
|
+
"""
|
|
134
|
+
kp = _get_keeper(store, collection)
|
|
135
|
+
results = kp.find(query, limit=limit)
|
|
136
|
+
typer.echo(_format_items(results, as_json=output_json))
|
|
137
|
+
|
|
138
|
+
|
|
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))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@app.command()
|
|
157
|
+
def search(
|
|
158
|
+
query: Annotated[str, typer.Argument(help="Full-text search query")],
|
|
159
|
+
store: StoreOption = None,
|
|
160
|
+
collection: CollectionOption = "default",
|
|
161
|
+
limit: LimitOption = 10,
|
|
162
|
+
output_json: JsonOption = False,
|
|
163
|
+
):
|
|
164
|
+
"""
|
|
165
|
+
Search item summaries using full-text search.
|
|
166
|
+
"""
|
|
167
|
+
kp = _get_keeper(store, collection)
|
|
168
|
+
results = kp.query_fulltext(query, limit=limit)
|
|
169
|
+
typer.echo(_format_items(results, as_json=output_json))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@app.command()
|
|
173
|
+
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,
|
|
176
|
+
store: StoreOption = None,
|
|
177
|
+
collection: CollectionOption = "default",
|
|
178
|
+
limit: LimitOption = 100,
|
|
179
|
+
output_json: JsonOption = False,
|
|
180
|
+
):
|
|
181
|
+
"""
|
|
182
|
+
Find items by tag.
|
|
183
|
+
"""
|
|
184
|
+
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
|
+
|
|
188
|
+
|
|
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",
|
|
194
|
+
tags: Annotated[Optional[list[str]], typer.Option(
|
|
195
|
+
"--tag", "-t",
|
|
196
|
+
help="Source tag as key=value (can be repeated)"
|
|
197
|
+
)] = 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,
|
|
203
|
+
):
|
|
204
|
+
"""
|
|
205
|
+
Add or update a document in the store.
|
|
206
|
+
|
|
207
|
+
Use --lazy for fast indexing when summarization is slow.
|
|
208
|
+
Run 'keep process-pending' later to generate real summaries.
|
|
209
|
+
"""
|
|
210
|
+
kp = _get_keeper(store, collection)
|
|
211
|
+
|
|
212
|
+
# Parse tags from key=value format
|
|
213
|
+
source_tags = {}
|
|
214
|
+
if tags:
|
|
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
|
+
source_tags[k] = v
|
|
221
|
+
|
|
222
|
+
item = kp.update(id, source_tags=source_tags or None, lazy=lazy)
|
|
223
|
+
typer.echo(_format_item(item, as_json=output_json))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@app.command()
|
|
227
|
+
def remember(
|
|
228
|
+
content: Annotated[str, typer.Argument(help="Content to remember")],
|
|
229
|
+
store: StoreOption = None,
|
|
230
|
+
collection: CollectionOption = "default",
|
|
231
|
+
id: Annotated[Optional[str], typer.Option(
|
|
232
|
+
"--id", "-i",
|
|
233
|
+
help="Custom identifier (default: auto-generated)"
|
|
234
|
+
)] = None,
|
|
235
|
+
tags: Annotated[Optional[list[str]], typer.Option(
|
|
236
|
+
"--tag", "-t",
|
|
237
|
+
help="Source tag as key=value (can be repeated)"
|
|
238
|
+
)] = None,
|
|
239
|
+
lazy: Annotated[bool, typer.Option(
|
|
240
|
+
"--lazy", "-l",
|
|
241
|
+
help="Fast mode: use truncated summary, queue for later processing"
|
|
242
|
+
)] = False,
|
|
243
|
+
output_json: JsonOption = False,
|
|
244
|
+
):
|
|
245
|
+
"""
|
|
246
|
+
Remember inline content (conversations, notes, insights).
|
|
247
|
+
|
|
248
|
+
Use --lazy for fast indexing when summarization is slow.
|
|
249
|
+
Run 'keep process-pending' later to generate real summaries.
|
|
250
|
+
"""
|
|
251
|
+
kp = _get_keeper(store, collection)
|
|
252
|
+
|
|
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
|
+
|
|
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))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@app.command()
|
|
268
|
+
def get(
|
|
269
|
+
id: Annotated[str, typer.Argument(help="URI of item to retrieve")],
|
|
270
|
+
store: StoreOption = None,
|
|
271
|
+
collection: CollectionOption = "default",
|
|
272
|
+
output_json: JsonOption = False,
|
|
273
|
+
):
|
|
274
|
+
"""
|
|
275
|
+
Retrieve a specific item by ID.
|
|
276
|
+
"""
|
|
277
|
+
kp = _get_keeper(store, collection)
|
|
278
|
+
item = kp.get(id)
|
|
279
|
+
|
|
280
|
+
if item is None:
|
|
281
|
+
typer.echo(f"Not found: {id}", err=True)
|
|
282
|
+
raise typer.Exit(1)
|
|
283
|
+
|
|
284
|
+
typer.echo(_format_item(item, as_json=output_json))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@app.command()
|
|
288
|
+
def exists(
|
|
289
|
+
id: Annotated[str, typer.Argument(help="URI to check")],
|
|
290
|
+
store: StoreOption = None,
|
|
291
|
+
collection: CollectionOption = "default",
|
|
292
|
+
):
|
|
293
|
+
"""
|
|
294
|
+
Check if an item exists in the store.
|
|
295
|
+
"""
|
|
296
|
+
kp = _get_keeper(store, collection)
|
|
297
|
+
found = kp.exists(id)
|
|
298
|
+
|
|
299
|
+
if found:
|
|
300
|
+
typer.echo(f"Exists: {id}")
|
|
301
|
+
else:
|
|
302
|
+
typer.echo(f"Not found: {id}")
|
|
303
|
+
raise typer.Exit(1)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@app.command("collections")
|
|
307
|
+
def list_collections(
|
|
308
|
+
store: StoreOption = None,
|
|
309
|
+
output_json: JsonOption = False,
|
|
310
|
+
):
|
|
311
|
+
"""
|
|
312
|
+
List all collections in the store.
|
|
313
|
+
"""
|
|
314
|
+
kp = _get_keeper(store, "default")
|
|
315
|
+
collections = kp.list_collections()
|
|
316
|
+
|
|
317
|
+
if output_json:
|
|
318
|
+
typer.echo(json.dumps(collections))
|
|
319
|
+
else:
|
|
320
|
+
if not collections:
|
|
321
|
+
typer.echo("No collections.")
|
|
322
|
+
else:
|
|
323
|
+
for c in collections:
|
|
324
|
+
typer.echo(c)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@app.command()
|
|
328
|
+
def init(
|
|
329
|
+
store: StoreOption = None,
|
|
330
|
+
collection: CollectionOption = "default",
|
|
331
|
+
):
|
|
332
|
+
"""
|
|
333
|
+
Initialize or verify the store is ready.
|
|
334
|
+
"""
|
|
335
|
+
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
|
+
|
|
342
|
+
# 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")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@app.command("system")
|
|
358
|
+
def list_system(
|
|
359
|
+
store: StoreOption = None,
|
|
360
|
+
output_json: JsonOption = False,
|
|
361
|
+
):
|
|
362
|
+
"""
|
|
363
|
+
List all system documents (schema as data).
|
|
364
|
+
"""
|
|
365
|
+
kp = _get_keeper(store, "default")
|
|
366
|
+
docs = kp.list_system_documents()
|
|
367
|
+
typer.echo(_format_items(docs, as_json=output_json))
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@app.command("routing")
|
|
371
|
+
def show_routing(
|
|
372
|
+
store: StoreOption = None,
|
|
373
|
+
output_json: JsonOption = False,
|
|
374
|
+
):
|
|
375
|
+
"""
|
|
376
|
+
Show the current routing configuration.
|
|
377
|
+
"""
|
|
378
|
+
kp = _get_keeper(store, "default")
|
|
379
|
+
routing = kp.get_routing()
|
|
380
|
+
|
|
381
|
+
if output_json:
|
|
382
|
+
from dataclasses import asdict
|
|
383
|
+
typer.echo(json.dumps(asdict(routing), indent=2))
|
|
384
|
+
else:
|
|
385
|
+
typer.echo(f"Summary: {routing.summary}")
|
|
386
|
+
typer.echo(f"Private patterns: {routing.private_patterns}")
|
|
387
|
+
typer.echo(f"Updated: {routing.updated}")
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@app.command("process-pending")
|
|
391
|
+
def process_pending(
|
|
392
|
+
store: StoreOption = None,
|
|
393
|
+
limit: Annotated[int, typer.Option(
|
|
394
|
+
"--limit", "-n",
|
|
395
|
+
help="Maximum items to process in this batch"
|
|
396
|
+
)] = 10,
|
|
397
|
+
all_items: Annotated[bool, typer.Option(
|
|
398
|
+
"--all", "-a",
|
|
399
|
+
help="Process all pending items (ignores --limit)"
|
|
400
|
+
)] = False,
|
|
401
|
+
daemon: Annotated[bool, typer.Option(
|
|
402
|
+
"--daemon",
|
|
403
|
+
hidden=True,
|
|
404
|
+
help="Run as background daemon (used internally)"
|
|
405
|
+
)] = False,
|
|
406
|
+
output_json: JsonOption = False,
|
|
407
|
+
):
|
|
408
|
+
"""
|
|
409
|
+
Process pending summaries from lazy indexing.
|
|
410
|
+
|
|
411
|
+
Items indexed with --lazy use a truncated placeholder summary.
|
|
412
|
+
This command generates real summaries for those items.
|
|
413
|
+
"""
|
|
414
|
+
kp = _get_keeper(store, "default")
|
|
415
|
+
|
|
416
|
+
# Daemon mode: write PID, process all, remove PID, exit silently
|
|
417
|
+
if daemon:
|
|
418
|
+
import signal
|
|
419
|
+
|
|
420
|
+
pid_path = kp._processor_pid_path
|
|
421
|
+
shutdown_requested = False
|
|
422
|
+
|
|
423
|
+
def handle_signal(signum, frame):
|
|
424
|
+
nonlocal shutdown_requested
|
|
425
|
+
shutdown_requested = True
|
|
426
|
+
|
|
427
|
+
# Handle common termination signals gracefully
|
|
428
|
+
signal.signal(signal.SIGTERM, handle_signal)
|
|
429
|
+
signal.signal(signal.SIGINT, handle_signal)
|
|
430
|
+
|
|
431
|
+
try:
|
|
432
|
+
# Write PID file
|
|
433
|
+
pid_path.write_text(str(os.getpid()))
|
|
434
|
+
|
|
435
|
+
# Process all items until queue empty or shutdown requested
|
|
436
|
+
while not shutdown_requested:
|
|
437
|
+
processed = kp.process_pending(limit=50)
|
|
438
|
+
if processed == 0:
|
|
439
|
+
break
|
|
440
|
+
|
|
441
|
+
finally:
|
|
442
|
+
# Clean up PID file
|
|
443
|
+
try:
|
|
444
|
+
pid_path.unlink()
|
|
445
|
+
except OSError:
|
|
446
|
+
pass
|
|
447
|
+
# Close resources
|
|
448
|
+
kp.close()
|
|
449
|
+
return
|
|
450
|
+
|
|
451
|
+
# Interactive mode
|
|
452
|
+
pending_before = kp.pending_count()
|
|
453
|
+
|
|
454
|
+
if pending_before == 0:
|
|
455
|
+
if output_json:
|
|
456
|
+
typer.echo(json.dumps({"processed": 0, "remaining": 0}))
|
|
457
|
+
else:
|
|
458
|
+
typer.echo("No pending summaries.")
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
if all_items:
|
|
462
|
+
# Process all items in batches
|
|
463
|
+
total_processed = 0
|
|
464
|
+
while True:
|
|
465
|
+
processed = kp.process_pending(limit=50)
|
|
466
|
+
total_processed += processed
|
|
467
|
+
if processed == 0:
|
|
468
|
+
break
|
|
469
|
+
if not output_json:
|
|
470
|
+
typer.echo(f" Processed {total_processed}...")
|
|
471
|
+
|
|
472
|
+
remaining = kp.pending_count()
|
|
473
|
+
if output_json:
|
|
474
|
+
typer.echo(json.dumps({
|
|
475
|
+
"processed": total_processed,
|
|
476
|
+
"remaining": remaining
|
|
477
|
+
}))
|
|
478
|
+
else:
|
|
479
|
+
typer.echo(f"✓ Processed {total_processed} items, {remaining} remaining")
|
|
480
|
+
else:
|
|
481
|
+
# Process limited batch
|
|
482
|
+
processed = kp.process_pending(limit=limit)
|
|
483
|
+
remaining = kp.pending_count()
|
|
484
|
+
|
|
485
|
+
if output_json:
|
|
486
|
+
typer.echo(json.dumps({
|
|
487
|
+
"processed": processed,
|
|
488
|
+
"remaining": remaining
|
|
489
|
+
}))
|
|
490
|
+
else:
|
|
491
|
+
typer.echo(f"✓ Processed {processed} items, {remaining} remaining")
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
# -----------------------------------------------------------------------------
|
|
495
|
+
# Entry point
|
|
496
|
+
# -----------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
def main():
|
|
499
|
+
app()
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
if __name__ == "__main__":
|
|
503
|
+
main()
|