clens-cli 1.0.1__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.
clens/__init__.py ADDED
File without changes
clens/app_config.py ADDED
@@ -0,0 +1,29 @@
1
+ from pydantic import BaseModel, Field
2
+ from pathlib import Path
3
+ from clens.paths import CONFIG_FILE
4
+ import json
5
+
6
+
7
+ class UserPreferences(BaseModel):
8
+ default_host: str = "127.0.0.1"
9
+ default_port: int = 6969
10
+
11
+
12
+ class AppConfig(BaseModel):
13
+ preferences: UserPreferences = Field(default_factory=UserPreferences)
14
+
15
+ @classmethod
16
+ def load_from_file(cls, path: Path = CONFIG_FILE) -> "AppConfig":
17
+ if not path.exists():
18
+ return cls()
19
+ try:
20
+ with open(path, "r") as f:
21
+ data = json.load(f)
22
+ return cls.model_validate(data)
23
+ except Exception:
24
+ return cls()
25
+
26
+ def save_to_file(self, path: Path = CONFIG_FILE) -> None:
27
+ path.parent.mkdir(parents=True, exist_ok=True)
28
+ with open(path, "w") as f:
29
+ f.write(self.model_dump_json(indent=2))
clens/cli.py ADDED
@@ -0,0 +1,389 @@
1
+ import os
2
+
3
+ os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
4
+ os.environ.setdefault("HF_HUB_VERBOSITY", "error")
5
+ os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
6
+ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
7
+
8
+ import argparse
9
+ import sys
10
+ from clens.app_config import AppConfig
11
+ from pathlib import Path
12
+ from typing import override
13
+ import logging
14
+ from clens.paths import CONFIG_FILE, RUNTIME_FILE
15
+ from clens.runtime import RuntimeState
16
+ from importlib.metadata import version
17
+
18
+
19
+ class ClensCLIParser(argparse.ArgumentParser):
20
+ COMMANDS = {
21
+ "server": {
22
+ "desc": "Manage the clens server",
23
+ "arguments": {
24
+ "start": {
25
+ "desc": "Start the clens server",
26
+ "arguments": {},
27
+ "options": {
28
+ "-H, --host": "Host to start the server on",
29
+ "-p, --port": "Port to start the server on",
30
+ },
31
+ "flags": {
32
+ "-d, --detach": "Detach server from the current shell session",
33
+ },
34
+ "req_server": False,
35
+ },
36
+ "stop": {
37
+ "desc": "Stop the clens server",
38
+ "arguments": {},
39
+ "options": {},
40
+ "flags": {},
41
+ "req_server": True,
42
+ },
43
+ "status": {
44
+ "desc": "Get the status of the clens server",
45
+ "arguments": {},
46
+ "options": {},
47
+ "flags": {},
48
+ "req_server": False,
49
+ },
50
+ },
51
+ "options": {},
52
+ "flags": {},
53
+ "req_server": False,
54
+ },
55
+ "index": {
56
+ "desc": "Index a given directory of images",
57
+ "arguments": {
58
+ "path": {
59
+ "desc": "Directory of images to index",
60
+ "arguments": {},
61
+ "options": {},
62
+ "flags": {},
63
+ },
64
+ },
65
+ "options": {},
66
+ "flags": {},
67
+ "req_server": True,
68
+ },
69
+ "find": {
70
+ "desc": "Find an image from the indexed directories",
71
+ "arguments": {
72
+ "query_term": {
73
+ "desc": "Query term to search for",
74
+ "arguments": {},
75
+ "options": {},
76
+ "flags": {},
77
+ }
78
+ },
79
+ "options": {"-c, --count": "Number of images to find (default: 5)"},
80
+ "flags": {"-p, --plain": "Output plain tab-seperated result"},
81
+ "req_server": True,
82
+ },
83
+ "match": {
84
+ "desc": "Match an image from the indexed directories",
85
+ "arguments": {
86
+ "image_path": {
87
+ "desc": "Image to find matches for",
88
+ "arguments": {},
89
+ "options": {},
90
+ "flags": {},
91
+ }
92
+ },
93
+ "options": {"-c, --count": "Numbers of images to find (default: 5)"},
94
+ "flags": {"-p, --plain": "Output plain tab-seperated result"},
95
+ "req_server": True,
96
+ },
97
+ "version": {
98
+ "desc": "Print the current clens version",
99
+ "arguments": {},
100
+ "options": {},
101
+ "flags": {},
102
+ "req_server": False,
103
+ },
104
+ }
105
+
106
+ def __init__(self, *args, custom_help: str | None = None, **kwargs):
107
+ super().__init__(*args, **kwargs)
108
+ self.custom_help = custom_help
109
+
110
+ @override
111
+ def format_help(self) -> str:
112
+ if self.custom_help:
113
+ return self.custom_help
114
+ if self.prog != "clens":
115
+ return super().format_help()
116
+ command_desc = ""
117
+ for command in self.COMMANDS.keys():
118
+ command_desc += f" {command}: {self.COMMANDS[command]['desc']}\n"
119
+
120
+ return f"""
121
+ clens: Local semantic image search tool
122
+
123
+ Description:
124
+ Clens is a semantic image search tool that allows users
125
+ to index a given directory of images and search through it
126
+ using natural language, search for matching images using
127
+ another image etc. locally on their device.
128
+
129
+ Usage:
130
+ clens [COMMAND] <ARGUMENT> <...options>
131
+ clens [COMMAND] --help
132
+
133
+ Commands:
134
+ {command_desc}
135
+
136
+ Examples:
137
+ clens server start --port 6969 --detach
138
+
139
+ clens index ./Pictures
140
+
141
+ clens find "a dog with a hat"
142
+
143
+ """
144
+
145
+
146
+ logging.basicConfig(
147
+ level=logging.INFO,
148
+ format=" [%(levelname)s] %(name)s: %(message)s",
149
+ )
150
+
151
+ logger = logging.getLogger(__name__)
152
+ runtime_state = RuntimeState().load_runtime()
153
+
154
+ config = AppConfig.load_from_file(CONFIG_FILE)
155
+ config.save_to_file()
156
+
157
+
158
+ def cli():
159
+
160
+ clens_parser = ClensCLIParser(prog="clens", add_help=True)
161
+ commands = clens_parser.add_subparsers(dest="command")
162
+
163
+ # Server sub command
164
+ server_parser = commands.add_parser(
165
+ "server",
166
+ custom_help=format_custom_help(
167
+ "clens server", ClensCLIParser.COMMANDS["server"]
168
+ ),
169
+ )
170
+ server_commands = server_parser.add_subparsers(dest="server_action")
171
+
172
+ server_start_parser = server_commands.add_parser(
173
+ "start",
174
+ custom_help=format_custom_help(
175
+ "clens server start",
176
+ ClensCLIParser.COMMANDS["server"]["arguments"]["start"],
177
+ ),
178
+ )
179
+ server_start_parser.add_argument(
180
+ "-H",
181
+ "--host",
182
+ type=str,
183
+ default=config.preferences.default_host,
184
+ )
185
+ server_start_parser.add_argument(
186
+ "-p",
187
+ "--port",
188
+ type=int,
189
+ default=config.preferences.default_port,
190
+ )
191
+ server_start_parser.add_argument(
192
+ "-d",
193
+ "--detach",
194
+ action="store_true",
195
+ )
196
+
197
+ server_commands.add_parser(
198
+ "stop",
199
+ custom_help=format_custom_help(
200
+ "clens server stop",
201
+ ClensCLIParser.COMMANDS["server"]["arguments"]["stop"],
202
+ ),
203
+ )
204
+ server_commands.add_parser(
205
+ "status",
206
+ custom_help=format_custom_help(
207
+ "clens server status",
208
+ ClensCLIParser.COMMANDS["server"]["arguments"]["status"],
209
+ ),
210
+ )
211
+
212
+ # Index sub command
213
+ index_parser = commands.add_parser(
214
+ "index",
215
+ custom_help=format_custom_help("clens index", ClensCLIParser.COMMANDS["index"]),
216
+ )
217
+ index_parser.add_argument(
218
+ "path",
219
+ nargs="?",
220
+ default=None,
221
+ )
222
+
223
+ # Find sub command
224
+ find_parser = commands.add_parser(
225
+ "find",
226
+ custom_help=format_custom_help("clens find", ClensCLIParser.COMMANDS["find"]),
227
+ )
228
+ find_parser.add_argument(
229
+ "query_term",
230
+ nargs="?",
231
+ default=None,
232
+ )
233
+ find_parser.add_argument(
234
+ "-p",
235
+ "--plain",
236
+ action="store_true",
237
+ )
238
+ find_parser.add_argument(
239
+ "-c",
240
+ "--count",
241
+ type=int,
242
+ default=5,
243
+ )
244
+
245
+ # Match sub command
246
+ match_parser = commands.add_parser(
247
+ "match",
248
+ custom_help=format_custom_help("clens match", ClensCLIParser.COMMANDS["match"]),
249
+ )
250
+ match_parser.add_argument(
251
+ "image_path",
252
+ nargs="?",
253
+ default=None,
254
+ )
255
+ match_parser.add_argument(
256
+ "-c",
257
+ "--count",
258
+ type=int,
259
+ default=5,
260
+ )
261
+ match_parser.add_argument(
262
+ "-p",
263
+ "--plain",
264
+ action="store_true",
265
+ )
266
+
267
+ # Version sub command
268
+ version_parser = commands.add_parser(
269
+ "version",
270
+ custom_help=format_custom_help(
271
+ "clens version", ClensCLIParser.COMMANDS["version"]
272
+ ),
273
+ )
274
+
275
+ args = clens_parser.parse_args()
276
+
277
+ if args.command is not None and clens_parser.COMMANDS[args.command]["req_server"]:
278
+ if not RUNTIME_FILE.exists():
279
+ logger.error("Clens server is not running!")
280
+ logger.info("Start the server first with: clens server start")
281
+ sys.exit(1)
282
+
283
+ match args.command:
284
+ case "server":
285
+ match args.server_action:
286
+ case "start":
287
+ from clens.command_handlers import cmd_start_server
288
+
289
+ cmd_start_server(args.host, args.port, args.detach)
290
+ case "stop":
291
+ from clens.command_handlers import cmd_stop_server
292
+
293
+ cmd_stop_server()
294
+ case "status":
295
+ from clens.command_handlers import cmd_server_status
296
+
297
+ cmd_server_status()
298
+ case _:
299
+ server_parser.print_help()
300
+ sys.exit(0)
301
+
302
+ case "index":
303
+ if args.path is None:
304
+ index_parser.print_help()
305
+ sys.exit(0)
306
+ from clens.command_handlers import cmd_index_images
307
+
308
+ cmd_index_images(args.path)
309
+ case "find":
310
+ if args.query_term is None:
311
+ find_parser.print_help()
312
+ sys.exit(0)
313
+ from clens.command_handlers import cmd_find_images
314
+
315
+ cmd_find_images(args.query_term, args.count, args.plain)
316
+ case "match":
317
+ if args.image_path is None:
318
+ match_parser.print_help()
319
+ sys.exit(0)
320
+ from clens.command_handlers import cmd_match_image
321
+
322
+ cmd_match_image(args.image_path, args.count, args.plain)
323
+ case "version":
324
+ print(f"clens v{version('clens-cli')}")
325
+ case _:
326
+ clens_parser.print_help()
327
+ sys.exit(0)
328
+
329
+
330
+ def format_custom_help(prog: str, cmd_data: dict) -> str:
331
+ prog_name = prog if prog.startswith("clens") else f"clens {prog}"
332
+ desc = cmd_data.get("desc", "")
333
+ args = cmd_data.get("arguments", {})
334
+ options = cmd_data.get("options", {})
335
+ flags = cmd_data.get("flags", {})
336
+
337
+ usage_parts = [prog_name, "[-h]"]
338
+
339
+ for flag_key in flags.keys():
340
+ short = flag_key.split(",")[0].strip()
341
+ usage_parts.append(f"[{short}]")
342
+
343
+ for opt_key in options.keys():
344
+ parts = [p.strip() for p in opt_key.split(",")]
345
+ short = parts[0]
346
+ val_name = parts[-1].lstrip("-").upper()
347
+ usage_parts.append(f"[{short} {val_name}]")
348
+
349
+ has_subcommands = len(args) > 1 or any(
350
+ isinstance(v, dict)
351
+ and (v.get("options") or v.get("flags") or v.get("arguments"))
352
+ for v in args.values()
353
+ )
354
+
355
+ if args:
356
+ if has_subcommands:
357
+ usage_parts.append(f"{{{','.join(args.keys())}}}")
358
+ else:
359
+ usage_parts.extend(f"<{k}>" for k in args.keys())
360
+
361
+ sections = [
362
+ f"\n{prog_name}: {desc}\n",
363
+ f"Usage:\n {' '.join(usage_parts)}\n",
364
+ ]
365
+
366
+ if args:
367
+ header = "Commands:" if has_subcommands else "Arguments:"
368
+ lines = [f"{header}"]
369
+ max_w = max(len(k) for k in args.keys())
370
+ for k, v in args.items():
371
+ arg_desc = v.get("desc", "") if isinstance(v, dict) else str(v)
372
+ lines.append(f" {k:<{max_w}} {arg_desc}")
373
+ sections.append("\n".join(lines) + "\n")
374
+
375
+ if options:
376
+ lines = ["Options:"]
377
+ max_w = max(len(k) for k in options.keys())
378
+ for k, v in options.items():
379
+ lines.append(f" {k:<{max_w}} {v}")
380
+ sections.append("\n".join(lines) + "\n")
381
+
382
+ all_flags = {**flags, "-h, --help": "Show this help message and exit\n"}
383
+ lines = ["Flags:"]
384
+ max_w = max(len(k) for k in all_flags.keys())
385
+ for k, v in all_flags.items():
386
+ lines.append(f" {k:<{max_w}} {v}")
387
+ sections.append("\n".join(lines) + "\n")
388
+
389
+ return "\n".join(sections)
@@ -0,0 +1,322 @@
1
+ import signal
2
+ import requests
3
+ from pathlib import Path
4
+ import logging
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ import time
9
+ from clens.data_schemas import ResponseBody
10
+ from pydantic import ValidationError
11
+ from urllib.parse import urljoin, quote
12
+ from clens.paths import RUNTIME_FILE, LOG_FILE
13
+ from clens.cli import runtime_state
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def cmd_start_server(host: str, port: int, detach: bool):
19
+
20
+ if RUNTIME_FILE.exists() and runtime_state.pid is not None:
21
+ logger.warning("Clens server is already running!")
22
+ return
23
+
24
+ os.environ["CLENS_RUNTIME_HOST"] = host
25
+ os.environ["CLENS_RUNTIME_PORT"] = str(port)
26
+
27
+ if not detach:
28
+ try:
29
+ logger.info(f"Server running at http://{host}:{port}")
30
+ import uvicorn
31
+
32
+ uvicorn.run(
33
+ "clens.server:clens_server",
34
+ host=host,
35
+ port=port,
36
+ log_level=logging.INFO,
37
+ access_log=False,
38
+ )
39
+
40
+ finally:
41
+ logger.info(f"Server shutting down....")
42
+ logger.info(f"Server shutdown successfully.")
43
+ else:
44
+ logger.info(f"Starting clens server...")
45
+ LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
46
+
47
+ output = open(LOG_FILE, "a")
48
+
49
+ start_cmd = [
50
+ sys.executable,
51
+ "-m",
52
+ "uvicorn",
53
+ "clens.server:clens_server",
54
+ "--host",
55
+ host,
56
+ "--port",
57
+ str(port),
58
+ "--log-level",
59
+ "warning",
60
+ ]
61
+
62
+ process = subprocess.Popen(
63
+ start_cmd, stdout=output, stderr=output, start_new_session=True
64
+ )
65
+ output.close()
66
+
67
+ # Check if server is actually up
68
+ health_url = f"http://{host}:{port}/api/health"
69
+ server_started = False
70
+ timeout = 60.0
71
+ start_time = time.time()
72
+
73
+ while time.time() - start_time < timeout:
74
+ exit_code = process.poll()
75
+ if exit_code is not None:
76
+ break
77
+
78
+ try:
79
+ res = requests.get(health_url, timeout=0.2)
80
+ if res.status_code == 200:
81
+ server_started = True
82
+ break
83
+ except requests.RequestException:
84
+ time.sleep(0.1)
85
+
86
+ if server_started:
87
+ logger.info(
88
+ f"Server started in background on http://{host}:{port} (PID: {process.pid})"
89
+ )
90
+ logger.info(f"Logs available at: {LOG_FILE}")
91
+ else:
92
+ # if server didnt respond check if the subprocess is still up
93
+
94
+ # if not timeout and kill the process
95
+ if process.poll() is None:
96
+ process.terminate()
97
+ process.wait(timeout=1.0)
98
+ logger.error("Server start failed! Clens server timed out.")
99
+
100
+ logger.error("Failed to start clens server!")
101
+
102
+
103
+ def cmd_stop_server():
104
+ if not RUNTIME_FILE.exists():
105
+ logger.error("No running clens server found.")
106
+ return
107
+ try:
108
+ os.kill(runtime_state.pid, signal.SIGTERM)
109
+ logger.info(f"Clens server stopped successfully (PID: {runtime_state.pid})")
110
+ except ProcessLookupError:
111
+ logger.info(f"Clens server was already stopped")
112
+
113
+
114
+ def cmd_server_status():
115
+ if RUNTIME_FILE.exists() and runtime_state.pid is not None:
116
+ try:
117
+ os.kill(runtime_state.pid, 0)
118
+ print(
119
+ f"Clens server is running on http://{runtime_state.active_host}:{runtime_state.active_port} (PID: {runtime_state.pid}) "
120
+ )
121
+ return
122
+ except ProcessLookupError, OSError:
123
+ pass
124
+ else:
125
+ print("Clens server isnt running!! Start the server with: clens server start")
126
+
127
+
128
+ def cmd_index_images(path: str) -> None:
129
+ root_dir = Path(path).absolute()
130
+ url = f"http://{runtime_state.active_host}:{runtime_state.active_port}/api/index"
131
+
132
+ logger.info(f"Indexing the directory: {str(root_dir)}...")
133
+ logger.info(f"This may take a few moments...")
134
+
135
+ try:
136
+ response = requests.post(
137
+ url,
138
+ json={"path": str(root_dir)},
139
+ )
140
+ response.raise_for_status()
141
+
142
+ data = ResponseBody.model_validate_json(response.text)
143
+
144
+ logger.info(f"{data.message}")
145
+
146
+ except ValidationError as VE:
147
+ logger.error(f"API contract violation (Invalid response schema): {VE}")
148
+ except requests.exceptions.ConnectionError:
149
+ logger.error(f"Could not connect to {url}")
150
+ except requests.exceptions.Timeout:
151
+ logger.error(f"Request to {url} timed out")
152
+ except requests.exceptions.HTTPError as e:
153
+ data = ResponseBody.model_validate_json(response.text)
154
+ logger.error(f"{data.message}")
155
+ except requests.exceptions.RequestException as e:
156
+ logger.error(f"Request failed: {e}")
157
+
158
+ return None
159
+
160
+
161
+ def cmd_find_images(query: str, count: int, plain: bool) -> None:
162
+
163
+ if count <= 0:
164
+ logger.error(f"count must be greater than 0: got -c/--count: {count}")
165
+ sys.exit(1)
166
+
167
+ url = urljoin(
168
+ f"http://{runtime_state.active_host}:{runtime_state.active_port}/api/find/",
169
+ quote(query),
170
+ )
171
+
172
+ logger.info(f"Finding results for {query}...")
173
+ logger.info(f"This may take a few moments...")
174
+
175
+ try:
176
+ response = requests.get(
177
+ url,
178
+ params={"count": count},
179
+ )
180
+ response.raise_for_status()
181
+ data = ResponseBody.model_validate_json(response.text)
182
+
183
+ logger.info(f"{data.message}")
184
+ if not plain:
185
+ display_results_table(data.body)
186
+ return
187
+ display_results_plain(data.body)
188
+
189
+ except ValidationError as VE:
190
+ logger.error(f"API contract violation (Invalid response schema): {VE}")
191
+ except requests.exceptions.ConnectionError:
192
+ logger.error(f"Connection: Could not connect to {url}")
193
+ except requests.exceptions.Timeout:
194
+ logger.error(f"Timeout: Request to {url} timed out")
195
+ except requests.exceptions.HTTPError:
196
+ data = ResponseBody.model_validate_json(response.text)
197
+ logger.error(f"{data.message}")
198
+ if data.error_type == "ChromaNotFoundError":
199
+ logger.info(f"Index a directory first! Try: clens index <path>")
200
+ except requests.exceptions.RequestException as e:
201
+ logger.error(f"Request failed: {e}")
202
+ return None
203
+
204
+
205
+ def cmd_match_image(img_path: str, count: int, plain: bool) -> None:
206
+ image_to_match = Path(img_path).absolute()
207
+ print(count)
208
+
209
+ if count <= 0:
210
+ logger.error(f"count must be greater than 0: got -c/--count: {count}")
211
+ sys.exit(1)
212
+
213
+ url = f"http://{runtime_state.active_host}:{runtime_state.active_port}/api/match"
214
+
215
+ logger.info(f"Finding matches for {os.path.basename(img_path)}...")
216
+ logger.info(f"This may take a few moments...")
217
+
218
+ try:
219
+ response = requests.get(
220
+ url,
221
+ params={"image_path": img_path, "count": count},
222
+ )
223
+ response.raise_for_status()
224
+ data = ResponseBody.model_validate_json(response.text)
225
+
226
+ logger.info(f"{data.message}")
227
+ if not plain:
228
+ display_results_table(data.body)
229
+ return
230
+ display_results_plain(data.body)
231
+
232
+ except ValidationError as VE:
233
+ logger.error(f"API contract violation (Invalid response schema): {VE}")
234
+ except requests.exceptions.ConnectionError:
235
+ logger.error(f"Connection: Could not connect to {url}")
236
+ except requests.exceptions.Timeout:
237
+ logger.error(f"Timeout: Request to {url} timed out")
238
+ except requests.exceptions.HTTPError:
239
+ data = ResponseBody.model_validate_json(response.text)
240
+ if data.error_type == "ChromaNotFoundError":
241
+ logger.info(f"Index a directory first! Try: clens index <path>")
242
+ except requests.exceptions.RequestException as e:
243
+ logger.error(f"Request failed: {e}")
244
+ return None
245
+
246
+
247
+ def display_results_table(response) -> None:
248
+ results = response.get("message", response)
249
+ metadatas = results.get("metadatas", [[]])
250
+ distances = results.get("distances", [[]])
251
+
252
+ if not metadatas or not metadatas[0]:
253
+ print("\n\033[33mNo matching images found.\033[0m\n")
254
+ return
255
+
256
+ items_meta = metadatas[0]
257
+ items_dist = distances[0] if distances else [0.0] * len(items_meta)
258
+
259
+ headers = ["RANK", "SCORE", "FILENAME", "PATH"]
260
+ rows = []
261
+
262
+ for i, (meta, dist) in enumerate(zip(items_meta, items_dist), start=1):
263
+ path_str = meta.get("path", "")
264
+ filename = Path(path_str).name or "Unknown"
265
+
266
+ # In ChromaDB cosine space: distance = 1 - cosine_similarity
267
+ # So: similarity = (1 - distance) * 100%
268
+ similarity = max(0.0, min(1.0, 1.0 - dist)) * 100
269
+
270
+ rows.append([f"#{i}", f"{similarity:.1f}%", filename, path_str])
271
+
272
+ # 1. Compute dynamic column widths so nothing wraps awkwardly
273
+ widths = [len(h) for h in headers]
274
+ for row in rows:
275
+ for idx, col in enumerate(row):
276
+ widths[idx] = max(widths[idx], len(col))
277
+
278
+ # 2. Build dividers and format strings
279
+ divider = "─" * (sum(widths) + 2 * (len(widths) - 1))
280
+
281
+ # 3. Print table with clean ANSI styling (hardcoded bright white base)
282
+ print()
283
+ header_str = " ".join(f"{h:<{widths[i]}}" for i, h in enumerate(headers))
284
+ print(f"\033[1;97m{header_str}\033[0m")
285
+ print(f"\033[97m{divider}\033[0m")
286
+
287
+ for rank, score, fname, pth in rows:
288
+ # Green score accent, clickable OSC 8 hyperlink on path, white base colors
289
+ # get file path with file:// prefixed
290
+ uri = Path(pth).resolve().as_uri()
291
+ # turn that into a hyprlink with OSC 8 esc sequence
292
+ linked_pth = f"\033]8;;{uri}\033\\{pth}\033]8;;\033\\"
293
+ print(
294
+ f"\033[97m{rank:<{widths[0]}}\033[0m "
295
+ f"\033[1;32m{score:<{widths[1]}}\033[0m "
296
+ f"\033[1;97m{fname:<{widths[2]}}\033[0m "
297
+ f"\033[97m{linked_pth}\033[0m"
298
+ )
299
+
300
+ print(f"\033[97m{divider}\033[0m")
301
+ print(f"\033[97mFound {len(rows)} matching results\033[0m\n")
302
+
303
+
304
+ def display_results_plain(response) -> None:
305
+ results = response.get("message", response)
306
+ metadatas = results.get("metadatas", [[]])
307
+ distances = results.get("distances", [[]])
308
+
309
+ if not metadatas or not metadatas[0]:
310
+ return
311
+
312
+ items_meta = metadatas[0]
313
+ items_dist = distances[0] if distances else [0.0] * len(items_meta)
314
+
315
+ headers = ["RANK", "SCORE", "FILENAME", "PATH"]
316
+ print("\t".join(headers))
317
+
318
+ for i, (meta, dist) in enumerate(zip(items_meta, items_dist), start=1):
319
+ path_str = meta.get("path", "")
320
+ filename = Path(path_str).name or "Unknown"
321
+ similarity = max(0.0, min(1.0, 1.0 - dist)) * 100
322
+ print(f"{i}\t{similarity:.1f}%\t{filename}\t{path_str}")
clens/data_schemas.py ADDED
@@ -0,0 +1,29 @@
1
+ from pydantic import BaseModel
2
+ from pathlib import Path
3
+ from typing import Generic, TypeVar, TypedDict
4
+ from numpy.typing import NDArray
5
+ import numpy as np
6
+
7
+ T = TypeVar("T")
8
+
9
+
10
+ class IndexDirData(BaseModel):
11
+ path: str
12
+
13
+
14
+ class ResponseBody(BaseModel, Generic[T]):
15
+ message: str
16
+ body: T | None = None
17
+ error_type: str | None = None
18
+
19
+
20
+ class ImageData(TypedDict):
21
+ id: str
22
+ file_type: str
23
+ path: Path
24
+ embedding: NDArray[np.float32]
25
+
26
+
27
+ class Candidate(TypedDict):
28
+ id: str
29
+ path: Path
clens/data_store.py ADDED
@@ -0,0 +1,56 @@
1
+ from typing import TYPE_CHECKING
2
+ from clens.paths import STORE_DIR
3
+ from clens.data_schemas import Candidate
4
+
5
+ if TYPE_CHECKING:
6
+ from chromadb.api import ClientAPI
7
+ from chromadb.api.types import QueryResult
8
+ from clens.embedder import ImageData
9
+
10
+
11
+ _chroma_client = None
12
+
13
+
14
+ def get_chroma_client() -> ClientAPI:
15
+ from chromadb import PersistentClient
16
+
17
+ global _chroma_client
18
+ if _chroma_client is None:
19
+ _chroma_client = PersistentClient(path=STORE_DIR)
20
+ return _chroma_client
21
+
22
+
23
+ def filter_preexisting_images(
24
+ client: ClientAPI, candidates: list[Candidate]
25
+ ) -> list[Candidate]:
26
+ image_collection = client.get_or_create_collection(
27
+ "image_data", configuration={"hnsw": {"space": "cosine"}}
28
+ )
29
+
30
+ existing_ids: set[str] = set(
31
+ image_collection.get(ids=[entry["id"] for entry in candidates])["ids"]
32
+ )
33
+
34
+ if len(existing_ids) == 0:
35
+ return candidates
36
+
37
+ return [entry for entry in candidates if entry["id"] not in existing_ids]
38
+
39
+
40
+ def store_image_data(client: ClientAPI, image_data: list[ImageData]) -> None:
41
+ image_collection = client.get_or_create_collection(
42
+ "image_data", configuration={"hnsw": {"space": "cosine"}}
43
+ )
44
+ image_collection.add(
45
+ ids=[str(data["id"]) for data in image_data],
46
+ embeddings=[data["embedding"] for data in image_data],
47
+ metadatas=[
48
+ {"file_type": str(data["file_type"]), "path": str(data["path"])}
49
+ for data in image_data
50
+ ],
51
+ )
52
+
53
+
54
+ def retrieve_images(embedding, client: ClientAPI, result_count: int) -> QueryResult:
55
+ image_collection = client.get_collection("image_data")
56
+ return image_collection.query(query_embeddings=embedding, n_results=result_count)
clens/dir_crawler.py ADDED
@@ -0,0 +1,18 @@
1
+ from pathlib import Path
2
+
3
+ image_types = ["jpg", "png"]
4
+
5
+
6
+ def find_images(root_dir: str | Path) -> list[Path]:
7
+ root = Path(root_dir)
8
+ if root.is_file():
9
+ raise NotADirectoryError(f"Path must be a directory and not a file: {root}")
10
+ if not root.exists():
11
+ raise FileNotFoundError(f"No such directory found: {root}")
12
+
13
+ file_paths: list[Path] = []
14
+ for image_type in image_types:
15
+ for file_path in root.rglob(f"*.{image_type}"):
16
+ file_paths.append(file_path.absolute())
17
+
18
+ return file_paths
clens/embedder.py ADDED
@@ -0,0 +1,74 @@
1
+ from pathlib import Path
2
+ from filetype import guess_mime
3
+ import uuid
4
+ import hashlib
5
+ from numpy.typing import NDArray
6
+ import numpy as np
7
+ from clens.data_schemas import ImageData, Candidate
8
+
9
+ CLIP_MODEL = "clip-ViT-B-32"
10
+ _model = None
11
+
12
+
13
+ def get_model():
14
+ from sentence_transformers import SentenceTransformer
15
+
16
+ global _model
17
+ if _model is None:
18
+ try:
19
+ _model = SentenceTransformer(CLIP_MODEL, local_files_only=True)
20
+ except Exception:
21
+ _model = SentenceTransformer(CLIP_MODEL)
22
+ return _model
23
+
24
+
25
+ def embed_image(image_data) -> NDArray[np.float32]:
26
+
27
+ model = get_model()
28
+ return model.encode(image_data, normalize_embeddings=True)
29
+
30
+
31
+ def generate_image_hash(image_path: Path) -> str:
32
+ sha256 = hashlib.sha256()
33
+ with open(image_path, "rb") as img:
34
+ while chunk := img.read(65536):
35
+ sha256.update(chunk)
36
+ return sha256.hexdigest()
37
+
38
+
39
+ def embed_images(files: list[Path]) -> list[ImageData]:
40
+ from clens.data_store import filter_preexisting_images, get_chroma_client
41
+
42
+ candidates: list[Candidate] = []
43
+ for file in files:
44
+ image_hash = generate_image_hash(file)
45
+ candidates.append({"id": image_hash, "path": file})
46
+
47
+ chroma_client = get_chroma_client()
48
+
49
+ new_images = filter_preexisting_images(chroma_client, candidates)
50
+
51
+ if len(new_images) == 0:
52
+ return []
53
+
54
+ embedded: list[ImageData] = []
55
+ for candidate in new_images:
56
+ from PIL import Image
57
+
58
+ pil_image = Image.open(candidate["path"])
59
+ embedding = embed_image(pil_image)
60
+ embedded.append(
61
+ {
62
+ "id": candidate["id"],
63
+ "file_type": guess_mime(candidate["path"]),
64
+ "path": candidate["path"],
65
+ "embedding": embedding,
66
+ }
67
+ )
68
+ return embedded
69
+
70
+
71
+ def embed_text(query_text: str) -> NDArray[np.float32]:
72
+ model = get_model()
73
+
74
+ return model.encode(query_text, normalize_embeddings=True)
clens/indexer.py ADDED
@@ -0,0 +1,20 @@
1
+ from clens.dir_crawler import find_images
2
+ from clens.embedder import embed_images
3
+ from clens.data_store import get_chroma_client, store_image_data
4
+
5
+
6
+ def index_images(root_dir: str) -> None:
7
+ # get images recursively form the provided dir
8
+ file_paths = find_images(root_dir)
9
+
10
+ # generate embeddings from images
11
+ image_data = embed_images(file_paths)
12
+
13
+ if len(image_data) == 0:
14
+ return
15
+
16
+ # get the shared chroma client
17
+ chroma_client = get_chroma_client()
18
+
19
+ # store the embeddings in chroma db
20
+ store_image_data(chroma_client, image_data)
clens/paths.py ADDED
@@ -0,0 +1,22 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ APP = "clens"
5
+
6
+
7
+ def _xdg(env: str, fallback: Path) -> Path:
8
+ base = os.environ.get(env)
9
+ return Path(base) if base else fallback
10
+
11
+
12
+ CONFIG_DIR = _xdg("XDG_CONFIG_HOME", Path.home() / ".config") / APP
13
+ DATA_DIR = _xdg("XDG_DATA_HOME", Path.home() / ".local/share") / APP
14
+ STATE_DIR = _xdg("XDG_STATE_HOME", Path.home() / ".local/state") / APP
15
+
16
+ _runtime = os.environ.get("XDG_RUNTIME_DIR")
17
+ RUNTIME_DIR = (Path(_runtime) if _runtime else STATE_DIR) / APP
18
+
19
+ CONFIG_FILE = CONFIG_DIR / "config.json"
20
+ STORE_DIR = DATA_DIR / "chroma"
21
+ LOG_FILE = STATE_DIR / "logs" / "clens.log"
22
+ RUNTIME_FILE = RUNTIME_DIR / "runtime.json"
clens/runtime.py ADDED
@@ -0,0 +1,31 @@
1
+ from pydantic import BaseModel, Field
2
+ from pathlib import Path
3
+ from clens.paths import RUNTIME_FILE
4
+ import json
5
+ import os
6
+
7
+
8
+ class RuntimeState(BaseModel):
9
+ active_host: str | None = None
10
+ active_port: int | None = None
11
+ pid: int | None = None
12
+
13
+ @classmethod
14
+ def load_runtime(cls, path: Path = RUNTIME_FILE) -> "RuntimeState":
15
+ if not path.exists():
16
+ return cls()
17
+ try:
18
+ with open(path, "r") as f:
19
+ runtime_data = json.load(f)
20
+ return cls.model_validate(runtime_data)
21
+ except:
22
+ return cls()
23
+
24
+ def save_runtime(self, path: Path = RUNTIME_FILE) -> None:
25
+ path.parent.mkdir(parents=True, exist_ok=True)
26
+ with open(path, "w") as f:
27
+ f.write(self.model_dump_json(indent=2))
28
+
29
+ def clear_runtime(self, path: Path = RUNTIME_FILE) -> None:
30
+ if path.exists():
31
+ os.remove(path)
clens/search.py ADDED
@@ -0,0 +1,25 @@
1
+ from pathlib import Path
2
+ from PIL import Image
3
+
4
+
5
+ def find_images(query_text: str, count: int):
6
+ from clens.embedder import embed_text
7
+ from clens.data_store import get_chroma_client, retrieve_images
8
+
9
+ query_text_embd = embed_text(query_text)
10
+ chroma_client = get_chroma_client()
11
+
12
+ return retrieve_images(query_text_embd, chroma_client, count)
13
+
14
+
15
+ def match_images(img_path: Path, count: int):
16
+ from clens.embedder import embed_image
17
+ from clens.data_store import get_chroma_client, retrieve_images
18
+ from PIL import Image
19
+
20
+ image_data = Image.open(img_path)
21
+ image_embedding = embed_image(image_data)
22
+
23
+ chroma_client = get_chroma_client()
24
+
25
+ return retrieve_images(image_embedding, chroma_client, count)
clens/server.py ADDED
@@ -0,0 +1,100 @@
1
+ from fastapi import FastAPI, Response, status
2
+ from contextlib import asynccontextmanager
3
+ from clens.data_schemas import IndexDirData, ResponseBody
4
+ import logging
5
+ from clens.embedder import get_model
6
+ from clens.data_store import get_chroma_client
7
+ import asyncio
8
+ from clens.runtime import RuntimeState
9
+ from chromadb.errors import NotFoundError as ChromaNotFoundError
10
+ import os
11
+
12
+ runtime_state = RuntimeState().load_runtime()
13
+
14
+
15
+ @asynccontextmanager
16
+ async def lifespan(app: FastAPI):
17
+ runtime_state.active_host = os.environ.get("CLENS_RUNTIME_HOST")
18
+ runtime_state.active_port = int(os.environ.get("CLENS_RUNTIME_PORT"))
19
+ runtime_state.pid = os.getpid()
20
+ runtime_state.save_runtime()
21
+ await asyncio.to_thread(get_model)
22
+ get_chroma_client()
23
+ yield
24
+ runtime_state.clear_runtime()
25
+
26
+
27
+ clens_server = FastAPI(lifespan=lifespan)
28
+
29
+ logging.basicConfig(
30
+ level=logging.INFO, format=" [%(levelname)s] %(name)s: %(message)s", force=True
31
+ )
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ @clens_server.get("/api/health", response_model=ResponseBody)
36
+ def health():
37
+ return ResponseBody(message="Status OK")
38
+
39
+
40
+ @clens_server.post("/api/index", response_model=ResponseBody)
41
+ def index_dir(req: IndexDirData, response: Response):
42
+ from clens.indexer import index_images
43
+
44
+ try:
45
+ index_images(req.path)
46
+ return ResponseBody(message=f"The directory {req.path} has been indexed")
47
+ except NotADirectoryError as NAD:
48
+ response.status_code = status.HTTP_400_BAD_REQUEST
49
+ return ResponseBody(message=f"{NAD}", error_type="NotADirectoryError")
50
+ except FileNotFoundError as FNF:
51
+ response.status_code = status.HTTP_400_BAD_REQUEST
52
+ return ResponseBody(message=f"{FNF}", error_type="FileNotFoundError")
53
+ except Exception as E:
54
+ response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
55
+ return ResponseBody(
56
+ message=f"Something went wrong", error_type="InternalServerError"
57
+ )
58
+
59
+
60
+ @clens_server.get("/api/find/{query_term}", response_model=ResponseBody)
61
+ def find_image(query_term: str, response: Response, count: int = 5):
62
+ from clens.search import find_images
63
+
64
+ try:
65
+ result = find_images(query_term, count)
66
+ return ResponseBody(message=f"Found results for {query_term}", body=result)
67
+ except ChromaNotFoundError:
68
+ response.status_code = status.HTTP_400_BAD_REQUEST
69
+ return ResponseBody(
70
+ message="Seems like you have no indexed directories",
71
+ error_type="ChromaNotFoundError",
72
+ )
73
+ except Exception:
74
+ response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
75
+ return ResponseBody(
76
+ message=f"Something went wrong", error_type="InternalServerError"
77
+ )
78
+
79
+
80
+ @clens_server.get("/api/match", response_model=ResponseBody)
81
+ def match_image(image_path: str, response: Response, count: int = 5):
82
+ from clens.search import match_images
83
+ from pathlib import Path
84
+
85
+ try:
86
+ result = match_images(Path(image_path).absolute(), count)
87
+ return ResponseBody(
88
+ message=f"Found matches for {os.path.basename(image_path)}", body=result
89
+ )
90
+ except ChromaNotFoundError:
91
+ response.status_code = status.HTTP_400_BAD_REQUEST
92
+ return ResponseBody(
93
+ message="Seems like you have no indexed directories",
94
+ error_type="ChromaNotFoundError",
95
+ )
96
+ except Exception:
97
+ response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
98
+ return ResponseBody(
99
+ message=f"Something went wrong", error_type="InternalServerError"
100
+ )
@@ -0,0 +1,210 @@
1
+ Metadata-Version: 2.3
2
+ Name: clens-cli
3
+ Version: 1.0.1
4
+ Summary: Local semantic image search tool
5
+ Keywords: cli,image search,semantic search,computer vision
6
+ Author: k4rkie
7
+ Author-email: k4rkie <karkeekrish07@gmail.com>
8
+ License: MIT License
9
+
10
+ Copyright (c) 2026 k4rkie
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ Classifier: Development Status :: 3 - Alpha
30
+ Classifier: Environment :: Console
31
+ Classifier: Intended Audience :: End Users/Desktop
32
+ Classifier: Programming Language :: Python :: 3.14
33
+ Classifier: Topic :: Multimedia :: Graphics
34
+ Requires-Dist: chromadb>=1.5.9
35
+ Requires-Dist: fastapi[standard]>=0.142.2
36
+ Requires-Dist: filetype>=1.2.0
37
+ Requires-Dist: pillow>=12.3.0
38
+ Requires-Dist: sentence-transformers>=6.1.0
39
+ Requires-Dist: torch>=2.14.0
40
+ Requires-Python: >=3.14
41
+ Description-Content-Type: text/markdown
42
+
43
+ <h1 align="center">clens</h1>
44
+
45
+ Clens is a semantic image search tool that allows users
46
+ to index a given directory of images and search through it
47
+ using natural language, search for matching images using
48
+ another image etc. locally on their device.
49
+
50
+ ## Installation
51
+
52
+ Clens currently requires Python 3.14 or newer. The recommended installation
53
+ uses the CPU-only PyTorch packages, so it does not download NVIDIA/CUDA
54
+ libraries on CPU machines.
55
+
56
+ ### Install with uv
57
+
58
+ Install the published package as an isolated command-line tool. The extra
59
+ PyTorch index is needed because CPU-only PyTorch wheels are published there:
60
+
61
+ ```bash
62
+ uv tool install \
63
+ --index https://download.pytorch.org/whl/cpu \
64
+ --default-index https://pypi.org/simple \
65
+ clens-cli
66
+ ```
67
+
68
+ ### Install with pipx
69
+
70
+ `pipx` also installs command-line applications into an isolated environment:
71
+
72
+ ```bash
73
+ pipx install \
74
+ --pip-args="--index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple" \
75
+ clens-cli
76
+ ```
77
+
78
+ After installation, verify the command:
79
+
80
+ ```bash
81
+ clens version
82
+ clens --help
83
+ ```
84
+
85
+ The first indexing or search operation downloads the local CLIP model so may take a few minutes. It is
86
+ cached locally and reused for later runs.
87
+
88
+ ## Usage
89
+
90
+ Clens uses a local background server (FastAPI + ChromaDB + CLIP) to keep embeddings loaded in memory for faster indexing and search.
91
+
92
+ ### 1. Server
93
+
94
+ Before indexing or searching, make sure the clens server is running.
95
+
96
+ Start the server:
97
+ ```bash
98
+ # Start in current shell
99
+ clens server start
100
+
101
+ # Or detach to run in background
102
+ clens server start --detach
103
+ ```
104
+
105
+ > Defaults to `127.0.0.1:6969`. You can pass custom host/port:
106
+ > ```bash
107
+ > clens server start --port 6969 --detach
108
+ > ```
109
+
110
+ Check status or stop:
111
+ ```bash
112
+ # Check if server is running
113
+ clens server status
114
+
115
+ # Stop running server
116
+ clens server stop
117
+ ```
118
+
119
+ ---
120
+
121
+ ### 2. Index a Directory
122
+
123
+ Recursively indexes `.png` and `.jpg` images from a directory and stores embeddings in local ChromaDB:
124
+
125
+ ```bash
126
+ clens index ./Pictures
127
+ ```
128
+ *Indexing may take a few moments depending on the number of images in the directory and your hardware.*
129
+
130
+ ---
131
+
132
+ ### 3. Find Images with Text (`find`)
133
+
134
+ Search indexed images with natural language:
135
+
136
+ ```bash
137
+ clens find "a dog with a hat"
138
+ ```
139
+
140
+ Change number of results with `-c` / `--count` (default is 5):
141
+ ```bash
142
+ clens find "a dog with a hat" -c 10
143
+ ```
144
+
145
+ ---
146
+
147
+ ### 4. Find Matching Images (`match`)
148
+
149
+ Pass an image to find visually and semantically matching images in your indexed directories:
150
+
151
+ ```bash
152
+ clens match ./sample_images/dog.png
153
+ ```
154
+
155
+ Specify number of matches:
156
+ ```bash
157
+ clens match ./sample_images/dog.png -c 3
158
+ ```
159
+
160
+ ---
161
+
162
+ ### 5. Plain Output for Scripts & Piping (`-p`)
163
+
164
+ By default, clens renders an ANSI table with match percentage and clickable file paths. Use `-p` / `--plain` to get raw tab-separated output (`RANK`, `SCORE`, `FILENAME`, `PATH`):
165
+
166
+ ```bash
167
+ # Tab-separated output
168
+ clens find "sunset" -p
169
+
170
+ # Example: pipe top result to xdg-open
171
+ clens find "cat sleeping" -c 1 -p | awk 'NR==2 {print $4}' | xargs xdg-open
172
+ ```
173
+
174
+ ---
175
+
176
+ ### Configuration
177
+
178
+ Clens automatically generates a config file at `~/.config/clens/config.json` (or `$XDG_CONFIG_HOME/clens/config.json`):
179
+
180
+ ```json
181
+ {
182
+ "preferences": {
183
+ "default_host": "127.0.0.1",
184
+ "default_port": 6969
185
+ }
186
+ }
187
+ ```
188
+
189
+ Currently available options:
190
+ * `preferences.default_host`: Default host the server binds to and client commands connect to (default: `"127.0.0.1"`).
191
+ * `preferences.default_port`: Default port used by the server and CLI (default: `6969`).
192
+
193
+ ---
194
+
195
+ ## Commands
196
+
197
+ ```
198
+ clens server start [-H HOST] [-p PORT] [-d] Start clens server (-d to detach)
199
+ clens server status Get server status
200
+ clens server stop Stop running server
201
+ clens index <path> Index a directory of images
202
+ clens find <query_term> [-c COUNT] [-p] Find images by text description
203
+ clens match <image_path> [-c COUNT] [-p] Find images matching an existing image
204
+ clens version Print current version
205
+ ```
206
+
207
+ For indepth info about the program
208
+ ```bash
209
+ clens -h/-help
210
+ ```
@@ -0,0 +1,17 @@
1
+ clens/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ clens/app_config.py,sha256=anvL6RbEIi0twmBSdi5hbXh7uzOHkEA_iqdBpESxR-c,847
3
+ clens/cli.py,sha256=9Q2g6HcOXMkqdT2uavxSUvBg7d62jSlx5mPaC2rY7Go,11623
4
+ clens/command_handlers.py,sha256=qR6OaA9erDmNO7N8QhKj5N7fRam4WPCEbEFxZ4mWElE,10804
5
+ clens/data_schemas.py,sha256=6_afD63bt8DWp3jdTeVntnsPjxeyamsKD97SQKtK7_4,510
6
+ clens/data_store.py,sha256=3_2RQwgbU3Qr7KMycv1oTNMu2CGKCv5j_1YcBtvozQE,1725
7
+ clens/dir_crawler.py,sha256=mo7PlZwnLUbO5buoCfey-7RJFLwGEnZadhVvtFcg-RM,537
8
+ clens/embedder.py,sha256=0TBTuIO_MD_MvducK3eYqWjDT-clXuMcSCWUfYgbndw,1972
9
+ clens/indexer.py,sha256=VHPYjooMnAUBh7CQ8x3oQwe9pNikO3ofeLCnlWb4kww,571
10
+ clens/paths.py,sha256=g1WCUBBkD6Q3FcUzvSA2SM3EpamHF8lnq1umx0mkzg0,653
11
+ clens/runtime.py,sha256=xf7u54O45wKygHvJpSDqpD2MY-I21brh-ey3dPvC8bc,911
12
+ clens/search.py,sha256=EzC5WmlQf6Zgb6tAXT7Bjg7QzJkXpcudsw5ZgG16730,736
13
+ clens/server.py,sha256=dxw0lFkt5b1ul-grYqNp-bpEruwT8h7llpLQ_3-OWRs,3588
14
+ clens_cli-1.0.1.dist-info/WHEEL,sha256=mru_b36sH6joUMnwf7IlFCun3RoDjrNg9RcfBmEcqsE,81
15
+ clens_cli-1.0.1.dist-info/entry_points.txt,sha256=06jhwVe7CveD15J4Sezfw6-HQHEGH-CNvD6uRUaXNwY,41
16
+ clens_cli-1.0.1.dist-info/METADATA,sha256=7DBy2qEwKhHAkdN6YbLDgAZFc_EIvWt2BThgeoPJXfQ,6098
17
+ clens_cli-1.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.11
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ clens = clens.cli:cli
3
+