nogic 0.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.
nogic/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Nogic CLI package."""
2
+
3
+ __version__ = "0.0.1"
nogic/api/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """API client module."""
2
+
3
+ from .client import (
4
+ IndexProgress,
5
+ IndexResult,
6
+ GraphWipeResult,
7
+ Project,
8
+ NogicClient,
9
+ UploadResult,
10
+ StagingStats,
11
+ get_directory_hash,
12
+ )
13
+
14
+ __all__ = [
15
+ "IndexProgress",
16
+ "IndexResult",
17
+ "GraphWipeResult",
18
+ "Project",
19
+ "NogicClient",
20
+ "UploadResult",
21
+ "StagingStats",
22
+ "get_directory_hash",
23
+ ]
nogic/api/client.py ADDED
@@ -0,0 +1,390 @@
1
+ """HTTP client for Nogic backend API."""
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ from dataclasses import dataclass
7
+ from typing import Iterator, Optional
8
+
9
+ import httpx
10
+
11
+ from nogic.config import Config
12
+
13
+
14
+ def get_directory_hash(directory: str = None) -> str:
15
+ """Generate SHA256 hash of absolute directory path."""
16
+ if directory is None:
17
+ directory = os.getcwd()
18
+ abs_path = os.path.abspath(directory)
19
+ return hashlib.sha256(abs_path.encode()).hexdigest()
20
+
21
+
22
+ @dataclass
23
+ class IndexProgress:
24
+ stage: str
25
+ message: str
26
+ current: int
27
+ total: int
28
+ files_indexed: Optional[int] = None
29
+ files_skipped: Optional[int] = None
30
+ nodes_created: Optional[int] = None
31
+ edges_created: Optional[int] = None
32
+ errors: Optional[list[dict]] = None
33
+
34
+
35
+ @dataclass
36
+ class IndexResult:
37
+ status: str
38
+ files_indexed: int
39
+ files_skipped: int
40
+ nodes_created: int
41
+ edges_created: int
42
+ errors: list[dict]
43
+
44
+
45
+ @dataclass
46
+ class UploadResult:
47
+ """Result from /v1/index/upload endpoint."""
48
+ files_parsed: int
49
+ total_staged: int
50
+ functions_found: int
51
+ classes_found: int
52
+
53
+
54
+ @dataclass
55
+ class StagingStats:
56
+ """Stats from /v1/index/staging/{project_id} endpoint."""
57
+ files: int
58
+ functions: int
59
+ classes: int
60
+ methods: int
61
+
62
+
63
+ @dataclass
64
+ class GraphWipeResult:
65
+ message: str
66
+ nodes_deleted: int
67
+
68
+
69
+ @dataclass
70
+ class Project:
71
+ id: str
72
+ name: str
73
+ directory_hash: Optional[str] = None
74
+ owner_id: Optional[str] = None
75
+ created_at: Optional[str] = None
76
+ updated_at: Optional[str] = None
77
+
78
+
79
+ class NogicClient:
80
+ def __init__(self, config: Config):
81
+ self.config = config
82
+ self.base_url = config.api_url.rstrip("/")
83
+ self._client: Optional[httpx.Client] = None
84
+
85
+ @property
86
+ def client(self) -> httpx.Client:
87
+ if self._client is None:
88
+ self._client = httpx.Client(
89
+ base_url=self.base_url,
90
+ timeout=60.0,
91
+ headers=self._headers(),
92
+ )
93
+ return self._client
94
+
95
+ def _headers(self) -> dict:
96
+ headers = {"Content-Type": "application/json"}
97
+ if self.config.api_key:
98
+ headers["Authorization"] = f"Bearer {self.config.api_key}"
99
+ return headers
100
+
101
+ def close(self):
102
+ if self._client:
103
+ self._client.close()
104
+ self._client = None
105
+
106
+ def verify_key(self) -> dict:
107
+ """Verify API key. Returns user info or raises."""
108
+ headers = {}
109
+ if self.config.api_key:
110
+ headers["X-API-Key"] = self.config.api_key
111
+ resp = self.client.post("/v1/keys/verify", headers=headers)
112
+ resp.raise_for_status()
113
+ return resp.json()
114
+
115
+ def list_projects(self) -> list[Project]:
116
+ """List user's projects."""
117
+ resp = self.client.get("/v1/projects")
118
+ resp.raise_for_status()
119
+ data = resp.json()
120
+ return [
121
+ Project(
122
+ id=p["id"],
123
+ name=p["name"],
124
+ directory_hash=p.get("directory_hash"),
125
+ created_at=p.get("created_at"),
126
+ updated_at=p.get("updated_at"),
127
+ )
128
+ for p in data["projects"]
129
+ ]
130
+
131
+ def get_project_by_directory(self, directory_hash: str) -> Optional[Project]:
132
+ """
133
+ Lookup project by directory hash.
134
+ Returns None if no project found (404).
135
+ """
136
+ resp = self.client.get(
137
+ "/v1/projects/by-directory",
138
+ params={"hash": directory_hash},
139
+ )
140
+ if resp.status_code == 404:
141
+ return None
142
+ resp.raise_for_status()
143
+ data = resp.json()
144
+ return Project(
145
+ id=data["id"],
146
+ name=data["name"],
147
+ directory_hash=data.get("directory_hash"),
148
+ owner_id=data.get("owner_id"),
149
+ created_at=data.get("created_at"),
150
+ updated_at=data.get("updated_at"),
151
+ )
152
+
153
+ def create_project(
154
+ self, name: str, directory_hash: Optional[str] = None
155
+ ) -> Project:
156
+ """
157
+ Create a new project.
158
+
159
+ Args:
160
+ name: Project name
161
+ directory_hash: SHA256 hash of absolute directory path
162
+
163
+ Returns:
164
+ Project object
165
+
166
+ Raises:
167
+ httpx.HTTPStatusError: 409 if project already exists for directory
168
+ """
169
+ payload = {"name": name}
170
+ if directory_hash:
171
+ payload["directory_hash"] = directory_hash
172
+
173
+ resp = self.client.post("/v1/projects", json=payload)
174
+ resp.raise_for_status()
175
+ data = resp.json()
176
+ return Project(
177
+ id=data.get("project_id") or data.get("id"),
178
+ name=data["name"],
179
+ directory_hash=data.get("directory_hash"),
180
+ )
181
+
182
+ def wipe_project_graph(self, project_id: str) -> GraphWipeResult:
183
+ """
184
+ Wipe all graph data for a project (for reindexing).
185
+
186
+ Deletes all nodes/edges in Neo4j but keeps project record in PostgreSQL.
187
+ Uses a longer timeout since batched deletion of large graphs can take time.
188
+ """
189
+ resp = self.client.delete(
190
+ f"/v1/projects/{project_id}/graph",
191
+ timeout=300.0,
192
+ )
193
+ resp.raise_for_status()
194
+ data = resp.json()
195
+ return GraphWipeResult(
196
+ message=data["message"],
197
+ nodes_deleted=data["nodes_deleted"],
198
+ )
199
+
200
+ def wipe_project_graph_stream(self, project_id: str) -> Iterator[IndexProgress]:
201
+ """
202
+ Wipe graph data with SSE streaming progress.
203
+
204
+ Yields IndexProgress events with stage "deleting" (current/total)
205
+ and "complete" when done.
206
+ Falls back to non-streaming wipe if server returns 404.
207
+ """
208
+ url = f"{self.base_url}/v1/projects/{project_id}/graph/stream"
209
+
210
+ with httpx.stream(
211
+ "DELETE",
212
+ url,
213
+ headers={**self._headers(), "Accept": "text/event-stream"},
214
+ timeout=300.0,
215
+ ) as response:
216
+ if response.status_code == 404:
217
+ # Server doesn't support streaming wipe, fall back
218
+ result = self.wipe_project_graph(project_id)
219
+ yield IndexProgress(
220
+ stage="complete",
221
+ message=result.message,
222
+ current=result.nodes_deleted,
223
+ total=result.nodes_deleted,
224
+ )
225
+ return
226
+ response.raise_for_status()
227
+ for line in response.iter_lines():
228
+ if line.startswith("data: "):
229
+ data = json.loads(line[6:].strip())
230
+ yield IndexProgress(
231
+ stage=data["stage"],
232
+ message=data["message"],
233
+ current=data["current"],
234
+ total=data["total"],
235
+ )
236
+
237
+ def delete_files(self, project_id: str, paths: list[str]) -> int:
238
+ """Delete file nodes from the graph. Returns number of nodes deleted."""
239
+ resp = self.client.post(
240
+ "/v1/index/delete-files",
241
+ json={"project_id": project_id, "paths": paths},
242
+ )
243
+ resp.raise_for_status()
244
+ return resp.json().get("deleted_nodes", 0)
245
+
246
+ def index_files(self, project_id: str, files: list[dict]) -> IndexResult:
247
+ """
248
+ Index raw code files (synchronous endpoint).
249
+
250
+ files: [{"path": str, "content": str, "language": str}]
251
+ """
252
+ resp = self.client.post(
253
+ "/v1/index",
254
+ json={"project_id": project_id, "files": files},
255
+ )
256
+ resp.raise_for_status()
257
+ data = resp.json()
258
+ return IndexResult(
259
+ status=data["status"],
260
+ files_indexed=data["files_indexed"],
261
+ files_skipped=data.get("files_skipped", 0),
262
+ nodes_created=data["nodes_created"],
263
+ edges_created=data["edges_created"],
264
+ errors=data.get("errors", []),
265
+ )
266
+
267
+ def index_files_stream(
268
+ self, project_id: str, files: list[dict]
269
+ ) -> Iterator[IndexProgress]:
270
+ """
271
+ Index files with SSE streaming progress updates.
272
+
273
+ files: [{"path": str, "content": str, "language": str}]
274
+ Yields IndexProgress events as they arrive.
275
+ """
276
+ url = f"{self.base_url}/v1/index/stream"
277
+ payload = {"project_id": project_id, "files": files}
278
+
279
+ with httpx.stream(
280
+ "POST",
281
+ url,
282
+ json=payload,
283
+ headers={**self._headers(), "Accept": "text/event-stream"},
284
+ timeout=300.0,
285
+ ) as response:
286
+ response.raise_for_status()
287
+ for line in response.iter_lines():
288
+ if line.startswith("data: "):
289
+ data = json.loads(line[6:])
290
+ yield IndexProgress(
291
+ stage=data["stage"],
292
+ message=data["message"],
293
+ current=data["current"],
294
+ total=data["total"],
295
+ files_indexed=data.get("files_indexed"),
296
+ files_skipped=data.get("files_skipped"),
297
+ nodes_created=data.get("nodes_created"),
298
+ edges_created=data.get("edges_created"),
299
+ errors=data.get("errors"),
300
+ )
301
+
302
+ # -------------------------------------------------------------------------
303
+ # Two-phase indexing endpoints (parallel upload + finalize)
304
+ # -------------------------------------------------------------------------
305
+
306
+ def upload_batch(self, project_id: str, files: list[dict]) -> UploadResult:
307
+ """
308
+ Upload and parse files without resolution (Phase 1).
309
+
310
+ Stages files for later finalization. Fast (~2-3s per batch).
311
+ files: [{"path": str, "content": str, "language": str, "hash": str}]
312
+ """
313
+ resp = self.client.post(
314
+ "/v1/index/upload",
315
+ json={"project_id": project_id, "files": files},
316
+ )
317
+ resp.raise_for_status()
318
+ data = resp.json()
319
+ return UploadResult(
320
+ files_parsed=data["files_parsed"],
321
+ total_staged=data["total_staged"],
322
+ functions_found=data["functions_found"],
323
+ classes_found=data["classes_found"],
324
+ )
325
+
326
+ def finalize_stream(self, project_id: str) -> Iterator[IndexProgress]:
327
+ """
328
+ Finalize staged files with global resolution (Phase 2).
329
+
330
+ Performs: imports resolution, call graph building, edge creation, embeddings.
331
+ Returns SSE stream with progress updates.
332
+ """
333
+ url = f"{self.base_url}/v1/index/finalize/stream"
334
+ payload = {"project_id": project_id}
335
+
336
+ with httpx.stream(
337
+ "POST",
338
+ url,
339
+ json=payload,
340
+ headers={**self._headers(), "Accept": "text/event-stream"},
341
+ timeout=600.0, # Finalization can take longer for large projects
342
+ ) as response:
343
+ response.raise_for_status()
344
+ for line in response.iter_lines():
345
+ if line.startswith("data: "):
346
+ data = json.loads(line[6:])
347
+ yield IndexProgress(
348
+ stage=data["stage"],
349
+ message=data["message"],
350
+ current=data["current"],
351
+ total=data["total"],
352
+ files_indexed=data.get("files_indexed"),
353
+ files_skipped=data.get("files_skipped"),
354
+ nodes_created=data.get("nodes_created"),
355
+ edges_created=data.get("edges_created"),
356
+ errors=data.get("errors"),
357
+ )
358
+
359
+ def get_staging_stats(self, project_id: str) -> Optional[StagingStats]:
360
+ """
361
+ Get staging stats for a project.
362
+
363
+ Returns None if server doesn't support two-phase indexing (404 or unexpected response).
364
+ """
365
+ resp = self.client.get(f"/v1/index/staging/{project_id}")
366
+ if resp.status_code == 404:
367
+ return None
368
+ resp.raise_for_status()
369
+ data = resp.json()
370
+ # Check for expected response format
371
+ if "files" not in data:
372
+ return None
373
+ return StagingStats(
374
+ files=data["files"],
375
+ functions=data["functions"],
376
+ classes=data["classes"],
377
+ methods=data["methods"],
378
+ )
379
+
380
+ def clear_staging(self, project_id: str) -> bool:
381
+ """
382
+ Clear staged files for a project (cancel/cleanup).
383
+
384
+ Returns True if cleared, False if nothing to clear.
385
+ """
386
+ resp = self.client.delete(f"/v1/index/staging/{project_id}")
387
+ if resp.status_code == 404:
388
+ return False
389
+ resp.raise_for_status()
390
+ return True
@@ -0,0 +1 @@
1
+ """CLI commands package."""
nogic/commands/init.py ADDED
@@ -0,0 +1,125 @@
1
+ """Init command - initialize a Nogic project."""
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated, Optional
5
+
6
+ import httpx
7
+ import typer
8
+
9
+ from nogic.config import Config, CONFIG_DIR, is_dev_mode, get_api_url
10
+ from nogic.api import NogicClient
11
+ from nogic.api.client import get_directory_hash
12
+ from nogic import telemetry, ui
13
+
14
+
15
+ def init(
16
+ directory: Annotated[Path, typer.Argument(help="Path to the project directory.")] = Path("."),
17
+ project_id: Annotated[Optional[str], typer.Option("--project-id", "-p", help="Use existing project ID.")] = None,
18
+ name: Annotated[Optional[str], typer.Option("--name", "-n", help="Project name.")] = None,
19
+ link: Annotated[bool, typer.Option("--link", help="Re-link an existing project.")] = False,
20
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Accept defaults, skip prompts.")] = False,
21
+ ):
22
+ """Initialize a Nogic project in a directory."""
23
+ directory = directory.resolve()
24
+ nogic_dir = directory / CONFIG_DIR
25
+ dir_hash = get_directory_hash(str(directory))
26
+
27
+ config = Config.load(directory)
28
+
29
+ if not config.api_key:
30
+ ui.error("Not logged in.")
31
+ ui.dim("Run `nogic login` to authenticate.")
32
+ raise typer.Exit(1)
33
+
34
+ if is_dev_mode():
35
+ ui.dev_banner(get_api_url())
36
+
37
+ client = NogicClient(config)
38
+
39
+ try:
40
+ if nogic_dir.exists() and not link and config.project_id:
41
+ ui.warn(f"Already initialized: {nogic_dir}")
42
+ ui.kv("Project ID", config.project_id)
43
+ ui.kv("Project Name", config.project_name or "unknown")
44
+ ui.dim("\nUse --link to re-link to a different project.")
45
+ return
46
+
47
+ if project_id:
48
+ config.project_id = project_id
49
+ config.project_name = name or directory.name
50
+ config.directory_hash = dir_hash
51
+ _finalize_init(directory, nogic_dir, config)
52
+ return
53
+
54
+ with ui.status_spinner("Checking for existing project..."):
55
+ existing_project = client.get_project_by_directory(dir_hash)
56
+
57
+ if existing_project:
58
+ ui.info(f"Found existing project '{existing_project.name}'")
59
+ ui.kv("Project ID", existing_project.id)
60
+
61
+ if yes or typer.confirm("Use this project?", default=True):
62
+ config.project_id = existing_project.id
63
+ config.project_name = existing_project.name
64
+ config.directory_hash = dir_hash
65
+ _finalize_init(directory, nogic_dir, config)
66
+ else:
67
+ ui.console.print("\nOptions:")
68
+ ui.console.print(" 1. Wipe graph data and reuse this project")
69
+ ui.console.print(" 2. Abort")
70
+
71
+ choice = "1" if yes else typer.prompt("Choose option", default="1")
72
+
73
+ if choice == "1":
74
+ with ui.status_spinner("Wiping graph data..."):
75
+ result = client.wipe_project_graph(existing_project.id)
76
+ ui.info(f"Deleted {result.nodes_deleted} nodes")
77
+ config.project_id = existing_project.id
78
+ config.project_name = existing_project.name
79
+ config.directory_hash = dir_hash
80
+ _finalize_init(directory, nogic_dir, config)
81
+ else:
82
+ ui.dim("Aborted.")
83
+ raise typer.Exit(0)
84
+ else:
85
+ project_name = name or (directory.name if yes else typer.prompt("Project name", default=directory.name))
86
+
87
+ try:
88
+ with ui.status_spinner("Creating project..."):
89
+ project = client.create_project(project_name, dir_hash)
90
+ config.project_id = project.id
91
+ config.project_name = project.name
92
+ config.directory_hash = dir_hash
93
+ ui.success(f"Created project '{project.name}'")
94
+ _finalize_init(directory, nogic_dir, config)
95
+ except httpx.HTTPStatusError as e:
96
+ if e.response.status_code == 409:
97
+ data = e.response.json()
98
+ ui.warn(f"Project already exists: {data.get('project_name')}")
99
+ if yes or typer.confirm("Use this project?", default=True):
100
+ config.project_id = data.get("project_id")
101
+ config.project_name = data.get("project_name")
102
+ config.directory_hash = dir_hash
103
+ _finalize_init(directory, nogic_dir, config)
104
+ else:
105
+ raise typer.Exit(1)
106
+ else:
107
+ ui.error(f"Error creating project ({e.response.status_code})")
108
+ raise typer.Exit(1)
109
+ finally:
110
+ client.close()
111
+
112
+
113
+ def _finalize_init(directory: Path, nogic_dir: Path, config: Config):
114
+ nogic_dir.mkdir(mode=0o700, exist_ok=True)
115
+ config.save_local(directory)
116
+
117
+ ui.console.print()
118
+ ui.banner("nogic init")
119
+ ui.kv("Directory", str(directory))
120
+ ui.kv("Project", config.project_name or "")
121
+ ui.kv("Project ID", config.project_id or "")
122
+ ui.console.print()
123
+ ui.dim("Ready to sync: nogic watch")
124
+
125
+ telemetry.capture("cli_init", {"project_name": config.project_name})
@@ -0,0 +1,75 @@
1
+ """Login command - authenticate with API key."""
2
+
3
+ import webbrowser
4
+ from typing import Annotated, Optional
5
+
6
+ import httpx
7
+ import typer
8
+
9
+ from nogic.config import Config, is_dev_mode, get_api_url
10
+ from nogic.api import NogicClient
11
+ from nogic import telemetry, ui
12
+
13
+ DASHBOARD_URL = "https://www.nogic.dev/dashboard"
14
+
15
+
16
+ def login(
17
+ api_key: Annotated[Optional[str], typer.Option(
18
+ "--api-key", "-k",
19
+ envvar="NOGIC_API_KEY",
20
+ help="API key (will prompt if not provided).",
21
+ )] = None,
22
+ ):
23
+ """Authenticate with your Nogic API key."""
24
+ if is_dev_mode():
25
+ ui.dev_banner(get_api_url())
26
+
27
+ if not api_key:
28
+ ui.console.print()
29
+ ui.info("Get your API key from the Nogic dashboard.")
30
+ ui.console.print()
31
+
32
+ try:
33
+ webbrowser.open(DASHBOARD_URL)
34
+ ui.dim(f" Opened {DASHBOARD_URL}")
35
+ except Exception:
36
+ ui.dim(f" Visit: {DASHBOARD_URL}")
37
+
38
+ ui.console.print()
39
+ api_key = typer.prompt("Paste your API key", hide_input=True)
40
+
41
+ api_key = api_key.strip()
42
+ if not api_key:
43
+ ui.error("No API key provided.")
44
+ raise typer.Exit(1)
45
+
46
+ config = Config.load()
47
+ config.api_key = api_key
48
+
49
+ client = NogicClient(config)
50
+ try:
51
+ with ui.status_spinner("Verifying..."):
52
+ user_info = client.verify_key()
53
+
54
+ ui.console.print()
55
+ ui.success(f"Logged in as {user_info['user_id'][:8]}...")
56
+ config.save_global()
57
+ ui.dim(" API key saved. You're ready to go.")
58
+ ui.console.print()
59
+ ui.dim(" Next: run `nogic init` in a project directory.")
60
+
61
+ telemetry.capture("cli_login", {"success": True})
62
+ telemetry.identify(user_info.get("user_id"))
63
+
64
+ except httpx.HTTPStatusError as e:
65
+ telemetry.capture("cli_login", {"success": False, "error": e.response.status_code})
66
+ if e.response.status_code == 401:
67
+ ui.error("Invalid API key. Please check and try again.")
68
+ else:
69
+ ui.error(f"Authentication failed ({e.response.status_code})")
70
+ raise typer.Exit(1)
71
+ except httpx.RequestError:
72
+ ui.error("Connection failed. Check your network and try again.")
73
+ raise typer.Exit(1)
74
+ finally:
75
+ client.close()