remoro 0.1.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.
remo/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """remo — Project-aware terminal companion for developers."""
2
+
3
+ __version__ = "0.1.0"
4
+ __author__ = "Nikhil"
5
+ __license__ = "MIT"
@@ -0,0 +1,32 @@
1
+ """Detached reminder worker — run as a subprocess, sleep, then notify.
2
+
3
+ Usage (internal — spawned by remo remind):
4
+ python -m remo._reminder_worker <seconds> <message> [project_name]
5
+ """
6
+
7
+ import sys
8
+ import time
9
+
10
+
11
+ def main() -> None:
12
+ """Sleep for the specified duration then fire a notification."""
13
+ if len(sys.argv) < 3:
14
+ sys.exit(1)
15
+
16
+ try:
17
+ seconds = int(sys.argv[1])
18
+ except ValueError:
19
+ sys.exit(1)
20
+
21
+ message = sys.argv[2]
22
+ project_name = sys.argv[3] if len(sys.argv) > 3 else "remo"
23
+
24
+ time.sleep(seconds)
25
+
26
+ title = f"remo · {project_name}"
27
+ from remo.notifications.dispatcher import notify
28
+ notify(title, message)
29
+
30
+
31
+ if __name__ == "__main__":
32
+ main()
@@ -0,0 +1 @@
1
+ """Checks sub-package."""
@@ -0,0 +1,66 @@
1
+ """Check a shell command's exit code."""
2
+
3
+ import subprocess
4
+ from typing import NamedTuple, Optional
5
+
6
+
7
+ class CmdCheckResult(NamedTuple):
8
+ """Result of a command exit-code check."""
9
+
10
+ status: str # "pass" | "fail"
11
+ cmd: str
12
+ label: str
13
+ exit_code: Optional[int]
14
+ message: str
15
+
16
+
17
+ def run_cmd_check(cmd: str, label: Optional[str] = None) -> CmdCheckResult:
18
+ """Run a shell command and check that it exits with code 0.
19
+
20
+ Args:
21
+ cmd: Shell command string to execute.
22
+ label: Human-readable label for display (defaults to cmd).
23
+
24
+ Returns:
25
+ CmdCheckResult with status and exit code.
26
+ """
27
+ display_label = label or cmd
28
+
29
+ try:
30
+ result = subprocess.run(
31
+ cmd,
32
+ shell=True,
33
+ capture_output=True,
34
+ timeout=15,
35
+ )
36
+ if result.returncode == 0:
37
+ return CmdCheckResult(
38
+ status="pass",
39
+ cmd=cmd,
40
+ label=display_label,
41
+ exit_code=0,
42
+ message="running (exit 0)",
43
+ )
44
+ return CmdCheckResult(
45
+ status="fail",
46
+ cmd=cmd,
47
+ label=display_label,
48
+ exit_code=result.returncode,
49
+ message=f"exited with code {result.returncode}",
50
+ )
51
+ except subprocess.TimeoutExpired:
52
+ return CmdCheckResult(
53
+ status="fail",
54
+ cmd=cmd,
55
+ label=display_label,
56
+ exit_code=None,
57
+ message="command timed out after 15s",
58
+ )
59
+ except Exception as exc:
60
+ return CmdCheckResult(
61
+ status="fail",
62
+ cmd=cmd,
63
+ label=display_label,
64
+ exit_code=None,
65
+ message=f"error running command: {exc}",
66
+ )
@@ -0,0 +1,90 @@
1
+ """Check .env file against .env.example — report missing keys."""
2
+
3
+ from pathlib import Path
4
+ from typing import Dict, List, NamedTuple
5
+
6
+
7
+ class EnvCheckResult(NamedTuple):
8
+ """Result of an .env check."""
9
+
10
+ status: str # "pass" | "fail" | "skip"
11
+ missing_keys: List[str]
12
+ present_count: int
13
+ total_count: int
14
+ message: str
15
+
16
+
17
+ def _parse_env_keys(path: Path) -> List[str]:
18
+ """Parse key names from a .env-style file (ignores comments and blanks)."""
19
+ keys: List[str] = []
20
+ with open(path, "r", encoding="utf-8") as fh:
21
+ for line in fh:
22
+ line = line.strip()
23
+ if not line or line.startswith("#"):
24
+ continue
25
+ if "=" in line:
26
+ key = line.split("=", 1)[0].strip()
27
+ if key:
28
+ keys.append(key)
29
+ return keys
30
+
31
+
32
+ def run_env_check(
33
+ example_file: str,
34
+ env_file: str = ".env",
35
+ base_dir: Path = Path("."),
36
+ ) -> EnvCheckResult:
37
+ """Compare .env against .env.example and report missing keys.
38
+
39
+ Args:
40
+ example_file: Path to the .env.example file (relative to base_dir).
41
+ env_file: Path to the .env file (relative to base_dir).
42
+ base_dir: Directory to resolve paths against.
43
+
44
+ Returns:
45
+ EnvCheckResult with status, missing keys, and counts.
46
+ """
47
+ example_path = base_dir / example_file
48
+ env_path = base_dir / env_file
49
+
50
+ if not example_path.exists():
51
+ return EnvCheckResult(
52
+ status="skip",
53
+ missing_keys=[],
54
+ present_count=0,
55
+ total_count=0,
56
+ message=f"{example_file} not found — skipping env check",
57
+ )
58
+
59
+ example_keys = _parse_env_keys(example_path)
60
+ total = len(example_keys)
61
+
62
+ if not env_path.exists():
63
+ return EnvCheckResult(
64
+ status="fail",
65
+ missing_keys=example_keys,
66
+ present_count=0,
67
+ total_count=total,
68
+ message=f".env not found — all {total} keys missing",
69
+ )
70
+
71
+ env_keys = set(_parse_env_keys(env_path))
72
+ missing = [k for k in example_keys if k not in env_keys]
73
+ present = total - len(missing)
74
+
75
+ if missing:
76
+ return EnvCheckResult(
77
+ status="fail",
78
+ missing_keys=missing,
79
+ present_count=present,
80
+ total_count=total,
81
+ message=f"missing keys: {', '.join(missing)}",
82
+ )
83
+
84
+ return EnvCheckResult(
85
+ status="pass",
86
+ missing_keys=[],
87
+ present_count=total,
88
+ total_count=total,
89
+ message=f"all {total} keys present",
90
+ )
@@ -0,0 +1,40 @@
1
+ """Check that a required file exists on disk."""
2
+
3
+ from pathlib import Path
4
+ from typing import NamedTuple
5
+
6
+
7
+ class FileCheckResult(NamedTuple):
8
+ """Result of a file existence check."""
9
+
10
+ status: str # "pass" | "fail"
11
+ path: str
12
+ message: str
13
+
14
+
15
+ def run_file_check(path: str, base_dir: Path = Path(".")) -> FileCheckResult:
16
+ """Assert that a file exists.
17
+
18
+ Args:
19
+ path: File path to check (relative to base_dir or absolute).
20
+ base_dir: Directory to resolve relative paths against.
21
+
22
+ Returns:
23
+ FileCheckResult with status and message.
24
+ """
25
+ target = Path(path)
26
+ if not target.is_absolute():
27
+ target = base_dir / path
28
+
29
+ if target.exists():
30
+ return FileCheckResult(
31
+ status="pass",
32
+ path=str(path),
33
+ message="exists",
34
+ )
35
+
36
+ return FileCheckResult(
37
+ status="fail",
38
+ path=str(path),
39
+ message=f"file not found: {target}",
40
+ )
@@ -0,0 +1,110 @@
1
+ """Check that a tool meets a minimum version requirement."""
2
+
3
+ import re
4
+ import shutil
5
+ import subprocess
6
+ from typing import List, NamedTuple, Optional, Tuple
7
+
8
+
9
+ class VersionCheckResult(NamedTuple):
10
+ """Result of a version check."""
11
+
12
+ status: str # "pass" | "fail" | "missing"
13
+ tool: str
14
+ required: str
15
+ found: Optional[str]
16
+ message: str
17
+
18
+
19
+ def _parse_version(version_str: str) -> Optional[Tuple[int, ...]]:
20
+ """Parse a version string like '3.11.2' into a tuple of ints."""
21
+ match = re.search(r"(\d+)(?:\.(\d+))?(?:\.(\d+))?", version_str)
22
+ if not match:
23
+ return None
24
+ parts = [int(g) for g in match.groups() if g is not None]
25
+ return tuple(parts)
26
+
27
+
28
+ def _get_tool_version(tool: str) -> Optional[str]:
29
+ """Run `tool --version` and return the output, or None if not found."""
30
+ if shutil.which(tool) is None:
31
+ return None
32
+
33
+ # Some tools use `--version`, some use `version`, some use `-v`
34
+ for flag in ["--version", "-v", "version"]:
35
+ try:
36
+ result = subprocess.run(
37
+ [tool, flag],
38
+ capture_output=True,
39
+ text=True,
40
+ timeout=5,
41
+ )
42
+ output = (result.stdout + result.stderr).strip()
43
+ if output:
44
+ return output
45
+ except (subprocess.TimeoutExpired, OSError):
46
+ continue
47
+
48
+ return None
49
+
50
+
51
+ def _compare_versions(found: Tuple[int, ...], required: Tuple[int, ...]) -> bool:
52
+ """Return True if found >= required."""
53
+ # Pad shorter tuple with zeros
54
+ max_len = max(len(found), len(required))
55
+ found_padded = found + (0,) * (max_len - len(found))
56
+ req_padded = required + (0,) * (max_len - len(required))
57
+ return found_padded >= req_padded
58
+
59
+
60
+ def run_version_check(tool: str, min_version: str) -> VersionCheckResult:
61
+ """Check that `tool` is installed and meets the minimum version.
62
+
63
+ Args:
64
+ tool: The tool name (e.g., "python", "node").
65
+ min_version: Minimum required version string (e.g., "3.11").
66
+
67
+ Returns:
68
+ VersionCheckResult with status and details.
69
+ """
70
+ version_output = _get_tool_version(tool)
71
+
72
+ if version_output is None:
73
+ return VersionCheckResult(
74
+ status="missing",
75
+ tool=tool,
76
+ required=min_version,
77
+ found=None,
78
+ message=f"{tool} not found in PATH",
79
+ )
80
+
81
+ found_version = _parse_version(version_output)
82
+ req_version = _parse_version(min_version)
83
+
84
+ if found_version is None or req_version is None:
85
+ return VersionCheckResult(
86
+ status="fail",
87
+ tool=tool,
88
+ required=min_version,
89
+ found=version_output,
90
+ message=f"could not parse version from output: {version_output!r}",
91
+ )
92
+
93
+ found_str = ".".join(str(x) for x in found_version)
94
+
95
+ if _compare_versions(found_version, req_version):
96
+ return VersionCheckResult(
97
+ status="pass",
98
+ tool=tool,
99
+ required=min_version,
100
+ found=found_str,
101
+ message=f"found {found_str} (>= {min_version})",
102
+ )
103
+
104
+ return VersionCheckResult(
105
+ status="fail",
106
+ tool=tool,
107
+ required=min_version,
108
+ found=found_str,
109
+ message=f"need >= {min_version}, found {found_str}",
110
+ )
remo/cli.py ADDED
@@ -0,0 +1,34 @@
1
+ """CLI entry point — registers all remo commands."""
2
+
3
+ import click
4
+ from remo import __version__
5
+ from remo.commands.startup import show_startup
6
+ from remo.commands.init import init
7
+ from remo.commands.remind import remind
8
+ from remo.commands.check import check
9
+ from remo.commands.log import log
10
+ from remo.commands.run import run, shortcuts
11
+ from remo.commands.snooze import snooze
12
+ from remo.commands.status import status
13
+
14
+
15
+ @click.group(invoke_without_command=True)
16
+ @click.version_option(version=__version__, prog_name="remo")
17
+ @click.pass_context
18
+ def main(ctx: click.Context) -> None:
19
+ """remo — Project-aware terminal companion for developers.
20
+
21
+ Run without arguments to show the startup panel for the current project.
22
+ """
23
+ if ctx.invoked_subcommand is None:
24
+ show_startup()
25
+
26
+
27
+ main.add_command(init)
28
+ main.add_command(remind)
29
+ main.add_command(check)
30
+ main.add_command(log)
31
+ main.add_command(run)
32
+ main.add_command(shortcuts)
33
+ main.add_command(snooze)
34
+ main.add_command(status)
@@ -0,0 +1 @@
1
+ """Commands sub-package."""
remo/commands/check.py ADDED
@@ -0,0 +1,146 @@
1
+ """remo check — run all environment checks defined in .remo config."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Any, Dict
6
+
7
+ import click
8
+ from rich.console import Console
9
+ from rich.rule import Rule
10
+
11
+ from remo.ui.colors import COLORS
12
+
13
+ console = Console()
14
+
15
+
16
+ def _run_single_check(check_def: Dict[str, Any], base_dir: Path) -> None:
17
+ """Run a single check definition and print the result."""
18
+ check_type = check_def.get("type")
19
+
20
+ if check_type == "env":
21
+ from remo.checks.env_check import run_env_check
22
+ example_file = check_def.get("file", ".env.example")
23
+ result = run_env_check(example_file, base_dir=base_dir)
24
+ label = ".env"
25
+ if result.status == "pass":
26
+ console.print(
27
+ f" [{COLORS['success']}]✓[/] [bold]{label}[/]"
28
+ f" [dim]{result.message}[/]"
29
+ )
30
+ elif result.status == "skip":
31
+ console.print(
32
+ f" [{COLORS['warning']}]![/] [bold]{label}[/]"
33
+ f" [dim]{result.message}[/]"
34
+ )
35
+ else:
36
+ console.print(
37
+ f" [{COLORS['error']}]✗[/] [bold]{label}[/]"
38
+ f" [dim]{result.message}[/]"
39
+ )
40
+
41
+ elif check_type == "version":
42
+ from remo.checks.version_check import run_version_check
43
+ tool = check_def.get("tool", "")
44
+ min_ver = check_def.get("min", "0")
45
+ result = run_version_check(tool, min_ver)
46
+
47
+ if result.status == "pass":
48
+ console.print(
49
+ f" [{COLORS['success']}]✓[/] [bold]{tool}[/]"
50
+ f" [dim]{result.message}[/]"
51
+ )
52
+ elif result.status == "missing":
53
+ console.print(
54
+ f" [{COLORS['warning']}]![/] [bold]{tool}[/]"
55
+ f" [dim]not found (shortcuts using {tool} will fail)[/]"
56
+ )
57
+ else:
58
+ console.print(
59
+ f" [{COLORS['error']}]✗[/] [bold]{tool}[/]"
60
+ f" [dim]{result.message}[/]"
61
+ )
62
+
63
+ elif check_type == "file":
64
+ from remo.checks.file_check import run_file_check
65
+ path = check_def.get("path", "")
66
+ result = run_file_check(path, base_dir=base_dir)
67
+ label = path
68
+
69
+ if result.status == "pass":
70
+ console.print(
71
+ f" [{COLORS['success']}]✓[/] [bold]{label}[/]"
72
+ f" [dim]{result.message}[/]"
73
+ )
74
+ else:
75
+ console.print(
76
+ f" [{COLORS['error']}]✗[/] [bold]{label}[/]"
77
+ f" [dim]{result.message}[/]"
78
+ )
79
+
80
+ elif check_type == "cmd":
81
+ from remo.checks.cmd_check import run_cmd_check
82
+ cmd = check_def.get("cmd", "")
83
+ label = check_def.get("label", cmd)
84
+ result = run_cmd_check(cmd, label=label)
85
+
86
+ if result.status == "pass":
87
+ console.print(
88
+ f" [{COLORS['success']}]✓[/] [bold]{label}[/]"
89
+ f" [dim]{result.message}[/]"
90
+ )
91
+ else:
92
+ console.print(
93
+ f" [{COLORS['error']}]✗[/] [bold]{label}[/]"
94
+ f" [dim]{result.message}[/]"
95
+ )
96
+ else:
97
+ console.print(f" [dim]? unknown check type: {check_type!r}[/]")
98
+
99
+
100
+ @click.command()
101
+ @click.option(
102
+ "--env-only",
103
+ is_flag=True,
104
+ default=False,
105
+ help="Only run the .env vs .env.example check.",
106
+ )
107
+ def check(env_only: bool) -> None:
108
+ """Run all environment checks defined in .remo config."""
109
+ from remo.config.loader import load_config, ConfigError
110
+
111
+ try:
112
+ config, config_dir = load_config()
113
+ except ConfigError as exc:
114
+ console.print(f"[red]✗ Error:[/red] {exc}")
115
+ if exc.hint:
116
+ console.print(f"[dim] Hint: {exc.hint}[/dim]")
117
+ sys.exit(1)
118
+
119
+ checks = config.get("checks", [])
120
+
121
+ if env_only:
122
+ checks = [c for c in checks if c.get("type") == "env"]
123
+ if not checks:
124
+ console.print(
125
+ f"[{COLORS['warning']}]![/] No env checks defined in .remo."
126
+ )
127
+ return
128
+
129
+ project_name = config.get("project", "Project")
130
+ console.print()
131
+ console.print(Rule(
132
+ f" [{COLORS['header']}]Environment Check — {project_name}[/] ",
133
+ style=COLORS["dim"],
134
+ ))
135
+
136
+ if not checks:
137
+ console.print(
138
+ f" [dim]No checks defined in .remo. "
139
+ "Add a 'checks' section to get started.[/]"
140
+ )
141
+ return
142
+
143
+ for check_def in checks:
144
+ _run_single_check(check_def, config_dir)
145
+
146
+ console.print()