grit-cli 0.1.0a0__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.
Files changed (73) hide show
  1. grit/__init__.py +4 -0
  2. grit/__main__.py +6 -0
  3. grit/cli/__init__.py +0 -0
  4. grit/cli/cmd_audit.py +124 -0
  5. grit/cli/cmd_auth.py +114 -0
  6. grit/cli/cmd_compliance.py +109 -0
  7. grit/cli/cmd_config.py +76 -0
  8. grit/cli/cmd_credential.py +142 -0
  9. grit/cli/cmd_daemon.py +106 -0
  10. grit/cli/cmd_enterprise.py +177 -0
  11. grit/cli/cmd_hook.py +60 -0
  12. grit/cli/cmd_profile.py +305 -0
  13. grit/cli/cmd_service.py +108 -0
  14. grit/cli/cmd_session.py +144 -0
  15. grit/cli/cmd_setup.py +92 -0
  16. grit/cli/cmd_sync.py +133 -0
  17. grit/cli/main.py +130 -0
  18. grit/cloud/__init__.py +0 -0
  19. grit/cloud/auth.py +24 -0
  20. grit/cloud/client.py +15 -0
  21. grit/cloud/sync.py +12 -0
  22. grit/config/__init__.py +0 -0
  23. grit/config/app_config.py +44 -0
  24. grit/config/paths.py +108 -0
  25. grit/config/subscription.py +293 -0
  26. grit/constants.py +38 -0
  27. grit/daemon/__init__.py +0 -0
  28. grit/daemon/hook_manager.py +67 -0
  29. grit/daemon/pid.py +55 -0
  30. grit/daemon/recovery.py +41 -0
  31. grit/daemon/server.py +187 -0
  32. grit/daemon/watchdog.py +78 -0
  33. grit/enterprise/__init__.py +0 -0
  34. grit/enterprise/audit.py +18 -0
  35. grit/enterprise/compliance.py +24 -0
  36. grit/enterprise/sso.py +45 -0
  37. grit/exceptions.py +81 -0
  38. grit/git/__init__.py +0 -0
  39. grit/git/config.py +138 -0
  40. grit/git/credentials.py +277 -0
  41. grit/git/gpg.py +52 -0
  42. grit/git/hook.py +94 -0
  43. grit/git/repo.py +50 -0
  44. grit/git/ssh.py +20 -0
  45. grit/ipc/__init__.py +0 -0
  46. grit/ipc/client.py +80 -0
  47. grit/ipc/protocol.py +80 -0
  48. grit/ipc/server.py +114 -0
  49. grit/models/__init__.py +0 -0
  50. grit/models/profile.py +46 -0
  51. grit/models/session.py +61 -0
  52. grit/platform/__init__.py +0 -0
  53. grit/platform/base.py +35 -0
  54. grit/platform/linux.py +93 -0
  55. grit/platform/macos.py +76 -0
  56. grit/platform/windows.py +53 -0
  57. grit/platform/windows_service.py +200 -0
  58. grit/session/__init__.py +0 -0
  59. grit/session/detector.py +104 -0
  60. grit/session/engine.py +163 -0
  61. grit/storage/__init__.py +0 -0
  62. grit/storage/_lock.py +45 -0
  63. grit/storage/profile_store.py +106 -0
  64. grit/storage/session_store.py +103 -0
  65. grit/ui/__init__.py +0 -0
  66. grit/ui/notifications.py +32 -0
  67. grit/ui/popup.py +124 -0
  68. grit/ui/tray.py +148 -0
  69. grit_cli-0.1.0a0.dist-info/METADATA +301 -0
  70. grit_cli-0.1.0a0.dist-info/RECORD +73 -0
  71. grit_cli-0.1.0a0.dist-info/WHEEL +4 -0
  72. grit_cli-0.1.0a0.dist-info/entry_points.txt +8 -0
  73. grit_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
