galvanize 0.2.0__tar.gz
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.
- galvanize-0.2.0/.github/workflows/ci.yml +28 -0
- galvanize-0.2.0/.gitignore +8 -0
- galvanize-0.2.0/LICENSE +21 -0
- galvanize-0.2.0/PKG-INFO +110 -0
- galvanize-0.2.0/README.md +80 -0
- galvanize-0.2.0/assets/galvanize-infographic.png +0 -0
- galvanize-0.2.0/galvanize/__init__.py +7 -0
- galvanize-0.2.0/galvanize/__main__.py +8 -0
- galvanize-0.2.0/galvanize/autostart.py +211 -0
- galvanize-0.2.0/galvanize/bus.py +76 -0
- galvanize-0.2.0/galvanize/cli.py +628 -0
- galvanize-0.2.0/galvanize/config.py +235 -0
- galvanize-0.2.0/galvanize/daemon.py +222 -0
- galvanize-0.2.0/galvanize/dispatch.py +89 -0
- galvanize-0.2.0/galvanize/events.py +42 -0
- galvanize-0.2.0/galvanize/harnesses.py +141 -0
- galvanize-0.2.0/galvanize/hermes.py +251 -0
- galvanize-0.2.0/galvanize/manage.py +468 -0
- galvanize-0.2.0/galvanize/mcp.py +170 -0
- galvanize-0.2.0/galvanize/migrate.py +161 -0
- galvanize-0.2.0/galvanize/paths.py +60 -0
- galvanize-0.2.0/galvanize/secrets.py +162 -0
- galvanize-0.2.0/galvanize/serve.py +240 -0
- galvanize-0.2.0/galvanize/sources/__init__.py +1 -0
- galvanize-0.2.0/galvanize/sources/folder.py +139 -0
- galvanize-0.2.0/galvanize/sources/githook.py +100 -0
- galvanize-0.2.0/galvanize/sources/imap.py +256 -0
- galvanize-0.2.0/galvanize/sources/relay.py +95 -0
- galvanize-0.2.0/galvanize/state.py +88 -0
- galvanize-0.2.0/galvanize/template.py +42 -0
- galvanize-0.2.0/plugins/hermes/__init__.py +306 -0
- galvanize-0.2.0/plugins/hermes/dashboard/dist/index.js +219 -0
- galvanize-0.2.0/plugins/hermes/dashboard/manifest.json +13 -0
- galvanize-0.2.0/plugins/hermes/dashboard/plugin_api.py +66 -0
- galvanize-0.2.0/plugins/hermes/plugin.yaml +11 -0
- galvanize-0.2.0/pyproject.toml +48 -0
- galvanize-0.2.0/relay/worker.js +84 -0
- galvanize-0.2.0/tests/conftest.py +34 -0
- galvanize-0.2.0/tests/greenmail/docker-compose.yml +10 -0
- galvanize-0.2.0/tests/test_core.py +161 -0
- galvanize-0.2.0/tests/test_folder_watch.py +67 -0
- galvanize-0.2.0/tests/test_imap_push.py +191 -0
- galvanize-0.2.0/tests/test_live_hermes_lane.py +146 -0
- galvanize-0.2.0/tests/test_relay.py +116 -0
- galvanize-0.2.0/tests/test_serve_doctor.py +107 -0
- galvanize-0.2.0/tests/test_template.py +29 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: ci
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main, master]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
strategy:
|
|
11
|
+
fail-fast: false
|
|
12
|
+
matrix:
|
|
13
|
+
os: [ubuntu-latest, windows-latest, macos-latest]
|
|
14
|
+
python-version: ["3.10", "3.13"]
|
|
15
|
+
runs-on: ${{ matrix.os }}
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
- uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: ${{ matrix.python-version }}
|
|
21
|
+
- name: Install
|
|
22
|
+
run: pip install -e ".[dev]"
|
|
23
|
+
- name: Import smoke
|
|
24
|
+
run: python -c "import galvanize; import galvanize.cli, galvanize.mcp, galvanize.daemon"
|
|
25
|
+
- name: CLI boots
|
|
26
|
+
run: galvanize --version
|
|
27
|
+
- name: Tests
|
|
28
|
+
run: pytest -q
|
galvanize-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jarvis Lai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
galvanize-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: galvanize
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Event-driven activation points for AI agents: wake a fresh Hermes (or any CLI agent) session when something happens.
|
|
5
|
+
Project-URL: Homepage, https://github.com/jarvis959/galvanize
|
|
6
|
+
Author: Jarvis Lai
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: agents,automation,events,hermes,triggers,webhooks
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Testing
|
|
21
|
+
Classifier: Topic :: System :: Monitoring
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: imap-tools<2,>=1.10
|
|
24
|
+
Requires-Dist: keyring<26,>=24
|
|
25
|
+
Requires-Dist: pyyaml<7,>=6.0
|
|
26
|
+
Requires-Dist: watchdog<7,>=4.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+

