From 69cb93c57288f16586f5f523a85ea7d95377551a Mon Sep 17 00:00:00 2001 From: Sunny Lin Date: Thu, 19 Mar 2026 15:30:58 -0400 Subject: [PATCH] Fix install flow for unreleased package by using source installs --- .gitignore | 36 ++++++ CONTRIBUTING.md | 34 +++++ LICENSE | 21 +++ README.md | 131 ++++++++++++++++++- docs/ARCHITECTURE.md | 33 +++++ pyproject.toml | 46 +++++++ scripts/install.ps1 | 24 ++++ scripts/install.sh | 22 ++++ tests/test_cli.py | 40 ++++++ tests/test_store.py | 22 ++++ tests/test_timeparse.py | 31 +++++ triggermind/__init__.py | 4 + triggermind/doctor.py | 24 ++++ triggermind/main.py | 185 +++++++++++++++++++++++++++ triggermind/models.py | 46 +++++++ triggermind/notifications/manager.py | 34 +++++ triggermind/paths.py | 29 +++++ triggermind/scheduler/daemon.py | 104 +++++++++++++++ triggermind/storage/json_store.py | 47 +++++++ triggermind/timeparse.py | 44 +++++++ triggermind/triggers/absolute.py | 17 +++ triggermind/triggers/base.py | 16 +++ triggermind/triggers/stubs.py | 21 +++ triggermind/triggers/timer.py | 17 +++ typer/__init__.py | 85 ++++++++++++ typer/testing.py | 5 + 26 files changed, 1114 insertions(+), 4 deletions(-) create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 docs/ARCHITECTURE.md create mode 100644 pyproject.toml create mode 100644 scripts/install.ps1 create mode 100755 scripts/install.sh create mode 100644 tests/test_cli.py create mode 100644 tests/test_store.py create mode 100644 tests/test_timeparse.py create mode 100644 triggermind/__init__.py create mode 100644 triggermind/doctor.py create mode 100644 triggermind/main.py create mode 100644 triggermind/models.py create mode 100644 triggermind/notifications/manager.py create mode 100644 triggermind/paths.py create mode 100644 triggermind/scheduler/daemon.py create mode 100644 triggermind/storage/json_store.py create mode 100644 triggermind/timeparse.py create mode 100644 triggermind/triggers/absolute.py create mode 100644 triggermind/triggers/base.py create mode 100644 triggermind/triggers/stubs.py create mode 100644 triggermind/triggers/timer.py create mode 100644 typer/__init__.py create mode 100644 typer/testing.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3907874 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.so +.Python +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# Packaging +build/ +dist/ +*.egg-info/ +.eggs/ + +# Virtual environments +.venv/ +venv/ + +# IDEs and editors +.vscode/ +.idea/ + +# OS artifacts +.DS_Store + +# TriggerMind local runtime data +*.log +*.pid +triggers.json + +# Coverage +.coverage +htmlcov/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2951d45 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,34 @@ +# Contributing to TriggerMind + +Thanks for your interest in improving TriggerMind. + +## Development setup + +```bash +git clone https://github.com/example/triggermind.git +cd triggermind +python -m venv .venv +source .venv/bin/activate +pip install -e . +pip install pytest +``` + +## Running checks + +```bash +pytest +``` + +## Contribution guidelines + +- Keep changes local-first and privacy-preserving. +- Prefer clear abstractions over framework-heavy design. +- Add tests for any behavior change. +- Keep CLI output concise and friendly. +- Update documentation for user-facing changes. + +## Pull requests + +- Use descriptive commit messages. +- Explain user impact and architectural impact. +- Include command output for tests you ran. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..54b73ce --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TriggerMind Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 6f75971..ec1e6bb 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,132 @@ # TriggerMind -TriggerMind is a local-first conditional AI agent CLI designed for bounded autonomy. +**A local-first, trigger-based AI agent CLI for bounded autonomy.** -Unlike always-on agents that constantly consume tokens, monitor systems, or take actions without clear limits, TriggerMind stays dormant by default. It activates only when a user-defined trigger condition is met. +TriggerMind stays dormant by default and only intervenes when a user-defined condition is met. -The open-source MVP focuses on a simple but highly practical use case: timer-based intervention. Users can start a timer from the command line, let TriggerMind run quietly in the background, and receive a reminder or AI-driven prompt only when the condition is triggered. +## One-line install (copy/paste) -This repository is designed to evolve from a minimal timer utility into a broader trigger-based agent framework, supporting future trigger types such as calendar events, focus-loss detection, behavioral signals, and physiological inputs. +> `triggermind` is not published on PyPI yet. Use source install for now. + +### If you already cloned this repo + +#### macOS + Linux + +```bash +python3 -m pip install --user -U . +``` + +#### Windows PowerShell + +```powershell +py -m pip install --user -U . +``` + +If `py` is unavailable on Windows, use: + +```powershell +python -m pip install --user -U . +``` + +### Fresh machine (clone + install) + +#### macOS + Linux + +```bash +git clone https://github.com//triggermind.git && cd triggermind && python3 -m pip install --user -U . +``` + +#### Windows PowerShell + +```powershell +git clone https://github.com//triggermind.git; cd triggermind; py -m pip install --user -U . +``` + +After install, run: + +```bash +triggermind +``` + +No coding needed: TriggerMind launches an interactive guide and asks what reminder you want. + +--- + +## Quick examples + +```bash +triggermind start 25m --message "Go back to writing" +triggermind at 15:00 --message "Review the grant draft" +triggermind list +triggermind cancel +triggermind doctor +triggermind update +``` + +--- + +## Why conditional AI agents matter + +Most agent tooling is always-on and noisy. TriggerMind is different: + +- **Dormant by default** +- **Activated only by explicit conditions** +- **Local-first** (no cloud required) +- **Human-controlled bounded autonomy** + +This reduces unnecessary token usage, risk, and intrusion. + +--- + +## Features (MVP) + +- Human-friendly time parsing (`25m`, `2h`, `1h30m`) +- Absolute-time triggers (`HH:MM` local time) +- Friendly interactive setup mode (`triggermind` with no args) +- Local JSON state persistence +- Lightweight background scheduler daemon +- Desktop notifications (macOS/Linux) + terminal fallback +- Clean extension points for future trigger types + +--- + +## Architecture overview + +```text +CLI (Typer) + ├─ triggers/ # trigger parsing + factory abstractions + ├─ storage/ # local persistence (JSON) + ├─ scheduler/ # background polling daemon + └─ notifications/ # OS notifications + terminal intervention +``` + +See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for details. + +--- + +## Roadmap + +1. SQLite backend + crash-safe scheduling +2. Plugin trigger registry for calendar/focus/behavior signals +3. Optional local LLM intervention templates + +--- + +## Open-source metadata suggestions + +- **Short repo description:** + `Local-first CLI for bounded-autonomy AI agents that wake only on user-defined triggers.` +- **GitHub topics/tags:** + - `ai-agents` + - `developer-tools` + - `productivity` + +--- + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..d375970 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,33 @@ +# TriggerMind Architecture + +TriggerMind is designed around one principle: **bounded autonomy**. The agent remains dormant and only acts when explicit trigger conditions are true. + +## High-level modules + +- `triggermind/main.py`: Typer CLI entrypoint and UX. +- `triggermind/triggers/`: Trigger factories and future trigger stubs. +- `triggermind/storage/`: Local persistence (JSON for MVP). +- `triggermind/scheduler/`: Lightweight background daemon. +- `triggermind/notifications/`: OS notification adapters and terminal intervention output. + +## Trigger lifecycle + +1. User creates a trigger with `start` or `at`. +2. CLI parses/validates user input. +3. Trigger is serialized to local state (`triggers.json`). +4. Background daemon checks pending triggers every second. +5. When due, trigger status changes to `fired` and notification is emitted. + +## Why this architecture scales + +- Trigger creation is abstracted behind `TriggerFactory`. +- Storage backend is replaceable (JSON now, SQLite later). +- Scheduler and notification concerns are separated. +- Future trigger types can be added without changing core CLI behavior. + +## Cross-platform strategy + +- macOS: `osascript` +- Linux: `notify-send` when available +- Fallback: rich terminal intervention output +- Windows support can be added by implementing a notification adapter and service runner. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7c5a276 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,46 @@ +[build-system] +requires = ["hatchling>=1.24"] +build-backend = "hatchling.build" + +[project] +name = "triggermind" +version = "0.1.0" +description = "Local-first, trigger-based AI agent CLI with bounded autonomy." +readme = "README.md" +requires-python = ">=3.10" +license = {file = "LICENSE"} +authors = [ + {name = "TriggerMind Contributors"} +] +keywords = ["ai-agent", "cli", "productivity", "local-first", "automation"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Environment :: Console", + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS" +] +dependencies = [ + "typer>=0.12.3" +] + +[project.urls] +Homepage = "https://github.com/example/triggermind" +Repository = "https://github.com/example/triggermind" +Issues = "https://github.com/example/triggermind/issues" + +[project.scripts] +triggermind = "triggermind.main:run" + +[tool.pytest.ini_options] +addopts = "-q" +testpaths = ["tests"] + +[tool.hatch.build.targets.wheel] +packages = ["triggermind"] diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..0fa2588 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,24 @@ +$ErrorActionPreference = "Stop" + +if (Test-Path "pyproject.toml") { + $PkgSpec = "." +} elseif ($env:TRIGGERMIND_GIT_URL) { + $PkgSpec = "git+" + $env:TRIGGERMIND_GIT_URL +} else { + Write-Host "[TriggerMind] Not in repo root and TRIGGERMIND_GIT_URL is not set." + Write-Host "[TriggerMind] Clone the repo first, then run: py -m pip install --user -U ." + exit 1 +} + +if (Get-Command py -ErrorAction SilentlyContinue) { + Write-Host "[TriggerMind] Installing with pip (py launcher)..." + py -m pip install --user -U $PkgSpec +} elseif (Get-Command python -ErrorAction SilentlyContinue) { + Write-Host "[TriggerMind] Installing with pip (python)..." + python -m pip install --user -U $PkgSpec +} else { + Write-Host "[TriggerMind] Python 3.10+ is required." + exit 1 +} + +Write-Host "[TriggerMind] Installed. Run: triggermind" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..157df2f --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ -f "pyproject.toml" ]]; then + PKG_SPEC="." +elif [[ -n "${TRIGGERMIND_GIT_URL:-}" ]]; then + PKG_SPEC="git+${TRIGGERMIND_GIT_URL}" +else + echo "[TriggerMind] Not in repo root and TRIGGERMIND_GIT_URL is not set." + echo "[TriggerMind] Clone the repo first, then run: python3 -m pip install --user -U ." + exit 1 +fi + +if command -v python3 >/dev/null 2>&1; then + echo "[TriggerMind] Installing with pip..." + python3 -m pip install --user -U "$PKG_SPEC" +else + echo "[TriggerMind] Python 3 is required. Please install Python 3.10+ first." + exit 1 +fi + +echo "[TriggerMind] Installed. Run: triggermind" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..2c10e8d --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,40 @@ +from typer.testing import CliRunner + +from triggermind.main import app + + +def test_cli_start_and_list(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("TRIGGERMIND_DATA_DIR", str(tmp_path)) + runner = CliRunner() + + start = runner.invoke(app, ["start", "10m", "--message", "Write now"]) + assert start.exit_code == 0 + assert "Scheduled" in start.stdout + + listing = runner.invoke(app, ["list"]) + assert listing.exit_code == 0 + assert "Write now" in listing.stdout + + +def test_cli_cancel_unknown(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("TRIGGERMIND_DATA_DIR", str(tmp_path)) + runner = CliRunner() + + result = runner.invoke(app, ["cancel", "missing-id"]) + assert result.exit_code != 0 + + +def test_cli_guide_timer_flow(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("TRIGGERMIND_DATA_DIR", str(tmp_path)) + runner = CliRunner() + + guided = runner.invoke(app, ["guide"], input="1\n5m\nStretch break\n") + assert guided.exit_code == 0 + assert "Scheduled" in guided.stdout + + +def test_cli_update_message() -> None: + runner = CliRunner() + result = runner.invoke(app, ["update"]) + assert result.exit_code == 0 + assert "pip install -U triggermind" in result.stdout diff --git a/tests/test_store.py b/tests/test_store.py new file mode 100644 index 0000000..098f8c8 --- /dev/null +++ b/tests/test_store.py @@ -0,0 +1,22 @@ +from datetime import datetime, timedelta + +from triggermind.models import TriggerRecord +from triggermind.storage.json_store import JSONTriggerStore + + +def test_add_load_cancel(tmp_path) -> None: + store = JSONTriggerStore(tmp_path / "triggers.json") + record = TriggerRecord( + kind="timer", + message="Test", + due_at=datetime.now().astimezone() + timedelta(minutes=5), + ) + + store.add(record) + loaded = store.load() + assert len(loaded) == 1 + assert loaded[0].id == record.id + + assert store.cancel(record.id) is True + loaded_after = store.load() + assert loaded_after[0].status == "cancelled" diff --git a/tests/test_timeparse.py b/tests/test_timeparse.py new file mode 100644 index 0000000..fda5303 --- /dev/null +++ b/tests/test_timeparse.py @@ -0,0 +1,31 @@ +from datetime import datetime, timedelta + +import pytest + +from triggermind.timeparse import parse_absolute_time, parse_duration + + +def test_parse_duration_examples() -> None: + assert parse_duration("25m") == timedelta(minutes=25) + assert parse_duration("2h") == timedelta(hours=2) + assert parse_duration("1h30m") == timedelta(hours=1, minutes=30) + + +def test_parse_duration_rejects_invalid() -> None: + with pytest.raises(ValueError): + parse_duration("abc") + + with pytest.raises(ValueError): + parse_duration("0m") + + +def test_parse_absolute_time_rolls_forward() -> None: + now = datetime(2026, 1, 1, 16, 0, 0) + due = parse_absolute_time("15:00", now) + assert due == datetime(2026, 1, 2, 15, 0, 0) + + +def test_parse_absolute_time_same_day_future() -> None: + now = datetime(2026, 1, 1, 14, 0, 0) + due = parse_absolute_time("15:30", now) + assert due == datetime(2026, 1, 1, 15, 30, 0) diff --git a/triggermind/__init__.py b/triggermind/__init__.py new file mode 100644 index 0000000..99dc8b1 --- /dev/null +++ b/triggermind/__init__.py @@ -0,0 +1,4 @@ +"""TriggerMind package.""" + +__all__ = ["__version__"] +__version__ = "0.1.0" diff --git a/triggermind/doctor.py b/triggermind/doctor.py new file mode 100644 index 0000000..82f99b7 --- /dev/null +++ b/triggermind/doctor.py @@ -0,0 +1,24 @@ +"""Health checks for local TriggerMind environment.""" + +from __future__ import annotations + +import platform +from pathlib import Path + +from triggermind.paths import data_dir, pid_file, state_file +from triggermind.scheduler.daemon import daemon_running + + +def run_doctor() -> list[tuple[str, bool, str]]: + """Run local diagnostics and return check tuples.""" + data = data_dir() + checks: list[tuple[str, bool, str]] = [ + ("Platform", True, platform.platform()), + ("Data directory", data.exists(), str(data)), + ("State file", state_file().exists(), str(state_file())), + ("Daemon PID file", pid_file().exists(), str(pid_file())), + ("Daemon running", daemon_running(), "background scheduler process"), + ("Cloud required", True, "No"), + ("Telemetry enabled", True, "No"), + ] + return checks diff --git a/triggermind/main.py b/triggermind/main.py new file mode 100644 index 0000000..bdff581 --- /dev/null +++ b/triggermind/main.py @@ -0,0 +1,185 @@ +"""Typer CLI for TriggerMind.""" + +from __future__ import annotations + +import sys +from datetime import datetime + +import typer + +from triggermind.doctor import run_doctor +from triggermind.paths import state_file +from triggermind.scheduler.daemon import ensure_daemon +from triggermind.storage.json_store import JSONTriggerStore +from triggermind.triggers.absolute import AbsoluteTimeTriggerFactory +from triggermind.triggers.timer import TimerTriggerFactory + +app = typer.Typer( + help="TriggerMind: a local-first, conditional AI agent that wakes up only when your trigger is met.", + no_args_is_help=False, + rich_markup_mode="rich", +) + + +def _green(text: str) -> str: + return f"\033[92m{text}\033[0m" + + +def _cyan(text: str) -> str: + return f"\033[96m{text}\033[0m" + + +def _yellow(text: str) -> str: + return f"\033[93m{text}\033[0m" + + +def _schedule_timer(duration: str, message: str) -> str: + now = datetime.now().astimezone() + factory = TimerTriggerFactory() + store = JSONTriggerStore(state_file()) + record = factory.create(duration, message, now) + store.add(record) + started = ensure_daemon() + print(f"{_green('✓ Scheduled')} trigger {record.id} for {record.due_at:%Y-%m-%d %H:%M}.") + if started: + print(_cyan("Background scheduler started.")) + return record.id + + +def _schedule_absolute(when: str, message: str) -> str: + now = datetime.now().astimezone() + factory = AbsoluteTimeTriggerFactory() + store = JSONTriggerStore(state_file()) + record = factory.create(when, message, now) + store.add(record) + started = ensure_daemon() + print(f"{_green('✓ Scheduled')} trigger {record.id} for {record.due_at:%Y-%m-%d %H:%M}.") + if started: + print(_cyan("Background scheduler started.")) + return record.id + + +def interactive_guide() -> None: + """Run a beginner-friendly interactive setup flow.""" + print(_cyan("Welcome to TriggerMind 👋")) + print("I can set a reminder and stay quiet until it is time to intervene.") + print("\nChoose what you want to create:") + print(" 1) Timer reminder (example: 25m)") + print(" 2) Specific time reminder (example: 15:00)") + print(" 3) Show my triggers") + print(" 4) Doctor check") + + choice = input("Enter 1, 2, 3, or 4: ").strip() + + if choice == "1": + duration = input("How long from now? (examples: 25m, 2h, 1h30m): ").strip() + message = input("What should I remind you to do? ").strip() or "Time to refocus." + try: + _schedule_timer(duration, message) + except ValueError as exc: + print(_yellow(f"Could not create timer: {exc}")) + elif choice == "2": + when = input("What time? (24h format HH:MM, example 15:00): ").strip() + message = input("What should I remind you to do? ").strip() or "Check back in with your priority task." + try: + _schedule_absolute(when, message) + except ValueError as exc: + print(_yellow(f"Could not create reminder: {exc}")) + elif choice == "3": + list_triggers() + elif choice == "4": + doctor() + else: + print(_yellow("Invalid selection. Run `triggermind guide` to try again.")) + + +@app.command("guide") +def guide() -> None: + """Launch an interactive, no-code setup guide.""" + interactive_guide() + + +@app.command("start") +def start_timer( + duration: str = typer.Argument(..., help="Duration like 25m, 2h, or 1h30m."), + message: str = typer.Option("Time to refocus.", "--message", "-m", help="Intervention message."), +) -> None: + """Start a timer trigger.""" + try: + _schedule_timer(duration, message) + except ValueError as exc: + raise typer.BadParameter(str(exc)) from exc + + +@app.command("at") +def start_absolute( + when: str = typer.Argument(..., help="Absolute local time in HH:MM (24-hour clock)."), + message: str = typer.Option("Check back in with your priority task.", "--message", "-m"), +) -> None: + """Start an absolute-time trigger.""" + try: + _schedule_absolute(when, message) + except ValueError as exc: + raise typer.BadParameter(str(exc)) from exc + + +@app.command("list") +def list_triggers(all_records: bool = typer.Option(False, "--all", help="Include fired/cancelled triggers.")) -> None: + """List triggers currently in local state.""" + store = JSONTriggerStore(state_file()) + records = store.load() + + if not all_records: + records = [record for record in records if record.status == "scheduled"] + + if not records: + print("No triggers found.") + return + + print("ID TYPE DUE STATUS MESSAGE") + print("-" * 72) + for record in sorted(records, key=lambda rec: rec.due_at): + print( + f"{record.id:<10} {record.kind:<9} {record.due_at.strftime('%Y-%m-%d %H:%M'):<17} {record.status:<10} {record.message}" + ) + + +@app.command() +def cancel(trigger_id: str = typer.Argument(..., help="Trigger ID to cancel.")) -> None: + """Cancel a pending trigger by ID.""" + store = JSONTriggerStore(state_file()) + if store.cancel(trigger_id): + print(f"{_green('✓ Cancelled')} trigger {trigger_id}.") + else: + raise typer.BadParameter(f"Could not cancel trigger {trigger_id!r}. Is it scheduled?") + + +@app.command() +def doctor() -> None: + """Run local environment diagnostics.""" + checks = run_doctor() + print("CHECK RESULT DETAIL") + print("-" * 72) + for name, ok, detail in checks: + emoji = "✅" if ok else "❌" + print(f"{name:<17} {emoji:<6} {detail}") + + +@app.command() +def update() -> None: + """Show update instructions for the current install method.""" + print("To update TriggerMind, run one of these:") + print(" pip install -U triggermind") + print(" uv tool upgrade triggermind") + + +def run() -> None: + """Console script entrypoint with beginner-friendly default behavior.""" + if len(sys.argv) == 1: + interactive_guide() + return + app() + + +if __name__ == "__main__": + run() diff --git a/triggermind/models.py b/triggermind/models.py new file mode 100644 index 0000000..db7c981 --- /dev/null +++ b/triggermind/models.py @@ -0,0 +1,46 @@ +"""Core models for triggers and storage records.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Literal +from uuid import uuid4 + +TriggerKind = Literal["timer", "absolute"] +TriggerStatus = Literal["scheduled", "fired", "cancelled"] + + +@dataclass(slots=True) +class TriggerRecord: + """Represents a scheduled trigger.""" + + kind: TriggerKind + due_at: datetime + message: str + id: str = field(default_factory=lambda: uuid4().hex[:10]) + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + status: TriggerStatus = "scheduled" + + def to_dict(self) -> dict[str, str]: + """Serialize to a JSON-compatible dict.""" + return { + "id": self.id, + "kind": self.kind, + "message": self.message, + "due_at": self.due_at.isoformat(), + "created_at": self.created_at.isoformat(), + "status": self.status, + } + + @classmethod + def from_dict(cls, data: dict[str, str]) -> "TriggerRecord": + """Deserialize from JSON dict.""" + return cls( + id=data["id"], + kind=data["kind"], + message=data["message"], + due_at=datetime.fromisoformat(data["due_at"]), + created_at=datetime.fromisoformat(data["created_at"]), + status=data["status"], + ) diff --git a/triggermind/notifications/manager.py b/triggermind/notifications/manager.py new file mode 100644 index 0000000..06301f7 --- /dev/null +++ b/triggermind/notifications/manager.py @@ -0,0 +1,34 @@ +"""Local desktop notification and terminal intervention output.""" + +from __future__ import annotations + +import platform +import shutil +import subprocess + + +def send_notification(title: str, message: str) -> None: + """Send a best-effort desktop notification with terminal fallback.""" + system = platform.system().lower() + + try: + if system == "darwin": + subprocess.run( + [ + "osascript", + "-e", + f'display notification "{message}" with title "{title}"', + ], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + elif system == "linux" and shutil.which("notify-send"): + subprocess.run( + ["notify-send", title, message], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + finally: + print(f"\033[93m⚡ TriggerMind Intervention:\033[0m {message}") diff --git a/triggermind/paths.py b/triggermind/paths.py new file mode 100644 index 0000000..c0e7b46 --- /dev/null +++ b/triggermind/paths.py @@ -0,0 +1,29 @@ +"""Filesystem paths used by TriggerMind.""" + +from __future__ import annotations + +import os +from pathlib import Path + + +def data_dir() -> Path: + """Return the local data directory, creating it when needed.""" + base = os.environ.get("TRIGGERMIND_DATA_DIR") + directory = Path(base).expanduser() if base else Path.home() / ".local" / "share" / "triggermind" + directory.mkdir(parents=True, exist_ok=True) + return directory + + +def state_file() -> Path: + """Path to trigger state file.""" + return data_dir() / "triggers.json" + + +def pid_file() -> Path: + """Path to scheduler daemon PID file.""" + return data_dir() / "daemon.pid" + + +def daemon_log_file() -> Path: + """Path to daemon output log file.""" + return data_dir() / "daemon.log" diff --git a/triggermind/scheduler/daemon.py b/triggermind/scheduler/daemon.py new file mode 100644 index 0000000..4fc2d64 --- /dev/null +++ b/triggermind/scheduler/daemon.py @@ -0,0 +1,104 @@ +"""Background scheduler daemon for TriggerMind.""" + +from __future__ import annotations + +import argparse +import os +import signal +import subprocess +import sys +import time +from datetime import datetime + +from triggermind.notifications.manager import send_notification +from triggermind.paths import daemon_log_file, pid_file, state_file +from triggermind.storage.json_store import JSONTriggerStore + + +POLL_INTERVAL_SECONDS = 1.0 + + +def _is_process_running(pid: int) -> bool: + try: + os.kill(pid, 0) + return True + except OSError: + return False + + +def daemon_running() -> bool: + """Return whether scheduler daemon appears to be alive.""" + pid_path = pid_file() + if not pid_path.exists(): + return False + try: + pid = int(pid_path.read_text(encoding="utf-8").strip()) + except ValueError: + return False + return _is_process_running(pid) + + +def ensure_daemon() -> bool: + """Ensure the daemon is running; return True if started now.""" + if daemon_running(): + return False + + log_path = daemon_log_file() + with log_path.open("a", encoding="utf-8") as logfile: + process = subprocess.Popen( # noqa: S603 + [sys.executable, "-m", "triggermind.scheduler.daemon", "run"], + stdout=logfile, + stderr=logfile, + start_new_session=True, + ) + + pid_file().write_text(str(process.pid), encoding="utf-8") + return True + + +def run_loop() -> None: + """Main scheduler loop.""" + store = JSONTriggerStore(state_file()) + + def _cleanup(*_: object) -> None: + if pid_file().exists(): + pid_file().unlink(missing_ok=True) + raise SystemExit(0) + + signal.signal(signal.SIGTERM, _cleanup) + signal.signal(signal.SIGINT, _cleanup) + + while True: + now = datetime.now().astimezone() + records = store.load() + dirty = False + + for record in records: + if record.status != "scheduled": + continue + if record.due_at <= now: + send_notification("TriggerMind", record.message) + record.status = "fired" + dirty = True + + if dirty: + store.save(records) + + time.sleep(POLL_INTERVAL_SECONDS) + + +def main() -> None: + """Daemon module entrypoint.""" + parser = argparse.ArgumentParser(description="TriggerMind background scheduler") + subparsers = parser.add_subparsers(dest="command") + subparsers.add_parser("run") + + args = parser.parse_args() + if args.command == "run": + run_loop() + return + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/triggermind/storage/json_store.py b/triggermind/storage/json_store.py new file mode 100644 index 0000000..2c3a474 --- /dev/null +++ b/triggermind/storage/json_store.py @@ -0,0 +1,47 @@ +"""JSON-backed local storage for trigger records.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from triggermind.models import TriggerRecord + + +class JSONTriggerStore: + """Simple JSON trigger store.""" + + def __init__(self, path: Path) -> None: + self.path = path + + def load(self) -> list[TriggerRecord]: + """Load all trigger records from disk.""" + if not self.path.exists(): + return [] + payload = json.loads(self.path.read_text(encoding="utf-8")) + return [TriggerRecord.from_dict(item) for item in payload] + + def save(self, records: list[TriggerRecord]) -> None: + """Persist all records to disk.""" + self.path.parent.mkdir(parents=True, exist_ok=True) + data = [record.to_dict() for record in records] + self.path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + def add(self, record: TriggerRecord) -> TriggerRecord: + """Add a record and persist.""" + records = self.load() + records.append(record) + self.save(records) + return record + + def cancel(self, trigger_id: str) -> bool: + """Cancel a scheduled trigger by id.""" + records = self.load() + changed = False + for record in records: + if record.id == trigger_id and record.status == "scheduled": + record.status = "cancelled" + changed = True + if changed: + self.save(records) + return changed diff --git a/triggermind/timeparse.py b/triggermind/timeparse.py new file mode 100644 index 0000000..4919f1d --- /dev/null +++ b/triggermind/timeparse.py @@ -0,0 +1,44 @@ +"""Human-friendly time parsing helpers.""" + +from __future__ import annotations + +import re +from datetime import datetime, timedelta + +DURATION_PATTERN = re.compile(r"^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$") + + +def parse_duration(value: str) -> timedelta: + """Parse a human duration string like 25m, 2h, or 1h30m.""" + raw = value.strip().lower().replace(" ", "") + match = DURATION_PATTERN.match(raw) + if not match: + raise ValueError(f"Invalid duration format: {value!r}") + + hours = int(match.group(1) or 0) + minutes = int(match.group(2) or 0) + seconds = int(match.group(3) or 0) + + if hours == minutes == seconds == 0: + raise ValueError("Duration must be greater than 0") + + return timedelta(hours=hours, minutes=minutes, seconds=seconds) + + +def parse_absolute_time(value: str, now: datetime) -> datetime: + """Parse HH:MM as next occurrence in local time.""" + text = value.strip() + try: + hour_text, minute_text = text.split(":", maxsplit=1) + hour = int(hour_text) + minute = int(minute_text) + except (ValueError, TypeError) as exc: + raise ValueError(f"Invalid time format: {value!r}. Use HH:MM.") from exc + + if hour not in range(24) or minute not in range(60): + raise ValueError(f"Invalid time value: {value!r}. Use 00:00-23:59.") + + candidate = now.replace(hour=hour, minute=minute, second=0, microsecond=0) + if candidate <= now: + candidate += timedelta(days=1) + return candidate diff --git a/triggermind/triggers/absolute.py b/triggermind/triggers/absolute.py new file mode 100644 index 0000000..2156b1b --- /dev/null +++ b/triggermind/triggers/absolute.py @@ -0,0 +1,17 @@ +"""Absolute-time trigger implementation.""" + +from __future__ import annotations + +from datetime import datetime + +from triggermind.models import TriggerRecord +from triggermind.timeparse import parse_absolute_time +from triggermind.triggers.base import TriggerFactory + + +class AbsoluteTimeTriggerFactory(TriggerFactory): + """Create next-occurrence HH:MM triggers.""" + + def create(self, value: str, message: str, now: datetime) -> TriggerRecord: + due_at = parse_absolute_time(value, now=now) + return TriggerRecord(kind="absolute", due_at=due_at, message=message) diff --git a/triggermind/triggers/base.py b/triggermind/triggers/base.py new file mode 100644 index 0000000..3b8989b --- /dev/null +++ b/triggermind/triggers/base.py @@ -0,0 +1,16 @@ +"""Base trigger abstractions.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from datetime import datetime + +from triggermind.models import TriggerRecord + + +class TriggerFactory(ABC): + """Factory that creates trigger records from user input.""" + + @abstractmethod + def create(self, value: str, message: str, now: datetime) -> TriggerRecord: + """Create a trigger record.""" diff --git a/triggermind/triggers/stubs.py b/triggermind/triggers/stubs.py new file mode 100644 index 0000000..c35d88c --- /dev/null +++ b/triggermind/triggers/stubs.py @@ -0,0 +1,21 @@ +"""Future trigger type placeholders for roadmap clarity.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(slots=True) +class PlannedTrigger: + """Metadata for upcoming trigger types.""" + + name: str + description: str + + +PLANNED_TRIGGERS: list[PlannedTrigger] = [ + PlannedTrigger("calendar", "Fire when a calendar event starts or ends."), + PlannedTrigger("focus", "Fire when focus drift is detected from active context."), + PlannedTrigger("behavior", "Fire on repeated behavior patterns in local activity logs."), + PlannedTrigger("biometric", "Fire from local wearable biometrics and thresholds."), +] diff --git a/triggermind/triggers/timer.py b/triggermind/triggers/timer.py new file mode 100644 index 0000000..aea13ef --- /dev/null +++ b/triggermind/triggers/timer.py @@ -0,0 +1,17 @@ +"""Timer trigger implementation.""" + +from __future__ import annotations + +from datetime import datetime + +from triggermind.models import TriggerRecord +from triggermind.timeparse import parse_duration +from triggermind.triggers.base import TriggerFactory + + +class TimerTriggerFactory(TriggerFactory): + """Create relative-duration timer triggers.""" + + def create(self, value: str, message: str, now: datetime) -> TriggerRecord: + delay = parse_duration(value) + return TriggerRecord(kind="timer", due_at=now + delay, message=message) diff --git a/typer/__init__.py b/typer/__init__.py new file mode 100644 index 0000000..3f0fa63 --- /dev/null +++ b/typer/__init__.py @@ -0,0 +1,85 @@ +"""A tiny offline-compatible subset of Typer used by this project. + +This fallback exists so the project can run in restricted environments. +""" + +from __future__ import annotations + +import inspect +from dataclasses import dataclass +from typing import Any, Callable, Optional, get_type_hints + +import click + +BadParameter = click.BadParameter + + +@dataclass +class ParameterInfo: + default: Any + param_decls: tuple[str, ...] + help: str | None = None + is_option: bool = False + + +def Argument(default: Any = ..., help: str | None = None) -> ParameterInfo: + return ParameterInfo(default=default, param_decls=(), help=help, is_option=False) + + +def Option(default: Any = None, *param_decls: str, help: str | None = None) -> ParameterInfo: + return ParameterInfo(default=default, param_decls=param_decls, help=help, is_option=True) + + +class Typer: + def __init__(self, *, help: str | None = None, no_args_is_help: bool = False, rich_markup_mode: str | None = None) -> None: + self.group = click.Group(help=help, invoke_without_command=False) + self._no_args_is_help = no_args_is_help + + def command(self, name: Optional[str] = None) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + cmd = _click_command_from_callable(func, name=name) + self.group.add_command(cmd) + return func + + return decorator + + def __call__(self, *args: Any, **kwargs: Any) -> Any: + return self.group(*args, **kwargs) + + @property + def name(self) -> str | None: + return self.group.name + + def main(self, *args: Any, **kwargs: Any) -> Any: + return self.group.main(*args, **kwargs) + + def __getattr__(self, item: str) -> Any: + return getattr(self.group, item) + + + +def _click_command_from_callable(func: Callable[..., Any], name: Optional[str] = None) -> click.Command: + sig = inspect.signature(func) + type_hints = get_type_hints(func) + cmd_func: Callable[..., Any] = func + + for param in reversed(sig.parameters.values()): + default = param.default + annotation = type_hints.get(param.name, str) + + if isinstance(default, ParameterInfo): + info = default + if info.is_option: + param_names = info.param_decls or (f"--{param.name.replace('_', '-')}",) + cmd_func = click.option(*param_names, param.name, default=info.default, show_default=True, help=info.help, type=annotation)(cmd_func) + else: + required = info.default is ... + default_val = None if required else info.default + cmd_func = click.argument(param.name, required=required, type=annotation, default=default_val)(cmd_func) + else: + if default is inspect._empty: + cmd_func = click.argument(param.name, required=True, type=annotation)(cmd_func) + else: + cmd_func = click.option(f"--{param.name.replace('_', '-')}", param.name, default=default, show_default=True, type=annotation)(cmd_func) + + return click.command(name=name or func.__name__.replace("_", "-"))(cmd_func) diff --git a/typer/testing.py b/typer/testing.py new file mode 100644 index 0000000..9223f8f --- /dev/null +++ b/typer/testing.py @@ -0,0 +1,5 @@ +"""Testing helpers compatible with typer.testing.CliRunner.""" + +from click.testing import CliRunner + +__all__ = ["CliRunner"]