grit/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Grit — session-based Git profile manager."""
2
+
3
+ __version__ = "0.1.0-alpha"
4
+ __app_name__ = "grit"
grit/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running grit as `python -m grit`."""
2
+
3
+ from grit.cli.main import cli
4
+
5
+ if __name__ == "__main__":
6
+ cli()
grit/cli/__init__.py ADDED
File without changes
grit/cli/cmd_audit.py ADDED
@@ -0,0 +1,124 @@
1
+ """CLI commands: grit audit <subcommand>
2
+
3
+ Enterprise audit log viewer and exporter.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import sys
10
+
11
+ import click
12
+
13
+
14
+ @click.group("audit")
15
+ def audit() -> None:
16
+ """View and export the enterprise audit log."""
17
+
18
+
19
+ @audit.command("show")
20
+ @click.option("--since", default=None, metavar="ISO_DATETIME",
21
+ help="Only show entries at or after this ISO timestamp (e.g. 2025-01-01T00:00:00Z).")
22
+ @click.option("--action", default=None, metavar="ACTION",
23
+ help="Filter by action type: profile_switch, session_create, git_config_write.")
24
+ @click.option("--limit", type=int, default=50, show_default=True,
25
+ help="Maximum number of entries to display.")
26
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON array.")
27
+ def show(since: str, action: str, limit: int, as_json: bool) -> None:
28
+ """Display recent audit log entries."""
29
+ from grit.config.subscription import require_pro_installed
30
+ require_pro_installed("audit log")
31
+ from grit.enterprise.audit import export_entries
32
+
33
+ entries = export_entries(since=since)
34
+
35
+ if action:
36
+ entries = [e for e in entries if e.get("action") == action]
37
+
38
+ entries = entries[-limit:] # most recent N
39
+
40
+ if as_json:
41
+ click.echo(json.dumps(entries, indent=2))
42
+ return
43
+
44
+ if not entries:
45
+ click.echo("No audit log entries found.")
46
+ return
47
+
48
+ for entry in entries:
49
+ ts = entry.get("timestamp", "?")
50
+ act = entry.get("action", "?")
51
+ repo = entry.get("repo_path", "")
52
+ profile = entry.get("profile_name", entry.get("profile_id", ""))
53
+ key = entry.get("key", "")
54
+
55
+ if act == "profile_switch":
56
+ click.echo(f"{ts} profile_switch {profile!r} → {repo}")
57
+ elif act == "session_create":
58
+ prof = profile or entry.get("profile_id", "?")
59
+ click.echo(f"{ts} session_create profile={prof} repo={repo}")
60
+ elif act == "git_config_write":
61
+ click.echo(f"{ts} git_config_write {key} → {repo}")
62
+ else:
63
+ extra = {k: v for k, v in entry.items() if k not in ("timestamp", "action")}
64
+ click.echo(f"{ts} {act} {json.dumps(extra)}")
65
+
66
+
67
+ @audit.command("export")
68
+ @click.option("--since", default=None, metavar="ISO_DATETIME",
69
+ help="Only export entries at or after this ISO timestamp.")
70
+ @click.option("--format", "fmt", type=click.Choice(["json", "csv", "jsonl"]), default="jsonl",
71
+ show_default=True)
72
+ @click.option("--output", "-o", default=None, metavar="FILE",
73
+ help="Write to FILE instead of stdout.")
74
+ def export(since: str, fmt: str, output: str) -> None:
75
+ """Export audit log entries to JSON, JSONL, or CSV."""
76
+ from grit.config.subscription import require_pro_installed
77
+ require_pro_installed("audit log")
78
+ import csv
79
+ import io
80
+
81
+ from grit.enterprise.audit import export_entries
82
+
83
+ entries = export_entries(since=since)
84
+
85
+ if fmt == "jsonl":
86
+ lines = "\n".join(json.dumps(e, ensure_ascii=False) for e in entries)
87
+ text = lines + ("\n" if lines else "")
88
+ elif fmt == "json":
89
+ text = json.dumps(entries, indent=2, ensure_ascii=False) + "\n"
90
+ else: # csv
91
+ buf = io.StringIO()
92
+ fieldnames = ["timestamp", "action", "repo_path", "profile_id", "profile_name", "key"]
93
+ writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction="ignore",
94
+ lineterminator="\n")
95
+ writer.writeheader()
96
+ writer.writerows(entries)
97
+ text = buf.getvalue()
98
+
99
+ if output:
100
+ try:
101
+ with open(output, "w", encoding="utf-8") as fh:
102
+ fh.write(text)
103
+ click.echo(f"Exported {len(entries)} entries to {output}")
104
+ except OSError as exc:
105
+ click.echo(f"Error writing {output}: {exc}", err=True)
106
+ sys.exit(1)
107
+ else:
108
+ click.echo(text, nl=False)
109
+
110
+
111
+ @audit.command("clear")
112
+ @click.confirmation_option(prompt="This will permanently delete all audit log entries. Continue?")
113
+ def clear() -> None:
114
+ """Delete the audit log file (irreversible)."""
115
+ from grit.config.subscription import require_pro_installed
116
+ require_pro_installed("audit log")
117
+ from grit.config.paths import audit_log_file
118
+
119
+ path = audit_log_file()
120
+ if path.exists():
121
+ path.unlink()
122
+ click.echo("Audit log cleared.")
123
+ else:
124
+ click.echo("No audit log found.")
grit/cli/cmd_auth.py ADDED
@@ -0,0 +1,114 @@
1
+ """CLI commands: grit auth <subcommand>
2
+
3
+ Manages cloud authentication for Grit Pro / Enterprise features.
4
+ Uses device-flow OAuth2 so users never type passwords into the terminal.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sys
11
+
12
+ import click
13
+
14
+
15
+ @click.group("auth")
16
+ def auth() -> None:
17
+ """Authenticate with Grit Cloud for Pro/Enterprise features."""
18
+
19
+
20
+ @auth.command("login")
21
+ @click.option("--provider", type=click.Choice(["github", "google"]), default="github",
22
+ help="OAuth provider to use.")
23
+ def login(provider: str) -> None:
24
+ """Log in to Grit Cloud via browser-based device flow."""
25
+ from grit.config.subscription import require_pro_installed
26
+ require_pro_installed("Grit Cloud login")
27
+ from grit.cloud.auth import poll_device_flow, save_tokens, start_device_flow
28
+
29
+ click.echo(f"Starting device-flow login with {provider}...")
30
+ try:
31
+ flow = start_device_flow(provider)
32
+ except Exception as exc:
33
+ click.echo(f"Login failed: {exc}", err=True)
34
+ sys.exit(1)
35
+
36
+ click.echo(f"\nOpen this URL in your browser:\n\n {flow['verification_uri']}\n")
37
+ click.echo(f"Enter code: {flow['user_code']}\n")
38
+ click.echo("Waiting for authorization...", nl=False)
39
+
40
+ try:
41
+ tokens = poll_device_flow(provider, flow["device_code"], flow["interval"])
42
+ except TimeoutError:
43
+ click.echo("\nLogin timed out. Please try again.")
44
+ sys.exit(1)
45
+ except Exception as exc:
46
+ click.echo(f"\nLogin failed: {exc}", err=True)
47
+ sys.exit(1)
48
+
49
+ save_tokens(tokens)
50
+ click.echo(" done!")
51
+
52
+ # Fetch and store license
53
+ try:
54
+ from grit.cloud.client import GritCloudClient
55
+ client = GritCloudClient()
56
+ license_data = client.get_license_status()
57
+ from grit.config.subscription import save_license
58
+ save_license(license_data["token"], license_data["claims"])
59
+ tier = license_data["claims"].get("tier", "free")
60
+ click.echo(f"Logged in. Plan: {tier.upper()}")
61
+ except Exception as exc:
62
+ click.echo(f"Could not fetch license: {exc}", err=True)
63
+
64
+
65
+ @auth.command("logout")
66
+ def logout() -> None:
67
+ """Log out and remove stored credentials."""
68
+ from grit.config.subscription import require_pro_installed
69
+ require_pro_installed("Grit Cloud login")
70
+ from grit.cloud.auth import clear_tokens
71
+ from grit.config.subscription import clear_license
72
+ clear_tokens()
73
+ clear_license()
74
+ click.echo("Logged out. Grit will operate in free tier mode.")
75
+
76
+
77
+ @auth.command("status")
78
+ @click.option("--json", "as_json", is_flag=True)
79
+ def status(as_json: bool) -> None:
80
+ """Show current authentication and subscription status."""
81
+ from grit.config.subscription import require_pro_installed
82
+ require_pro_installed("Grit Cloud login")
83
+ from grit.cloud.auth import load_tokens
84
+ from grit.config.subscription import load_license
85
+
86
+ tokens = load_tokens()
87
+ lic = load_license()
88
+
89
+ if as_json:
90
+ data = {
91
+ "authenticated": tokens is not None,
92
+ "tier": lic.tier,
93
+ "email": lic.email,
94
+ "expires_at": lic.expires_at,
95
+ "is_valid": lic.is_valid,
96
+ "in_grace_period": lic.is_in_grace_period,
97
+ }
98
+ click.echo(json.dumps(data, indent=2))
99
+ return
100
+
101
+ if not tokens:
102
+ click.echo("Not logged in. Run `grit auth login` to authenticate.")
103
+ else:
104
+ click.echo(f"Logged in as: {lic.email or 'unknown'}")
105
+
106
+ click.echo(f"Plan: {lic.tier.upper()}")
107
+ click.echo(f"Expires: {lic.expires_at}")
108
+ if lic.is_in_grace_period:
109
+ click.echo(" (License expired — within 30-day grace period)")
110
+ elif not lic.is_valid:
111
+ click.echo(" (License expired — operating in free tier)")
112
+ click.echo(f"Profile limit: {'unlimited' if lic.profile_limit == -1 else lic.profile_limit}")
113
+ click.echo(f"Cloud sync: {'yes' if lic.allows_cloud_sync() else 'no (Pro)'}")
114
+ click.echo(f"Team profiles:{'yes' if lic.allows_team_profiles() else 'no (Pro)'}")
@@ -0,0 +1,109 @@
1
+ """CLI commands: grit compliance <subcommand>
2
+
3
+ Enterprise compliance reporting.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ import click
13
+
14
+
15
+ @click.group("compliance")
16
+ def compliance() -> None:
17
+ """Enterprise compliance checks and reporting."""
18
+
19
+
20
+ @compliance.command("report")
21
+ @click.option("--since", default=None, metavar="ISO_DATETIME",
22
+ help="Include audit events since this ISO timestamp.")
23
+ @click.option("--output", "-o", default=None, metavar="FILE",
24
+ help="Write JSON report to FILE (default: print to stdout).")
25
+ @click.option("--json", "as_json", is_flag=True, help="Always output raw JSON.")
26
+ def report(since: str, output: str, as_json: bool) -> None:
27
+ """Generate a compliance report (hooks, GPG, SSO, audit summary)."""
28
+ from grit.enterprise.compliance import generate_report, write_report
29
+
30
+ if output:
31
+ path = Path(output)
32
+ data = write_report(path, since=since)
33
+ click.echo(f"Report written to {output} (compliant={data['compliant']})")
34
+ if not data["compliant"]:
35
+ sys.exit(1)
36
+ return
37
+
38
+ data = generate_report(since=since)
39
+
40
+ if as_json:
41
+ click.echo(json.dumps(data, indent=2))
42
+ return
43
+
44
+ click.echo(f"Compliance report — {data['generated_at']}")
45
+ click.echo(f"Overall: {'PASS' if data['compliant'] else 'FAIL'}")
46
+ click.echo("")
47
+
48
+ hooks = data["sections"].get("hook_inventory", {})
49
+ click.echo(
50
+ f"Hook inventory: {hooks.get('hooks_installed', '?')}/"
51
+ f"{hooks.get('total_repos', '?')} repos have Grit hook"
52
+ )
53
+ if hooks.get("missing_repos"):
54
+ for r in hooks["missing_repos"]:
55
+ click.echo(f" MISSING {r}")
56
+
57
+ gpg = data["sections"].get("gpg_enforcement", {})
58
+ click.echo(
59
+ f"GPG signing: {gpg.get('gpg_enabled', '?')}/"
60
+ f"{gpg.get('total_profiles', '?')} profiles have GPG key"
61
+ )
62
+ if gpg.get("profiles_without_gpg"):
63
+ for name in gpg["profiles_without_gpg"]:
64
+ click.echo(f" NO GPG {name}")
65
+
66
+ sso = data["sections"].get("sso_compliance", {})
67
+ click.echo(
68
+ f"SSO: configured={sso.get('sso_configured', '?')} "
69
+ f"enforce={sso.get('enforce_sso', '?')} "
70
+ f"active_session={sso.get('active_sso_session', '?')}"
71
+ )
72
+
73
+ audit = data["sections"].get("audit_summary", {})
74
+ click.echo(f"Audit events: {audit.get('total_events', 0)} total")
75
+ for action, count in audit.get("by_action", {}).items():
76
+ click.echo(f" {action}: {count}")
77
+
78
+ if not data["compliant"]:
79
+ sys.exit(1)
80
+
81
+
82
+ @compliance.command("hooks")
83
+ def hooks() -> None:
84
+ """Check Grit hook installation across all watched repositories."""
85
+ from grit.enterprise.compliance import check_hook_inventory
86
+
87
+ result = check_hook_inventory()
88
+ status = "OK" if result["hooks_missing"] == 0 else "FAIL"
89
+ click.echo(
90
+ f"[{status}] {result['hooks_installed']}/{result['total_repos']} repos have hook installed"
91
+ )
92
+ for repo in result.get("missing_repos", []):
93
+ click.echo(f" MISSING: {repo}")
94
+ if result["hooks_missing"]:
95
+ sys.exit(1)
96
+
97
+
98
+ @compliance.command("gpg")
99
+ def gpg() -> None:
100
+ """Check GPG signing configuration across all profiles."""
101
+ from grit.enterprise.compliance import check_gpg_enforcement
102
+
103
+ result = check_gpg_enforcement()
104
+ status = "OK" if result["gpg_missing"] == 0 else "WARN"
105
+ click.echo(
106
+ f"[{status}] {result['gpg_enabled']}/{result['total_profiles']} profiles have GPG key"
107
+ )
108
+ for name in result.get("profiles_without_gpg", []):
109
+ click.echo(f" NO GPG: {name}")
grit/cli/cmd_config.py ADDED
@@ -0,0 +1,76 @@
1
+ """CLI commands: grit config <subcommand>"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+
8
+ import click
9
+
10
+ from grit.config.app_config import AppConfig
11
+
12
+
13
+ @click.group("config")
14
+ def config() -> None:
15
+ """Get or set Grit application configuration."""
16
+
17
+
18
+ @config.command("get")
19
+ @click.argument("key")
20
+ @click.option("--json", "as_json", is_flag=True)
21
+ def get(key: str, as_json: bool) -> None:
22
+ """Get a configuration value."""
23
+ cfg = AppConfig.load()
24
+ if not hasattr(cfg, key):
25
+ click.echo(f"Unknown config key: {key!r}", err=True)
26
+ sys.exit(1)
27
+ value = getattr(cfg, key)
28
+ if as_json:
29
+ click.echo(json.dumps({key: value}))
30
+ else:
31
+ click.echo(f"{key} = {value}")
32
+
33
+
34
+ @config.command("set")
35
+ @click.argument("key")
36
+ @click.argument("value")
37
+ def set_config(key: str, value: str) -> None:
38
+ """Set a configuration value."""
39
+ cfg = AppConfig.load()
40
+ if not hasattr(cfg, key):
41
+ click.echo(f"Unknown config key: {key!r}", err=True)
42
+ sys.exit(1)
43
+ # Coerce value to the field's type
44
+ field_type = type(getattr(cfg, key))
45
+ try:
46
+ coerced = value.lower() in ("true", "1", "yes") if field_type is bool else field_type(value)
47
+ except (ValueError, TypeError) as exc:
48
+ click.echo(f"Invalid value for {key!r}: {exc}", err=True)
49
+ sys.exit(1)
50
+ setattr(cfg, key, coerced)
51
+ cfg.save()
52
+ click.echo(f"Set {key} = {coerced}")
53
+
54
+
55
+ @config.command("reset")
56
+ @click.option("--force", is_flag=True, help="Skip confirmation.")
57
+ def reset(force: bool) -> None:
58
+ """Reset all configuration to defaults."""
59
+ if not force:
60
+ click.confirm("Reset all configuration to defaults?", abort=True)
61
+ AppConfig().save()
62
+ click.echo("Configuration reset to defaults.")
63
+
64
+
65
+ @config.command("list")
66
+ @click.option("--json", "as_json", is_flag=True)
67
+ def list_config(as_json: bool) -> None:
68
+ """Show all configuration values."""
69
+ cfg = AppConfig.load()
70
+ from dataclasses import asdict
71
+ data = asdict(cfg)
72
+ if as_json:
73
+ click.echo(json.dumps(data, indent=2))
74
+ return
75
+ for k, v in data.items():
76
+ click.echo(f" {k} = {v}")
@@ -0,0 +1,142 @@
1
+ """CLI commands: grit credential <subcommand>
2
+
3
+ Manages per-profile HTTPS credentials in the OS credential store so that
4
+ git push/pull authenticates as the right GitHub account when a Grit session
5
+ is active.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+
12
+ import click
13
+
14
+ from grit.exceptions import GritError, ProfileNotFoundError
15
+ from grit.models.profile import Profile
16
+ from grit.storage.profile_store import ProfileStore
17
+
18
+
19
+ @click.group()
20
+ def credential() -> None:
21
+ """Manage HTTPS credentials for Git profiles."""
22
+
23
+
24
+ def _store() -> ProfileStore:
25
+ return ProfileStore()
26
+
27
+
28
+ def _require_http_username(profile_name: str) -> tuple[Profile, str]:
29
+ """Load profile and return (profile, http_username), exiting on error."""
30
+ store = _store()
31
+ try:
32
+ p = store.get_by_name(profile_name)
33
+ except ProfileNotFoundError as exc:
34
+ click.echo(str(exc), err=True)
35
+ sys.exit(1)
36
+
37
+ if not p.http_username:
38
+ click.echo(
39
+ f"Profile {profile_name!r} has no HTTP username set.\n"
40
+ f"Set one first with:\n\n"
41
+ f" grit profile edit {profile_name} --http-username <your-github-username>\n",
42
+ err=True,
43
+ )
44
+ sys.exit(1)
45
+
46
+ return p, p.http_username
47
+
48
+
49
+ @credential.command("login")
50
+ @click.argument("profile_name", metavar="PROFILE")
51
+ @click.option(
52
+ "--host", default="github.com", show_default=True, help="Git host to authenticate with."
53
+ )
54
+ def login(profile_name: str, host: str) -> None:
55
+ """Open a browser to authenticate with GitHub and store the credential.
56
+
57
+ Triggers GitHub's device flow: a browser tab opens with the authorization
58
+ code pre-filled so you just need to click Authorize.
59
+ """
60
+ from grit.git.credentials import github_browser_login, store_credential
61
+
62
+ p, username = _require_http_username(profile_name)
63
+
64
+ click.echo(f"Authenticating profile {profile_name!r} ({username}@{host}) via browser...")
65
+ try:
66
+ token = github_browser_login(username_hint=username)
67
+ except GritError as exc:
68
+ click.echo(str(exc), err=True)
69
+ sys.exit(1)
70
+
71
+ try:
72
+ store_credential(host, username, token)
73
+ except GritError as exc:
74
+ click.echo(f"Failed to store credential: {exc}", err=True)
75
+ sys.exit(1)
76
+
77
+ click.echo(f"Authorized as {username} — credential stored.")
78
+ click.echo(f"\nNext time you run `grit session set {profile_name}`, git push will")
79
+ click.echo(f"authenticate as {username} automatically.")
80
+
81
+
82
+ @credential.command("set")
83
+ @click.argument("profile_name", metavar="PROFILE")
84
+ @click.option("--token", required=True, prompt=True, hide_input=True, help="Personal access token.")
85
+ @click.option("--host", default="github.com", show_default=True, help="Git host.")
86
+ def set_credential(profile_name: str, token: str, host: str) -> None:
87
+ """Store a Personal Access Token for a profile (manual alternative to login).
88
+
89
+ Use `grit credential login` for the browser-based flow instead.
90
+ """
91
+ from grit.git.credentials import store_credential
92
+
93
+ p, username = _require_http_username(profile_name)
94
+
95
+ try:
96
+ store_credential(host, username, token)
97
+ except GritError as exc:
98
+ click.echo(f"Failed to store credential: {exc}", err=True)
99
+ sys.exit(1)
100
+
101
+ click.echo(f"Credential stored for {username}@{host}.")
102
+
103
+
104
+ @credential.command("clear")
105
+ @click.argument("profile_name", metavar="PROFILE")
106
+ @click.option("--host", default="github.com", show_default=True, help="Git host.")
107
+ def clear(profile_name: str, host: str) -> None:
108
+ """Remove the stored credential for a profile."""
109
+ from grit.git.credentials import delete_credential
110
+
111
+ p, username = _require_http_username(profile_name)
112
+
113
+ delete_credential(host, username)
114
+ click.echo(f"Credential for {username}@{host} cleared.")
115
+
116
+
117
+ @credential.command("show")
118
+ @click.argument("profile_name", metavar="PROFILE")
119
+ @click.option("--host", default="github.com", show_default=True, help="Git host.")
120
+ def show(profile_name: str, host: str) -> None:
121
+ """Show whether a credential is stored for a profile (never shows the value)."""
122
+ from grit.git.credentials import has_credential
123
+
124
+ store = _store()
125
+ try:
126
+ p = store.get_by_name(profile_name)
127
+ except ProfileNotFoundError as exc:
128
+ click.echo(str(exc), err=True)
129
+ sys.exit(1)
130
+
131
+ click.echo(f"\n[{p.name}]")
132
+ click.echo(f" HTTP user: {p.http_username or '(not set)'}")
133
+ click.echo(f" Host: {host}")
134
+
135
+ if not p.http_username:
136
+ click.echo(" Credential stored: N/A (no HTTP username configured)")
137
+ return
138
+
139
+ stored = has_credential(host, p.http_username)
140
+ click.echo(f" Credential stored: {'Yes' if stored else 'No'}")
141
+ if not stored:
142
+ click.echo(f"\n Run `grit credential login {profile_name}` to authenticate.")
grit/cli/cmd_daemon.py ADDED
@@ -0,0 +1,106 @@
1
+ """CLI commands: grit daemon <subcommand>"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import sys
8
+
9
+ import click
10
+
11
+ from grit.daemon import pid as pid_mod
12
+ from grit.exceptions import DaemonNotRunningError
13
+
14
+
15
+ @click.group()
16
+ def daemon() -> None:
17
+ """Control the Grit background daemon."""
18
+
19
+
20
+ @daemon.command("start")
21
+ @click.option("--foreground", "-f", is_flag=True, help="Run in foreground (don't daemonise).")
22
+ @click.option("--verbose", "-v", is_flag=True)
23
+ def start(foreground: bool, verbose: bool) -> None:
24
+ """Start the Grit daemon."""
25
+ running = pid_mod.get_running_pid()
26
+ if running:
27
+ click.echo(f"Daemon already running (PID {running}).")
28
+ return
29
+
30
+ cmd = [sys.executable, "-m", "grit.daemon.server"]
31
+ if verbose:
32
+ cmd.append("--verbose")
33
+
34
+ if foreground:
35
+ import os
36
+ os.execv(sys.executable, cmd) # replace current process
37
+ else:
38
+ proc = subprocess.Popen(
39
+ cmd,
40
+ stdout=subprocess.DEVNULL,
41
+ stderr=subprocess.DEVNULL,
42
+ start_new_session=True,
43
+ )
44
+ click.echo(f"Daemon started (PID {proc.pid}).")
45
+
46
+
47
+ @daemon.command("stop")
48
+ def stop() -> None:
49
+ """Stop the running daemon."""
50
+ import os
51
+ import signal as sig_mod
52
+ running = pid_mod.get_running_pid()
53
+ if not running:
54
+ click.echo("Daemon is not running.")
55
+ return
56
+ try:
57
+ os.kill(running, sig_mod.SIGTERM)
58
+ click.echo(f"Sent SIGTERM to daemon (PID {running}).")
59
+ except OSError as exc:
60
+ click.echo(f"Could not stop daemon: {exc}", err=True)
61
+ sys.exit(1)
62
+
63
+
64
+ @daemon.command("status")
65
+ @click.option("--json", "as_json", is_flag=True)
66
+ def status(as_json: bool) -> None:
67
+ """Show daemon status."""
68
+ from grit.ipc.client import send_request
69
+ running = pid_mod.get_running_pid()
70
+ if not running:
71
+ if as_json:
72
+ click.echo(json.dumps({"running": False}))
73
+ else:
74
+ click.echo("Daemon is not running.")
75
+ sys.exit(2)
76
+ try:
77
+ resp = send_request("daemon-status")
78
+ info = resp.get("payload", {})
79
+ except DaemonNotRunningError:
80
+ info = {}
81
+
82
+ if as_json:
83
+ click.echo(json.dumps({"running": True, **info}))
84
+ return
85
+ click.echo(f"Daemon running (PID {running})")
86
+ if info:
87
+ click.echo(f" Version: {info.get('version', '?')}")
88
+ click.echo(f" Active sessions: {info.get('active_sessions', '?')}")
89
+ click.echo(f" Profiles: {info.get('profile_count', '?')}")
90
+
91
+
92
+ @daemon.command("restart")
93
+ @click.pass_context
94
+ def restart(ctx: click.Context) -> None:
95
+ """Restart the daemon (stop then start)."""
96
+ import time
97
+ ctx.invoke(stop)
98
+ # SIGTERM is asynchronous — wait until the process is actually gone
99
+ for _ in range(50): # up to 5 seconds
100
+ time.sleep(0.1)
101
+ if not pid_mod.get_running_pid():
102
+ break
103
+ else:
104
+ click.echo("Timed out waiting for daemon to stop.", err=True)
105
+ sys.exit(1)
106
+ ctx.invoke(start)