|
|
32
|
+
|
|
33
|
+
**Wake your AI agent when something happens, not on a timer.**
|
|
34
|
+
|
|
35
|
+
galvanize watches the real world (new mail in an inbox, a file landing in a folder, a git commit, a webhook call, an event from a script) and wakes a fresh AI agent session the moment one of those things occurs, with a prompt you wrote. Until now, the only event an agent could see was the clock: "trigger me when X" always became an hourly cron poll, because polling was the only surface the agent had. galvanize puts real event triggers directly into the agent's own tool list (native plugin for Hermes, MCP server for Claude Code and Codex), so when you say "wake me when resumes land", the agent wires an actual push trigger instead. Passwords go to your OS keyring, each trigger starts watching in seconds, and results arrive wherever you asked (Telegram, Discord, or just the log).
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
Works on Windows, macOS, and Linux (Python 3.10+). No cloning needed — one command installs straight from GitHub:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pipx install "git+https://github.com/jarvis959/galvanize.git"
|
|
43
|
+
# or: uv tool install --from "git+https://github.com/jarvis959/galvanize.git" galvanize
|
|
44
|
+
# or: pip install git+https://github.com/jarvis959/galvanize.git
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Then run setup once:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
galvanize init
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`init` enables the Hermes webhook platform (backs up your config first), installs the agent plugin, registers the daemon to start at login, and auto-registers the MCP tool surface into Claude Code / Codex if it finds them on this machine. Everything is confirmed on screen and reversible; `--yes` accepts defaults.
|
|
54
|
+
|
|
55
|
+
After that, the whole surface is five verbs:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
galvanize add folder ~/watch --wake hermes # + a live test fire
|
|
59
|
+
galvanize add imap you@gmail.com --wake hermes # app-password to keyring
|
|
60
|
+
galvanize status # watching? last fire, errors, health
|
|
61
|
+
galvanize doctor # deep health check
|
|
62
|
+
galvanize test cad-drops # inject a synthetic event through the real path
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Why
|
|
66
|
+
|
|
67
|
+
Every "trigger me when X" conversation defaults to an hourly cron poller, because polling is the only surface the agent can see. galvanize puts event triggers *in the agent's own tool list*, so when you say "wake me when resumes land", the agent wires a real push trigger instead of a poll job.
|
|
68
|
+
|
|
69
|
+
- **Fresh sessions, not thread injections.** Each event spawns a clean one-shot run: no context pollution, results delivered where you asked.
|
|
70
|
+
- **Management everywhere cron is managed.** Dashboard `/triggers` tab, `/triggers` slash command, `hermes triggers` CLI, agent tools, plus `galvanize status` / `doctor` / `daemon`: all surfaces, one ops core.
|
|
71
|
+
- **Push email that survives real life.** IDLE with 25-min re-arm, UID dedupe, reconnect catch-up, keyring-held credentials, and a `migrate hermes-cron` command that converts your existing email pollers.
|
|
72
|
+
- **Zero-inbound-port webhooks.** Optional user-owned Cloudflare Worker relay (`relay/worker.js`): services POST to your URL, your laptop pulls the queue.
|
|
73
|
+
|
|
74
|
+
## Working with each harness
|
|
75
|
+
|
|
76
|
+
### Hermes
|
|
77
|
+
|
|
78
|
+
`galvanize init` does the setup: enables `platforms.webhook` in your `config.yaml` (backup saved first), pip-installs the package into the interpreter Hermes runs in, copies the plugin into `~/.hermes/plugins` and enables it. Restart the Hermes gateway once to load the webhook platform; the `trigger_*` tools appear in new sessions. From then on, "wake me when a file lands in ~/cad-drops" creates a real trigger in conversation.
|
|
79
|
+
|
|
80
|
+
### Claude Code / Codex
|
|
81
|
+
|
|
82
|
+
`init` writes the MCP server entry into `~/.claude.json` / `~/.codex/config.toml` automatically when it finds them (Codex gets `default_tools_approval_mode = "approve"` pre-declared, as newer Codex builds otherwise hide the tools). New session: `trigger_add` and friends are in the tool list. Wake presets:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
galvanize add folder ~/inbox --wake claude # claude -p "{prompt}"
|
|
86
|
+
galvanize add folder ~/inbox --wake codex # codex exec (sandbox pre-declared)
|
|
87
|
+
galvanize add git-hook ~/code/myrepo --wake codex # wake Codex on commits
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Any other CLI agent
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
galvanize add folder ~/watch --wake shell --command 'myagent run "{prompt}"'
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Daily use
|
|
97
|
+
|
|
98
|
+
Triggers the agent creates itself with its `trigger_add` tool are the primary path; the CLI is the no-agent fallback. Both write the same `~/.galvanize/triggers.yaml`, and the dashboard tab manages what either creates (creation stays conversation-first by design).
|
|
99
|
+
|
|
100
|
+
## Development
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
git clone https://github.com/jarvis959/galvanize && cd galvanize
|
|
104
|
+
python -m venv .venv && .venv/bin/pip install -e ".[dev]" # Scripts/ on Windows
|
|
105
|
+
.venv/bin/pytest # unit suite; live-lane + docker tests skip cleanly
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The live Hermes-lane test runs inside a Hermes checkout's venv (`pytest tests/test_live_hermes_lane.py`); the IMAP suite drives a GreenMail container and skips when Docker is unavailable.
|
|
109
|
+
|
|
110
|
+
MIT licensed. Built for the Hermes ecosystem; architecture is harness-neutral.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
**Wake your AI agent when something happens, not on a timer.**
|
|
4
|
+
|
|
5
|
+
galvanize watches the real world (new mail in an inbox, a file landing in a folder, a git commit, a webhook call, an event from a script) and wakes a fresh AI agent session the moment one of those things occurs, with a prompt you wrote. Until now, the only event an agent could see was the clock: "trigger me when X" always became an hourly cron poll, because polling was the only surface the agent had. galvanize puts real event triggers directly into the agent's own tool list (native plugin for Hermes, MCP server for Claude Code and Codex), so when you say "wake me when resumes land", the agent wires an actual push trigger instead. Passwords go to your OS keyring, each trigger starts watching in seconds, and results arrive wherever you asked (Telegram, Discord, or just the log).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Works on Windows, macOS, and Linux (Python 3.10+). No cloning needed — one command installs straight from GitHub:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pipx install "git+https://github.com/jarvis959/galvanize.git"
|
|
13
|
+
# or: uv tool install --from "git+https://github.com/jarvis959/galvanize.git" galvanize
|
|
14
|
+
# or: pip install git+https://github.com/jarvis959/galvanize.git
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Then run setup once:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
galvanize init
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`init` enables the Hermes webhook platform (backs up your config first), installs the agent plugin, registers the daemon to start at login, and auto-registers the MCP tool surface into Claude Code / Codex if it finds them on this machine. Everything is confirmed on screen and reversible; `--yes` accepts defaults.
|
|
24
|
+
|
|
25
|
+
After that, the whole surface is five verbs:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
galvanize add folder ~/watch --wake hermes # + a live test fire
|
|
29
|
+
galvanize add imap you@gmail.com --wake hermes # app-password to keyring
|
|
30
|
+
galvanize status # watching? last fire, errors, health
|
|
31
|
+
galvanize doctor # deep health check
|
|
32
|
+
galvanize test cad-drops # inject a synthetic event through the real path
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Why
|
|
36
|
+
|
|
37
|
+
Every "trigger me when X" conversation defaults to an hourly cron poller, because polling is the only surface the agent can see. galvanize puts event triggers *in the agent's own tool list*, so when you say "wake me when resumes land", the agent wires a real push trigger instead of a poll job.
|
|
38
|
+
|
|
39
|
+
- **Fresh sessions, not thread injections.** Each event spawns a clean one-shot run: no context pollution, results delivered where you asked.
|
|
40
|
+
- **Management everywhere cron is managed.** Dashboard `/triggers` tab, `/triggers` slash command, `hermes triggers` CLI, agent tools, plus `galvanize status` / `doctor` / `daemon`: all surfaces, one ops core.
|
|
41
|
+
- **Push email that survives real life.** IDLE with 25-min re-arm, UID dedupe, reconnect catch-up, keyring-held credentials, and a `migrate hermes-cron` command that converts your existing email pollers.
|
|
42
|
+
- **Zero-inbound-port webhooks.** Optional user-owned Cloudflare Worker relay (`relay/worker.js`): services POST to your URL, your laptop pulls the queue.
|
|
43
|
+
|
|
44
|
+
## Working with each harness
|
|
45
|
+
|
|
46
|
+
### Hermes
|
|
47
|
+
|
|
48
|
+
`galvanize init` does the setup: enables `platforms.webhook` in your `config.yaml` (backup saved first), pip-installs the package into the interpreter Hermes runs in, copies the plugin into `~/.hermes/plugins` and enables it. Restart the Hermes gateway once to load the webhook platform; the `trigger_*` tools appear in new sessions. From then on, "wake me when a file lands in ~/cad-drops" creates a real trigger in conversation.
|
|
49
|
+
|
|
50
|
+
### Claude Code / Codex
|
|
51
|
+
|
|
52
|
+
`init` writes the MCP server entry into `~/.claude.json` / `~/.codex/config.toml` automatically when it finds them (Codex gets `default_tools_approval_mode = "approve"` pre-declared, as newer Codex builds otherwise hide the tools). New session: `trigger_add` and friends are in the tool list. Wake presets:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
galvanize add folder ~/inbox --wake claude # claude -p "{prompt}"
|
|
56
|
+
galvanize add folder ~/inbox --wake codex # codex exec (sandbox pre-declared)
|
|
57
|
+
galvanize add git-hook ~/code/myrepo --wake codex # wake Codex on commits
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Any other CLI agent
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
galvanize add folder ~/watch --wake shell --command 'myagent run "{prompt}"'
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Daily use
|
|
67
|
+
|
|
68
|
+
Triggers the agent creates itself with its `trigger_add` tool are the primary path; the CLI is the no-agent fallback. Both write the same `~/.galvanize/triggers.yaml`, and the dashboard tab manages what either creates (creation stays conversation-first by design).
|
|
69
|
+
|
|
70
|
+
## Development
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
git clone https://github.com/jarvis959/galvanize && cd galvanize
|
|
74
|
+
python -m venv .venv && .venv/bin/pip install -e ".[dev]" # Scripts/ on Windows
|
|
75
|
+
.venv/bin/pytest # unit suite; live-lane + docker tests skip cleanly
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The live Hermes-lane test runs inside a Hermes checkout's venv (`pytest tests/test_live_hermes_lane.py`); the IMAP suite drives a GreenMail container and skips when Docker is unavailable.
|
|
79
|
+
|
|
80
|
+
MIT licensed. Built for the Hermes ecosystem; architecture is harness-neutral.
|
|
Binary file
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Start-at-login install for the galvanize daemon.
|
|
2
|
+
|
|
3
|
+
User-facing behavior (PLAN §5): after `galvanize init`, the daemon is
|
|
4
|
+
registered to start at login and running right now. Mechanisms per OS:
|
|
5
|
+
Windows : Task Scheduler, per-user onlogon task (no admin, hidden window)
|
|
6
|
+
Linux : systemd user unit (+ enable --now; lingering hint if no session)
|
|
7
|
+
macOS : LaunchAgent plist + launchctl bootstrap
|
|
8
|
+
|
|
9
|
+
Everything is per-user, reversible (`daemon remove`), and prints exactly
|
|
10
|
+
what it did. The task/agent runs `galvanize run`; hot state lives in
|
|
11
|
+
~/.galvanize so a reboot resumes all triggers.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import plistlib
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from .paths import ensure_home
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
TASK_NAME = "GalvanizeTriggerDaemon"
|
|
26
|
+
UNIT_NAME = "galvanize"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _galvanize_run_cmd() -> list[str]:
|
|
30
|
+
"""Interpreter + args that run `galvanize run` with no console window.
|
|
31
|
+
|
|
32
|
+
Handles both launch shapes: `python -m galvanize` (sys.executable is the
|
|
33
|
+
interpreter) and the installed `galvanize.exe` launcher (find python(.w)
|
|
34
|
+
next to it). Prefers pythonw.exe on Windows — windowless.
|
|
35
|
+
"""
|
|
36
|
+
exe = Path(sys.executable)
|
|
37
|
+
if exe.name.lower() not in ("python.exe", "pythonw.exe"):
|
|
38
|
+
# frozen entry-point launcher -> sibling interpreter in Scripts/..
|
|
39
|
+
cand = exe.parent.parent / "Scripts" / "python.exe"
|
|
40
|
+
if not cand.exists():
|
|
41
|
+
cand = exe.parent / "python.exe"
|
|
42
|
+
if cand.exists():
|
|
43
|
+
exe = cand
|
|
44
|
+
if os.name == "nt":
|
|
45
|
+
windowless = exe.with_name("pythonw.exe")
|
|
46
|
+
if windowless.exists():
|
|
47
|
+
exe = windowless
|
|
48
|
+
return [str(exe), "-m", "galvanize.cli", "run"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ------------------------------------------------------------------ Windows
|
|
52
|
+
|
|
53
|
+
def _startup_lnk() -> Path:
|
|
54
|
+
return Path(os.environ.get("APPDATA", str(Path.home()))) / \
|
|
55
|
+
"Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup" / "Galvanize.lnk"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _write_startup_shortcut() -> None:
|
|
59
|
+
"""Shell:startup .lnk via PowerShell WScript.Shell (no admin needed)."""
|
|
60
|
+
target, *arglist = _galvanize_run_cmd()
|
|
61
|
+
args = " ".join(arglist)
|
|
62
|
+
lnk = _startup_lnk()
|
|
63
|
+
ps = (
|
|
64
|
+
"$s=(New-Object -ComObject WScript.Shell).CreateShortcut('%s');"
|
|
65
|
+
"$s.TargetPath='%s';$s.Arguments='%s';$s.WorkingDirectory='%s';$s.WindowStyle=7;$s.Save()"
|
|
66
|
+
% (str(lnk).replace("'", "''"), target, args,
|
|
67
|
+
str(ensure_home()).replace("'", "''"))
|
|
68
|
+
)
|
|
69
|
+
subprocess.run(["powershell", "-NoProfile", "-Command", ps],
|
|
70
|
+
capture_output=True, text=True, check=True)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _win_install() -> tuple[bool, list[str]]:
|
|
74
|
+
# Preferred: Startup-folder shortcut — per-user, no elevation.
|
|
75
|
+
try:
|
|
76
|
+
_write_startup_shortcut()
|
|
77
|
+
lines = ["✔ Daemon will start at login (Startup shortcut created)."]
|
|
78
|
+
# start it right now, detached, windowless
|
|
79
|
+
creationflags = 0x00000008 | 0x00000200 # DETACHED_PROCESS | NEW_PROCESS_GROUP
|
|
80
|
+
subprocess.Popen(_win_startup_cmd(), stdout=subprocess.DEVNULL,
|
|
81
|
+
stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
|
|
82
|
+
creationflags=creationflags)
|
|
83
|
+
lines.append("✔ Daemon started now.")
|
|
84
|
+
return True, lines
|
|
85
|
+
except Exception as e:
|
|
86
|
+
return False, [f"✖ Startup shortcut failed: {e}",
|
|
87
|
+
" Fallback: galvanize run in a terminal, or 'galvanize daemon install' as admin"]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _win_startup_cmd() -> list[str]:
|
|
91
|
+
"""Direct detached run (pythonw -m galvanize.cli run)."""
|
|
92
|
+
return _galvanize_run_cmd()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _win_remove() -> tuple[bool, list[str]]:
|
|
96
|
+
ok = True
|
|
97
|
+
lines = []
|
|
98
|
+
lnk = _startup_lnk()
|
|
99
|
+
try:
|
|
100
|
+
lnk.unlink(missing_ok=True)
|
|
101
|
+
lines.append("✔ Removed Startup shortcut (won't start at login).")
|
|
102
|
+
except OSError as e:
|
|
103
|
+
ok = False
|
|
104
|
+
lines.append(f"✖ Could not remove Startup shortcut: {e}")
|
|
105
|
+
# also clear a legacy schtasks entry if one exists
|
|
106
|
+
subprocess.run(["schtasks", "/End", "/TN", TASK_NAME], capture_output=True)
|
|
107
|
+
subprocess.run(["schtasks", "/Delete", "/F", "/TN", TASK_NAME], capture_output=True)
|
|
108
|
+
return ok, lines
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _win_installed() -> bool:
|
|
112
|
+
return _startup_lnk().exists()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ------------------------------------------------------------------ Linux
|
|
116
|
+
|
|
117
|
+
def _unit_path() -> Path:
|
|
118
|
+
return Path.home() / ".config" / "systemd" / "user" / f"{UNIT_NAME}.service"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _linux_install() -> tuple[bool, list[str]]:
|
|
122
|
+
lines = []
|
|
123
|
+
exe = " ".join(_galvanize_run_cmd())
|
|
124
|
+
unit = (
|
|
125
|
+
"[Unit]\nDescription=Galvanize trigger daemon\n\n"
|
|
126
|
+
"[Service]\nExecStart=" + exe + "\nRestart=on-failure\n\n"
|
|
127
|
+
"[Install]\nWantedBy=default.target\n"
|
|
128
|
+
)
|
|
129
|
+
_unit_path().parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
_unit_path().write_text(unit, encoding="utf-8")
|
|
131
|
+
p = subprocess.run(["systemctl", "--user", "enable", "--now", f"{UNIT_NAME}.service"],
|
|
132
|
+
capture_output=True, text=True)
|
|
133
|
+
if p.returncode == 0:
|
|
134
|
+
return True, ["✔ systemd user service enabled and started.",
|
|
135
|
+
" (Server/logind headless: run 'loginctl enable-linger $USER' to keep it up at logout.)"]
|
|
136
|
+
return False, [f"✖ systemctl failed: {(p.stderr or p.stdout).strip()[:200]}",
|
|
137
|
+
f" Unit written to {_unit_path()} — enable manually."]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _linux_remove() -> tuple[bool, list[str]]:
|
|
141
|
+
subprocess.run(["systemctl", "--user", "disable", "--now", f"{UNIT_NAME}.service"],
|
|
142
|
+
capture_output=True)
|
|
143
|
+
try:
|
|
144
|
+
_unit_path().unlink(missing_ok=True)
|
|
145
|
+
except OSError:
|
|
146
|
+
pass
|
|
147
|
+
return True, ["✔ systemd user service removed."]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _linux_installed() -> bool:
|
|
151
|
+
return _unit_path().exists()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ------------------------------------------------------------------ macOS
|
|
155
|
+
|
|
156
|
+
def _plist_path() -> Path:
|
|
157
|
+
return Path.home() / "Library" / "LaunchAgents" / f"agent.galvanize.{UNIT_NAME}.plist"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _macos_install() -> tuple[bool, list[str]]:
|
|
161
|
+
plist = {
|
|
162
|
+
"Label": f"agent.galvanize.{UNIT_NAME}",
|
|
163
|
+
"ProgramArguments": _galvanize_run_cmd(),
|
|
164
|
+
"RunAtLoad": True,
|
|
165
|
+
"KeepAlive": {"SuccessfulExit": False},
|
|
166
|
+
}
|
|
167
|
+
_plist_path().parent.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
with open(_plist_path(), "wb") as fh:
|
|
169
|
+
plistlib.dump(plist, fh)
|
|
170
|
+
p = subprocess.run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(_plist_path())],
|
|
171
|
+
capture_output=True, text=True)
|
|
172
|
+
if p.returncode == 0 or "already loaded" in (p.stderr or "").lower():
|
|
173
|
+
return True, ["✔ LaunchAgent installed and loaded."]
|
|
174
|
+
return False, [f"✖ launchctl failed: {(p.stderr or p.stdout).strip()[:200]}"]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _macos_remove() -> tuple[bool, list[str]]:
|
|
178
|
+
subprocess.run(["launchctl", "bootout", f"gui/{os.getuid()}", str(_plist_path())],
|
|
179
|
+
capture_output=True)
|
|
180
|
+
try:
|
|
181
|
+
_plist_path().unlink(missing_ok=True)
|
|
182
|
+
except OSError:
|
|
183
|
+
pass
|
|
184
|
+
return True, ["✔ LaunchAgent removed."]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _macos_installed() -> bool:
|
|
188
|
+
return _plist_path().exists()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ------------------------------------------------------------------ facade
|
|
192
|
+
|
|
193
|
+
def _os() -> str:
|
|
194
|
+
if sys.platform == "win32":
|
|
195
|
+
return "win"
|
|
196
|
+
if sys.platform == "darwin":
|
|
197
|
+
return "macos"
|
|
198
|
+
return "linux"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def install() -> tuple[bool, list[str]]:
|
|
202
|
+
ensure_home()
|
|
203
|
+
return {"win": _win_install, "linux": _linux_install, "macos": _macos_install}[_os()]()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def remove() -> tuple[bool, list[str]]:
|
|
207
|
+
return {"win": _win_remove, "linux": _linux_remove, "macos": _macos_remove}[_os()]()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def installed() -> bool:
|
|
211
|
+
return {"win": _win_installed, "linux": _linux_installed, "macos": _macos_installed}[_os()]()
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""The Trigger Bus: dedupe + cooldown, then dispatch.
|
|
2
|
+
|
|
3
|
+
Every source funnels normalized events through here so the guards live in
|
|
4
|
+
exactly one place:
|
|
5
|
+
- dedupe_key: repeated events carrying the same key collapse to one wake
|
|
6
|
+
(folder-watch double-fires, re-adding a file, source+lane overlap)
|
|
7
|
+
- cooldown_s: minimum seconds between two wakes for one trigger
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from typing import Dict, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
from . import dispatch as dispatch_mod
|
|
18
|
+
from .config import Trigger, load_triggers
|
|
19
|
+
from .events import Event
|
|
20
|
+
from .template import render
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("galvanize.bus")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TriggerBus:
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
self._lock = threading.Lock()
|
|
28
|
+
self._last_fire: Dict[str, float] = {} # successful dispatches
|
|
29
|
+
self._last_attempt: Dict[str, float] = {} # for cooldown throttling
|
|
30
|
+
self._seen_keys: Dict[str, Tuple[float, str]] = {} # trigger -> (ts, key)
|
|
31
|
+
|
|
32
|
+
def _gate(self, t: Trigger, event: Event) -> Tuple[Optional[str], Optional[str]]:
|
|
33
|
+
"""Return (skip_reason, pending_dedupe_key). skip_reason None -> fire."""
|
|
34
|
+
now = time.time()
|
|
35
|
+
pending: Optional[str] = None
|
|
36
|
+
with self._lock:
|
|
37
|
+
if t.dedupe_key:
|
|
38
|
+
key = render(t.dedupe_key, event.payload)
|
|
39
|
+
pending = key
|
|
40
|
+
prev = self._seen_keys.get(t.name)
|
|
41
|
+
if prev and prev[1] == key and (
|
|
42
|
+
not t.cooldown_s or now - prev[0] < t.cooldown_s
|
|
43
|
+
):
|
|
44
|
+
return f"dedupe '{key}'", None
|
|
45
|
+
if t.cooldown_s:
|
|
46
|
+
last = self._last_attempt.get(t.name, 0.0)
|
|
47
|
+
if now - last < t.cooldown_s:
|
|
48
|
+
return f"cooldown ({int(now - last)}s < {int(t.cooldown_s)}s)", None
|
|
49
|
+
self._last_attempt[t.name] = now
|
|
50
|
+
return None, pending
|
|
51
|
+
|
|
52
|
+
def handle(self, t: Trigger, event: Event) -> Tuple[bool, str]:
|
|
53
|
+
"""Handle an event for a trigger. Returns (ok_or_skipped, detail)."""
|
|
54
|
+
if not t.enabled:
|
|
55
|
+
return True, "trigger disabled"
|
|
56
|
+
reason, pending_key = self._gate(t, event)
|
|
57
|
+
if reason:
|
|
58
|
+
logger.info("skip %s: %s", t.name, reason)
|
|
59
|
+
return True, f"skipped: {reason}"
|
|
60
|
+
prompt = render(t.prompt, event.payload)
|
|
61
|
+
ok, detail = dispatch_mod.dispatch(t, event, prompt)
|
|
62
|
+
if ok:
|
|
63
|
+
with self._lock:
|
|
64
|
+
self._last_fire[t.name] = time.time()
|
|
65
|
+
if pending_key is not None:
|
|
66
|
+
self._seen_keys[t.name] = (time.time(), pending_key)
|
|
67
|
+
else:
|
|
68
|
+
logger.warning("dispatch failed for %s: %s", t.name, detail)
|
|
69
|
+
return ok, detail
|
|
70
|
+
|
|
71
|
+
def handle_named(self, trigger_name: str, event: Event) -> Tuple[bool, str]:
|
|
72
|
+
ts = load_triggers()
|
|
73
|
+
t = ts.get(trigger_name)
|
|
74
|
+
if t is None:
|
|
75
|
+
return False, f"unknown trigger '{trigger_name}'"
|
|
76
|
+
return self.handle(t, event)
|