nacho-cli 0.1.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,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: nacho-cli
3
+ Version: 0.1.0
4
+ Summary: Nacho CLI — push, pull, and search context files; set up the Nacho builder
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: httpx>=0.28.0
7
+ Requires-Dist: pyyaml>=6.0
8
+ Provides-Extra: test
9
+ Requires-Dist: pytest>=8.0; extra == "test"
File without changes
@@ -0,0 +1,114 @@
1
+ import httpx
2
+
3
+ from nacho.config import get_api_url, load_token
4
+
5
+
6
+ class APIClient:
7
+ def __init__(self):
8
+ self.base_url = get_api_url()
9
+ self.token = load_token()
10
+
11
+ @property
12
+ def headers(self) -> dict:
13
+ h = {"Content-Type": "application/json"}
14
+ if self.token:
15
+ h["Authorization"] = f"Bearer {self.token}"
16
+ return h
17
+
18
+ def login(self, email: str, password: str) -> dict:
19
+ resp = httpx.post(
20
+ f"{self.base_url}/auth/login",
21
+ json={"email": email, "password": password},
22
+ )
23
+ resp.raise_for_status()
24
+ return resp.json()
25
+
26
+ def push(self, file_path: str, name: str, title: str, **kwargs) -> dict:
27
+ with open(file_path, "rb") as f:
28
+ files = {"file": (file_path, f, "text/markdown")}
29
+ data = {"name": name, "title": title}
30
+ data.update({k: v for k, v in kwargs.items() if v is not None})
31
+ headers = {}
32
+ if self.token:
33
+ headers["Authorization"] = f"Bearer {self.token}"
34
+ resp = httpx.post(
35
+ f"{self.base_url}/contexts",
36
+ files=files,
37
+ data=data,
38
+ headers=headers,
39
+ )
40
+ resp.raise_for_status()
41
+ return resp.json()
42
+
43
+ def push_version(self, context_id: str, file_path: str, changelog: str | None = None) -> dict:
44
+ with open(file_path, "rb") as f:
45
+ files = {"file": (file_path, f, "text/markdown")}
46
+ data = {}
47
+ if changelog:
48
+ data["changelog"] = changelog
49
+ headers = {}
50
+ if self.token:
51
+ headers["Authorization"] = f"Bearer {self.token}"
52
+ resp = httpx.post(
53
+ f"{self.base_url}/contexts/{context_id}/versions",
54
+ files=files,
55
+ data=data,
56
+ headers=headers,
57
+ )
58
+ resp.raise_for_status()
59
+ return resp.json()
60
+
61
+ def get_my_context(self, name: str) -> dict | None:
62
+ resp = httpx.get(f"{self.base_url}/auth/me", headers=self.headers)
63
+ resp.raise_for_status()
64
+ username = resp.json()["username"]
65
+ resp = httpx.get(f"{self.base_url}/contexts/{username}/{name}", headers=self.headers)
66
+ if resp.status_code == 404:
67
+ return None
68
+ resp.raise_for_status()
69
+ return resp.json()
70
+
71
+ def pull(self, username: str, context_name: str, version: int | None = None) -> bytes:
72
+ # First get context info
73
+ resp = httpx.get(
74
+ f"{self.base_url}/contexts/{username}/{context_name}",
75
+ headers=self.headers,
76
+ )
77
+ resp.raise_for_status()
78
+ context = resp.json()
79
+
80
+ params = {}
81
+ if version:
82
+ params["version"] = version
83
+ resp = httpx.get(
84
+ f"{self.base_url}/contexts/{context['id']}/download",
85
+ params=params,
86
+ headers=self.headers,
87
+ follow_redirects=True,
88
+ )
89
+ resp.raise_for_status()
90
+ return resp.content
91
+
92
+ def upvote(self, username: str, context_name: str) -> dict:
93
+ resp = httpx.get(
94
+ f"{self.base_url}/contexts/{username}/{context_name}",
95
+ headers=self.headers,
96
+ )
97
+ resp.raise_for_status()
98
+ context = resp.json()
99
+
100
+ resp = httpx.post(
101
+ f"{self.base_url}/contexts/{context['id']}/upvote",
102
+ headers=self.headers,
103
+ )
104
+ resp.raise_for_status()
105
+ return resp.json()
106
+
107
+ def search(self, query: str, page: int = 1) -> dict:
108
+ resp = httpx.get(
109
+ f"{self.base_url}/contexts/search",
110
+ params={"q": query, "page": page},
111
+ headers=self.headers,
112
+ )
113
+ resp.raise_for_status()
114
+ return resp.json()
@@ -0,0 +1,411 @@
1
+ import argparse
2
+ import getpass
3
+ import json
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ from importlib import resources as importlib_resources
9
+ from pathlib import Path
10
+
11
+ from nacho.api_client import APIClient
12
+ from nacho.config import clear_token, load_tracked_contexts, save_token, save_tracked_context
13
+
14
+
15
+ # ── Context Hub commands ─────────────────────────────────────────────
16
+
17
+
18
+ def cmd_login(args):
19
+ """Login and store API token."""
20
+ client = APIClient()
21
+ email = input("Email: ")
22
+ password = getpass.getpass("Password: ")
23
+
24
+ try:
25
+ data = client.login(email, password)
26
+ save_token(data["access_token"])
27
+ print("Logged in successfully!")
28
+ print("Tip: For long-lived access, create an API token at https://nacho.bot/settings")
29
+ except Exception as e:
30
+ print(f"Login failed: {e}", file=sys.stderr)
31
+ sys.exit(1)
32
+
33
+
34
+ def cmd_token(args):
35
+ """Store an API token directly."""
36
+ token = args.token or getpass.getpass("API Token: ")
37
+ save_token(token)
38
+ print("Token saved.")
39
+
40
+
41
+ def cmd_logout(args):
42
+ """Clear stored credentials."""
43
+ clear_token()
44
+ print("Logged out.")
45
+
46
+
47
+ def _push_tracked(client, file_path, tracked_meta, changelog=None):
48
+ """Push a new version for a tracked context. Returns True on success."""
49
+ name = tracked_meta["name"]
50
+ existing = client.get_my_context(name)
51
+ if not existing:
52
+ print(f"Error: remote context '{name}' not found for tracked file '{file_path}'", file=sys.stderr)
53
+ return False
54
+ result = client.push_version(existing["id"], file_path, changelog=changelog)
55
+ print(f"Pushed {existing['owner_username']}/{existing['name']} v{result['version']}")
56
+ return True
57
+
58
+
59
+ def cmd_push(args):
60
+ """Push a context file."""
61
+ client = APIClient()
62
+ file_path = args.file
63
+ tracked = load_tracked_contexts()
64
+
65
+ # nacho push . — push all tracked contexts
66
+ if file_path == ".":
67
+ if not tracked:
68
+ print("Error: no tracked contexts in .nacho/contexts.yml", file=sys.stderr)
69
+ sys.exit(1)
70
+ failed = False
71
+ for fpath, meta in tracked.items():
72
+ try:
73
+ if not _push_tracked(client, fpath, meta, changelog=args.changelog):
74
+ failed = True
75
+ except Exception as e:
76
+ print(f"Push failed for {fpath}: {e}", file=sys.stderr)
77
+ failed = True
78
+ if failed:
79
+ sys.exit(1)
80
+ return
81
+
82
+ # nacho push <file>
83
+ if file_path in tracked:
84
+ try:
85
+ _push_tracked(client, file_path, tracked[file_path], changelog=args.changelog)
86
+ except Exception as e:
87
+ print(f"Push failed: {e}", file=sys.stderr)
88
+ sys.exit(1)
89
+ return
90
+
91
+ # Untracked file — first push, --name required
92
+ name = args.name
93
+ if not name:
94
+ print("Error: --name is required for first push of an untracked file", file=sys.stderr)
95
+ sys.exit(1)
96
+
97
+ title = args.title or name
98
+
99
+ try:
100
+ result = client.push(
101
+ file_path=file_path,
102
+ name=name,
103
+ title=title,
104
+ short_description=args.description,
105
+ tags=args.tags or "",
106
+ license=args.license,
107
+ )
108
+ context = result["context"]
109
+ version = result["version"]
110
+ print(f"Pushed {context['owner_username']}/{context['name']} v{version['version']}")
111
+
112
+ meta = {"name": name, "title": title}
113
+ if args.description:
114
+ meta["description"] = args.description
115
+ if args.tags:
116
+ meta["tags"] = args.tags
117
+ if args.license:
118
+ meta["license"] = args.license
119
+ save_tracked_context(file_path, meta)
120
+ except Exception as e:
121
+ print(f"Push failed: {e}", file=sys.stderr)
122
+ sys.exit(1)
123
+
124
+
125
+ def cmd_pull(args):
126
+ """Pull a context file."""
127
+ client = APIClient()
128
+
129
+ ref = args.ref
130
+ if "/" not in ref:
131
+ print("Error: ref must be in format 'username/context-name'", file=sys.stderr)
132
+ sys.exit(1)
133
+
134
+ username, context_name = ref.split("/", 1)
135
+
136
+ try:
137
+ content = client.pull(username, context_name, version=args.version)
138
+ output = args.output or f"{context_name}.md"
139
+
140
+ if os.path.exists(output) and not args.force:
141
+ answer = input(f"File '{output}' already exists. Replace? [y/N] ").strip().lower()
142
+ if answer not in ("y", "yes"):
143
+ print("Cancelled.")
144
+ return
145
+
146
+ with open(output, "wb") as f:
147
+ f.write(content)
148
+ print(f"Downloaded to {output}")
149
+
150
+ if client.token:
151
+ try:
152
+ owned = client.get_my_context(context_name)
153
+ if owned:
154
+ save_tracked_context(output, {
155
+ "name": context_name,
156
+ "title": owned.get("title", context_name),
157
+ })
158
+ except Exception:
159
+ pass
160
+ except Exception as e:
161
+ print(f"Pull failed: {e}", file=sys.stderr)
162
+ sys.exit(1)
163
+
164
+
165
+ def cmd_upvote(args):
166
+ """Toggle upvote on a context."""
167
+ client = APIClient()
168
+
169
+ ref = args.ref
170
+ if "/" not in ref:
171
+ print("Error: ref must be in format 'username/context-name'", file=sys.stderr)
172
+ sys.exit(1)
173
+
174
+ username, context_name = ref.split("/", 1)
175
+
176
+ try:
177
+ result = client.upvote(username, context_name)
178
+ if result["upvoted"]:
179
+ print(f"Upvoted {ref} ({result['upvote_count']} upvotes)")
180
+ else:
181
+ print(f"Removed upvote from {ref} ({result['upvote_count']} upvotes)")
182
+ except Exception as e:
183
+ print(f"Upvote failed: {e}", file=sys.stderr)
184
+ sys.exit(1)
185
+
186
+
187
+ def cmd_search(args):
188
+ """Search for contexts."""
189
+ client = APIClient()
190
+
191
+ try:
192
+ result = client.search(args.query)
193
+ items = result.get("items", [])
194
+ if not items:
195
+ print("No results found.")
196
+ return
197
+
198
+ for context in items:
199
+ tags = ", ".join(context.get("tags", []))
200
+ print(f" {context['owner_username']}/{context['name']} - {context['title']}")
201
+ if context.get("short_description"):
202
+ print(f" {context['short_description']}")
203
+ if tags:
204
+ print(f" Tags: {tags}")
205
+ print()
206
+ except Exception as e:
207
+ print(f"Search failed: {e}", file=sys.stderr)
208
+ sys.exit(1)
209
+
210
+
211
+ # ── Builder setup commands ───────────────────────────────────────────
212
+
213
+
214
+ def _setup_mcp_server():
215
+ """Register the Nacho MCP server with Claude Code (user scope)."""
216
+ try:
217
+ result = subprocess.run(
218
+ ["claude", "mcp", "list"],
219
+ capture_output=True, text=True, timeout=10,
220
+ )
221
+ if "nacho" in result.stdout:
222
+ print(" MCP server already registered")
223
+ return
224
+ except Exception:
225
+ pass
226
+
227
+ try:
228
+ result = subprocess.run(
229
+ ["claude", "mcp", "add", "--scope", "user", "nacho", "--", "uvx", "nacho-mcp"],
230
+ capture_output=True, text=True, timeout=30,
231
+ )
232
+ if result.returncode == 0:
233
+ print(" MCP server registered")
234
+ else:
235
+ print(" MCP server ready")
236
+ except Exception as e:
237
+ print(f" Warning: Could not register MCP server: {e}", file=sys.stderr)
238
+
239
+
240
+ def _setup_slash_command():
241
+ """Copy nachoinit.md to ~/.claude/commands/."""
242
+ commands_dir = Path.home() / ".claude" / "commands"
243
+ commands_dir.mkdir(parents=True, exist_ok=True)
244
+ dest = commands_dir / "nachoinit.md"
245
+
246
+ source = importlib_resources.files("nacho").joinpath("data", "nachoinit.md")
247
+ dest.write_text(source.read_text())
248
+ print(" /nachoinit command installed")
249
+
250
+
251
+ def _setup_permissions():
252
+ """Add mcp__nacho__* to global Claude Code permissions."""
253
+ settings_file = Path.home() / ".claude" / "settings.json"
254
+
255
+ settings = {}
256
+ if settings_file.exists():
257
+ try:
258
+ settings = json.loads(settings_file.read_text())
259
+ except (json.JSONDecodeError, OSError):
260
+ settings = {}
261
+
262
+ permissions = settings.setdefault("permissions", {})
263
+ allow = permissions.setdefault("allow", [])
264
+
265
+ nacho_rule = "mcp__nacho__*"
266
+ if nacho_rule not in allow:
267
+ allow.append(nacho_rule)
268
+ settings_file.parent.mkdir(parents=True, exist_ok=True)
269
+ settings_file.write_text(json.dumps(settings, indent=2) + "\n")
270
+ print(" Nacho tool permissions configured")
271
+ else:
272
+ print(" Permissions already configured")
273
+
274
+
275
+ def _setup_project_permissions(project_dir: Path):
276
+ """Create .claude/settings.json in the project with dev tool permissions."""
277
+ settings_dir = project_dir / ".claude"
278
+ settings_dir.mkdir(parents=True, exist_ok=True)
279
+ settings_file = settings_dir / "settings.json"
280
+
281
+ settings = {
282
+ "permissions": {
283
+ "defaultMode": "acceptEdits",
284
+ "allow": [
285
+ "Bash(mkdir *)",
286
+ "Bash(cp *)",
287
+ "Bash(npm *)",
288
+ "Bash(npx *)",
289
+ "Bash(node *)",
290
+ "Bash(pnpm *)",
291
+ "Bash(yarn *)",
292
+ "Bash(uv *)",
293
+ "Bash(pip *)",
294
+ "Bash(pip3 *)",
295
+ "Bash(python *)",
296
+ "Bash(python3 *)",
297
+ "Bash(source *)",
298
+ "Bash(go *)",
299
+ "Bash(cargo *)",
300
+ "Bash(curl *)",
301
+ "Bash(git *)",
302
+ "Bash(mix *)",
303
+ "Bash(elixir *)",
304
+ "mcp__nacho__*",
305
+ ],
306
+ }
307
+ }
308
+ settings_file.write_text(json.dumps(settings, indent=2) + "\n")
309
+ print(" Project permissions configured")
310
+
311
+
312
+ def cmd_init(args):
313
+ """Set up Claude Code integration and launch /nachoinit."""
314
+ if not shutil.which("claude"):
315
+ print("Claude Code is not installed.")
316
+ print("Get it at: https://claude.ai/download")
317
+ sys.exit(1)
318
+
319
+ print("Setting up Nacho for Claude Code...\n")
320
+ _setup_mcp_server()
321
+ _setup_slash_command()
322
+ _setup_permissions()
323
+
324
+ project_dir = Path.cwd()
325
+ _setup_project_permissions(project_dir)
326
+
327
+ print("\nLaunching Claude Code...")
328
+ print("(First launch may take a moment while the Nacho tools are downloaded)\n")
329
+ sys.stdout.flush()
330
+ os.execlp("claude", "claude", "/nachoinit")
331
+
332
+
333
+ # ── Entry point ──────────────────────────────────────────────────────
334
+
335
+
336
+ def main():
337
+ import importlib.metadata
338
+
339
+ try:
340
+ version = importlib.metadata.version("nacho-cli")
341
+ except importlib.metadata.PackageNotFoundError:
342
+ version = "dev"
343
+
344
+ parser = argparse.ArgumentParser(
345
+ prog="nacho",
346
+ description="Nacho CLI — build apps and manage context files.",
347
+ )
348
+ parser.add_argument("--version", action="version", version=f"%(prog)s {version}")
349
+ subparsers = parser.add_subparsers(dest="command")
350
+
351
+ # init
352
+ subparsers.add_parser("init", help="Set up Claude Code + launch /nachoinit builder wizard")
353
+
354
+ # login
355
+ subparsers.add_parser("login", help="Login with email/password")
356
+
357
+ # token
358
+ token_parser = subparsers.add_parser("token", help="Store an API token")
359
+ token_parser.add_argument("token", nargs="?", help="The API token")
360
+
361
+ # logout
362
+ subparsers.add_parser("logout", help="Clear stored credentials")
363
+
364
+ # push
365
+ push_parser = subparsers.add_parser("push", help="Push a context file")
366
+ push_parser.add_argument("file", help="Path to .md file, or '.' to push all tracked contexts")
367
+ push_parser.add_argument("--name", help="Context name (required for first push)")
368
+ push_parser.add_argument("--title", help="Context title")
369
+ push_parser.add_argument("--description", help="Short description")
370
+ push_parser.add_argument("--tags", help="Comma-separated tags")
371
+ push_parser.add_argument("--license", help="License")
372
+ push_parser.add_argument("--changelog", help="Changelog for this version")
373
+
374
+ # pull
375
+ pull_parser = subparsers.add_parser("pull", help="Pull a context file")
376
+ pull_parser.add_argument("ref", help="username/context-name")
377
+ pull_parser.add_argument("--version", type=int, help="Specific version")
378
+ pull_parser.add_argument("-o", "--output", help="Output filename")
379
+ pull_parser.add_argument("-f", "--force", action="store_true", help="Overwrite without prompting")
380
+
381
+ # search
382
+ search_parser = subparsers.add_parser("search", help="Search for contexts")
383
+ search_parser.add_argument("query", help="Search query")
384
+
385
+ # upvote
386
+ upvote_parser = subparsers.add_parser("upvote", help="Toggle upvote on a context")
387
+ upvote_parser.add_argument("ref", help="username/context-name")
388
+
389
+ args = parser.parse_args()
390
+
391
+ commands = {
392
+ "init": cmd_init,
393
+ "login": cmd_login,
394
+ "token": cmd_token,
395
+ "logout": cmd_logout,
396
+ "push": cmd_push,
397
+ "pull": cmd_pull,
398
+ "search": cmd_search,
399
+ "upvote": cmd_upvote,
400
+ }
401
+
402
+ if args.command in commands:
403
+ commands[args.command](args)
404
+ elif args.command is None:
405
+ cmd_init(args)
406
+ else:
407
+ parser.print_help()
408
+
409
+
410
+ if __name__ == "__main__":
411
+ main()
@@ -0,0 +1,71 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ import yaml
5
+
6
+ DEFAULT_API_URL = "https://nacho.bot/api"
7
+ CREDENTIALS_DIR = Path.home() / ".nacho"
8
+ CREDENTIALS_FILE = CREDENTIALS_DIR / "credentials"
9
+
10
+
11
+ def get_api_url() -> str:
12
+ return os.environ.get("NACHO_API_URL", DEFAULT_API_URL)
13
+
14
+
15
+ def save_token(token: str) -> None:
16
+ CREDENTIALS_DIR.mkdir(parents=True, exist_ok=True)
17
+ CREDENTIALS_FILE.write_text(token)
18
+ CREDENTIALS_FILE.chmod(0o600)
19
+
20
+
21
+ def load_token() -> str | None:
22
+ if CREDENTIALS_FILE.exists():
23
+ return CREDENTIALS_FILE.read_text().strip()
24
+ return None
25
+
26
+
27
+ def clear_token() -> None:
28
+ if CREDENTIALS_FILE.exists():
29
+ CREDENTIALS_FILE.unlink()
30
+
31
+
32
+ def load_context_config(path: str = ".") -> dict:
33
+ """Load .nacho.yml from the given directory."""
34
+ config_path = Path(path) / ".nacho.yml"
35
+ if not config_path.exists():
36
+ return {}
37
+ with open(config_path) as f:
38
+ return yaml.safe_load(f) or {}
39
+
40
+
41
+ NACHO_DIR = Path(".nacho")
42
+ CONTEXTS_FILE = NACHO_DIR / "contexts.yml"
43
+
44
+
45
+ def load_tracked_contexts(path: str = ".") -> dict:
46
+ """Read .nacho/contexts.yml and return dict keyed by file path."""
47
+ contexts_file = Path(path) / CONTEXTS_FILE
48
+ if not contexts_file.exists():
49
+ return {}
50
+ with open(contexts_file) as f:
51
+ data = yaml.safe_load(f) or {}
52
+ return data.get("contexts", {})
53
+
54
+
55
+ def save_tracked_context(file_path: str, metadata: dict, path: str = ".") -> None:
56
+ """Upsert one entry into .nacho/contexts.yml, creating .nacho/ dir if needed."""
57
+ nacho_dir = Path(path) / NACHO_DIR
58
+ contexts_file = Path(path) / CONTEXTS_FILE
59
+
60
+ nacho_dir.mkdir(parents=True, exist_ok=True)
61
+
62
+ existing = {}
63
+ if contexts_file.exists():
64
+ with open(contexts_file) as f:
65
+ existing = yaml.safe_load(f) or {}
66
+
67
+ contexts = existing.get("contexts", {})
68
+ contexts[file_path] = metadata
69
+
70
+ with open(contexts_file, "w") as f:
71
+ yaml.dump({"contexts": contexts}, f, default_flow_style=False)