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,167 @@
1
+ """Watch command for file syncing."""
2
+
3
+ import json
4
+ import sys
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Annotated, Optional
8
+
9
+ import typer
10
+
11
+ from nogic.config import Config, is_dev_mode, get_api_url
12
+ from nogic.ignore import build_ignore_matcher
13
+ from nogic.watcher import FileMonitor, SyncService
14
+ from nogic import ui
15
+
16
+
17
+ def _emit_json(event: str, **kwargs):
18
+ """Emit a single NDJSON line to stdout."""
19
+ payload = {"event": event, "timestamp": int(time.time()), **kwargs}
20
+ sys.stdout.write(json.dumps(payload) + "\n")
21
+ sys.stdout.flush()
22
+
23
+
24
+ def watch(
25
+ directory: Annotated[Path, typer.Argument(help="Path to the directory to watch.")] = Path("."),
26
+ ignore: Annotated[Optional[list[str]], typer.Option("--ignore", help="Patterns to ignore.")] = None,
27
+ format: Annotated[Optional[str], typer.Option("--format", help="Output format: text or json.")] = None,
28
+ ):
29
+ """Watch a directory for file changes and sync to backend."""
30
+ directory = directory.resolve()
31
+ nogic_dir = directory / ".nogic"
32
+ ignore = ignore or []
33
+ json_mode = format == "json"
34
+
35
+ if not nogic_dir.exists():
36
+ if json_mode:
37
+ _emit_json("error", message="Not a Nogic project. Run `nogic init` to initialize.")
38
+ else:
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
+ if json_mode:
47
+ _emit_json("error", message="Not logged in. Run `nogic login` to authenticate.")
48
+ else:
49
+ ui.error("Not logged in.")
50
+ ui.dim("Run `nogic login` to authenticate.")
51
+ raise typer.Exit(1)
52
+
53
+ if not config.project_id:
54
+ if json_mode:
55
+ _emit_json("error", message="No project configured. Run `nogic init` to initialize.")
56
+ else:
57
+ ui.error("No project configured.")
58
+ ui.dim("Run `nogic init` to initialize your project.")
59
+ raise typer.Exit(1)
60
+
61
+ if not json_mode:
62
+ if is_dev_mode():
63
+ ui.dev_banner(get_api_url())
64
+ ui.banner("nogic watch", str(directory))
65
+ ui.kv("Project", f"{config.project_id[:8]}...")
66
+
67
+ log_fn = (lambda msg: None) if json_mode else (lambda msg: ui.dim(f" {msg}"))
68
+ sync_service = SyncService(config, directory, log=log_fn, json_mode=json_mode)
69
+
70
+ should_ignore = build_ignore_matcher(directory, extra_patterns=ignore)
71
+
72
+ # Initial scan
73
+ try:
74
+ sync_service.initial_scan(directory, should_ignore)
75
+ if json_mode:
76
+ files_indexed = len(sync_service._file_cache)
77
+ _emit_json("initial_scan_complete", files_indexed=files_indexed)
78
+ except KeyboardInterrupt:
79
+ if not json_mode:
80
+ ui.console.print()
81
+ ui.dim("Interrupted during initial scan. Cleaning up...")
82
+ try:
83
+ sync_service.client.clear_staging(config.project_id)
84
+ except Exception:
85
+ pass
86
+ sync_service.close()
87
+ raise typer.Exit(1)
88
+
89
+ def on_change(path: Path):
90
+ try:
91
+ rel = path.relative_to(directory)
92
+ except ValueError:
93
+ return
94
+ try:
95
+ if sync_service.sync_file_immediate(path):
96
+ if json_mode:
97
+ _emit_json("synced", path=str(rel))
98
+ else:
99
+ ui.console.print(f" [green]SYNCED[/] {rel}")
100
+ except Exception as e:
101
+ err_msg = str(e)
102
+ if "413" in err_msg:
103
+ if json_mode:
104
+ _emit_json("skip", path=str(rel), reason="file too large")
105
+ else:
106
+ ui.console.print(f" [yellow]SKIP[/] {rel} (file too large for API)")
107
+ elif "503" in err_msg or "502" in err_msg:
108
+ if json_mode:
109
+ _emit_json("error", path=str(rel), message="backend unavailable")
110
+ else:
111
+ ui.console.print(f" [red]ERROR[/] {rel} (backend unavailable, will sync on next change)")
112
+ else:
113
+ if json_mode:
114
+ _emit_json("error", path=str(rel), message=err_msg[:120])
115
+ else:
116
+ ui.console.print(f" [red]ERROR[/] {rel}: {err_msg[:120]}")
117
+
118
+ def on_delete(path: Path):
119
+ try:
120
+ rel = path.relative_to(directory)
121
+ except ValueError:
122
+ return
123
+ try:
124
+ if sync_service.delete_file_immediate(path):
125
+ if json_mode:
126
+ _emit_json("deleted", path=str(rel))
127
+ else:
128
+ ui.console.print(f" [red]DELETED[/] {rel}")
129
+ else:
130
+ if json_mode:
131
+ _emit_json("deleted", path=str(rel))
132
+ else:
133
+ ui.console.print(f" [dim]DELETED[/] {rel} (not indexed)")
134
+ except Exception as e:
135
+ if json_mode:
136
+ _emit_json("error", path=str(rel), message=str(e)[:80])
137
+ else:
138
+ ui.console.print(f" [red]DELETED[/] {rel} (error: {str(e)[:80]})")
139
+
140
+ monitor = FileMonitor(
141
+ root_path=directory,
142
+ on_change=on_change,
143
+ on_delete=on_delete,
144
+ should_ignore=should_ignore,
145
+ )
146
+
147
+ if json_mode:
148
+ _emit_json("ready", message="Watching for changes...")
149
+ else:
150
+ ui.console.print()
151
+ ui.info("Watching for changes... (Ctrl+C to stop)")
152
+ ui.console.print()
153
+
154
+ monitor.start()
155
+ try:
156
+ while monitor.is_alive():
157
+ time.sleep(1)
158
+ except KeyboardInterrupt:
159
+ if not json_mode:
160
+ ui.console.print()
161
+ ui.dim("Stopping...")
162
+ finally:
163
+ monitor.stop()
164
+ sync_service.close()
165
+
166
+ if not json_mode:
167
+ ui.success("Done.")
nogic/config.py ADDED
@@ -0,0 +1,157 @@
1
+ """Configuration management for Nogic CLI."""
2
+
3
+ import os
4
+ import json
5
+ from pathlib import Path
6
+ from dataclasses import dataclass, field
7
+ from typing import Optional
8
+
9
+ from dotenv import load_dotenv
10
+
11
+ load_dotenv()
12
+
13
+ CONFIG_DIR = ".nogic"
14
+ CONFIG_FILE = "config.json"
15
+ GLOBAL_CONFIG_DIR = Path.home() / ".nogic"
16
+
17
+ _PRODUCTION_URL = "https://api.nogic.dev"
18
+
19
+
20
+ def is_dev_mode() -> bool:
21
+ """True when NOGIC_API_URL env var overrides the production URL."""
22
+ return bool(os.getenv("NOGIC_API_URL"))
23
+
24
+
25
+ def get_api_url() -> str:
26
+ """Return NOGIC_API_URL if set, otherwise the production URL."""
27
+ return os.getenv("NOGIC_API_URL", _PRODUCTION_URL).strip().rstrip("/")
28
+
29
+
30
+ def dev_mode_banner() -> str | None:
31
+ """Return a dev mode banner string, or None if in production mode."""
32
+ if is_dev_mode():
33
+ return f"[DEV] Using {get_api_url()}"
34
+ return None
35
+
36
+
37
+ @dataclass
38
+ class Config:
39
+ api_key: Optional[str]
40
+ project_id: Optional[str]
41
+ project_name: Optional[str] = None
42
+ directory_hash: Optional[str] = None
43
+
44
+ @property
45
+ def api_url(self) -> str:
46
+ return get_api_url()
47
+
48
+ @classmethod
49
+ def load(cls, directory: Path = Path.cwd()) -> "Config":
50
+ """
51
+ Load config from multiple sources (priority order):
52
+ 1. Environment variables
53
+ 2. Global config: ~/.nogic/config.json (api_key)
54
+ 3. Local config: <directory>/.nogic/config.json (project_id, project_name, directory_hash)
55
+ """
56
+ api_key = os.getenv("NOGIC_API_KEY")
57
+ project_id = None
58
+ project_name = None
59
+ directory_hash = None
60
+
61
+ global_config_path = GLOBAL_CONFIG_DIR / CONFIG_FILE
62
+ if global_config_path.exists():
63
+ try:
64
+ with open(global_config_path, encoding="utf-8") as f:
65
+ data = json.load(f)
66
+ if not api_key:
67
+ api_key = data.get("api_key")
68
+ except (json.JSONDecodeError, OSError):
69
+ pass # Corrupted config - continue with defaults
70
+
71
+ local_config_path = directory / CONFIG_DIR / CONFIG_FILE
72
+ if local_config_path.exists():
73
+ try:
74
+ with open(local_config_path, encoding="utf-8") as f:
75
+ data = json.load(f)
76
+ project_id = data.get("project_id")
77
+ project_name = data.get("project_name")
78
+ directory_hash = data.get("directory_hash")
79
+ except (json.JSONDecodeError, OSError):
80
+ pass # Corrupted config - continue with defaults
81
+
82
+ if api_key:
83
+ api_key = api_key.strip()
84
+
85
+ return cls(
86
+ api_key=api_key,
87
+ project_id=project_id,
88
+ project_name=project_name,
89
+ directory_hash=directory_hash,
90
+ )
91
+
92
+ def save_global(self):
93
+ """Save API key to ~/.nogic/config.json (preserves other settings)."""
94
+ GLOBAL_CONFIG_DIR.mkdir(mode=0o700, exist_ok=True)
95
+
96
+ existing = {}
97
+ config_path = GLOBAL_CONFIG_DIR / CONFIG_FILE
98
+ if config_path.exists():
99
+ try:
100
+ with open(config_path, encoding="utf-8") as f:
101
+ existing = json.load(f)
102
+ except (json.JSONDecodeError, OSError):
103
+ pass # Corrupted config - will be overwritten
104
+
105
+ if self.api_key:
106
+ existing["api_key"] = self.api_key
107
+
108
+ # Never store api_url on disk
109
+ existing.pop("api_url", None)
110
+
111
+ with open(config_path, "w", encoding="utf-8") as f:
112
+ json.dump(existing, f, indent=2)
113
+
114
+ os.chmod(config_path, 0o600)
115
+
116
+ def save_local(self, directory: Path = Path.cwd()):
117
+ """Save project config to <directory>/.nogic/config.json."""
118
+ config_dir = directory / CONFIG_DIR
119
+ config_dir.mkdir(mode=0o700, exist_ok=True)
120
+
121
+ data = {}
122
+ if self.project_id:
123
+ data["project_id"] = self.project_id
124
+ if self.project_name:
125
+ data["project_name"] = self.project_name
126
+ if self.directory_hash:
127
+ data["directory_hash"] = self.directory_hash
128
+
129
+ config_path = config_dir / CONFIG_FILE
130
+ with open(config_path, "w", encoding="utf-8") as f:
131
+ json.dump(data, f, indent=2)
132
+
133
+ os.chmod(config_path, 0o600)
134
+
135
+ def save(self, directory: Path = Path.cwd()):
136
+ """Save both global and local config."""
137
+ self.save_global()
138
+ self.save_local(directory)
139
+
140
+
141
+ def get_cli_db_path(project_id: str) -> Path:
142
+ """Get the CLI's sync database path: ~/.nogic/sync/<project_id>/data.db"""
143
+ sync_dir = GLOBAL_CONFIG_DIR / "sync" / project_id
144
+ sync_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
145
+ return sync_dir / "data.db"
146
+
147
+
148
+ def get_language(path: Path) -> Optional[str]:
149
+ """Get language from file extension."""
150
+ ext_map = {
151
+ ".py": "python",
152
+ ".js": "javascript",
153
+ ".jsx": "javascript",
154
+ ".ts": "typescript",
155
+ ".tsx": "typescript",
156
+ }
157
+ return ext_map.get(path.suffix.lower())
nogic/ignore.py ADDED
@@ -0,0 +1,109 @@
1
+ """Gitignore-aware file ignore logic.
2
+
3
+ Consolidates ignore pattern handling used across sync, watch, and reindex commands.
4
+ Uses pathspec for full .gitignore spec support (gitwildmatch).
5
+ """
6
+
7
+ from pathlib import Path
8
+ from typing import Callable
9
+
10
+ import pathspec
11
+
12
+ DEFAULT_IGNORE_PATTERNS = [
13
+ ".git/",
14
+ "__pycache__/",
15
+ "*.pyc",
16
+ ".venv/",
17
+ "venv/",
18
+ "env/",
19
+ "node_modules/",
20
+ ".nogic/",
21
+ ".DS_Store",
22
+ "*.swp",
23
+ "*.swo",
24
+ "*.tmp.*",
25
+ "*~",
26
+ # Build output directories
27
+ "dist/",
28
+ "build/",
29
+ "out/",
30
+ ".next/",
31
+ ".nuxt/",
32
+ ".output/",
33
+ # Coverage and test output
34
+ "coverage/",
35
+ ".coverage",
36
+ "htmlcov/",
37
+ # IDE and editor files
38
+ ".idea/",
39
+ ".vscode/",
40
+ # Bundled/minified files
41
+ "*.min.js",
42
+ "*.min.css",
43
+ "*.bundle.js",
44
+ "*.chunk.js",
45
+ # Lock files and binary artifacts
46
+ "*.lock",
47
+ "package-lock.json",
48
+ "yarn.lock",
49
+ "pnpm-lock.yaml",
50
+ "*.map",
51
+ ]
52
+
53
+
54
+ def load_gitignore_patterns(root: Path) -> list[str]:
55
+ """Read .gitignore from root and return pattern lines.
56
+
57
+ Skips blank lines and comments. Returns empty list if no .gitignore exists.
58
+ """
59
+ gitignore_path = root / ".gitignore"
60
+ if not gitignore_path.is_file():
61
+ return []
62
+
63
+ try:
64
+ text = gitignore_path.read_text(encoding="utf-8")
65
+ except (OSError, UnicodeDecodeError):
66
+ return []
67
+
68
+ patterns = []
69
+ for line in text.splitlines():
70
+ stripped = line.strip()
71
+ if stripped and not stripped.startswith("#"):
72
+ patterns.append(stripped)
73
+ return patterns
74
+
75
+
76
+ def build_ignore_matcher(
77
+ root: Path,
78
+ extra_patterns: tuple[str, ...] | list[str] = (),
79
+ ) -> Callable[[Path], bool]:
80
+ """Build an ignore checker combining defaults + .gitignore + extra patterns.
81
+
82
+ Returns a callable that takes an absolute Path and returns True if it should
83
+ be ignored. Uses pathspec with gitwildmatch for full .gitignore compatibility.
84
+ """
85
+ root = root.resolve()
86
+
87
+ all_patterns = (
88
+ list(DEFAULT_IGNORE_PATTERNS)
89
+ + load_gitignore_patterns(root)
90
+ + list(extra_patterns)
91
+ )
92
+
93
+ spec = pathspec.PathSpec.from_lines("gitignore", all_patterns)
94
+
95
+ def should_ignore(path: Path) -> bool:
96
+ try:
97
+ rel_path = path.relative_to(root)
98
+ except ValueError:
99
+ return True
100
+
101
+ rel_str = str(rel_path)
102
+ # pathspec needs forward slashes; on Windows Path uses backslashes
103
+ rel_str = rel_str.replace("\\", "/")
104
+ # Append trailing slash for directories so dir patterns match
105
+ if path.is_dir():
106
+ rel_str += "/"
107
+ return spec.match_file(rel_str)
108
+
109
+ return should_ignore
nogic/main.py ADDED
@@ -0,0 +1,58 @@
1
+ """Main CLI entry point."""
2
+
3
+ import atexit
4
+
5
+ import typer
6
+
7
+ from nogic import __version__
8
+ from nogic import telemetry
9
+
10
+ app = typer.Typer(
11
+ name="nogic",
12
+ help="Nogic CLI - Code Intelligence for AI Agents.",
13
+ no_args_is_help=True,
14
+ rich_markup_mode="rich",
15
+ pretty_exceptions_enable=False,
16
+ )
17
+
18
+
19
+ def version_callback(value: bool):
20
+ if value:
21
+ typer.echo(f"nogic {__version__}")
22
+ raise typer.Exit()
23
+
24
+
25
+ @app.callback()
26
+ def main(
27
+ version: bool = typer.Option(None, "--version", "-V", callback=version_callback, is_eager=True, help="Show version."),
28
+ ):
29
+ """Nogic CLI - Code Intelligence for AI Agents."""
30
+ atexit.register(telemetry.shutdown)
31
+
32
+
33
+ # Import and register commands
34
+ from nogic.commands.login import login
35
+ from nogic.commands.init import init
36
+ from nogic.commands.watch import watch
37
+ from nogic.commands.sync import sync
38
+ from nogic.commands.reindex import reindex
39
+ from nogic.commands.status import status
40
+ from nogic.commands.projects import projects_app
41
+ from nogic.commands.telemetry_cmd import telemetry_app
42
+
43
+ app.command()(login)
44
+ app.command()(init)
45
+ app.command()(watch)
46
+ app.command()(sync)
47
+ app.command()(reindex)
48
+ app.command()(status)
49
+ app.add_typer(projects_app, name="projects", help="Manage Nogic projects.")
50
+ app.add_typer(telemetry_app, name="telemetry", help="Manage telemetry settings.")
51
+
52
+
53
+ def entry():
54
+ app()
55
+
56
+
57
+ if __name__ == "__main__":
58
+ entry()
@@ -0,0 +1,22 @@
1
+ """
2
+ Parsing module for extracting code intelligence from source files.
3
+ """
4
+
5
+ from .types import (
6
+ ExtractedFunction,
7
+ ExtractedClass,
8
+ ExtractedCall,
9
+ ExtractedImport,
10
+ ParseResult,
11
+ )
12
+ from .parser import ParserService, parser_service
13
+
14
+ __all__ = [
15
+ "ExtractedFunction",
16
+ "ExtractedClass",
17
+ "ExtractedCall",
18
+ "ExtractedImport",
19
+ "ParseResult",
20
+ "ParserService",
21
+ "parser_service",
22
+ ]