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.
@@ -0,0 +1,138 @@
1
+ """Projects command - list and manage projects."""
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import httpx
7
+ import typer
8
+
9
+ from nogic.config import Config
10
+ from nogic.api import NogicClient
11
+ from nogic.api.client import get_directory_hash
12
+ from nogic import ui
13
+
14
+ projects_app = typer.Typer(no_args_is_help=True, rich_markup_mode="rich")
15
+
16
+
17
+ def _require_auth() -> Config:
18
+ config = Config.load()
19
+ if not config.api_key:
20
+ ui.error("Not logged in.")
21
+ ui.dim("Run `nogic login` to authenticate.")
22
+ raise typer.Exit(1)
23
+ return config
24
+
25
+
26
+ @projects_app.command("list")
27
+ def list_projects(
28
+ verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Show detailed info.")] = False,
29
+ ):
30
+ """List your projects."""
31
+ config = _require_auth()
32
+
33
+ client = NogicClient(config)
34
+ try:
35
+ with ui.status_spinner("Fetching projects..."):
36
+ projects_list = client.list_projects()
37
+
38
+ if not projects_list:
39
+ ui.info("No projects found.")
40
+ ui.dim("Create one with: nogic init")
41
+ return
42
+
43
+ ui.banner("nogic projects", f"{len(projects_list)} project(s)")
44
+ for p in projects_list:
45
+ marker = " [green]*[/]" if p.id == config.project_id else ""
46
+ ui.console.print(f" [bold]{p.name}[/] [dim]({p.id[:8]}...)[/]{marker}")
47
+ if verbose:
48
+ if p.directory_hash:
49
+ ui.dim(f" Hash: {p.directory_hash[:16]}...")
50
+ if p.created_at:
51
+ ui.dim(f" Created: {p.created_at}")
52
+
53
+ if config.project_id:
54
+ ui.console.print()
55
+ ui.dim(" * = current project")
56
+
57
+ except httpx.HTTPStatusError as e:
58
+ ui.error(f"Error ({e.response.status_code})")
59
+ raise typer.Exit(1)
60
+ finally:
61
+ client.close()
62
+
63
+
64
+ @projects_app.command("use")
65
+ def use_project(
66
+ project_id: Annotated[str, typer.Argument(help="Project ID (prefix match).")],
67
+ ):
68
+ """Set the current project by ID."""
69
+ cwd = Path.cwd()
70
+ config = _require_auth()
71
+
72
+ client = NogicClient(config)
73
+ try:
74
+ with ui.status_spinner("Fetching projects..."):
75
+ projects_list = client.list_projects()
76
+ matched = [p for p in projects_list if p.id.startswith(project_id)]
77
+
78
+ if not matched:
79
+ ui.error(f"No project found matching: {project_id}")
80
+ raise typer.Exit(1)
81
+ if len(matched) > 1:
82
+ ui.error(f"Multiple projects match '{project_id}':")
83
+ for p in matched:
84
+ ui.console.print(f" {p.name} ({p.id})")
85
+ raise typer.Exit(1)
86
+
87
+ config.project_id = matched[0].id
88
+ config.save_local(cwd)
89
+ ui.success(f"Now using project: {matched[0].name}")
90
+
91
+ except httpx.HTTPStatusError as e:
92
+ ui.error(f"Error ({e.response.status_code})")
93
+ raise typer.Exit(1)
94
+ finally:
95
+ client.close()
96
+
97
+
98
+ @projects_app.command("create")
99
+ def create_project(
100
+ name: Annotated[str, typer.Argument(help="Project name.")],
101
+ use: Annotated[bool, typer.Option("--use", help="Set as current project.")] = False,
102
+ link: Annotated[bool, typer.Option("--link", help="Link to current directory.")] = False,
103
+ ):
104
+ """Create a new project."""
105
+ cwd = Path.cwd()
106
+ config = _require_auth()
107
+
108
+ dir_hash = get_directory_hash(str(cwd)) if link else None
109
+
110
+ client = NogicClient(config)
111
+ try:
112
+ with ui.status_spinner("Creating project..."):
113
+ project = client.create_project(name, dir_hash)
114
+
115
+ ui.success(f"Created project: {project.name}")
116
+ ui.kv("Project ID", project.id)
117
+ if link:
118
+ ui.kv("Linked to", str(cwd))
119
+
120
+ if use or link:
121
+ config.project_id = project.id
122
+ config.project_name = project.name
123
+ if link:
124
+ config.directory_hash = dir_hash
125
+ config.save_local(cwd)
126
+ ui.dim("Set as current project.")
127
+
128
+ except httpx.HTTPStatusError as e:
129
+ if e.response.status_code == 409:
130
+ data = e.response.json()
131
+ ui.error("A project already exists for this directory.")
132
+ ui.kv("Project", f"{data.get('project_name')} ({data.get('project_id')[:8]}...)", indent=4)
133
+ ui.dim(" Use 'nogic init' to reuse the existing project.")
134
+ else:
135
+ ui.error(f"Error ({e.response.status_code})")
136
+ raise typer.Exit(1)
137
+ finally:
138
+ client.close()
@@ -0,0 +1,117 @@
1
+ """Reindex command - wipe graph data and re-index from scratch."""
2
+
3
+ import json as _json
4
+ import sys
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Annotated, Optional
8
+
9
+ import httpx
10
+ import typer
11
+
12
+ from nogic.config import Config, CONFIG_DIR, is_dev_mode, get_api_url
13
+ from nogic.ignore import build_ignore_matcher
14
+ from nogic.watcher import SyncService
15
+ from nogic.api import NogicClient
16
+ from nogic import ui
17
+
18
+
19
+ def _emit_json(event: str, **kwargs):
20
+ """Emit a single NDJSON line to stdout."""
21
+ payload = {"event": event, "timestamp": int(time.time()), **kwargs}
22
+ sys.stdout.write(_json.dumps(payload) + "\n")
23
+ sys.stdout.flush()
24
+
25
+
26
+ def reindex(
27
+ directory: Annotated[Path, typer.Argument(help="Path to the project directory.")] = Path("."),
28
+ ignore: Annotated[Optional[list[str]], typer.Option("--ignore", help="Patterns to ignore.")] = None,
29
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation.")] = False,
30
+ format: Annotated[Optional[str], typer.Option("--format", help="Output format: text or json.")] = None,
31
+ ):
32
+ """Wipe graph data and re-index the entire project."""
33
+ directory = directory.resolve()
34
+ nogic_dir = directory / CONFIG_DIR
35
+ ignore = ignore or []
36
+ json_mode = format == "json"
37
+
38
+ if not nogic_dir.exists():
39
+ ui.error("Not a Nogic project.")
40
+ ui.dim("Run `nogic init` to initialize your project.")
41
+ raise typer.Exit(1)
42
+
43
+ config = Config.load(directory)
44
+
45
+ if not config.api_key:
46
+ ui.error("Not logged in.")
47
+ ui.dim("Run `nogic login` to authenticate.")
48
+ raise typer.Exit(1)
49
+
50
+ if not config.project_id:
51
+ ui.error("No project configured.")
52
+ ui.dim("Run `nogic init` to initialize your project.")
53
+ raise typer.Exit(1)
54
+
55
+ if not json_mode:
56
+ if is_dev_mode():
57
+ ui.dev_banner(get_api_url())
58
+
59
+ if not yes:
60
+ ui.banner("nogic reindex")
61
+ ui.kv("Project", config.project_name or f"{config.project_id[:8]}...")
62
+ ui.kv("Directory", str(directory))
63
+ ui.console.print()
64
+ ui.warn("This will delete all graph data and re-index from scratch.")
65
+ ui.console.print()
66
+ if not typer.confirm("Continue?", default=False):
67
+ ui.dim("Aborted.")
68
+ raise typer.Exit(0)
69
+
70
+ client = NogicClient(config)
71
+ nodes_deleted = 0
72
+
73
+ try:
74
+ if json_mode:
75
+ _emit_json("progress", phase="wiping")
76
+ else:
77
+ wipe_ctx = ui.status_spinner("Wiping graph data...")
78
+ wipe_ctx.__enter__()
79
+
80
+ try:
81
+ result = client.wipe_project_graph(config.project_id)
82
+ nodes_deleted = result.nodes_deleted
83
+ except httpx.HTTPStatusError as e:
84
+ if e.response.status_code == 404:
85
+ pass
86
+ else:
87
+ if json_mode:
88
+ _emit_json("error", message=f"Error wiping graph ({e.response.status_code})")
89
+ else:
90
+ ui.error(f"Error wiping graph ({e.response.status_code})")
91
+ raise typer.Exit(1)
92
+
93
+ if not json_mode:
94
+ wipe_ctx.__exit__(None, None, None)
95
+
96
+ if json_mode:
97
+ _emit_json("wiped", nodes_deleted=nodes_deleted)
98
+ elif nodes_deleted:
99
+ ui.info(f"Deleted {nodes_deleted} nodes")
100
+
101
+ should_ignore = build_ignore_matcher(directory, extra_patterns=ignore)
102
+ log_fn = (lambda msg: None) if json_mode else (lambda msg: ui.dim(f" {msg}"))
103
+ sync_service = SyncService(config, directory, log=log_fn, json_mode=json_mode)
104
+
105
+ sync_service.initial_scan(directory, should_ignore)
106
+ sync_service.close()
107
+
108
+ if not json_mode:
109
+ ui.console.print()
110
+ ui.success("Reindex complete!")
111
+ if nodes_deleted > 0:
112
+ ui.dim(f" Old nodes deleted: {nodes_deleted}")
113
+ ui.console.print()
114
+ ui.dim("Run 'nogic watch' to continue monitoring for changes.")
115
+
116
+ finally:
117
+ client.close()
@@ -0,0 +1,165 @@
1
+ """Status command - show project status and verify configuration."""
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+ from datetime import datetime
7
+ from typing import Annotated, Optional
8
+
9
+ import httpx
10
+ import typer
11
+
12
+ from nogic.config import Config, CONFIG_DIR, is_dev_mode, get_api_url
13
+ from nogic.api import NogicClient
14
+ from nogic.api.client import get_directory_hash
15
+ from nogic import ui
16
+
17
+
18
+ def status(
19
+ directory: Annotated[Path, typer.Argument(help="Path to the project directory.")] = Path("."),
20
+ format: Annotated[Optional[str], typer.Option("--format", help="Output format: text or json.")] = None,
21
+ ):
22
+ """Show project status and verify configuration."""
23
+ directory = directory.resolve()
24
+ nogic_dir = directory / CONFIG_DIR
25
+ current_dir_hash = get_directory_hash(str(directory))
26
+ json_mode = format == "json"
27
+
28
+ if not nogic_dir.exists():
29
+ if json_mode:
30
+ _emit_json_status(error="Not a Nogic project.")
31
+ else:
32
+ ui.error("Not a Nogic project.")
33
+ ui.dim("Run `nogic init` to initialize your project.")
34
+ raise typer.Exit(1)
35
+
36
+ config = Config.load(directory)
37
+
38
+ if json_mode:
39
+ _status_json(directory, config, current_dir_hash)
40
+ return
41
+
42
+ if is_dev_mode():
43
+ ui.dev_banner(get_api_url())
44
+
45
+ ui.banner("nogic status")
46
+
47
+ ui.section("Local Configuration")
48
+ ui.kv("Directory", str(directory))
49
+ ui.kv("Project ID", config.project_id or "(not set)")
50
+ ui.kv("Project Name", config.project_name or "(not set)")
51
+ ui.kv("Directory Hash", f"{current_dir_hash[:16]}...")
52
+ ui.kv("Logged in", "Yes" if config.api_key else "[red]No[/]")
53
+
54
+ if config.directory_hash and config.directory_hash != current_dir_hash:
55
+ ui.console.print()
56
+ ui.warn("Directory hash mismatch!")
57
+ ui.kv("Config hash", f"{config.directory_hash[:16]}...", indent=4)
58
+ ui.kv("Current hash", f"{current_dir_hash[:16]}...", indent=4)
59
+ ui.dim(" Project may have been moved. Run 'nogic init --link' to update.")
60
+
61
+ # Backend verification
62
+ if config.api_key and config.project_id:
63
+ client = NogicClient(config)
64
+ try:
65
+ with ui.status_spinner("Checking backend..."):
66
+ backend_project = client.get_project_by_directory(current_dir_hash)
67
+
68
+ ui.section("Backend Status")
69
+ if backend_project:
70
+ ui.kv("Project found", backend_project.name)
71
+ ui.kv("Project ID", backend_project.id)
72
+ if backend_project.created_at:
73
+ ui.kv("Created", _format_datetime(backend_project.created_at))
74
+ if backend_project.updated_at:
75
+ ui.kv("Updated", _format_datetime(backend_project.updated_at))
76
+
77
+ if backend_project.id != config.project_id:
78
+ ui.console.print()
79
+ ui.warn("Project ID mismatch!")
80
+ ui.kv("Local config", config.project_id, indent=4)
81
+ ui.kv("Backend", backend_project.id, indent=4)
82
+ ui.dim(" Run 'nogic init --link' to fix.")
83
+ else:
84
+ ui.dim(" No project found for this directory in backend")
85
+ if config.project_id:
86
+ ui.dim(f" Local config has project ID: {config.project_id}")
87
+ ui.dim(" Consider running 'nogic init --link' to link it.")
88
+
89
+ except httpx.HTTPStatusError as e:
90
+ ui.section("Backend Status")
91
+ ui.error(f"Error ({e.response.status_code})")
92
+ if e.response.status_code == 401:
93
+ ui.dim(" Authentication failed. Run: nogic login")
94
+ except httpx.RequestError:
95
+ ui.section("Backend Status")
96
+ ui.error("Connection failed")
97
+ finally:
98
+ client.close()
99
+ elif not config.api_key:
100
+ ui.section("Backend Status")
101
+ ui.dim(" Not logged in. Run `nogic login` to authenticate.")
102
+ else:
103
+ ui.section("Backend Status")
104
+ ui.dim(" No project configured. Run `nogic init` to set up.")
105
+
106
+ ui.console.print()
107
+
108
+
109
+ def _status_json(directory: Path, config: Config, dir_hash: str):
110
+ """Output status as a single JSON object to stdout."""
111
+ result: dict = {
112
+ "project_name": config.project_name or None,
113
+ "project_id": config.project_id or None,
114
+ "directory": str(directory),
115
+ "logged_in": bool(config.api_key),
116
+ "backend": None,
117
+ }
118
+
119
+ if config.api_key and config.project_id:
120
+ client = NogicClient(config)
121
+ try:
122
+ backend_project = client.get_project_by_directory(dir_hash)
123
+ if backend_project:
124
+ result["backend"] = {
125
+ "status": "connected",
126
+ "project_name": backend_project.name,
127
+ "project_id": backend_project.id,
128
+ "created_at": backend_project.created_at,
129
+ "updated_at": backend_project.updated_at,
130
+ }
131
+ else:
132
+ result["backend"] = {"status": "not_found"}
133
+ except httpx.HTTPStatusError as e:
134
+ result["backend"] = {
135
+ "status": "error",
136
+ "error": f"HTTP {e.response.status_code}",
137
+ }
138
+ except httpx.RequestError as e:
139
+ result["backend"] = {
140
+ "status": "error",
141
+ "error": str(e),
142
+ }
143
+ finally:
144
+ client.close()
145
+ elif not config.api_key:
146
+ result["backend"] = {"status": "not_logged_in"}
147
+ else:
148
+ result["backend"] = {"status": "not_configured"}
149
+
150
+ sys.stdout.write(json.dumps(result) + "\n")
151
+ sys.stdout.flush()
152
+
153
+
154
+ def _emit_json_status(**kwargs):
155
+ """Emit a JSON status object with error info."""
156
+ sys.stdout.write(json.dumps(kwargs) + "\n")
157
+ sys.stdout.flush()
158
+
159
+
160
+ def _format_datetime(dt_string: str) -> str:
161
+ try:
162
+ dt = datetime.fromisoformat(dt_string.replace("Z", "+00:00"))
163
+ return dt.strftime("%Y-%m-%d %H:%M:%S")
164
+ except (ValueError, AttributeError):
165
+ return dt_string
nogic/commands/sync.py ADDED
@@ -0,0 +1,72 @@
1
+ """Sync command - one-time full sync to backend."""
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated, Optional
5
+
6
+ import typer
7
+
8
+ from nogic.config import Config, is_dev_mode, get_api_url
9
+ from nogic.ignore import build_ignore_matcher
10
+ from nogic.watcher import SyncService
11
+ from nogic import telemetry, ui
12
+
13
+
14
+ def sync(
15
+ directory: Annotated[Path, typer.Argument(help="Path to the directory to sync.")] = Path("."),
16
+ ignore: Annotated[Optional[list[str]], typer.Option("--ignore", help="Patterns to ignore.")] = None,
17
+ format: Annotated[Optional[str], typer.Option("--format", help="Output format: text or json.")] = None,
18
+ ):
19
+ """One-time sync of a directory to backend."""
20
+ directory = directory.resolve()
21
+ nogic_dir = directory / ".nogic"
22
+ ignore = ignore or []
23
+ json_mode = format == "json"
24
+
25
+ if not nogic_dir.exists():
26
+ ui.error("Not a Nogic project.")
27
+ ui.dim("Run `nogic init` to initialize your project.")
28
+ raise typer.Exit(1)
29
+
30
+ config = Config.load(directory)
31
+
32
+ if not config.api_key:
33
+ ui.error("Not logged in.")
34
+ ui.dim("Run `nogic login` to authenticate.")
35
+ raise typer.Exit(1)
36
+
37
+ if not config.project_id:
38
+ ui.error("No project configured.")
39
+ ui.dim("Run `nogic init` to initialize your project.")
40
+ raise typer.Exit(1)
41
+
42
+ if not json_mode:
43
+ if is_dev_mode():
44
+ ui.dev_banner(get_api_url())
45
+ ui.banner("nogic sync", str(directory))
46
+ ui.kv("Project", f"{config.project_id[:8]}...")
47
+
48
+ log_fn = (lambda msg: None) if json_mode else (lambda msg: ui.dim(f" {msg}"))
49
+ sync_service = SyncService(config, directory, log=log_fn, json_mode=json_mode)
50
+
51
+ try:
52
+ sync_service.initial_scan(directory, build_ignore_matcher(directory, extra_patterns=ignore))
53
+ telemetry.capture("cli_sync", {"status": "success"})
54
+ except KeyboardInterrupt:
55
+ if not json_mode:
56
+ ui.console.print()
57
+ ui.dim("Interrupted. Cleaning up...")
58
+ try:
59
+ sync_service.client.clear_staging(config.project_id)
60
+ except Exception:
61
+ pass
62
+ telemetry.capture("cli_sync", {"status": "interrupted"})
63
+ raise typer.Exit(1)
64
+ except Exception as e:
65
+ telemetry.capture("cli_sync", {"status": "error", "error": str(e)})
66
+ raise
67
+ finally:
68
+ sync_service.close()
69
+
70
+ if not json_mode:
71
+ ui.console.print()
72
+ ui.success("Done.")
@@ -0,0 +1,65 @@
1
+ """Telemetry management commands."""
2
+
3
+ import json
4
+ import os
5
+
6
+ import typer
7
+
8
+ from nogic.config import GLOBAL_CONFIG_DIR, CONFIG_FILE
9
+ from nogic import ui
10
+
11
+ telemetry_app = typer.Typer(no_args_is_help=True, rich_markup_mode="rich")
12
+
13
+
14
+ def _load_global_config() -> dict:
15
+ config_path = GLOBAL_CONFIG_DIR / CONFIG_FILE
16
+ if config_path.exists():
17
+ try:
18
+ with open(config_path, encoding="utf-8") as f:
19
+ return json.load(f)
20
+ except (json.JSONDecodeError, OSError):
21
+ return {}
22
+ return {}
23
+
24
+
25
+ def _save_global_config(data: dict):
26
+ GLOBAL_CONFIG_DIR.mkdir(mode=0o700, exist_ok=True)
27
+ config_path = GLOBAL_CONFIG_DIR / CONFIG_FILE
28
+ with open(config_path, "w", encoding="utf-8") as f:
29
+ json.dump(data, f, indent=2)
30
+ os.chmod(config_path, 0o600)
31
+
32
+
33
+ @telemetry_app.command("status")
34
+ def telemetry_status():
35
+ """Show current telemetry status."""
36
+ config = _load_global_config()
37
+ enabled = config.get("telemetry_enabled", True) is not False
38
+
39
+ if enabled:
40
+ ui.success("Telemetry is enabled")
41
+ ui.dim(" Run `nogic telemetry disable` to opt out")
42
+ else:
43
+ ui.warn("Telemetry is disabled")
44
+ ui.dim(" Run `nogic telemetry enable` to enable")
45
+
46
+ ui.console.print()
47
+ ui.dim("Telemetry helps us improve Nogic. All data is anonymized.")
48
+
49
+
50
+ @telemetry_app.command("enable")
51
+ def telemetry_enable():
52
+ """Enable telemetry."""
53
+ config = _load_global_config()
54
+ config["telemetry_enabled"] = True
55
+ _save_global_config(config)
56
+ ui.success("Telemetry enabled")
57
+
58
+
59
+ @telemetry_app.command("disable")
60
+ def telemetry_disable():
61
+ """Disable telemetry."""
62
+ config = _load_global_config()
63
+ config["telemetry_enabled"] = False
64
+ _save_global_config(config)
65
+ ui.success("Telemetry disabled")