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.
- nacho_cli-0.1.0/PKG-INFO +9 -0
- nacho_cli-0.1.0/nacho/__init__.py +0 -0
- nacho_cli-0.1.0/nacho/api_client.py +114 -0
- nacho_cli-0.1.0/nacho/commands.py +411 -0
- nacho_cli-0.1.0/nacho/config.py +71 -0
- nacho_cli-0.1.0/nacho/data/nachoinit.md +358 -0
- nacho_cli-0.1.0/nacho_cli.egg-info/PKG-INFO +9 -0
- nacho_cli-0.1.0/nacho_cli.egg-info/SOURCES.txt +15 -0
- nacho_cli-0.1.0/nacho_cli.egg-info/dependency_links.txt +1 -0
- nacho_cli-0.1.0/nacho_cli.egg-info/entry_points.txt +2 -0
- nacho_cli-0.1.0/nacho_cli.egg-info/requires.txt +5 -0
- nacho_cli-0.1.0/nacho_cli.egg-info/top_level.txt +1 -0
- nacho_cli-0.1.0/pyproject.toml +22 -0
- nacho_cli-0.1.0/setup.cfg +4 -0
- nacho_cli-0.1.0/tests/test_api_client.py +206 -0
- nacho_cli-0.1.0/tests/test_commands.py +404 -0
- nacho_cli-0.1.0/tests/test_config.py +137 -0
nacho_cli-0.1.0/PKG-INFO
ADDED
|
@@ -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)
|