aihook 0.1.5__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.
- aihook/SKILL.md +180 -0
- aihook/__init__.py +28 -0
- aihook/cli.py +456 -0
- aihook/core.py +481 -0
- aihook/learnings/README.md +7 -0
- aihook/release.py +2 -0
- aihook-0.1.5.dist-info/METADATA +198 -0
- aihook-0.1.5.dist-info/RECORD +12 -0
- aihook-0.1.5.dist-info/WHEEL +5 -0
- aihook-0.1.5.dist-info/entry_points.txt +2 -0
- aihook-0.1.5.dist-info/licenses/LICENSE +21 -0
- aihook-0.1.5.dist-info/top_level.txt +1 -0
aihook/SKILL.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: aihook
|
|
3
|
+
description: "Pause a running Python script and explore or manipulate its live namespace over HTTP from an AI coding agent. Use for debugging hard-to-reproduce runtime state, inspecting real (not mocked) objects, and trying fixes against the live process before editing source files. Includes a CLI (aihook) with lock-file-based session discovery."
|
|
4
|
+
version: "0.1.5"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# aihook — Agentic REPL Skill
|
|
10
|
+
|
|
11
|
+
Pause a running Python script and explore / manipulate its live namespace
|
|
12
|
+
over HTTP. Designed to be driven by an AI coding agent.
|
|
13
|
+
|
|
14
|
+
## When to use
|
|
15
|
+
|
|
16
|
+
- Debugging hard-to-reproduce runtime state.
|
|
17
|
+
- Exploring real (not mocked) objects: shapes, keys, attributes.
|
|
18
|
+
- Trying fixes against the live process before editing source files.
|
|
19
|
+
|
|
20
|
+
## Install the hook in the host script
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from aihook import agent_hook
|
|
24
|
+
agent_hook()
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Calling `agent_hook()` with no arguments uses the caller's globals and
|
|
28
|
+
locals (locals override globals on name collision). It starts an HTTP REPL
|
|
29
|
+
bound to `127.0.0.1` on a free port in `5001-5101` and blocks until
|
|
30
|
+
`exit()` is sent.
|
|
31
|
+
|
|
32
|
+
## Canonical agent workflow
|
|
33
|
+
|
|
34
|
+
1. Start the host script **in the background** with output redirected to a
|
|
35
|
+
log file:
|
|
36
|
+
```bash
|
|
37
|
+
python path/to/host_script.py > aihook-host.log 2>&1 &
|
|
38
|
+
```
|
|
39
|
+
**Do not run the host script in the foreground.** It will not return
|
|
40
|
+
until `exit()` is sent, which would block the agent's shell turn.
|
|
41
|
+
`aihook-host.log` is the recommended name for the log file. If you need
|
|
42
|
+
multiple log files append a number, e.g. `aihook-host1.log` etc.
|
|
43
|
+
|
|
44
|
+
2.
|
|
45
|
+
- Interact with the paused script. The CLI waits up to 180s by default
|
|
46
|
+
for `./aihook-lock.yml` to appear, validates that the process is alive and
|
|
47
|
+
the port is responding, then resolves the port automatically:
|
|
48
|
+
```bash
|
|
49
|
+
aihook 'complex_var["nested"]["value"]'
|
|
50
|
+
aihook -f snippet.py
|
|
51
|
+
```
|
|
52
|
+
- Use `-f FILE` to reuse complex testing snippets after editing the host
|
|
53
|
+
code — rerun the same file after each change. `aihook-snippet.py` is the
|
|
54
|
+
recommended name. If you need multiple snippets append a number, e.g.
|
|
55
|
+
`aihook-snippet1.py` etc.
|
|
56
|
+
|
|
57
|
+
- If 180s is not enough (rare), override with `--wait`:
|
|
58
|
+
```bash
|
|
59
|
+
aihook --wait 600 'x'
|
|
60
|
+
```
|
|
61
|
+
- When the session is found, the CLI prints how long it waited. If the
|
|
62
|
+
timeout expires without finding a healthy session, it exits with a clear
|
|
63
|
+
error — check the host script's log file for crashes.
|
|
64
|
+
|
|
65
|
+
4. End the session:
|
|
66
|
+
```bash
|
|
67
|
+
aihook --exit
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Iterative probe loop.** For anything beyond short snippets (1-5 lines),
|
|
71
|
+
keep a `snippet.py` next to the host script and rerun it after each edit:
|
|
72
|
+
```
|
|
73
|
+
# edit snippet.py
|
|
74
|
+
aihook -f snippet.py
|
|
75
|
+
# observe, edit snippet.py again, repeat
|
|
76
|
+
```
|
|
77
|
+
This is the primary workflow; the single-expression form is for quick
|
|
78
|
+
probes only.
|
|
79
|
+
|
|
80
|
+
## CLI reference
|
|
81
|
+
|
|
82
|
+
- `aihook '<code>'` — send code to the active session.
|
|
83
|
+
- `aihook -f FILE` — send the contents of FILE as the command.
|
|
84
|
+
- `aihook -f -` — same, but read the file from stdin.
|
|
85
|
+
- `aihook -` — read code from stdin (also the default when stdin is piped).
|
|
86
|
+
- `aihook --exit` — send `exit()` to shut the session down.
|
|
87
|
+
- `aihook --status` — show whether a session is active, stale, or absent (exits 0 if healthy).
|
|
88
|
+
- `aihook --clean` — remove a stale lock file; refuses if the session is active.
|
|
89
|
+
- `aihook -p PORT` — target a specific port (skips lock-file discovery).
|
|
90
|
+
- `aihook --lockfile PATH` — use a custom lock-file path.
|
|
91
|
+
- `aihook --wait SECONDS` — how long to wait for a healthy session (default 180s). Increase
|
|
92
|
+
for unusually slow startup; if it times out, check the host script's log for crashes.
|
|
93
|
+
|
|
94
|
+
Exit code is non-zero if the remote code raised or wrote to stderr.
|
|
95
|
+
|
|
96
|
+
`-f FILE` is opened by the CLI process (agent side), so relative paths
|
|
97
|
+
resolve against the **agent's** current working directory, not the host
|
|
98
|
+
script's. Use an absolute path (e.g. `-f "$PWD/snippet.py"`) if you are
|
|
99
|
+
not certain the cwds match, or if your shell tool chains `cd` commands
|
|
100
|
+
unreliably.
|
|
101
|
+
|
|
102
|
+
## Auto-print last expression
|
|
103
|
+
|
|
104
|
+
If the submitted command parses as a **single expression**, its `repr()` is
|
|
105
|
+
printed automatically (unless the value is `None`). You don't need to wrap
|
|
106
|
+
probes in `print(...)`.
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
aihook 'complex_var["nested"]'
|
|
110
|
+
# -> {'value': 42, 'items': [1, 2, 3, 4]}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Multi-statement blocks do NOT auto-print.** Statements (`=`, `def`, `for`,
|
|
114
|
+
multi-line snippets) execute silently unless you add explicit `print()` calls.
|
|
115
|
+
A probe that returns nothing is often a missing `print()`, not an empty result.
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
# snippet.py — always use print() in multi-line snippets
|
|
119
|
+
for k, v in data.items():
|
|
120
|
+
print(k, v)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Session discovery
|
|
124
|
+
|
|
125
|
+
On startup, the host writes `./aihook-lock.yml` in its current working
|
|
126
|
+
directory, containing `pid`, `port`, `cwd`, `start_time`, `script`. The
|
|
127
|
+
file is removed on clean shutdown. The banner also prints the source file and
|
|
128
|
+
line number where `agent_hook()` was called, which is useful when a script
|
|
129
|
+
has multiple hook points.
|
|
130
|
+
|
|
131
|
+
**Assumption: at most one aihook process per working directory.** If a
|
|
132
|
+
second host script is started in the same cwd while another is active, it
|
|
133
|
+
will refuse to start and point you to the existing lock file. Stale lock
|
|
134
|
+
files (pid no longer alive) are overwritten automatically.
|
|
135
|
+
|
|
136
|
+
Lock-file discovery does **not** walk up parent directories — `aihook`
|
|
137
|
+
only looks at `./aihook-lock.yml` in the invoking shell's exact cwd.
|
|
138
|
+
Run `aihook` from the same directory as the host script.
|
|
139
|
+
|
|
140
|
+
If the host process is killed uncleanly (e.g. `SIGKILL`, OOM), the lock
|
|
141
|
+
file may remain. A subsequent `agent_hook()` call in the same cwd will
|
|
142
|
+
detect this automatically (pid not alive) and overwrite it. To clean up
|
|
143
|
+
manually, just `rm ./aihook-lock.yml`.
|
|
144
|
+
|
|
145
|
+
## CPython caveat: local-variable write-back
|
|
146
|
+
|
|
147
|
+
Rebinding a **local** variable of the calling function from inside the
|
|
148
|
+
REPL does *not* write back to that function's fast-locals. This is the
|
|
149
|
+
same limitation as `pdb`:
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
# In host_script.py inside my_function():
|
|
153
|
+
# x = 1
|
|
154
|
+
# agent_hook()
|
|
155
|
+
# print(x) # will still print 1, even if you did `x = 2` via aihook
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**Mutating mutable objects works as expected:**
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
complex_var["nested"]["items"].append(99) # visible in the host afterwards
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Prefer mutation for testing fixes. To change a simple local, mutate a
|
|
165
|
+
container, a module-level global, or an object attribute instead.
|
|
166
|
+
|
|
167
|
+
## Environment variables
|
|
168
|
+
|
|
169
|
+
- `AIHOOK_PORT=NNNN` — force a specific port.
|
|
170
|
+
- `AIHOOK_PORT_RANGE=LO-HI` — override the default `5001-5101` range.
|
|
171
|
+
|
|
172
|
+
## Agent-specific learnings library
|
|
173
|
+
|
|
174
|
+
For niche, agent-specific topics (e.g., specific Python package quirks, custom workflow tips) that are too detailed for this main skill file, refer to the `learnings` directory.
|
|
175
|
+
|
|
176
|
+
The `learnings` directory is managed by `aihook` and stored in your platform's user data directory (e.g., `~/.local/share/aihook/learnings` on Linux, `~/Library/Application Support/aihook/learnings` on macOS, `C:\Users\<User>\AppData\Local\aihook\learnings` on Windows). It is created when you run `aihook --bootstrap`.
|
|
177
|
+
|
|
178
|
+
The directory contains markdown files named `topic_<name>.md` (e.g., `topic_numpy.md`) covering independent topics relevant to multiple agent sessions. Default files (like `README.md`) are copied from the aihook package; user-added topic files are never overwritten.
|
|
179
|
+
|
|
180
|
+
Add your own topic files to this directory as your agents accumulate learnings.
|
aihook/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from .release import __version__
|
|
2
|
+
|
|
3
|
+
# the package is imported during installation
|
|
4
|
+
# however installation happens in an isolated build environment
|
|
5
|
+
# where no dependencies are installed.
|
|
6
|
+
|
|
7
|
+
# this means: no importing the following modules will fail
|
|
8
|
+
# during installation. This is OK, but only during installation
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from . import core
|
|
13
|
+
from .core import agent_hook, AgenticREPL
|
|
14
|
+
except ImportError:
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
# detect whether installation is running
|
|
19
|
+
cond1 = "PIP_BUILD_TRACKER" in os.environ # triggered by pip
|
|
20
|
+
cond2 = os.path.join("uv", "builds-v") in sys.executable
|
|
21
|
+
cond3 = "_PYPROJECT_HOOKS_BUILD_BACKEND" in os.environ # triggered by uv pip install
|
|
22
|
+
cond4 = "PEP517_BUILD_BACKEND" in os.environ # triggered during `python -m build`
|
|
23
|
+
|
|
24
|
+
if any((cond1, cond2, cond3, cond4)):
|
|
25
|
+
pass
|
|
26
|
+
else:
|
|
27
|
+
# raise the original exception
|
|
28
|
+
raise
|
aihook/cli.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command line interface for aihook.
|
|
3
|
+
|
|
4
|
+
Sends Python code to an active aihook session (started by ``agent_hook()``
|
|
5
|
+
in a host script) and prints the resulting stdout / stderr.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import socket as _socket
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from urllib import request as urlrequest
|
|
15
|
+
from urllib.error import URLError
|
|
16
|
+
|
|
17
|
+
import platformdirs
|
|
18
|
+
from importlib.resources import files as _resource_files
|
|
19
|
+
|
|
20
|
+
from . import core
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
AIDER_DESK_SKILL_DIR = os.path.expanduser("~/.aider-desk/skills/aihook")
|
|
24
|
+
CLAUDE_CODE_COMMANDS_DIR = os.path.expanduser("~/.claude/commands")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
DEFAULT_WAIT_SECONDS = 180.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _port_is_listening(port, timeout=1.0):
|
|
31
|
+
"""Return True if 127.0.0.1:port is accepting connections."""
|
|
32
|
+
try:
|
|
33
|
+
s = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
|
|
34
|
+
s.settimeout(timeout)
|
|
35
|
+
s.connect(("127.0.0.1", int(port)))
|
|
36
|
+
s.close()
|
|
37
|
+
return True
|
|
38
|
+
except OSError:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _resolve_port(args):
|
|
43
|
+
"""
|
|
44
|
+
Resolve the target port according to the documented priority:
|
|
45
|
+
1. --port
|
|
46
|
+
2. --lockfile PATH
|
|
47
|
+
3. ./aihook-lock.yml
|
|
48
|
+
If no port is resolvable yet and a wait is configured, wait up to the
|
|
49
|
+
configured number of seconds for the lock file to appear (and its pid to
|
|
50
|
+
be alive and its port to be listening).
|
|
51
|
+
"""
|
|
52
|
+
if args.port is not None:
|
|
53
|
+
return args.port
|
|
54
|
+
|
|
55
|
+
lockfile_path = args.lockfile or os.path.join(os.getcwd(), core.LOCKFILE_NAME)
|
|
56
|
+
wait_seconds = args.wait if args.wait is not None else DEFAULT_WAIT_SECONDS
|
|
57
|
+
|
|
58
|
+
start = time.monotonic()
|
|
59
|
+
deadline = start + max(0.0, wait_seconds)
|
|
60
|
+
first = True
|
|
61
|
+
while True:
|
|
62
|
+
if os.path.exists(lockfile_path):
|
|
63
|
+
try:
|
|
64
|
+
data = core.read_lockfile(lockfile_path)
|
|
65
|
+
except Exception as e:
|
|
66
|
+
if time.monotonic() >= deadline:
|
|
67
|
+
sys.stderr.write(f"aihook: could not parse lock file {lockfile_path}: {e}\n")
|
|
68
|
+
sys.exit(2)
|
|
69
|
+
time.sleep(0.1)
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
pid = data.get("pid")
|
|
73
|
+
port = data.get("port")
|
|
74
|
+
if pid and port and core._pid_alive(pid):
|
|
75
|
+
if _port_is_listening(port):
|
|
76
|
+
elapsed = time.monotonic() - start
|
|
77
|
+
if elapsed >= 0.5:
|
|
78
|
+
sys.stderr.write(f"aihook: session found after {elapsed:.1f}s\n")
|
|
79
|
+
return int(port)
|
|
80
|
+
# PID alive but port not responding — server may have crashed
|
|
81
|
+
if time.monotonic() >= deadline:
|
|
82
|
+
sys.stderr.write(
|
|
83
|
+
f"aihook: pid {pid} is alive but port {port} is not responding.\n"
|
|
84
|
+
f"aihook: the server may have crashed. "
|
|
85
|
+
f"Run 'aihook --clean' to remove the lock file.\n"
|
|
86
|
+
)
|
|
87
|
+
sys.exit(2)
|
|
88
|
+
else:
|
|
89
|
+
# Stale lock file: wait a bit in case it is being rewritten.
|
|
90
|
+
if time.monotonic() >= deadline:
|
|
91
|
+
sys.stderr.write(f"aihook: lock file {lockfile_path} is stale (pid {pid} not alive).\n")
|
|
92
|
+
sys.exit(2)
|
|
93
|
+
|
|
94
|
+
if time.monotonic() >= deadline:
|
|
95
|
+
sys.stderr.write(
|
|
96
|
+
f"aihook: no active session found.\n"
|
|
97
|
+
f"aihook: expected lock file at {lockfile_path}.\n"
|
|
98
|
+
f"aihook: hint: start the host script (see SKILL.md) or use --wait.\n"
|
|
99
|
+
)
|
|
100
|
+
sys.exit(2)
|
|
101
|
+
|
|
102
|
+
if first:
|
|
103
|
+
first = False
|
|
104
|
+
time.sleep(0.1)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _send(port, command, timeout=30.0):
|
|
108
|
+
"""POST ``command`` to the /execute endpoint, request JSON response."""
|
|
109
|
+
url = f"http://127.0.0.1:{port}/execute?format=json"
|
|
110
|
+
data = command.encode("utf-8")
|
|
111
|
+
req = urlrequest.Request(url, data=data, method="POST", headers={"Content-Type": "text/plain"})
|
|
112
|
+
try:
|
|
113
|
+
with urlrequest.urlopen(req, timeout=timeout) as resp:
|
|
114
|
+
body = resp.read().decode("utf-8")
|
|
115
|
+
except URLError as e:
|
|
116
|
+
sys.stderr.write(f"aihook: cannot reach session on port {port}: {e}\n")
|
|
117
|
+
sys.exit(2)
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
return json.loads(body)
|
|
121
|
+
except json.JSONDecodeError:
|
|
122
|
+
# Fallback: treat as plain text on stdout.
|
|
123
|
+
return {"stdout": body, "stderr": "", "result_repr": None, "exception": None}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _status_cmd(lockfile_path):
|
|
127
|
+
"""Report the status of the aihook session at lockfile_path."""
|
|
128
|
+
if not os.path.exists(lockfile_path):
|
|
129
|
+
print(f"aihook: no active session (no lock file at {lockfile_path})")
|
|
130
|
+
sys.exit(0)
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
data = core.read_lockfile(lockfile_path)
|
|
134
|
+
except Exception as e:
|
|
135
|
+
sys.stderr.write(f"aihook: corrupt lock file at {lockfile_path}: {e}\n")
|
|
136
|
+
sys.exit(2)
|
|
137
|
+
|
|
138
|
+
pid = data.get("pid")
|
|
139
|
+
port = data.get("port")
|
|
140
|
+
started = data.get("start_time", "unknown")
|
|
141
|
+
tool = data.get("tool")
|
|
142
|
+
|
|
143
|
+
if tool and tool != "aihook":
|
|
144
|
+
sys.stderr.write(f"aihook: warning: lock file 'tool' field is {tool!r}, expected 'aihook'\n")
|
|
145
|
+
|
|
146
|
+
if core.lockfile_is_stale(lockfile_path):
|
|
147
|
+
sys.stderr.write(
|
|
148
|
+
f"aihook: stale lock file at {lockfile_path} (pid {pid} not alive)\n"
|
|
149
|
+
f"aihook: run 'aihook --clean' to remove it.\n"
|
|
150
|
+
)
|
|
151
|
+
sys.exit(1)
|
|
152
|
+
|
|
153
|
+
if port and _port_is_listening(port):
|
|
154
|
+
print(f"aihook: session active — pid={pid}, port={port}, started={started}")
|
|
155
|
+
sys.exit(0)
|
|
156
|
+
else:
|
|
157
|
+
sys.stderr.write(
|
|
158
|
+
f"aihook: pid {pid} is alive but port {port} is not responding.\n"
|
|
159
|
+
f"aihook: the server may have crashed. Run 'aihook --clean' to remove the lock file.\n"
|
|
160
|
+
)
|
|
161
|
+
sys.exit(1)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _clean_cmd(lockfile_path):
|
|
165
|
+
"""Remove a stale lock file. Refuse if the session is active."""
|
|
166
|
+
if not os.path.exists(lockfile_path):
|
|
167
|
+
print(f"aihook: nothing to clean (no lock file at {lockfile_path})")
|
|
168
|
+
sys.exit(0)
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
data = core.read_lockfile(lockfile_path)
|
|
172
|
+
corrupt = False
|
|
173
|
+
except Exception:
|
|
174
|
+
data = {}
|
|
175
|
+
corrupt = True
|
|
176
|
+
|
|
177
|
+
if not corrupt and not core.lockfile_is_stale(lockfile_path):
|
|
178
|
+
pid = data.get("pid")
|
|
179
|
+
port = data.get("port")
|
|
180
|
+
sys.stderr.write(
|
|
181
|
+
f"aihook: refusing to remove lock file for active session "
|
|
182
|
+
f"(pid={pid}, port={port}).\n"
|
|
183
|
+
f"aihook: use 'aihook --exit' to stop the session first.\n"
|
|
184
|
+
)
|
|
185
|
+
sys.exit(1)
|
|
186
|
+
|
|
187
|
+
pid = data.get("pid")
|
|
188
|
+
core.remove_lockfile(lockfile_path)
|
|
189
|
+
if corrupt:
|
|
190
|
+
print(f"aihook: removed corrupt lock file {lockfile_path}")
|
|
191
|
+
else:
|
|
192
|
+
print(f"aihook: removed stale lock file {lockfile_path} (pid {pid} was not alive)")
|
|
193
|
+
sys.exit(0)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _read_command(args):
|
|
197
|
+
"""Determine the command source."""
|
|
198
|
+
if args.exit:
|
|
199
|
+
return "exit()"
|
|
200
|
+
|
|
201
|
+
if args.file:
|
|
202
|
+
if args.file == "-":
|
|
203
|
+
return sys.stdin.read()
|
|
204
|
+
with open(args.file, "r", encoding="utf-8") as f:
|
|
205
|
+
return f.read()
|
|
206
|
+
|
|
207
|
+
if args.cmd == "-" or (args.cmd is None and not sys.stdin.isatty()):
|
|
208
|
+
return sys.stdin.read()
|
|
209
|
+
|
|
210
|
+
if args.cmd is None:
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
return args.cmd
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _build_parser():
|
|
217
|
+
parser = argparse.ArgumentParser(
|
|
218
|
+
prog="aihook",
|
|
219
|
+
description="Send Python code to a running aihook REPL session.",
|
|
220
|
+
add_help=False, # We'll add custom help
|
|
221
|
+
)
|
|
222
|
+
parser.add_argument(
|
|
223
|
+
"cmd",
|
|
224
|
+
nargs="?",
|
|
225
|
+
help="Python code to execute. Use '-' to read from stdin.",
|
|
226
|
+
)
|
|
227
|
+
parser.add_argument(
|
|
228
|
+
"-p",
|
|
229
|
+
"--port",
|
|
230
|
+
type=int,
|
|
231
|
+
default=None,
|
|
232
|
+
help="Target port. Overrides lock-file discovery.",
|
|
233
|
+
)
|
|
234
|
+
parser.add_argument(
|
|
235
|
+
"-f",
|
|
236
|
+
"--file",
|
|
237
|
+
default=None,
|
|
238
|
+
help="Read code from FILE and send it. Useful for reusing snippets.",
|
|
239
|
+
)
|
|
240
|
+
parser.add_argument(
|
|
241
|
+
"--exit",
|
|
242
|
+
action="store_true",
|
|
243
|
+
help="Send exit() to shut down the session.",
|
|
244
|
+
)
|
|
245
|
+
parser.add_argument(
|
|
246
|
+
"--wait",
|
|
247
|
+
type=float,
|
|
248
|
+
default=None,
|
|
249
|
+
help=f"Seconds to wait for a lock file to appear (default: {DEFAULT_WAIT_SECONDS}s).",
|
|
250
|
+
)
|
|
251
|
+
parser.add_argument(
|
|
252
|
+
"--lockfile",
|
|
253
|
+
default=None,
|
|
254
|
+
help="Path to the lock file (default: ./aihook-lock.yml).",
|
|
255
|
+
)
|
|
256
|
+
parser.add_argument(
|
|
257
|
+
"--status",
|
|
258
|
+
action="store_true",
|
|
259
|
+
help="Show status of the current aihook session and exit.",
|
|
260
|
+
)
|
|
261
|
+
parser.add_argument(
|
|
262
|
+
"--clean",
|
|
263
|
+
action="store_true",
|
|
264
|
+
help="Remove a stale lock file and exit. Refuses if a session is active.",
|
|
265
|
+
)
|
|
266
|
+
parser.add_argument(
|
|
267
|
+
"--bootstrap",
|
|
268
|
+
action="store_true",
|
|
269
|
+
help=(
|
|
270
|
+
f"Install SKILL.md for the target agent (see --agent) and create the learnings "
|
|
271
|
+
f"directory, then exit. Aider: {AIDER_DESK_SKILL_DIR}/SKILL.md. "
|
|
272
|
+
f"Claude: {CLAUDE_CODE_COMMANDS_DIR}/aihook.md."
|
|
273
|
+
),
|
|
274
|
+
)
|
|
275
|
+
parser.add_argument(
|
|
276
|
+
"--agent",
|
|
277
|
+
default="aider",
|
|
278
|
+
choices=["aider", "claude", "all"],
|
|
279
|
+
help="Target agent for --bootstrap skill installation (default: aider).",
|
|
280
|
+
)
|
|
281
|
+
parser.add_argument(
|
|
282
|
+
"--allow-overwrite-SKILL.md",
|
|
283
|
+
dest="allow_overwrite_SKILL_md",
|
|
284
|
+
action="store_true",
|
|
285
|
+
help="With --bootstrap: overwrite an existing skill file at the destination.",
|
|
286
|
+
)
|
|
287
|
+
parser.add_argument(
|
|
288
|
+
"--version",
|
|
289
|
+
action="store_true",
|
|
290
|
+
help="Show the version and exit.",
|
|
291
|
+
)
|
|
292
|
+
parser.add_argument(
|
|
293
|
+
"-h",
|
|
294
|
+
"--help",
|
|
295
|
+
action="store_true",
|
|
296
|
+
help="Show this help message and exit.",
|
|
297
|
+
)
|
|
298
|
+
return parser
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _load_skill_content():
|
|
302
|
+
"""Return the packaged SKILL.md text, or exit with an error."""
|
|
303
|
+
try:
|
|
304
|
+
source = _resource_files("aihook").joinpath("SKILL.md")
|
|
305
|
+
return source.read_text(encoding="utf-8")
|
|
306
|
+
except (FileNotFoundError, ModuleNotFoundError) as e:
|
|
307
|
+
sys.stderr.write(f"aihook: could not locate packaged SKILL.md: {e}\n")
|
|
308
|
+
sys.exit(1)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _install_skill_aider(allow_overwrite):
|
|
312
|
+
dest_skill = os.path.join(AIDER_DESK_SKILL_DIR, "SKILL.md")
|
|
313
|
+
if os.path.exists(dest_skill) and not allow_overwrite:
|
|
314
|
+
sys.stderr.write(
|
|
315
|
+
f"aihook: {dest_skill} already exists.\n"
|
|
316
|
+
f"aihook: pass --allow-overwrite-SKILL.md to replace it.\n"
|
|
317
|
+
)
|
|
318
|
+
sys.exit(1)
|
|
319
|
+
skill_content = _load_skill_content()
|
|
320
|
+
os.makedirs(AIDER_DESK_SKILL_DIR, exist_ok=True)
|
|
321
|
+
with open(dest_skill, "w", encoding="utf-8") as f:
|
|
322
|
+
f.write(skill_content)
|
|
323
|
+
print(f"aihook: wrote {dest_skill}")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _install_skill_claude(allow_overwrite):
|
|
327
|
+
dest_skill = os.path.join(CLAUDE_CODE_COMMANDS_DIR, "aihook.md")
|
|
328
|
+
if os.path.exists(dest_skill) and not allow_overwrite:
|
|
329
|
+
sys.stderr.write(
|
|
330
|
+
f"aihook: {dest_skill} already exists.\n"
|
|
331
|
+
f"aihook: pass --allow-overwrite-SKILL.md to replace it.\n"
|
|
332
|
+
)
|
|
333
|
+
sys.exit(1)
|
|
334
|
+
skill_content = _load_skill_content()
|
|
335
|
+
os.makedirs(CLAUDE_CODE_COMMANDS_DIR, exist_ok=True)
|
|
336
|
+
with open(dest_skill, "w", encoding="utf-8") as f:
|
|
337
|
+
f.write(skill_content)
|
|
338
|
+
print(f"aihook: wrote {dest_skill}")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _setup_learnings():
|
|
342
|
+
try:
|
|
343
|
+
aihook_data_dir = platformdirs.user_data_dir("aihook")
|
|
344
|
+
except Exception as e:
|
|
345
|
+
sys.stderr.write(f"aihook: could not determine user data directory: {e}\n")
|
|
346
|
+
sys.exit(1)
|
|
347
|
+
learnings_dest = os.path.join(aihook_data_dir, "learnings")
|
|
348
|
+
os.makedirs(learnings_dest, exist_ok=True)
|
|
349
|
+
print(f"aihook: learnings directory at {learnings_dest}")
|
|
350
|
+
|
|
351
|
+
try:
|
|
352
|
+
pkg_learnings = _resource_files("aihook").joinpath("learnings")
|
|
353
|
+
for item in pkg_learnings.iterdir():
|
|
354
|
+
if item.is_file():
|
|
355
|
+
dest_file = os.path.join(learnings_dest, item.name)
|
|
356
|
+
if os.path.exists(dest_file):
|
|
357
|
+
sys.stderr.write(f"aihook: warning: {dest_file} already exists, skipping.\n")
|
|
358
|
+
continue
|
|
359
|
+
try:
|
|
360
|
+
content = item.read_text(encoding="utf-8")
|
|
361
|
+
except Exception as e:
|
|
362
|
+
sys.stderr.write(f"aihook: could not read packaged learnings file {item.name}: {e}\n")
|
|
363
|
+
continue
|
|
364
|
+
with open(dest_file, "w", encoding="utf-8") as f:
|
|
365
|
+
f.write(content)
|
|
366
|
+
print(f"aihook: wrote {dest_file}")
|
|
367
|
+
except (FileNotFoundError, ModuleNotFoundError) as e:
|
|
368
|
+
sys.stderr.write(f"aihook: no default learnings found in package: {e}\n")
|
|
369
|
+
except Exception as e:
|
|
370
|
+
sys.stderr.write(f"aihook: error processing learnings: {e}\n")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _bootstrap(allow_overwrite_skillmd, agent):
|
|
374
|
+
if agent in ("aider", "all"):
|
|
375
|
+
_install_skill_aider(allow_overwrite_skillmd)
|
|
376
|
+
if agent in ("claude", "all"):
|
|
377
|
+
_install_skill_claude(allow_overwrite_skillmd)
|
|
378
|
+
_setup_learnings()
|
|
379
|
+
print("aihook: bootstrap complete.")
|
|
380
|
+
sys.exit(0)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def main(argv=None):
|
|
384
|
+
parser = _build_parser()
|
|
385
|
+
args = parser.parse_args(argv)
|
|
386
|
+
|
|
387
|
+
if args.help:
|
|
388
|
+
parser.print_help()
|
|
389
|
+
installed = []
|
|
390
|
+
aider_skill = os.path.join(AIDER_DESK_SKILL_DIR, "SKILL.md")
|
|
391
|
+
if os.path.exists(aider_skill):
|
|
392
|
+
installed.append(f" SKILL.md (aider): {aider_skill}")
|
|
393
|
+
claude_skill = os.path.join(CLAUDE_CODE_COMMANDS_DIR, "aihook.md")
|
|
394
|
+
if os.path.exists(claude_skill):
|
|
395
|
+
installed.append(f" aihook.md (claude): {claude_skill}")
|
|
396
|
+
try:
|
|
397
|
+
learnings_dir = os.path.join(platformdirs.user_data_dir("aihook"), "learnings")
|
|
398
|
+
except Exception:
|
|
399
|
+
learnings_dir = None
|
|
400
|
+
if learnings_dir and os.path.exists(learnings_dir):
|
|
401
|
+
installed.append(f" Learnings dir: {learnings_dir}")
|
|
402
|
+
if installed:
|
|
403
|
+
print("\nInstalled files/directories:")
|
|
404
|
+
for line in installed:
|
|
405
|
+
print(line)
|
|
406
|
+
sys.exit(0)
|
|
407
|
+
|
|
408
|
+
if args.version:
|
|
409
|
+
from .release import __version__
|
|
410
|
+
|
|
411
|
+
print(f"aihook {__version__}")
|
|
412
|
+
sys.exit(0)
|
|
413
|
+
|
|
414
|
+
if args.status:
|
|
415
|
+
lockfile_path = args.lockfile or os.path.join(os.getcwd(), core.LOCKFILE_NAME)
|
|
416
|
+
_status_cmd(lockfile_path)
|
|
417
|
+
|
|
418
|
+
if args.clean:
|
|
419
|
+
lockfile_path = args.lockfile or os.path.join(os.getcwd(), core.LOCKFILE_NAME)
|
|
420
|
+
_clean_cmd(lockfile_path)
|
|
421
|
+
|
|
422
|
+
if args.bootstrap:
|
|
423
|
+
_bootstrap(args.allow_overwrite_SKILL_md, args.agent)
|
|
424
|
+
|
|
425
|
+
if args.agent != "aider":
|
|
426
|
+
parser.error("--agent only makes sense with --bootstrap")
|
|
427
|
+
if args.allow_overwrite_SKILL_md:
|
|
428
|
+
parser.error("--allow-overwrite-SKILL.md only makes sense with --bootstrap")
|
|
429
|
+
|
|
430
|
+
command = _read_command(args)
|
|
431
|
+
if command is None:
|
|
432
|
+
parser.error("no command given (pass a positional arg, -f FILE, stdin, or --exit)")
|
|
433
|
+
|
|
434
|
+
port = _resolve_port(args)
|
|
435
|
+
result = _send(port, command)
|
|
436
|
+
|
|
437
|
+
stdout = result.get("stdout", "") or ""
|
|
438
|
+
stderr = result.get("stderr", "") or ""
|
|
439
|
+
exception = result.get("exception")
|
|
440
|
+
|
|
441
|
+
if stdout:
|
|
442
|
+
sys.stdout.write(stdout)
|
|
443
|
+
if not stdout.endswith("\n"):
|
|
444
|
+
sys.stdout.write("\n")
|
|
445
|
+
if stderr:
|
|
446
|
+
sys.stderr.write(stderr)
|
|
447
|
+
if not stderr.endswith("\n"):
|
|
448
|
+
sys.stderr.write("\n")
|
|
449
|
+
|
|
450
|
+
if exception or stderr:
|
|
451
|
+
sys.exit(1)
|
|
452
|
+
sys.exit(0)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
if __name__ == "__main__":
|
|
456
|
+
main()
|