claude-ai-clone-client 3.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: claude-ai-clone-client
3
+ Version: 3.0.0
4
+ Summary: CLI client for the local self-hosted AI platform — no external APIs, fully local
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: requests>=2.32.3
@@ -0,0 +1,916 @@
1
+ """CLI for the local AI platform.
2
+
3
+ Commands:
4
+ ai health Check backend health
5
+ ai hardware Show hardware info
6
+ ai model list List installed models
7
+ ai model versions <model_id> List versions of a model
8
+ ai model download <repo-url> Download from GitHub releases
9
+ ai model add <path-or-url> Import from local path or GitHub URL
10
+ ai model use <name>[:version] Load a model (optionally set version)
11
+ ai model update Check all sources for new versions
12
+ ai source list List tracked sources
13
+ ai source add Add a GitHub repo to track
14
+ ai source remove <model_id> Remove a tracked source
15
+ ai chat --model <id> Interactive chat
16
+ ai ask --model <id> --prompt .. One-shot prompt
17
+ ai run <model_id> Load model and start chatting
18
+ """
19
+
20
+ import argparse
21
+ import json
22
+ import os
23
+ import sys
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ import requests
28
+
29
+ DEFAULT_HOST = "http://127.0.0.1:8090"
30
+
31
+
32
+ # ── HTTP helpers ───────────────────────────────────────────────
33
+
34
+
35
+ def _request(
36
+ method: str, url: str, payload: dict[str, Any] | None = None
37
+ ) -> dict[str, Any]:
38
+ response = requests.request(method, url, json=payload, timeout=600)
39
+ response.raise_for_status()
40
+ return response.json()
41
+
42
+
43
+ def _stream_chat(url: str, payload: dict[str, Any]) -> str:
44
+ with requests.post(url, json=payload, stream=True, timeout=600) as response:
45
+ response.raise_for_status()
46
+ final = []
47
+ for line in response.iter_lines(decode_unicode=True):
48
+ if not line or not line.startswith("data: "):
49
+ continue
50
+ event = json.loads(line[6:])
51
+ token = event.get("token")
52
+ if token:
53
+ print(token, end="", flush=True)
54
+ final.append(token)
55
+ print()
56
+ return "".join(final).strip()
57
+
58
+
59
+ def _print_json(data: Any) -> None:
60
+ print(json.dumps(data, indent=2))
61
+
62
+
63
+ def _print_table(rows: list[dict], columns: list[str]) -> None:
64
+ if not rows:
65
+ print("(none)")
66
+ return
67
+ widths = {col: len(col) for col in columns}
68
+ for row in rows:
69
+ for col in columns:
70
+ widths[col] = max(widths[col], len(str(row.get(col, ""))))
71
+ header = " ".join(col.ljust(widths[col]) for col in columns)
72
+ print(header)
73
+ print(" ".join("-" * widths[col] for col in columns))
74
+ for row in rows:
75
+ print(" ".join(str(row.get(col, "")).ljust(widths[col]) for col in columns))
76
+
77
+
78
+ # ── Command handlers ───────────────────────────────────────────
79
+
80
+
81
+ def cmd_health(args):
82
+ data = _request("GET", f"{args.host}/health")
83
+ _print_json(data)
84
+
85
+
86
+ def cmd_hardware(args):
87
+ data = _request("GET", f"{args.host}/hardware")
88
+ _print_json(data)
89
+
90
+
91
+ def cmd_model_catalog(args):
92
+ """Browse the curated catalog of free AI models."""
93
+ data = _request("GET", f"{args.host}/catalog")
94
+ models = data.get("models", [])
95
+ if args.json:
96
+ _print_json(data)
97
+ return
98
+
99
+ # Also get installed models to show status
100
+ installed_data = _request("GET", f"{args.host}/models")
101
+ installed_ids = {m["model_id"] for m in installed_data.get("models", [])}
102
+
103
+ if args.use_case:
104
+ models = [m for m in models if args.use_case in m.get("use_case", [])]
105
+
106
+ rows = []
107
+ for m in models:
108
+ rows.append({
109
+ "model_id": m["model_id"],
110
+ "params": m.get("parameters", "?"),
111
+ "size": f"{m.get('size_gb', '?')} GB",
112
+ "license": (m.get("license") or "?")[:18],
113
+ "use_case": ", ".join(m.get("use_case", [])),
114
+ "status": "installed" if m["model_id"] in installed_ids else "available",
115
+ })
116
+ _print_table(rows, ["model_id", "params", "size", "license", "use_case", "status"])
117
+ print(f"\n {len(models)} models | To download: ai model download --model-id <id> --url <github-repo> --runtime llama_cpp")
118
+
119
+
120
+ def cmd_model_list(args):
121
+ data = _request("GET", f"{args.host}/models")
122
+ models = data.get("models", [])
123
+ if args.json:
124
+ _print_json(data)
125
+ return
126
+ rows = []
127
+ for m in models:
128
+ rows.append({
129
+ "model_id": m["model_id"],
130
+ "runtime": m["runtime"],
131
+ "format": m.get("format") or "—",
132
+ "active_v": f"v{m.get('active_version', '?')}",
133
+ "versions": len(m.get("versions", [])),
134
+ "source": m.get("source_repo") or "local",
135
+ })
136
+ _print_table(rows, ["model_id", "runtime", "format", "active_v", "versions", "source"])
137
+
138
+
139
+ def cmd_model_versions(args):
140
+ data = _request("GET", f"{args.host}/models/{args.model_id}/versions")
141
+ versions = data.get("versions", [])
142
+ if args.json:
143
+ _print_json(data)
144
+ return
145
+ rows = []
146
+ for v in versions:
147
+ rows.append({
148
+ "version": f"v{v['version']}",
149
+ "tag": v.get("version_tag") or "—",
150
+ "sha256": (v.get("sha256") or "—")[:12],
151
+ "size_gb": str(v.get("size_gb") or "—"),
152
+ "created": (v.get("created_at") or "—")[:19],
153
+ })
154
+ _print_table(rows, ["version", "tag", "sha256", "size_gb", "created"])
155
+
156
+
157
+ def cmd_model_download(args):
158
+ """Download a model — supports both direct URLs and GitHub repo discovery."""
159
+ url = args.url
160
+
161
+ # Check if this looks like a GitHub repo (not a direct file URL)
162
+ if _is_github_repo(url):
163
+ print(f"Discovering model assets in {url}...")
164
+ data = _request("GET", f"{args.host}/github/discover", None)
165
+ # Use query param instead
166
+ response = requests.get(
167
+ f"{args.host}/github/discover",
168
+ params={"repo_url": url},
169
+ timeout=30,
170
+ )
171
+ response.raise_for_status()
172
+ data = response.json()
173
+ assets = data.get("assets", [])
174
+ if not assets:
175
+ print("No model assets found in this repository's releases.", file=sys.stderr)
176
+ return
177
+
178
+ # Filter by runtime/format if specified
179
+ pattern = args.asset_pattern or f"*.{_runtime_ext(args.runtime)}"
180
+ import fnmatch
181
+ matching = [a for a in assets if fnmatch.fnmatch(a["asset_name"].lower(), pattern.lower())]
182
+
183
+ if not matching:
184
+ print(f"No assets matching '{pattern}'. Available:", file=sys.stderr)
185
+ for a in assets[:10]:
186
+ print(f" {a['asset_name']} ({a['tag']})", file=sys.stderr)
187
+ return
188
+
189
+ # Download the latest matching asset
190
+ asset = matching[0]
191
+ print(f"Found: {asset['asset_name']} (tag: {asset['tag']}, {asset['size_bytes'] / 1e9:.1f} GB)")
192
+ payload = {
193
+ "model_id": args.model_id,
194
+ "source_url": asset["download_url"],
195
+ "runtime": args.runtime,
196
+ "filename": asset["asset_name"],
197
+ "format": asset.get("format"),
198
+ "version_tag": asset["tag"],
199
+ "license": args.license,
200
+ }
201
+ else:
202
+ # Direct URL download
203
+ payload = {
204
+ "model_id": args.model_id,
205
+ "source_url": url,
206
+ "runtime": args.runtime,
207
+ "filename": args.filename,
208
+ "sha256": args.sha256,
209
+ "license": args.license,
210
+ "size_gb": args.size_gb,
211
+ "format": args.format,
212
+ "version_tag": args.version_tag,
213
+ }
214
+
215
+ print(f"Downloading {args.model_id}...")
216
+ data = _request("POST", f"{args.host}/models/download", payload)
217
+ model = data.get("model", {})
218
+ print(f"Registered {model.get('model_id')} v{model.get('active_version')} ({model.get('runtime')})")
219
+
220
+
221
+ def cmd_model_add(args):
222
+ """Smart import: detects local path vs GitHub URL."""
223
+ target = args.target
224
+
225
+ # Local path?
226
+ path = Path(target)
227
+ if path.exists():
228
+ payload = {
229
+ "model_id": args.model_id,
230
+ "local_path": str(path.resolve()),
231
+ "runtime": args.runtime,
232
+ "license": args.license,
233
+ "format": args.format,
234
+ }
235
+ data = _request("POST", f"{args.host}/models/import", payload)
236
+ model = data.get("model", {})
237
+ print(f"Imported {model.get('model_id')} v{model.get('active_version')} from {target}")
238
+ return
239
+
240
+ # GitHub URL?
241
+ if "github.com" in target or "/" in target:
242
+ # Treat as a download
243
+ args.url = target
244
+ args.filename = None
245
+ args.sha256 = None
246
+ args.size_gb = None
247
+ args.version_tag = None
248
+ args.asset_pattern = None
249
+ cmd_model_download(args)
250
+ return
251
+
252
+ print(f"Cannot resolve '{target}': not a local path or GitHub URL", file=sys.stderr)
253
+
254
+
255
+ def cmd_model_use(args):
256
+ model_id, _, version_str = args.model_version.partition(":")
257
+ if version_str:
258
+ version = int(version_str.lstrip("v"))
259
+ _request("POST", f"{args.host}/models/set-version", {
260
+ "model_id": model_id,
261
+ "version": version,
262
+ })
263
+ print(f"Set {model_id} active version to v{version}")
264
+
265
+ payload = {"model_id": model_id}
266
+ if args.threads:
267
+ payload["threads"] = args.threads
268
+ data = _request("POST", f"{args.host}/models/load", payload)
269
+ print(f"Loaded {data.get('loaded_model_id')} v{data.get('loaded_version')} "
270
+ f"({data.get('loaded_runtime')}, {data.get('threads')} threads)")
271
+
272
+
273
+ def cmd_model_update(args):
274
+ """Check tracked sources for new model versions."""
275
+ if args.model_id:
276
+ print(f"Checking updates for {args.model_id}...")
277
+ data = _request("POST", f"{args.host}/models/update/{args.model_id}")
278
+ else:
279
+ print("Checking all tracked sources for updates...")
280
+ data = _request("POST", f"{args.host}/models/update")
281
+
282
+ results = data.get("results", [])
283
+ if not results:
284
+ print("No tracked sources configured. Use 'ai source add' first.")
285
+ return
286
+
287
+ for r in results:
288
+ new = r.get("new_versions", [])
289
+ skipped = r.get("skipped_existing", 0) + r.get("skipped_duplicate", 0)
290
+ errors = r.get("errors", [])
291
+ print(f"\n {r['model_id']}:")
292
+ if new:
293
+ print(f" New versions downloaded: {', '.join(new)}")
294
+ else:
295
+ print(f" No new versions (skipped {skipped} existing)")
296
+ for err in errors:
297
+ print(f" Error: {err}")
298
+
299
+
300
+ def cmd_source_list(args):
301
+ data = _request("GET", f"{args.host}/sources")
302
+ sources = data.get("sources", [])
303
+ if args.json:
304
+ _print_json(data)
305
+ return
306
+ rows = []
307
+ for s in sources:
308
+ rows.append({
309
+ "model_id": s["model_id"],
310
+ "repo": f"{s['owner']}/{s['repo']}",
311
+ "runtime": s["runtime"],
312
+ "pattern": s["asset_pattern"],
313
+ "auto": "yes" if s.get("auto_update", True) else "no",
314
+ "checked": (s.get("last_checked") or "never")[:19],
315
+ })
316
+ _print_table(rows, ["model_id", "repo", "runtime", "pattern", "auto", "checked"])
317
+
318
+
319
+ def cmd_source_add(args):
320
+ payload = {
321
+ "repo_url": args.repo_url,
322
+ "model_id": args.model_id,
323
+ "runtime": args.runtime,
324
+ "asset_pattern": args.asset_pattern,
325
+ "license": args.license,
326
+ }
327
+ data = _request("POST", f"{args.host}/sources/add", payload)
328
+ s = data.get("source", {})
329
+ print(f"Tracking {s.get('owner')}/{s.get('repo')} -> {s.get('model_id')} (pattern: {s.get('asset_pattern')})")
330
+
331
+
332
+ def cmd_source_remove(args):
333
+ _request("DELETE", f"{args.host}/sources/{args.model_id}")
334
+ print(f"Removed source: {args.model_id}")
335
+
336
+
337
+ def cmd_chat(args):
338
+ conversation_id = args.conversation_id
339
+ print("Interactive chat (type 'exit' to quit)")
340
+ print("-" * 40)
341
+ while True:
342
+ try:
343
+ prompt = input("you> ").strip()
344
+ except EOFError:
345
+ break
346
+ if prompt.lower() in {"exit", "quit", "q"}:
347
+ break
348
+ if not prompt:
349
+ continue
350
+ payload = {
351
+ "model_id": args.model,
352
+ "messages": [{"role": "user", "content": prompt}],
353
+ "temperature": args.temperature,
354
+ "max_tokens": args.max_tokens,
355
+ "stream": True,
356
+ "conversation_id": conversation_id,
357
+ }
358
+ print("ai> ", end="", flush=True)
359
+ _stream_chat(f"{args.host}/chat", payload)
360
+
361
+
362
+ def cmd_ask(args):
363
+ payload = {
364
+ "model_id": args.model,
365
+ "messages": [{"role": "user", "content": args.prompt}],
366
+ "temperature": args.temperature,
367
+ "max_tokens": args.max_tokens,
368
+ "stream": False,
369
+ }
370
+ result = _request("POST", f"{args.host}/chat", payload)
371
+ print(result.get("output", ""))
372
+
373
+
374
+ def cmd_run(args):
375
+ payload = {"model_id": args.model_id}
376
+ if args.threads:
377
+ payload["threads"] = args.threads
378
+ data = _request("POST", f"{args.host}/models/load", payload)
379
+ print(f"Loaded {data.get('loaded_model_id')} v{data.get('loaded_version')}")
380
+ args.model = args.model_id
381
+ args.conversation_id = None
382
+ args.temperature = 0.3
383
+ args.max_tokens = 512
384
+ cmd_chat(args)
385
+
386
+
387
+ def cmd_task(args):
388
+ """Intelligent task execution with prompt expansion and deep directory awareness.
389
+
390
+ The agent:
391
+ 1. Scans the entire directory tree (current dir + all children)
392
+ 2. Detects languages, frameworks, dependencies
393
+ 3. Reads git status for change context
394
+ 4. Reads relevant file contents based on the task
395
+ 5. Expands the user's prompt into a detailed plan
396
+ 6. Executes the plan with full code output
397
+ """
398
+ import subprocess
399
+
400
+ prompt_text = args.prompt
401
+ model = args.model
402
+ cwd = Path.cwd()
403
+
404
+ print("Analyzing project context...")
405
+
406
+ # ── Step 1: Deep directory scan ──────────────────────────
407
+ skip_dirs = {".git", "node_modules", "__pycache__", ".venv", "venv",
408
+ "dist", "build", ".next", ".cache", "target", ".tox",
409
+ "egg-info", ".eggs", ".mypy_cache", ".pytest_cache"}
410
+
411
+ all_files = []
412
+ lang_counts: dict[str, int] = {}
413
+ ext_map = {
414
+ ".py": "Python", ".js": "JavaScript", ".ts": "TypeScript", ".tsx": "TypeScript/React",
415
+ ".jsx": "React", ".rs": "Rust", ".go": "Go", ".java": "Java", ".c": "C",
416
+ ".cpp": "C++", ".h": "C/C++ Header", ".rb": "Ruby", ".php": "PHP",
417
+ ".swift": "Swift", ".kt": "Kotlin", ".sql": "SQL", ".sh": "Shell",
418
+ ".css": "CSS", ".html": "HTML", ".yaml": "YAML", ".yml": "YAML",
419
+ ".json": "JSON", ".toml": "TOML", ".md": "Markdown",
420
+ }
421
+
422
+ for p in sorted(cwd.rglob("*")):
423
+ rel = p.relative_to(cwd)
424
+ parts = rel.parts
425
+ if any(part.startswith(".") or any(skip in part for skip in skip_dirs) for part in parts):
426
+ continue
427
+ if p.is_file():
428
+ all_files.append(str(rel))
429
+ ext = p.suffix.lower()
430
+ if ext in ext_map:
431
+ lang = ext_map[ext]
432
+ lang_counts[lang] = lang_counts.get(lang, 0) + 1
433
+
434
+ # Detect primary language
435
+ sorted_langs = sorted(lang_counts.items(), key=lambda x: -x[1])
436
+ primary_lang = sorted_langs[0][0] if sorted_langs else "Unknown"
437
+
438
+ # ── Step 2: Git context ──────────────────────────────────
439
+ git_info = ""
440
+ try:
441
+ status = subprocess.check_output(["git", "status", "--short"], text=True, cwd=str(cwd), timeout=5).strip()
442
+ branch = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"], text=True, cwd=str(cwd), timeout=5).strip()
443
+ recent = subprocess.check_output(
444
+ ["git", "log", "--oneline", "-5"], text=True, cwd=str(cwd), timeout=5
445
+ ).strip()
446
+ git_info = f"Branch: {branch}\nRecent commits:\n{recent}\n"
447
+ if status:
448
+ git_info += f"Uncommitted changes:\n{status}\n"
449
+ except Exception:
450
+ git_info = "(not a git repository)"
451
+
452
+ # ── Step 3: Read key project files ───────────────────────
453
+ config_files = [
454
+ "README.md", "pyproject.toml", "package.json", "Cargo.toml", "go.mod",
455
+ "Makefile", "Dockerfile", "docker-compose.yml", "requirements.txt",
456
+ "tsconfig.json", ".eslintrc.json", "setup.py", "setup.cfg",
457
+ ]
458
+ config_excerpts = []
459
+ for cf in config_files:
460
+ cf_path = cwd / cf
461
+ if cf_path.exists():
462
+ try:
463
+ content = cf_path.read_text(encoding="utf-8")[:800]
464
+ config_excerpts.append(f"--- {cf} ---\n{content}")
465
+ except Exception:
466
+ pass
467
+
468
+ # ── Step 4: Find task-relevant files ─────────────────────
469
+ # Search for files that might be relevant to the task prompt
470
+ relevant_files = []
471
+ keywords = [w.lower() for w in prompt_text.split() if len(w) > 3]
472
+ for f in all_files:
473
+ f_lower = f.lower()
474
+ if any(kw in f_lower for kw in keywords):
475
+ relevant_files.append(f)
476
+
477
+ relevant_contents = []
478
+ for rf in relevant_files[:5]: # max 5 relevant files
479
+ rf_path = cwd / rf
480
+ try:
481
+ content = rf_path.read_text(encoding="utf-8")[:2000]
482
+ relevant_contents.append(f"--- {rf} ---\n{content}")
483
+ except Exception:
484
+ pass
485
+
486
+ # ── Build context ────────────────────────────────────────
487
+ context_parts = [
488
+ f"WORKING DIRECTORY: {cwd}",
489
+ f"PRIMARY LANGUAGE: {primary_lang}",
490
+ f"LANGUAGES DETECTED: {', '.join(f'{lang} ({n} files)' for lang, n in sorted_langs[:8])}",
491
+ f"TOTAL FILES: {len(all_files)}",
492
+ f"\nGIT STATUS:\n{git_info}",
493
+ ]
494
+
495
+ # File tree (compact)
496
+ context_parts.append(f"\nFILE TREE ({len(all_files)} files):")
497
+ for f in all_files[:80]:
498
+ context_parts.append(f" {f}")
499
+ if len(all_files) > 80:
500
+ context_parts.append(f" ... and {len(all_files) - 80} more")
501
+
502
+ if config_excerpts:
503
+ context_parts.append("\nPROJECT CONFIG FILES:")
504
+ context_parts.extend(config_excerpts)
505
+
506
+ if relevant_contents:
507
+ context_parts.append(f"\nTASK-RELEVANT FILES (matched keywords from your prompt):")
508
+ context_parts.extend(relevant_contents)
509
+
510
+ context = "\n".join(context_parts)
511
+
512
+ print(f" Found {len(all_files)} files across {len(lang_counts)} languages")
513
+ print(f" Primary language: {primary_lang}")
514
+ if relevant_files:
515
+ print(f" Task-relevant files: {', '.join(relevant_files[:5])}")
516
+
517
+ # ── Step 5: Expand prompt into plan ──────────────────────
518
+ print("\nExpanding task into detailed plan...")
519
+
520
+ expand_prompt = (
521
+ f"You are an expert coding AI agent operating on the user's local machine. "
522
+ f"You have deep awareness of their entire project.\n\n"
523
+ f"PROJECT CONTEXT:\n{context}\n\n"
524
+ f"USER TASK: {prompt_text}\n\n"
525
+ f"Create a precise, numbered step-by-step plan:\n"
526
+ f"- For each step, specify the EXACT file path to modify or create\n"
527
+ f"- Describe the specific code changes (not vague descriptions)\n"
528
+ f"- Consider how changes interact with existing code\n"
529
+ f"- Include any new dependencies or configuration changes\n"
530
+ f"- Consider edge cases and error handling\n"
531
+ f"- If tests exist, include steps to update them\n\n"
532
+ f"Be actionable and complete. This plan will be executed."
533
+ )
534
+
535
+ payload = {
536
+ "model_id": model,
537
+ "messages": [{"role": "user", "content": expand_prompt}],
538
+ "temperature": 0.2,
539
+ "max_tokens": args.max_tokens,
540
+ "stream": True,
541
+ }
542
+
543
+ print("\n--- Task Plan ---\n")
544
+ plan = _stream_chat(f"{args.host}/chat", payload)
545
+ print()
546
+
547
+ # ── Step 6: Confirm ──────────────────────────────────────
548
+ if not args.auto:
549
+ try:
550
+ confirm = input("Execute this plan? [y/N] ").strip().lower()
551
+ if confirm not in {"y", "yes"}:
552
+ print("Aborted.")
553
+ return
554
+ except EOFError:
555
+ print("Aborted.")
556
+ return
557
+
558
+ # ── Step 7: Execute with full code output ────────────────
559
+ print("\n--- Executing ---\n")
560
+
561
+ execute_prompt = (
562
+ f"You are an expert coding AI agent. Execute this plan NOW.\n\n"
563
+ f"PROJECT CONTEXT:\n{context}\n\n"
564
+ f"PLAN TO EXECUTE:\n{plan}\n\n"
565
+ f"For each step, output the COMPLETE file contents or code changes.\n"
566
+ f"Format each file change as:\n"
567
+ f"```path/to/file.ext\n"
568
+ f"<complete file content or diff>\n"
569
+ f"```\n\n"
570
+ f"Rules:\n"
571
+ f"- Output COMPLETE, WORKING code — no placeholders, no '...', no truncation\n"
572
+ f"- Include all imports, error handling, and type annotations\n"
573
+ f"- Match the existing code style of the project\n"
574
+ f"- If creating new files, include all necessary boilerplate\n"
575
+ f"- If modifying existing files, show the complete modified version"
576
+ )
577
+
578
+ exec_payload = {
579
+ "model_id": model,
580
+ "messages": [{"role": "user", "content": execute_prompt}],
581
+ "temperature": 0.1,
582
+ "max_tokens": args.max_tokens,
583
+ "stream": True,
584
+ }
585
+
586
+ _stream_chat(f"{args.host}/chat", exec_payload)
587
+ print("\n\nTask complete.")
588
+
589
+
590
+ # ── Helpers ────────────────────────────────────────────────────
591
+
592
+
593
+ def _is_github_repo(url: str) -> bool:
594
+ """Check if URL points to a GitHub repo (not a direct file)."""
595
+ if not ("github.com" in url or "/" in url):
596
+ return False
597
+ # Direct file downloads have extensions
598
+ path = url.rstrip("/").split("/")[-1] if "/" in url else url
599
+ return "." not in path or path.endswith(".git")
600
+
601
+
602
+ def _runtime_ext(runtime: str) -> str:
603
+ return {"llama_cpp": "gguf", "onnx": "onnx"}.get(runtime, "safetensors")
604
+
605
+
606
+ # ── Argument parser ────────────────────────────────────────────
607
+
608
+
609
+ def _fetch_model_names(host: str = DEFAULT_HOST) -> list[str]:
610
+ """Fetch available model names from the backend for shell completion."""
611
+ try:
612
+ resp = requests.get(f"{host}/models/names", timeout=2)
613
+ resp.raise_for_status()
614
+ return resp.json().get("names", [])
615
+ except Exception:
616
+ return []
617
+
618
+
619
+ def cmd_completion(args):
620
+ """Generate shell completion scripts."""
621
+ shell = args.shell
622
+ if shell == "bash":
623
+ print(_BASH_COMPLETION)
624
+ elif shell == "zsh":
625
+ print(_ZSH_COMPLETION)
626
+ elif shell == "fish":
627
+ print(_FISH_COMPLETION)
628
+ elif shell == "names":
629
+ # Raw model name list — used by completion scripts
630
+ for name in _fetch_model_names(args.host):
631
+ print(name)
632
+
633
+
634
+ _BASH_COMPLETION = r'''
635
+ # bash completion for ai CLI — add to ~/.bashrc:
636
+ # eval "$(ai completion bash)"
637
+
638
+ _ai_complete() {
639
+ local cur prev words cword
640
+ _init_completion || return
641
+
642
+ case "${words[1]}" in
643
+ model)
644
+ case "${words[2]}" in
645
+ use|versions|update)
646
+ COMPREPLY=( $(compgen -W "$(ai completion names 2>/dev/null)" -- "$cur") )
647
+ return ;;
648
+ list|download|add) return ;;
649
+ esac
650
+ COMPREPLY=( $(compgen -W "list versions download add use update" -- "$cur") )
651
+ return ;;
652
+ source)
653
+ case "${words[2]}" in
654
+ remove)
655
+ COMPREPLY=( $(compgen -W "$(ai completion names 2>/dev/null)" -- "$cur") )
656
+ return ;;
657
+ esac
658
+ COMPREPLY=( $(compgen -W "list add remove" -- "$cur") )
659
+ return ;;
660
+ chat|ask)
661
+ case "$prev" in
662
+ --model)
663
+ COMPREPLY=( $(compgen -W "$(ai completion names 2>/dev/null)" -- "$cur") )
664
+ return ;;
665
+ esac
666
+ return ;;
667
+ run)
668
+ if [ $cword -eq 2 ]; then
669
+ COMPREPLY=( $(compgen -W "$(ai completion names 2>/dev/null)" -- "$cur") )
670
+ return
671
+ fi ;;
672
+ completion) return ;;
673
+ esac
674
+ COMPREPLY=( $(compgen -W "health hardware model source chat ask run completion" -- "$cur") )
675
+ }
676
+ complete -F _ai_complete ai
677
+ '''.strip()
678
+
679
+ _ZSH_COMPLETION = r'''
680
+ # zsh completion for ai CLI — add to ~/.zshrc:
681
+ # eval "$(ai completion zsh)"
682
+
683
+ _ai() {
684
+ local -a model_names
685
+ _get_model_names() { model_names=(${(f)"$(ai completion names 2>/dev/null)"}) }
686
+
687
+ _arguments -C '1:command:(health hardware model source chat ask run completion)' '*::arg:->args'
688
+
689
+ case $words[1] in
690
+ model)
691
+ _arguments -C '1:subcommand:(list versions download add use update)' '*::arg:->margs'
692
+ case $words[1] in
693
+ use|versions|update)
694
+ _get_model_names
695
+ _describe 'model' model_names ;;
696
+ esac ;;
697
+ source)
698
+ _arguments -C '1:subcommand:(list add remove)' '*::arg:->sargs'
699
+ case $words[1] in
700
+ remove) _get_model_names; _describe 'model' model_names ;;
701
+ esac ;;
702
+ chat|ask)
703
+ _arguments '--model[Model ID]:model:->mname'
704
+ if [[ $state == mname ]]; then
705
+ _get_model_names
706
+ _describe 'model' model_names
707
+ fi ;;
708
+ run)
709
+ _get_model_names; _describe 'model' model_names ;;
710
+ esac
711
+ }
712
+ compdef _ai ai
713
+ '''.strip()
714
+
715
+ _FISH_COMPLETION = r'''
716
+ # fish completion for ai CLI — add to ~/.config/fish/completions/ai.fish:
717
+ # ai completion fish > ~/.config/fish/completions/ai.fish
718
+
719
+ function __ai_model_names
720
+ ai completion names 2>/dev/null
721
+ end
722
+ complete -c ai -n "__fish_use_subcommand" -a "health hardware model source chat ask run completion"
723
+ complete -c ai -n "__fish_seen_subcommand_from model" -a "list versions download add use update"
724
+ complete -c ai -n "__fish_seen_subcommand_from model; and __fish_seen_subcommand_from use versions update" -a "(__ai_model_names)"
725
+ complete -c ai -n "__fish_seen_subcommand_from source" -a "list add remove"
726
+ complete -c ai -n "__fish_seen_subcommand_from source; and __fish_seen_subcommand_from remove" -a "(__ai_model_names)"
727
+ complete -c ai -n "__fish_seen_subcommand_from chat ask" -l model -a "(__ai_model_names)"
728
+ complete -c ai -n "__fish_seen_subcommand_from run" -a "(__ai_model_names)"
729
+ '''.strip()
730
+
731
+
732
+ def build_argparser() -> argparse.ArgumentParser:
733
+ parser = argparse.ArgumentParser(
734
+ prog="ai",
735
+ description="Self-hosted local AI platform CLI — no external APIs, fully local",
736
+ formatter_class=argparse.RawDescriptionHelpFormatter,
737
+ epilog="""examples:
738
+ ai model list
739
+ ai model download --model-id tinyllama --url owner/repo --runtime llama_cpp
740
+ ai model download --model-id phi3 --url https://github.com/owner/repo/releases/download/v1/model.gguf --runtime llama_cpp
741
+ ai model add --model-id my-model --target ./model.gguf --runtime llama_cpp
742
+ ai model use tinyllama:v2
743
+ ai model update
744
+ ai source add --repo-url owner/repo --model-id tinyllama --runtime llama_cpp
745
+ ai source list
746
+ ai chat --model tinyllama
747
+ ai ask --model tinyllama --prompt "Explain quicksort"
748
+ ai run tinyllama
749
+ """,
750
+ )
751
+ parser.add_argument("--host", default=DEFAULT_HOST, help="Backend base URL")
752
+ sub = parser.add_subparsers(dest="command", required=True)
753
+
754
+ # health
755
+ sub.add_parser("health", help="Check backend health")
756
+
757
+ # hardware
758
+ sub.add_parser("hardware", help="Show hardware info")
759
+
760
+ # ── model subcommands ──────────────────────────────────────
761
+
762
+ model_parser = sub.add_parser("model", help="Model management")
763
+ model_sub = model_parser.add_subparsers(dest="model_command", required=True)
764
+
765
+ # model catalog
766
+ mc = model_sub.add_parser("catalog", help="Browse free models available from GitHub")
767
+ mc.add_argument("--json", action="store_true", help="Output raw JSON")
768
+ mc.add_argument("--use-case", choices=["coding", "chat", "general", "completion"],
769
+ help="Filter by use case")
770
+
771
+ # model list
772
+ ml = model_sub.add_parser("list", help="List installed models")
773
+ ml.add_argument("--json", action="store_true", help="Output raw JSON")
774
+
775
+ # model versions
776
+ mv = model_sub.add_parser("versions", help="List versions of a model")
777
+ mv.add_argument("model_id", help="Model ID")
778
+ mv.add_argument("--json", action="store_true", help="Output raw JSON")
779
+
780
+ # model download
781
+ md = model_sub.add_parser("download", help="Download from GitHub repo or direct URL")
782
+ md.add_argument("--model-id", required=True, help="Model identifier")
783
+ md.add_argument("--url", required=True, help="GitHub repo (owner/repo) or direct asset URL")
784
+ md.add_argument("--runtime", required=True, choices=["llama_cpp", "transformers", "vllm", "onnx"])
785
+ md.add_argument("--filename", help="Override filename")
786
+ md.add_argument("--sha256", help="Expected checksum")
787
+ md.add_argument("--license", help="License type")
788
+ md.add_argument("--size-gb", type=float, help="Expected size in GB")
789
+ md.add_argument("--format", choices=["gguf", "safetensors", "onnx", "pytorch"])
790
+ md.add_argument("--version-tag", help="Version tag for this download")
791
+ md.add_argument("--asset-pattern", help="Glob pattern for asset discovery (default: *.gguf)")
792
+
793
+ # model add (smart import)
794
+ ma = model_sub.add_parser("add", help="Import from local path or GitHub URL")
795
+ ma.add_argument("--model-id", required=True, help="Model identifier")
796
+ ma.add_argument("--target", required=True, help="Local path or GitHub URL")
797
+ ma.add_argument("--runtime", required=True, choices=["llama_cpp", "transformers", "vllm", "onnx"])
798
+ ma.add_argument("--license", help="License type")
799
+ ma.add_argument("--format", choices=["gguf", "safetensors", "onnx", "pytorch"])
800
+
801
+ # model use
802
+ mu = model_sub.add_parser("use", help="Load a model (optionally set version)")
803
+ mu.add_argument("model_version", help="model_id or model_id:vN")
804
+ mu.add_argument("--threads", type=int, help="CPU threads for inference")
805
+
806
+ # model update
807
+ mup = model_sub.add_parser("update", help="Check tracked sources for new versions")
808
+ mup.add_argument("model_id", nargs="?", help="Specific model to update (default: all)")
809
+
810
+ # ── source subcommands ─────────────────────────────────────
811
+
812
+ source_parser = sub.add_parser("source", help="Manage tracked GitHub sources")
813
+ source_sub = source_parser.add_subparsers(dest="source_command", required=True)
814
+
815
+ sl = source_sub.add_parser("list", help="List tracked sources")
816
+ sl.add_argument("--json", action="store_true", help="Output raw JSON")
817
+
818
+ sa = source_sub.add_parser("add", help="Track a GitHub repository")
819
+ sa.add_argument("--repo-url", required=True, help="GitHub repo URL or owner/repo")
820
+ sa.add_argument("--model-id", required=True, help="Model identifier")
821
+ sa.add_argument("--runtime", default="llama_cpp", choices=["llama_cpp", "transformers", "vllm", "onnx"])
822
+ sa.add_argument("--asset-pattern", default="*.gguf", help="Glob pattern for model assets")
823
+ sa.add_argument("--license", help="License type")
824
+
825
+ sr = source_sub.add_parser("remove", help="Stop tracking a source")
826
+ sr.add_argument("model_id", help="Model ID to untrack")
827
+
828
+ # ── chat / ask / run ───────────────────────────────────────
829
+
830
+ chat_p = sub.add_parser("chat", help="Interactive chat session")
831
+ chat_p.add_argument("--model", required=True, help="Model ID")
832
+ chat_p.add_argument("--temperature", type=float, default=0.3)
833
+ chat_p.add_argument("--max-tokens", type=int, default=512)
834
+ chat_p.add_argument("--conversation-id", help="Resume a conversation")
835
+
836
+ ask_p = sub.add_parser("ask", help="One-shot prompt")
837
+ ask_p.add_argument("--model", required=True, help="Model ID")
838
+ ask_p.add_argument("--prompt", required=True, help="Prompt text")
839
+ ask_p.add_argument("--temperature", type=float, default=0.3)
840
+ ask_p.add_argument("--max-tokens", type=int, default=512)
841
+
842
+ run_p = sub.add_parser("run", help="Load model and start chatting")
843
+ run_p.add_argument("model_id", help="Model to load")
844
+ run_p.add_argument("--threads", type=int, help="CPU threads")
845
+
846
+ # task (AI agent)
847
+ task_p = sub.add_parser("task", help="AI agent: analyze, plan, and execute a coding task")
848
+ task_p.add_argument("prompt", help="Task description (natural language)")
849
+ task_p.add_argument("--model", required=True, help="Model ID to use")
850
+ task_p.add_argument("--auto", action="store_true", help="Execute without confirmation")
851
+ task_p.add_argument("--max-tokens", type=int, default=2048, help="Max tokens per response")
852
+
853
+ # completion
854
+ comp_p = sub.add_parser("completion", help="Generate shell completion scripts")
855
+ comp_p.add_argument("shell", choices=["bash", "zsh", "fish", "names"],
856
+ help="Shell type (or 'names' for raw model list)")
857
+
858
+ return parser
859
+
860
+
861
+ COMMAND_DISPATCH = {
862
+ "health": cmd_health,
863
+ "hardware": cmd_hardware,
864
+ "chat": cmd_chat,
865
+ "ask": cmd_ask,
866
+ "run": cmd_run,
867
+ "task": cmd_task,
868
+ "completion": cmd_completion,
869
+ }
870
+
871
+ MODEL_DISPATCH = {
872
+ "catalog": cmd_model_catalog,
873
+ "list": cmd_model_list,
874
+ "versions": cmd_model_versions,
875
+ "download": cmd_model_download,
876
+ "add": cmd_model_add,
877
+ "use": cmd_model_use,
878
+ "update": cmd_model_update,
879
+ }
880
+
881
+ SOURCE_DISPATCH = {
882
+ "list": cmd_source_list,
883
+ "add": cmd_source_add,
884
+ "remove": cmd_source_remove,
885
+ }
886
+
887
+
888
+ def main() -> int:
889
+ args = build_argparser().parse_args()
890
+ try:
891
+ if args.command == "model":
892
+ handler = MODEL_DISPATCH.get(args.model_command)
893
+ if handler:
894
+ handler(args)
895
+ return 0
896
+ elif args.command == "source":
897
+ handler = SOURCE_DISPATCH.get(args.source_command)
898
+ if handler:
899
+ handler(args)
900
+ return 0
901
+ else:
902
+ handler = COMMAND_DISPATCH.get(args.command)
903
+ if handler:
904
+ handler(args)
905
+ return 0
906
+ print(f"Unknown command: {args.command}", file=sys.stderr)
907
+ return 1
908
+ except requests.RequestException as exc:
909
+ print(f"Request failed: {exc}", file=sys.stderr)
910
+ return 2
911
+ except KeyboardInterrupt:
912
+ return 130
913
+
914
+
915
+ if __name__ == "__main__":
916
+ raise SystemExit(main())
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: claude-ai-clone-client
3
+ Version: 3.0.0
4
+ Summary: CLI client for the local self-hosted AI platform — no external APIs, fully local
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: requests>=2.32.3
@@ -0,0 +1,8 @@
1
+ ai_cli.py
2
+ pyproject.toml
3
+ claude_ai_clone_client.egg-info/PKG-INFO
4
+ claude_ai_clone_client.egg-info/SOURCES.txt
5
+ claude_ai_clone_client.egg-info/dependency_links.txt
6
+ claude_ai_clone_client.egg-info/entry_points.txt
7
+ claude_ai_clone_client.egg-info/requires.txt
8
+ claude_ai_clone_client.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ai = ai_cli:main
@@ -0,0 +1,17 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "claude-ai-clone-client"
7
+ version = "3.0.0"
8
+ description = "CLI client for the local self-hosted AI platform — no external APIs, fully local"
9
+ requires-python = ">=3.10"
10
+ license = {text = "MIT"}
11
+ dependencies = ["requests>=2.32.3"]
12
+
13
+ [project.scripts]
14
+ ai = "ai_cli:main"
15
+
16
+ [tool.setuptools]
17
+ py-modules = ["ai_cli"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+