navig-github 0.2.0__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.
- navig_github/__init__.py +3 -0
- navig_github/commands/__init__.py +0 -0
- navig_github/commands/github.py +612 -0
- navig_github/engine/__init__.py +18 -0
- navig_github/engine/__main__.py +11 -0
- navig_github/engine/analytics.py +567 -0
- navig_github/engine/attachments.py +519 -0
- navig_github/engine/cli.py +4969 -0
- navig_github/engine/config.py +285 -0
- navig_github/engine/diff.py +628 -0
- navig_github/engine/gists.py +469 -0
- navig_github/engine/git_utils.py +488 -0
- navig_github/engine/github_api.py +1634 -0
- navig_github/engine/incremental.py +367 -0
- navig_github/engine/mirror.py +293 -0
- navig_github/engine/models.py +555 -0
- navig_github/engine/notifications.py +679 -0
- navig_github/engine/restore.py +613 -0
- navig_github/engine/rich_utils.py +124 -0
- navig_github/engine/scheduler.py +366 -0
- navig_github/engine/templates.py +575 -0
- navig_github/engine/transfer.py +501 -0
- navig_github/engine/validation.py +219 -0
- navig_github/engine/verify.py +379 -0
- navig_github/plugin.py +52 -0
- navig_github/skills/github-ops/SKILL.md +25 -0
- navig_github-0.2.0.dist-info/METADATA +61 -0
- navig_github-0.2.0.dist-info/RECORD +32 -0
- navig_github-0.2.0.dist-info/WHEEL +5 -0
- navig_github-0.2.0.dist-info/entry_points.txt +5 -0
- navig_github-0.2.0.dist-info/licenses/LICENSE +173 -0
- navig_github-0.2.0.dist-info/top_level.txt +1 -0
navig_github/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
"""
|
|
2
|
+
navig github — the full GitHub toolbox, wired natively into navig.
|
|
3
|
+
|
|
4
|
+
The GitHub engine (`navig_github.engine`) is bundled and runs
|
|
5
|
+
**in-process**: every engine command is mounted as a `navig github`
|
|
6
|
+
subcommand (backup, exports, analytics, scheduling …), plus navig's own
|
|
7
|
+
curated wrappers (`backup`, `search`, `clone`) and vault-backed `token`
|
|
8
|
+
management. A group callback injects the resolved token into GITHUB_TOKEN,
|
|
9
|
+
which every engine command reads via its `envvar` — so the whole surface is
|
|
10
|
+
vault-wired with zero per-command glue.
|
|
11
|
+
|
|
12
|
+
Token resolution order:
|
|
13
|
+
1. navig vault (secret name: github_token)
|
|
14
|
+
2. GITHUB_TOKEN env var
|
|
15
|
+
3. ~/.navig/config.yaml → github.token
|
|
16
|
+
4. No token → public repos only (curated `clone` falls back to plain git)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import subprocess
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Annotated
|
|
23
|
+
|
|
24
|
+
import typer
|
|
25
|
+
|
|
26
|
+
from navig.core.yaml_io import load_yaml_for_update, safe_load_yaml
|
|
27
|
+
from navig.lazy_loader import lazy_import
|
|
28
|
+
from navig.platform.paths import config_dir
|
|
29
|
+
|
|
30
|
+
ch = lazy_import("navig.console_helper")
|
|
31
|
+
|
|
32
|
+
github_app = typer.Typer(
|
|
33
|
+
name="github",
|
|
34
|
+
help="🐙 GitHub: backup · mirror · export · manage (full GitHub engine, vault-wired)",
|
|
35
|
+
no_args_is_help=True,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@github_app.callback()
|
|
40
|
+
def _github_root(ctx: typer.Context) -> None:
|
|
41
|
+
# Vault → GITHUB_TOKEN before any subcommand runs: every mounted engine
|
|
42
|
+
# command declares envvar="GITHUB_TOKEN", so this one hook authenticates
|
|
43
|
+
# the entire surface. An explicitly exported GITHUB_TOKEN always wins.
|
|
44
|
+
if not os.environ.get("GITHUB_TOKEN"):
|
|
45
|
+
tok = _resolve_github_token()
|
|
46
|
+
if tok:
|
|
47
|
+
os.environ["GITHUB_TOKEN"] = tok
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Token helpers
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _resolve_github_token() -> str | None:
|
|
55
|
+
"""
|
|
56
|
+
Resolve a GitHub token with the full navig credential chain.
|
|
57
|
+
|
|
58
|
+
Priority:
|
|
59
|
+
1. navig vault (secret name: github_token)
|
|
60
|
+
2. GITHUB_TOKEN environment variable
|
|
61
|
+
3. ~/.navig/config.yaml → github.token
|
|
62
|
+
|
|
63
|
+
Returns None if not found anywhere.
|
|
64
|
+
"""
|
|
65
|
+
# 1 — navig vault
|
|
66
|
+
try:
|
|
67
|
+
from navig.vault import get_vault # type: ignore
|
|
68
|
+
|
|
69
|
+
vault = get_vault()
|
|
70
|
+
secret = vault.get("github_token", caller="navig-github")
|
|
71
|
+
if secret is not None:
|
|
72
|
+
# Credential exposes the decrypted payload via `.data` / `.get_secret()`
|
|
73
|
+
# — there is NO `.value` attribute. The token is stored under "value"
|
|
74
|
+
# (also accept "token"/"api_key" for older entries).
|
|
75
|
+
tok = None
|
|
76
|
+
data = getattr(secret, "data", None)
|
|
77
|
+
if isinstance(data, dict):
|
|
78
|
+
tok = data.get("value") or data.get("token") or data.get("api_key")
|
|
79
|
+
if not tok and hasattr(secret, "get_secret"):
|
|
80
|
+
tok = secret.get_secret("value") or secret.get_secret("token")
|
|
81
|
+
if not tok:
|
|
82
|
+
tok = getattr(secret, "value", None)
|
|
83
|
+
if tok:
|
|
84
|
+
return str(tok)
|
|
85
|
+
except Exception: # noqa: BLE001
|
|
86
|
+
pass # best-effort; failure is non-critical
|
|
87
|
+
|
|
88
|
+
# 2 — env var
|
|
89
|
+
token = os.environ.get("GITHUB_TOKEN", "").strip()
|
|
90
|
+
if token:
|
|
91
|
+
return token
|
|
92
|
+
|
|
93
|
+
# 3 — ~/.navig/config.yaml
|
|
94
|
+
try:
|
|
95
|
+
cfg_path = config_dir() / "config.yaml"
|
|
96
|
+
cfg = safe_load_yaml(cfg_path) or {}
|
|
97
|
+
token = cfg.get("github", {}).get("token", "").strip()
|
|
98
|
+
if token:
|
|
99
|
+
return token
|
|
100
|
+
except Exception: # noqa: BLE001
|
|
101
|
+
pass # best-effort; failure is non-critical
|
|
102
|
+
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _engine_available() -> bool:
|
|
107
|
+
"""Return True if the bundled GitHub engine is importable."""
|
|
108
|
+
try:
|
|
109
|
+
import navig_github.engine # noqa: F401
|
|
110
|
+
|
|
111
|
+
return True
|
|
112
|
+
except ImportError:
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _require_engine() -> bool:
|
|
117
|
+
"""
|
|
118
|
+
Ensure the GitHub engine is importable; print help if missing.
|
|
119
|
+
Returns True if available.
|
|
120
|
+
"""
|
|
121
|
+
if _engine_available():
|
|
122
|
+
return True
|
|
123
|
+
ch.error(
|
|
124
|
+
"the navig-github engine is missing — reinstall the plugin:\n"
|
|
125
|
+
" pip install navig-github"
|
|
126
|
+
)
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _run_engine(args: list[str], token: str | None) -> None:
|
|
131
|
+
"""Dispatch into the bundled GitHub engine's CLI **in-process** (no subprocess).
|
|
132
|
+
|
|
133
|
+
An explicit *token* (from a --token flag) wins over whatever the group
|
|
134
|
+
callback already injected into GITHUB_TOKEN.
|
|
135
|
+
"""
|
|
136
|
+
if not _require_engine():
|
|
137
|
+
raise typer.Exit(1)
|
|
138
|
+
if token:
|
|
139
|
+
os.environ["GITHUB_TOKEN"] = token
|
|
140
|
+
|
|
141
|
+
import click
|
|
142
|
+
from navig_github.engine.cli import app as engine_cli
|
|
143
|
+
from typer.main import get_command
|
|
144
|
+
|
|
145
|
+
cmd = get_command(engine_cli)
|
|
146
|
+
try:
|
|
147
|
+
cmd.main(list(args), prog_name="navig github", standalone_mode=False)
|
|
148
|
+
except click.exceptions.Exit as exc: # typer.Exit inside the engine lands here
|
|
149
|
+
code = exc.exit_code or 0
|
|
150
|
+
if code:
|
|
151
|
+
raise typer.Exit(code) from None
|
|
152
|
+
except click.exceptions.Abort:
|
|
153
|
+
ch.warning("Aborted.")
|
|
154
|
+
raise typer.Exit(1) from None
|
|
155
|
+
except click.ClickException as exc:
|
|
156
|
+
exc.show()
|
|
157
|
+
raise typer.Exit(exc.exit_code) from None
|
|
158
|
+
except KeyboardInterrupt:
|
|
159
|
+
pass # user interrupted; clean exit
|
|
160
|
+
except SystemExit as exc: # defensive: bare sys.exit() inside the engine
|
|
161
|
+
code = exc.code if isinstance(exc.code, int) else 1
|
|
162
|
+
if code:
|
|
163
|
+
raise typer.Exit(code) from None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
# Fallback: navig-native git clone (no token required)
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _fallback_git_clone(repo_url: str, dest: str) -> None:
|
|
172
|
+
"""
|
|
173
|
+
Minimal git-based clone used when no GitHub token is available.
|
|
174
|
+
Leverages the git binary already required by navig.
|
|
175
|
+
"""
|
|
176
|
+
ch.warning("No GitHub token found — using plain `git clone` (public repos only).")
|
|
177
|
+
dest_path = Path(dest)
|
|
178
|
+
dest_path.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
|
|
180
|
+
repo_name = repo_url.rstrip("/").split("/")[-1].removesuffix(".git")
|
|
181
|
+
clone_dest = dest_path / repo_name
|
|
182
|
+
|
|
183
|
+
if clone_dest.exists():
|
|
184
|
+
ch.info(f"Updating existing repo at {clone_dest} …")
|
|
185
|
+
result = subprocess.run(
|
|
186
|
+
["git", "-C", str(clone_dest), "pull", "--ff-only"],
|
|
187
|
+
capture_output=True,
|
|
188
|
+
text=True,
|
|
189
|
+
)
|
|
190
|
+
else:
|
|
191
|
+
ch.info(f"Cloning {repo_url} → {clone_dest} …")
|
|
192
|
+
result = subprocess.run(
|
|
193
|
+
["git", "clone", repo_url, str(clone_dest)], capture_output=True, text=True
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if result.returncode == 0:
|
|
197
|
+
ch.success(f"Done: {clone_dest}")
|
|
198
|
+
else:
|
|
199
|
+
ch.error(result.stderr.strip() or "git clone failed")
|
|
200
|
+
raise typer.Exit(1)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
# sub-commands
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@github_app.command("search", rich_help_panel="Backup & mirror")
|
|
209
|
+
def gh_search(
|
|
210
|
+
query: Annotated[str, typer.Argument(help="Search keyword (e.g. 'agent soul')")],
|
|
211
|
+
output_dir: Annotated[
|
|
212
|
+
Path | None, typer.Option("--output-dir", "-o", help="Destination directory")
|
|
213
|
+
] = None,
|
|
214
|
+
limit: Annotated[
|
|
215
|
+
int, typer.Option("--limit", "-l", min=1, max=100, help="Max repos to clone")
|
|
216
|
+
] = 20,
|
|
217
|
+
language: Annotated[str | None, typer.Option("--language", help="Filter by language")] = None,
|
|
218
|
+
min_stars: Annotated[int | None, typer.Option("--min-stars", help="Minimum star count")] = None,
|
|
219
|
+
sort: Annotated[
|
|
220
|
+
str, typer.Option("--sort", help="Sort: stars|forks|updated|best-match")
|
|
221
|
+
] = "stars",
|
|
222
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Auto-confirm, no prompt")] = False,
|
|
223
|
+
workers: Annotated[int, typer.Option("--workers", "-w", help="Parallel clone workers")] = 4,
|
|
224
|
+
token: Annotated[
|
|
225
|
+
str | None,
|
|
226
|
+
typer.Option("--token", "-t", help="GitHub token (overrides auto-resolve)"),
|
|
227
|
+
] = None,
|
|
228
|
+
flat: Annotated[
|
|
229
|
+
bool,
|
|
230
|
+
typer.Option(
|
|
231
|
+
"--flat/--no-flat",
|
|
232
|
+
help="Clone FLAT (<dir>/<repo>, collisions suffixed -<owner>) vs nested by owner",
|
|
233
|
+
),
|
|
234
|
+
] = True,
|
|
235
|
+
):
|
|
236
|
+
"""
|
|
237
|
+
🔍 Search GitHub and clone matching repositories.
|
|
238
|
+
|
|
239
|
+
Token resolution: vault → GITHUB_TOKEN env → ~/.navig/config.yaml.
|
|
240
|
+
Without a token only public repos at low rate limits are accessible.
|
|
241
|
+
|
|
242
|
+
Downloads are FLAT by default (repos land directly as <dir>/<repo>, so a
|
|
243
|
+
second run updates them in place); use --no-flat for the owner-nested layout.
|
|
244
|
+
|
|
245
|
+
Examples:
|
|
246
|
+
navig github search "agent soul" -o ./mirrors --limit 50 -y
|
|
247
|
+
navig github search "machine learning" --language python --min-stars 500
|
|
248
|
+
navig github search "cli tools" -o ./tools --no-flat # nested by owner
|
|
249
|
+
"""
|
|
250
|
+
resolved_token = token or _resolve_github_token()
|
|
251
|
+
|
|
252
|
+
if not resolved_token:
|
|
253
|
+
ch.warning(
|
|
254
|
+
"No GitHub token found. Using unauthenticated access (60 req/hour).\n"
|
|
255
|
+
"Set one with: navig github token set <your-token>"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
if not _require_engine():
|
|
259
|
+
raise typer.Exit(1)
|
|
260
|
+
|
|
261
|
+
args = [
|
|
262
|
+
"search",
|
|
263
|
+
query,
|
|
264
|
+
"--limit",
|
|
265
|
+
str(limit),
|
|
266
|
+
"--sort",
|
|
267
|
+
sort,
|
|
268
|
+
"--workers",
|
|
269
|
+
str(workers),
|
|
270
|
+
]
|
|
271
|
+
if output_dir:
|
|
272
|
+
args += ["--output-dir", str(output_dir)]
|
|
273
|
+
if language:
|
|
274
|
+
args += ["--language", language]
|
|
275
|
+
if min_stars is not None:
|
|
276
|
+
args += ["--min-stars", str(min_stars)]
|
|
277
|
+
if yes:
|
|
278
|
+
args += ["--yes"]
|
|
279
|
+
if flat:
|
|
280
|
+
args += ["--flat-structure"]
|
|
281
|
+
|
|
282
|
+
_run_engine(args, resolved_token)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@github_app.command("backup", rich_help_panel="Backup & mirror")
|
|
286
|
+
def gh_backup(
|
|
287
|
+
target: Annotated[str, typer.Argument(help="GitHub username (or org with --org) to back up")],
|
|
288
|
+
dest: Annotated[Path | None, typer.Option("--dest", "-d", help="Destination directory")] = None,
|
|
289
|
+
visibility: Annotated[str, typer.Option("--visibility", help="all|public|private")] = "all",
|
|
290
|
+
org: Annotated[bool, typer.Option("--org", help="Treat target as an organisation, not a user")] = False,
|
|
291
|
+
workers: Annotated[int, typer.Option("--workers", "-w", help="Parallel clone workers")] = 4,
|
|
292
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Auto-confirm")] = False,
|
|
293
|
+
flat: Annotated[
|
|
294
|
+
bool,
|
|
295
|
+
typer.Option("--flat/--no-flat", help="Clone FLAT (<dir>/<repo>) vs nested by visibility/owner"),
|
|
296
|
+
] = True,
|
|
297
|
+
token: Annotated[str | None, typer.Option("--token", "-t", help="GitHub token")] = None,
|
|
298
|
+
):
|
|
299
|
+
"""
|
|
300
|
+
📦 Clone / mirror every repo for a user (or --org organisation).
|
|
301
|
+
|
|
302
|
+
Downloads are FLAT by default (<dir>/<repo>, updates in place); --no-flat
|
|
303
|
+
keeps the repos/<visibility>/<owner> layout.
|
|
304
|
+
|
|
305
|
+
Examples:
|
|
306
|
+
navig github backup myuser -d ./mirrors -y
|
|
307
|
+
navig github backup myorg --org --visibility public
|
|
308
|
+
navig github backup myuser --no-flat # nested by visibility/owner
|
|
309
|
+
"""
|
|
310
|
+
resolved_token = token or _resolve_github_token()
|
|
311
|
+
|
|
312
|
+
if not resolved_token:
|
|
313
|
+
ch.warning(
|
|
314
|
+
"No GitHub token found — only public repos accessible.\n"
|
|
315
|
+
"Set a token with: navig github token set <your-token>"
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
if not _require_engine():
|
|
319
|
+
raise typer.Exit(1)
|
|
320
|
+
|
|
321
|
+
# the engine exposes per-target commands `user` / `org` (there is no `backup`).
|
|
322
|
+
# 0.11 flags: concurrency is --max-workers; user/org don't prompt (no --yes).
|
|
323
|
+
# Full engine surface (incremental, --include-issues, --bare, --lfs …) is
|
|
324
|
+
# available directly: navig github user|org <name> --help
|
|
325
|
+
subcommand = "org" if org else "user"
|
|
326
|
+
args = [subcommand, target, "--visibility", visibility, "--max-workers", str(workers)]
|
|
327
|
+
if dest:
|
|
328
|
+
args += ["--dest", str(dest)]
|
|
329
|
+
if flat:
|
|
330
|
+
args += ["--flat"]
|
|
331
|
+
|
|
332
|
+
_run_engine(args, resolved_token)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@github_app.command("clone", rich_help_panel="Backup & mirror")
|
|
336
|
+
def gh_clone(
|
|
337
|
+
repo: Annotated[str, typer.Argument(help="owner/repo or full GitHub URL")],
|
|
338
|
+
dest: Annotated[Path | None, typer.Option("--dest", "-d", help="Destination directory")] = None,
|
|
339
|
+
token: Annotated[str | None, typer.Option("--token", "-t", help="GitHub token")] = None,
|
|
340
|
+
):
|
|
341
|
+
"""
|
|
342
|
+
⬇️ Clone a single repository (with the engine if available, else plain git).
|
|
343
|
+
|
|
344
|
+
Always FLAT — lands directly as <dest>/<repo> (like `git clone`). Falls back
|
|
345
|
+
to a plain `git clone` when no token is configured and the engine is unavailable.
|
|
346
|
+
|
|
347
|
+
Examples:
|
|
348
|
+
navig github clone owner/my-repo -d ./mirrors
|
|
349
|
+
navig github clone https://github.com/owner/repo
|
|
350
|
+
"""
|
|
351
|
+
resolved_token = token or _resolve_github_token()
|
|
352
|
+
|
|
353
|
+
# Normalise URL
|
|
354
|
+
if not repo.startswith("http") and "/" in repo:
|
|
355
|
+
repo_url = f"https://github.com/{repo}"
|
|
356
|
+
else:
|
|
357
|
+
repo_url = repo
|
|
358
|
+
|
|
359
|
+
if not _engine_available():
|
|
360
|
+
# Graceful fallback: plain git clone (always flat into dest/<repo>).
|
|
361
|
+
_fallback_git_clone(repo_url, str(dest or Path.cwd()))
|
|
362
|
+
return
|
|
363
|
+
|
|
364
|
+
# `engine clone <repo> [dest]` — dest is a POSITIONAL arg, and the clone is
|
|
365
|
+
# already flat (lands as <dest>/<repo>), so no extra flags needed.
|
|
366
|
+
args = ["clone", repo_url]
|
|
367
|
+
if dest:
|
|
368
|
+
args += [str(dest)]
|
|
369
|
+
|
|
370
|
+
_run_engine(args, resolved_token)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
# ---------------------------------------------------------------------------
|
|
374
|
+
# Token management sub-commands
|
|
375
|
+
# ---------------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
token_app = typer.Typer(
|
|
378
|
+
name="token",
|
|
379
|
+
help="Manage the GitHub token used by the GitHub engine",
|
|
380
|
+
no_args_is_help=True,
|
|
381
|
+
)
|
|
382
|
+
github_app.add_typer(token_app, name="token", rich_help_panel="Setup")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@token_app.command("set")
|
|
386
|
+
def token_set(
|
|
387
|
+
value: Annotated[str, typer.Argument(help="GitHub Personal Access Token")],
|
|
388
|
+
use_vault: Annotated[
|
|
389
|
+
bool, typer.Option("--vault/--no-vault", help="Store in navig vault (default)")
|
|
390
|
+
] = True,
|
|
391
|
+
):
|
|
392
|
+
"""
|
|
393
|
+
🔑 Save a GitHub token for the engine to use automatically.
|
|
394
|
+
|
|
395
|
+
Stores in the navig vault by default (preferred). Falls back to
|
|
396
|
+
writing to ~/.navig/config.yaml when the vault is unavailable.
|
|
397
|
+
|
|
398
|
+
Examples:
|
|
399
|
+
navig github token set ghp_xxxxxxxxxxxxxxxx
|
|
400
|
+
"""
|
|
401
|
+
if use_vault:
|
|
402
|
+
try:
|
|
403
|
+
from navig.vault import get_vault # type: ignore
|
|
404
|
+
|
|
405
|
+
vault = get_vault()
|
|
406
|
+
existing = vault.get("github_token", caller="navig-github.token_set")
|
|
407
|
+
if existing:
|
|
408
|
+
vault.update(existing.id, data={"value": value})
|
|
409
|
+
ch.success("GitHub token updated in navig vault.")
|
|
410
|
+
else:
|
|
411
|
+
vault.add(
|
|
412
|
+
provider="github_token",
|
|
413
|
+
credential_type="token",
|
|
414
|
+
data={"value": value},
|
|
415
|
+
metadata={"description": "GitHub PAT used by the GitHub engine"},
|
|
416
|
+
)
|
|
417
|
+
ch.success("GitHub token saved to navig vault.")
|
|
418
|
+
return
|
|
419
|
+
except Exception as exc:
|
|
420
|
+
ch.warning(f"Vault unavailable ({exc}), falling back to config file.")
|
|
421
|
+
|
|
422
|
+
# Fallback: write to ~/.navig/config.yaml
|
|
423
|
+
try:
|
|
424
|
+
cfg_path = config_dir() / "config.yaml"
|
|
425
|
+
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
|
426
|
+
cfg = load_yaml_for_update(cfg_path)
|
|
427
|
+
cfg.setdefault("github", {})["token"] = value
|
|
428
|
+
from navig.core.yaml_io import atomic_write_yaml
|
|
429
|
+
|
|
430
|
+
atomic_write_yaml(cfg, cfg_path)
|
|
431
|
+
ch.success(f"GitHub token saved to {cfg_path}")
|
|
432
|
+
except Exception as exc:
|
|
433
|
+
ch.error(f"Failed to save token: {exc}")
|
|
434
|
+
raise typer.Exit(1) from exc
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
@token_app.command("show")
|
|
438
|
+
def token_show():
|
|
439
|
+
"""
|
|
440
|
+
🔍 Display where the current GitHub token comes from (masked).
|
|
441
|
+
|
|
442
|
+
Examples:
|
|
443
|
+
navig github token show
|
|
444
|
+
"""
|
|
445
|
+
token = _resolve_github_token()
|
|
446
|
+
if token:
|
|
447
|
+
masked = token[:6] + "..." + token[-4:]
|
|
448
|
+
ch.success(f"GitHub token found: {masked}")
|
|
449
|
+
# Identify source
|
|
450
|
+
try:
|
|
451
|
+
from navig.vault import get_vault # type: ignore
|
|
452
|
+
|
|
453
|
+
v = get_vault()
|
|
454
|
+
s = v.get("github_token", caller="navig-github.token_show")
|
|
455
|
+
if s and getattr(s, "value", None) == token:
|
|
456
|
+
ch.info("Source: navig vault")
|
|
457
|
+
return
|
|
458
|
+
except Exception: # noqa: BLE001
|
|
459
|
+
pass # best-effort; failure is non-critical
|
|
460
|
+
if os.environ.get("GITHUB_TOKEN", "").strip() == token:
|
|
461
|
+
ch.info("Source: GITHUB_TOKEN env var")
|
|
462
|
+
else:
|
|
463
|
+
ch.info("Source: ~/.navig/config.yaml")
|
|
464
|
+
else:
|
|
465
|
+
ch.warning(
|
|
466
|
+
"No GitHub token configured.\n"
|
|
467
|
+
"Set one with: navig github token set <your-token>\n"
|
|
468
|
+
"Without a token, the engine only accesses public repos at low rate limits."
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
@token_app.command("remove")
|
|
473
|
+
def token_remove():
|
|
474
|
+
"""
|
|
475
|
+
🗑 Remove the stored GitHub token.
|
|
476
|
+
|
|
477
|
+
Examples:
|
|
478
|
+
navig github token remove
|
|
479
|
+
"""
|
|
480
|
+
removed = False
|
|
481
|
+
|
|
482
|
+
# Vault
|
|
483
|
+
try:
|
|
484
|
+
from navig.vault import get_vault # type: ignore
|
|
485
|
+
|
|
486
|
+
vault = get_vault()
|
|
487
|
+
secret = vault.get("github_token", caller="navig-github.token_remove")
|
|
488
|
+
if secret:
|
|
489
|
+
vault.delete(secret.id)
|
|
490
|
+
ch.success("GitHub token removed from navig vault.")
|
|
491
|
+
removed = True
|
|
492
|
+
except Exception: # noqa: BLE001
|
|
493
|
+
pass # best-effort; failure is non-critical
|
|
494
|
+
|
|
495
|
+
# Config file
|
|
496
|
+
try:
|
|
497
|
+
cfg_path = config_dir() / "config.yaml"
|
|
498
|
+
if cfg_path.exists():
|
|
499
|
+
cfg = load_yaml_for_update(cfg_path)
|
|
500
|
+
if cfg.get("github", {}).get("token"):
|
|
501
|
+
cfg["github"].pop("token")
|
|
502
|
+
from navig.core.yaml_io import atomic_write_yaml
|
|
503
|
+
|
|
504
|
+
atomic_write_yaml(cfg, cfg_path)
|
|
505
|
+
ch.success(f"GitHub token removed from {cfg_path}")
|
|
506
|
+
removed = True
|
|
507
|
+
except Exception: # noqa: BLE001
|
|
508
|
+
pass # best-effort; failure is non-critical
|
|
509
|
+
|
|
510
|
+
if not removed:
|
|
511
|
+
ch.info("No stored GitHub token found.")
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
@github_app.command("status", rich_help_panel="Setup")
|
|
515
|
+
def gh_status():
|
|
516
|
+
"""
|
|
517
|
+
ℹ️ Show engine installation status and token configuration.
|
|
518
|
+
|
|
519
|
+
Examples:
|
|
520
|
+
navig github status
|
|
521
|
+
"""
|
|
522
|
+
# engine availability (bundled engine — a hard dependency of navig-github)
|
|
523
|
+
if _engine_available():
|
|
524
|
+
try:
|
|
525
|
+
import navig_github.engine as fm # type: ignore
|
|
526
|
+
|
|
527
|
+
version = getattr(fm, "__version__", "unknown")
|
|
528
|
+
ch.success(f"GitHub engine bundled — version {version} (in-process)")
|
|
529
|
+
except Exception:
|
|
530
|
+
ch.success("GitHub engine bundled (in-process)")
|
|
531
|
+
else:
|
|
532
|
+
ch.error("GitHub engine MISSING (it is a dependency of navig-github).")
|
|
533
|
+
ch.info("Reinstall the plugin: pip install navig-github")
|
|
534
|
+
|
|
535
|
+
# Token check
|
|
536
|
+
token = _resolve_github_token()
|
|
537
|
+
if token:
|
|
538
|
+
masked = token[:6] + "..." + token[-4:]
|
|
539
|
+
ch.success(f"GitHub token configured: {masked}")
|
|
540
|
+
else:
|
|
541
|
+
ch.warning(
|
|
542
|
+
"No GitHub token — only public repos accessible at low rate limits.\n"
|
|
543
|
+
"Set one with: navig github token set <your-token>"
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
# ---------------------------------------------------------------------------
|
|
548
|
+
# Full engine mount — every engine command becomes `navig github <command>`
|
|
549
|
+
# ---------------------------------------------------------------------------
|
|
550
|
+
|
|
551
|
+
# Curated navig-side names that must not be shadowed by the engine's versions
|
|
552
|
+
# (`search`/`clone` keep navig's friendlier flags + git fallback; `backup`
|
|
553
|
+
# wraps user/org; `token`/`status` are navig-only).
|
|
554
|
+
_CURATED_CMDS = {"search", "backup", "clone", "token", "status"}
|
|
555
|
+
|
|
556
|
+
# --help organization for the mounted engine commands.
|
|
557
|
+
_ENGINE_PANELS: dict[str, str] = {
|
|
558
|
+
# backup & mirror
|
|
559
|
+
"user": "Backup & mirror", "org": "Backup & mirror", "repo": "Backup & mirror",
|
|
560
|
+
"starred": "Backup & mirror", "watched": "Backup & mirror", "gists": "Backup & mirror",
|
|
561
|
+
# exports
|
|
562
|
+
"issues": "Export", "pulls": "Export", "releases": "Export", "wiki": "Export",
|
|
563
|
+
"labels": "Export", "milestones": "Export", "workflows": "Export",
|
|
564
|
+
"attachments": "Export", "discussions": "Export", "followers": "Export",
|
|
565
|
+
"webhooks": "Export", "secrets": "Export", "profile": "Export",
|
|
566
|
+
# integrity & analytics
|
|
567
|
+
"verify": "Integrity & analytics", "diff": "Integrity & analytics",
|
|
568
|
+
"snapshot": "Integrity & analytics", "analytics": "Integrity & analytics",
|
|
569
|
+
"analytics-history": "Integrity & analytics",
|
|
570
|
+
# automation & config
|
|
571
|
+
"schedule-add": "Automation", "schedule-list": "Automation",
|
|
572
|
+
"schedule-remove": "Automation", "schedule-run": "Automation",
|
|
573
|
+
"notify-test": "Automation", "notify-status": "Automation",
|
|
574
|
+
"config-save": "Automation", "config-load": "Automation",
|
|
575
|
+
"config-list": "Automation", "config-delete": "Automation",
|
|
576
|
+
"config-export": "Automation", "config-import": "Automation",
|
|
577
|
+
"templates": "Automation", "template-show": "Automation",
|
|
578
|
+
"template-use": "Automation", "template-create": "Automation",
|
|
579
|
+
"template-delete": "Automation",
|
|
580
|
+
# destructive remote operations
|
|
581
|
+
"delete": "Danger zone (remote)", "transfer": "Danger zone (remote)",
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _mount_engine_commands() -> None:
|
|
586
|
+
"""Mount every engine command (minus curated overrides) into `navig github`.
|
|
587
|
+
|
|
588
|
+
In-process: the engine's Typer CommandInfo objects are attached directly, so
|
|
589
|
+
`navig github issues owner/repo` runs the engine's own implementation. The
|
|
590
|
+
group callback above has already placed the vault token in GITHUB_TOKEN,
|
|
591
|
+
which each engine command reads via envvar. Import failure degrades to the
|
|
592
|
+
curated commands only (never blocks the CLI).
|
|
593
|
+
"""
|
|
594
|
+
try:
|
|
595
|
+
from navig_github.engine.cli import app as engine_cli
|
|
596
|
+
except Exception: # noqa: BLE001 — degraded, never blocks boot
|
|
597
|
+
return
|
|
598
|
+
existing = {
|
|
599
|
+
(c.name or (c.callback.__name__.replace("_", "-") if c.callback else ""))
|
|
600
|
+
for c in github_app.registered_commands
|
|
601
|
+
}
|
|
602
|
+
for info in engine_cli.registered_commands:
|
|
603
|
+
name = info.name or (info.callback.__name__.replace("_", "-") if info.callback else "")
|
|
604
|
+
if not name or name in _CURATED_CMDS or name in existing:
|
|
605
|
+
continue
|
|
606
|
+
# unset = typer's DefaultPlaceholder, not None — only respect real strings
|
|
607
|
+
if not isinstance(getattr(info, "rich_help_panel", None), str):
|
|
608
|
+
info.rich_help_panel = _ENGINE_PANELS.get(name, "More (engine)")
|
|
609
|
+
github_app.registered_commands.append(info)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
_mount_engine_commands()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The navig-github engine — GitHub backup, mirror, export & manage.
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
__version__ = "0.11.0"
|
|
7
|
+
__author__ = "miztizm"
|
|
8
|
+
__license__ = "MIT"
|
|
9
|
+
|
|
10
|
+
from .models import Config, Repository, TargetType, Visibility
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Config",
|
|
14
|
+
"Repository",
|
|
15
|
+
"TargetType",
|
|
16
|
+
"Visibility",
|
|
17
|
+
"__version__",
|
|
18
|
+
]
|