promptqueue 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: promptqueue
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Dependency-free prompt scheduler for AI rate-limit resets.
|
|
5
|
+
Author: Atharva Maik
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/AtharvaMaik/PromptQueue
|
|
8
|
+
Project-URL: Repository, https://github.com/AtharvaMaik/PromptQueue
|
|
9
|
+
Project-URL: Issues, https://github.com/AtharvaMaik/PromptQueue/issues
|
|
10
|
+
Keywords: ai,automation,scheduler,claude,codex,chatgpt,cursor,gemini
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
15
|
+
Classifier: Operating System :: MacOS
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# PromptQueue
|
|
29
|
+
|
|
30
|
+
[](https://github.com/AtharvaMaik/PromptQueue/actions/workflows/ci.yml)
|
|
31
|
+
[](LICENSE)
|
|
32
|
+
[](https://www.python.org/)
|
|
33
|
+
[](https://pypi.org/project/promptqueue/)
|
|
34
|
+
|
|
35
|
+
Schedule AI prompts for the moment your limits reset.
|
|
36
|
+
|
|
37
|
+
PromptQueue is a tiny, dependency-free prompt scheduler for Claude, Codex, ChatGPT, Gemini, Copilot, Cursor, Antigravity, and anything else you can launch from your machine.
|
|
38
|
+
|
|
39
|
+
When an AI tool says "try again at 7:30 PM", do not keep the tab open, set a reminder, or paste the same prompt later. Queue it now. PromptQueue waits, opens or focuses the target app, pastes the prompt, and submits it when the time arrives.
|
|
40
|
+
|
|
41
|
+
```powershell
|
|
42
|
+
promptqueue add 19:30 claude finish the migration plan
|
|
43
|
+
promptqueue run
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
One Python file. Standard library only. No accounts, no server, no private APIs.
|
|
47
|
+
|
|
48
|
+

|
|
49
|
+
|
|
50
|
+
## Why This Exists
|
|
51
|
+
|
|
52
|
+
AI rate limits waste the worst kind of time: attention.
|
|
53
|
+
|
|
54
|
+
You already know what you want to ask next. The only problem is the clock. PromptQueue turns "come back later" into a queued job you can trust.
|
|
55
|
+
|
|
56
|
+
Use it when:
|
|
57
|
+
|
|
58
|
+
- Claude, Codex, ChatGPT, Gemini, or Copilot tells you to wait for a reset
|
|
59
|
+
- you want a long prompt to run after you sleep
|
|
60
|
+
- you want a coding agent to resume work when capacity returns
|
|
61
|
+
- you want a dead-simple local queue instead of another SaaS dashboard
|
|
62
|
+
|
|
63
|
+
If this saves you one context switch, it has already done its job.
|
|
64
|
+
|
|
65
|
+
## 30 Second Demo
|
|
66
|
+
|
|
67
|
+
Queue a Claude prompt for 11:30 PM:
|
|
68
|
+
|
|
69
|
+
```powershell
|
|
70
|
+
promptqueue add 23:30 claude write a first draft of the launch email
|
|
71
|
+
promptqueue run
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Queue a Codex CLI prompt:
|
|
75
|
+
|
|
76
|
+
```powershell
|
|
77
|
+
promptqueue add 23:30 codex-exec add tests for the queue runner
|
|
78
|
+
promptqueue run
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Queue a multiline prompt safely:
|
|
82
|
+
|
|
83
|
+
```powershell
|
|
84
|
+
@'
|
|
85
|
+
Review this repo.
|
|
86
|
+
Find the smallest useful next improvement.
|
|
87
|
+
Then implement it.
|
|
88
|
+
'@ | promptqueue add --stdin 23:30 claude
|
|
89
|
+
promptqueue run
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
View what is waiting:
|
|
93
|
+
|
|
94
|
+
```powershell
|
|
95
|
+
promptqueue list --full
|
|
96
|
+
promptqueue show JOB_ID
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Supported Targets
|
|
100
|
+
|
|
101
|
+
```text
|
|
102
|
+
antigravity Google Antigravity UI
|
|
103
|
+
chatgpt ChatGPT web UI
|
|
104
|
+
claude Claude web UI
|
|
105
|
+
claude-code Claude CLI
|
|
106
|
+
codex Codex desktop UI
|
|
107
|
+
codex-exec Codex CLI
|
|
108
|
+
copilot Copilot web UI
|
|
109
|
+
copy clipboard only
|
|
110
|
+
cursor Cursor UI
|
|
111
|
+
gemini Gemini web UI
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Run this to see the built-in aliases on your machine:
|
|
115
|
+
|
|
116
|
+
```powershell
|
|
117
|
+
promptqueue targets
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
UI targets open or focus the app, click near the composer, paste, then press Enter by default.
|
|
121
|
+
|
|
122
|
+
CLI targets run the command directly with the prompt as one argument.
|
|
123
|
+
|
|
124
|
+
## Agent Ready
|
|
125
|
+
|
|
126
|
+
This repo includes instruction files for common AI coding tools:
|
|
127
|
+
|
|
128
|
+
```text
|
|
129
|
+
AGENTS.md Codex and general agents
|
|
130
|
+
CLAUDE.md Claude Code
|
|
131
|
+
GEMINI.md Gemini CLI
|
|
132
|
+
.cursor/rules/promptqueue.mdc Cursor
|
|
133
|
+
.agents/skills/promptqueue/ Antigravity-style skill
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
That means you can tell an agent:
|
|
137
|
+
|
|
138
|
+
```text
|
|
139
|
+
My limits reset at 7:30 pm. Schedule this prompt for Claude:
|
|
140
|
+
"Continue the refactor and run the tests."
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The agent should queue it with PromptQueue instead of making you remember.
|
|
144
|
+
|
|
145
|
+
## Requirements
|
|
146
|
+
|
|
147
|
+
- Python 3.10+
|
|
148
|
+
- Windows for GUI paste/submit automation
|
|
149
|
+
- macOS/Linux work for queue management, URLs, clipboard fallback, and CLI targets, but GUI paste currently uses Windows APIs
|
|
150
|
+
|
|
151
|
+
## Install
|
|
152
|
+
|
|
153
|
+
Install from PyPI:
|
|
154
|
+
|
|
155
|
+
```powershell
|
|
156
|
+
pip install promptqueue
|
|
157
|
+
promptqueue selftest
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Or clone the repo and run the single Python file:
|
|
161
|
+
|
|
162
|
+
```powershell
|
|
163
|
+
git clone https://github.com/AtharvaMaik/PromptQueue.git
|
|
164
|
+
cd PromptQueue
|
|
165
|
+
python promptqueue.py selftest
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
After `pip install`, use `promptqueue`. When running from a clone without installing, use `python promptqueue.py`.
|
|
169
|
+
|
|
170
|
+
PromptQueue stores jobs in:
|
|
171
|
+
|
|
172
|
+
```text
|
|
173
|
+
%USERPROFILE%\.promptqueue.json
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Override the queue path when you want an isolated queue:
|
|
177
|
+
|
|
178
|
+
```powershell
|
|
179
|
+
$env:PROMPTQUEUE_FILE="C:\path\queue.json"
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## How It Works
|
|
183
|
+
|
|
184
|
+
1. `add` writes a job to the local queue file.
|
|
185
|
+
2. `run` checks for due jobs.
|
|
186
|
+
3. When a job is due, PromptQueue copies the prompt to the clipboard.
|
|
187
|
+
4. For UI targets, it opens or focuses the app, clicks the composer, pastes, and optionally submits.
|
|
188
|
+
5. For CLI targets, it launches the command directly.
|
|
189
|
+
6. Attempts, failures, retries, and history stay visible in the queue file.
|
|
190
|
+
|
|
191
|
+
If the machine wakes up after the scheduled time, `run` catches overdue jobs.
|
|
192
|
+
|
|
193
|
+
## Commands
|
|
194
|
+
|
|
195
|
+
### `add`
|
|
196
|
+
|
|
197
|
+
Queue a prompt.
|
|
198
|
+
|
|
199
|
+
```powershell
|
|
200
|
+
promptqueue add [options] WHEN TARGET PROMPT...
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
`WHEN` can be a local time like `23:30` or an ISO-ish datetime like `2026-06-24 01:15`.
|
|
204
|
+
|
|
205
|
+
Options:
|
|
206
|
+
|
|
207
|
+
```text
|
|
208
|
+
--click bottom Click near the bottom of the focused window before paste.
|
|
209
|
+
--click x,y Click x/y pixels from the target window top-left.
|
|
210
|
+
--command-template TEMPLATE Run a custom command; use {prompt}.
|
|
211
|
+
--delay SECONDS Wait before paste/submit. Default: 5.
|
|
212
|
+
--file FILE Read prompt text from a UTF-8 file.
|
|
213
|
+
--max-attempts N Attempts before marking failed. Default: 5.
|
|
214
|
+
--no-submit Paste only; do not press Enter.
|
|
215
|
+
--pre-keys KEYS Windows SendKeys before paste, e.g. "{ESC}".
|
|
216
|
+
--retry-base SECONDS Initial retry delay. Default: 30.
|
|
217
|
+
--stdin Read prompt text from stdin.
|
|
218
|
+
--window TITLE Focus a window whose title contains TITLE.
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Examples:
|
|
222
|
+
|
|
223
|
+
```powershell
|
|
224
|
+
promptqueue add 23:30 claude summarize this paper
|
|
225
|
+
promptqueue add --window Codex 23:30 copy this lands in Codex
|
|
226
|
+
promptqueue add --no-submit 23:30 claude paste this but do not send it
|
|
227
|
+
promptqueue add --file prompt.txt 23:30 claude
|
|
228
|
+
"prompt from stdin" | promptqueue add --stdin 23:30 copy
|
|
229
|
+
promptqueue add --command-template "claude -p {prompt}" 23:30 command review this repo
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### `run`
|
|
233
|
+
|
|
234
|
+
Start the queue worker:
|
|
235
|
+
|
|
236
|
+
```powershell
|
|
237
|
+
promptqueue run
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Check once and exit:
|
|
241
|
+
|
|
242
|
+
```powershell
|
|
243
|
+
promptqueue run --once
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Poll faster or slower:
|
|
247
|
+
|
|
248
|
+
```powershell
|
|
249
|
+
promptqueue run --poll 5
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
The runner must be alive when jobs are due.
|
|
253
|
+
|
|
254
|
+
### `list`
|
|
255
|
+
|
|
256
|
+
Show queued jobs:
|
|
257
|
+
|
|
258
|
+
```powershell
|
|
259
|
+
promptqueue list
|
|
260
|
+
promptqueue list --all
|
|
261
|
+
promptqueue list --full
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### `show`
|
|
265
|
+
|
|
266
|
+
Show one job as JSON:
|
|
267
|
+
|
|
268
|
+
```powershell
|
|
269
|
+
promptqueue show JOB_ID
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### `remove`
|
|
273
|
+
|
|
274
|
+
Delete one job:
|
|
275
|
+
|
|
276
|
+
```powershell
|
|
277
|
+
promptqueue remove JOB_ID
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### `windows`
|
|
281
|
+
|
|
282
|
+
List visible Windows window titles. Use this to find the right `--window` value.
|
|
283
|
+
|
|
284
|
+
```powershell
|
|
285
|
+
promptqueue windows
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### `targets`
|
|
289
|
+
|
|
290
|
+
List built-in target aliases:
|
|
291
|
+
|
|
292
|
+
```powershell
|
|
293
|
+
promptqueue targets
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
### `selftest`
|
|
297
|
+
|
|
298
|
+
Run the built-in smoke test:
|
|
299
|
+
|
|
300
|
+
```powershell
|
|
301
|
+
promptqueue selftest
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
## Retries And History
|
|
305
|
+
|
|
306
|
+
Every job stores attempts and history. Failures stay visible in `list` and `show`.
|
|
307
|
+
|
|
308
|
+
```powershell
|
|
309
|
+
promptqueue add --max-attempts 8 --retry-base 60 23:30 claude retry this more patiently
|
|
310
|
+
promptqueue list --all
|
|
311
|
+
promptqueue show JOB_ID
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
Backoff is exponential and capped at one hour.
|
|
315
|
+
|
|
316
|
+
## Notes
|
|
317
|
+
|
|
318
|
+
PromptQueue intentionally does the boring thing: it uses local files, the clipboard, app launching, and keyboard paste. That keeps it portable and inspectable.
|
|
319
|
+
|
|
320
|
+
For GUI apps, it does not use private app APIs. If one app misses the composer, adjust `--window`, `--click`, `--delay`, or `--pre-keys`.
|
|
321
|
+
|
|
322
|
+
## Contributing
|
|
323
|
+
|
|
324
|
+
Issues and PRs are welcome. Start with [CONTRIBUTING.md](CONTRIBUTING.md) and [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
|
|
325
|
+
|
|
326
|
+
The best contributions are small and practical: a better target alias, a failing selftest for a real bug, clearer setup docs, or a platform-specific paste improvement.
|
|
327
|
+
|
|
328
|
+
## License
|
|
329
|
+
|
|
330
|
+
MIT. See [LICENSE](LICENSE).
|
|
331
|
+
|
|
332
|
+
Star the repo if it saves you from waiting around for an AI limit reset. That is the whole point.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
promptqueue.py,sha256=hdTdJhu5Ury17vi_oCBRGZ366uGar8unGncQzIFKYoc,23696
|
|
2
|
+
promptqueue-0.1.0.dist-info/licenses/LICENSE,sha256=TvH1qPRT2DZu6H7KH5OrJyzMaQuggdIPchxUg71ZGjo,1069
|
|
3
|
+
promptqueue-0.1.0.dist-info/METADATA,sha256=YVe6xfd_2OftPgA-_Ls0VCZ9xG8YJoL0Wb944rHkW0U,9453
|
|
4
|
+
promptqueue-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
5
|
+
promptqueue-0.1.0.dist-info/entry_points.txt,sha256=wEwgTafBztxGXi0wrytejTOBmtT_Dn--Dfqj2HHVx48,49
|
|
6
|
+
promptqueue-0.1.0.dist-info/top_level.txt,sha256=jw98DQae1I1hAfZmbAtwjEoFc2EyUlH0YwfV493SbuQ,12
|
|
7
|
+
promptqueue-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Atharva Maik
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
promptqueue
|
promptqueue.py
ADDED
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import shlex
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
import webbrowser
|
|
16
|
+
from datetime import datetime, timedelta
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
QUEUE_FILE = Path(os.getenv("PROMPTQUEUE_FILE", Path.home() / ".promptqueue.json"))
|
|
21
|
+
|
|
22
|
+
TARGETS = {
|
|
23
|
+
"copy": "copy",
|
|
24
|
+
"claude": "https://claude.ai/new",
|
|
25
|
+
"claude-code": "claude",
|
|
26
|
+
"chatgpt": "https://chatgpt.com/",
|
|
27
|
+
"copilot": "https://copilot.microsoft.com/",
|
|
28
|
+
"gemini": "https://gemini.google.com/app",
|
|
29
|
+
"cursor": "cursor",
|
|
30
|
+
"antigravity": "agy",
|
|
31
|
+
"codex": "app:OpenAI.Codex_2p2nqsd0c76g0!App",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
WINDOW_HINTS = {
|
|
35
|
+
"claude": "Claude",
|
|
36
|
+
"chatgpt": "ChatGPT",
|
|
37
|
+
"copilot": "Copilot",
|
|
38
|
+
"gemini": "Gemini",
|
|
39
|
+
"cursor": "Cursor",
|
|
40
|
+
"antigravity": "Antigravity",
|
|
41
|
+
"codex": "Codex",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
CLICK_HINTS = {target: "bottom" for target in WINDOW_HINTS}
|
|
45
|
+
|
|
46
|
+
COMMAND_TARGETS = {
|
|
47
|
+
"claude-code": "claude -p {prompt}",
|
|
48
|
+
"codex-exec": "codex exec {prompt}",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
MAX_HISTORY = 25
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def echo(text: str) -> None:
|
|
55
|
+
encoding = sys.stdout.encoding or "utf-8"
|
|
56
|
+
print(text.encode(encoding, errors="replace").decode(encoding))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_when(value: str, now: datetime | None = None) -> datetime:
|
|
60
|
+
now = now or datetime.now()
|
|
61
|
+
value = value.strip()
|
|
62
|
+
|
|
63
|
+
if re.fullmatch(r"\d{1,2}:\d{2}", value):
|
|
64
|
+
hour, minute = map(int, value.split(":"))
|
|
65
|
+
due = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
|
66
|
+
return due if due > now else due + timedelta(days=1)
|
|
67
|
+
|
|
68
|
+
return datetime.fromisoformat(value.replace(" ", "T", 1))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_queue(path: Path = QUEUE_FILE) -> dict:
|
|
72
|
+
if not path.exists():
|
|
73
|
+
return {"jobs": []}
|
|
74
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def save_queue(queue: dict, path: Path = QUEUE_FILE) -> None:
|
|
78
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
path.write_text(json.dumps(queue, indent=2), encoding="utf-8")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def iso_now(now: datetime | None = None) -> str:
|
|
83
|
+
return (now or datetime.now()).isoformat(timespec="seconds")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_iso(value: str | None) -> datetime | None:
|
|
87
|
+
return datetime.fromisoformat(value) if value else None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def append_history(job: dict, event: str, now: datetime | None = None, **fields) -> None:
|
|
91
|
+
entry = {"at": iso_now(now), "event": event, **fields}
|
|
92
|
+
job["history"] = [*job.get("history", [])[-(MAX_HISTORY - 1) :], entry]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def job_ready(job: dict, now: datetime | None = None) -> bool:
|
|
96
|
+
now = now or datetime.now()
|
|
97
|
+
if job.get("sent_at") or job.get("failed_at"):
|
|
98
|
+
return False
|
|
99
|
+
if parse_when(job["at"], now) > now:
|
|
100
|
+
return False
|
|
101
|
+
if (parse_iso(job.get("next_attempt_at")) or now) > now:
|
|
102
|
+
return False
|
|
103
|
+
if (parse_iso(job.get("leased_until")) or now) > now:
|
|
104
|
+
return False
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def mark_sent(job: dict, now: datetime | None = None) -> None:
|
|
109
|
+
now = now or datetime.now()
|
|
110
|
+
job["sent_at"] = iso_now(now)
|
|
111
|
+
job.pop("last_error", None)
|
|
112
|
+
job.pop("next_attempt_at", None)
|
|
113
|
+
job.pop("leased_until", None)
|
|
114
|
+
append_history(job, "sent", now, attempt=job.get("attempts", 0))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def mark_failed(job: dict, error: str, now: datetime | None = None) -> None:
|
|
118
|
+
now = now or datetime.now()
|
|
119
|
+
attempts = int(job.get("attempts", 0)) + 1
|
|
120
|
+
max_attempts = int(job.get("max_attempts", 5))
|
|
121
|
+
retry_base = float(job.get("retry_base", 30))
|
|
122
|
+
retry_delay = min(retry_base * (2 ** (attempts - 1)), 3600)
|
|
123
|
+
|
|
124
|
+
job["attempts"] = attempts
|
|
125
|
+
job["last_error"] = error
|
|
126
|
+
job.pop("leased_until", None)
|
|
127
|
+
append_history(job, "failed", now, attempt=attempts, error=error)
|
|
128
|
+
|
|
129
|
+
if attempts >= max_attempts:
|
|
130
|
+
job["failed_at"] = iso_now(now)
|
|
131
|
+
job.pop("next_attempt_at", None)
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
job["next_attempt_at"] = iso_now(now + timedelta(seconds=retry_delay))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def job_status(job: dict) -> str:
|
|
138
|
+
if job.get("sent_at"):
|
|
139
|
+
return "sent"
|
|
140
|
+
if job.get("failed_at"):
|
|
141
|
+
return "failed"
|
|
142
|
+
if job.get("next_attempt_at"):
|
|
143
|
+
return "retry"
|
|
144
|
+
return "queued"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def tkinter_copy_clipboard(text: str) -> None:
|
|
148
|
+
import tkinter as tk
|
|
149
|
+
|
|
150
|
+
root = tk.Tk()
|
|
151
|
+
root.withdraw()
|
|
152
|
+
root.clipboard_clear()
|
|
153
|
+
root.clipboard_append(text)
|
|
154
|
+
root.update()
|
|
155
|
+
root.destroy()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def copy_clipboard(text: str, runner=subprocess.run, fallback=tkinter_copy_clipboard, system=platform.system) -> None:
|
|
159
|
+
if system() == "Windows":
|
|
160
|
+
try:
|
|
161
|
+
runner(["clip"], input=text, text=True, check=True, capture_output=True)
|
|
162
|
+
return
|
|
163
|
+
except subprocess.CalledProcessError as exc:
|
|
164
|
+
error = (exc.stderr or exc.stdout or str(exc)).strip()
|
|
165
|
+
raise RuntimeError(f"clipboard unavailable: {error}") from exc
|
|
166
|
+
except OSError as exc:
|
|
167
|
+
raise RuntimeError(f"clipboard unavailable: {exc}") from exc
|
|
168
|
+
|
|
169
|
+
fallback(text)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def split_command(command: str) -> list[str]:
|
|
173
|
+
return shlex.split(command, posix=platform.system() != "Windows")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def protected_windowsapps_path(path: str, system=platform.system) -> bool:
|
|
177
|
+
normalized = path.replace("/", "\\").lower()
|
|
178
|
+
return system() == "Windows" and "\\program files\\windowsapps\\" in normalized
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def command_issue(args: list[str], which=shutil.which, system=platform.system) -> str | None:
|
|
182
|
+
if not args:
|
|
183
|
+
return "command required"
|
|
184
|
+
path = which(args[0])
|
|
185
|
+
if not path:
|
|
186
|
+
return f"command not found: {args[0]}"
|
|
187
|
+
if protected_windowsapps_path(path, system):
|
|
188
|
+
return f"command not runnable: {args[0]} (protected WindowsApps package)"
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def launch_status(launch: str, which=shutil.which, system=platform.system) -> str:
|
|
193
|
+
if launch in {"copy", "clipboard", "none"}:
|
|
194
|
+
return "internal"
|
|
195
|
+
if launch.startswith(("http://", "https://")):
|
|
196
|
+
return "url"
|
|
197
|
+
if launch.startswith("app:"):
|
|
198
|
+
return "app"
|
|
199
|
+
issue = command_issue(split_command(launch.replace("{prompt}", "prompt")), which, system)
|
|
200
|
+
if issue and "not runnable" in issue:
|
|
201
|
+
return "blocked"
|
|
202
|
+
return "missing" if issue else "found"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def raise_command_error(action: str, command: str, exc: BaseException) -> None:
|
|
206
|
+
raise RuntimeError(f"{action} not runnable: {command} ({exc})") from exc
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def launch_target(target: str) -> None:
|
|
210
|
+
target = TARGETS.get(target, target)
|
|
211
|
+
if target in {"copy", "clipboard", "none"}:
|
|
212
|
+
return
|
|
213
|
+
if target.startswith(("http://", "https://")):
|
|
214
|
+
webbrowser.open(target)
|
|
215
|
+
return
|
|
216
|
+
if target.startswith("app:"):
|
|
217
|
+
if platform.system() != "Windows":
|
|
218
|
+
raise RuntimeError("Windows app launch is Windows-only")
|
|
219
|
+
subprocess.Popen(["explorer.exe", f"shell:AppsFolder\\{target[4:]}"])
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
issue = command_issue(split_command(target))
|
|
223
|
+
if issue:
|
|
224
|
+
raise RuntimeError(issue)
|
|
225
|
+
try:
|
|
226
|
+
process = subprocess.Popen(target, shell=True, stderr=subprocess.PIPE, text=True)
|
|
227
|
+
except PermissionError as exc:
|
|
228
|
+
raise_command_error("launcher", split_command(target)[0], exc)
|
|
229
|
+
time.sleep(0.25)
|
|
230
|
+
if process.poll() not in (None, 0):
|
|
231
|
+
error = process.stderr.read().strip() if process.stderr else ""
|
|
232
|
+
raise RuntimeError(f"launcher failed: {target}{': ' + error if error else ''}")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def command_args(template: str, prompt: str) -> list[str]:
|
|
236
|
+
marker = "__PROMPTQUEUE_PROMPT__"
|
|
237
|
+
if "{prompt}" not in template:
|
|
238
|
+
template = f"{template} {{prompt}}"
|
|
239
|
+
|
|
240
|
+
parts = shlex.split(
|
|
241
|
+
template.replace("{prompt}", marker),
|
|
242
|
+
posix=platform.system() != "Windows",
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def clean(part: str) -> str:
|
|
246
|
+
if len(part) >= 2 and part[0] == part[-1] and part[0] in {"'", '"'}:
|
|
247
|
+
return part[1:-1]
|
|
248
|
+
return part
|
|
249
|
+
|
|
250
|
+
return [prompt if clean(part) == marker else clean(part) for part in parts]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def run_command_template(template: str, prompt: str, runner=subprocess.run, which=shutil.which) -> None:
|
|
254
|
+
args = command_args(template, prompt)
|
|
255
|
+
issue = command_issue(args, which)
|
|
256
|
+
if issue:
|
|
257
|
+
raise RuntimeError(issue)
|
|
258
|
+
try:
|
|
259
|
+
runner(args, check=True)
|
|
260
|
+
except PermissionError as exc:
|
|
261
|
+
raise_command_error("command", args[0], exc)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def visible_windows() -> list[tuple[int, int, str]]:
|
|
265
|
+
if platform.system() != "Windows":
|
|
266
|
+
return []
|
|
267
|
+
|
|
268
|
+
import ctypes
|
|
269
|
+
from ctypes import wintypes
|
|
270
|
+
|
|
271
|
+
user32 = ctypes.windll.user32
|
|
272
|
+
rows = []
|
|
273
|
+
callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
|
|
274
|
+
|
|
275
|
+
def callback(hwnd, _lparam):
|
|
276
|
+
if not user32.IsWindowVisible(hwnd):
|
|
277
|
+
return True
|
|
278
|
+
|
|
279
|
+
length = user32.GetWindowTextLengthW(hwnd)
|
|
280
|
+
if not length:
|
|
281
|
+
return True
|
|
282
|
+
|
|
283
|
+
title = ctypes.create_unicode_buffer(length + 1)
|
|
284
|
+
user32.GetWindowTextW(hwnd, title, length + 1)
|
|
285
|
+
pid = wintypes.DWORD()
|
|
286
|
+
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
|
287
|
+
rows.append((int(hwnd), int(pid.value), title.value))
|
|
288
|
+
return True
|
|
289
|
+
|
|
290
|
+
user32.EnumWindows(callback_type(callback), 0)
|
|
291
|
+
return rows
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def focus_window(query: str) -> tuple[int, str]:
|
|
295
|
+
import ctypes
|
|
296
|
+
from ctypes import wintypes
|
|
297
|
+
|
|
298
|
+
matches = [row for row in visible_windows() if query.lower() in row[2].lower()]
|
|
299
|
+
if not matches:
|
|
300
|
+
raise RuntimeError(f"window not found: {query}")
|
|
301
|
+
|
|
302
|
+
hwnd = wintypes.HWND(matches[0][0])
|
|
303
|
+
user32 = ctypes.windll.user32
|
|
304
|
+
|
|
305
|
+
# ponytail: title targeting is the cheap reliable-enough path; app APIs if titles collide.
|
|
306
|
+
user32.ShowWindow(hwnd, 9)
|
|
307
|
+
user32.keybd_event(0x12, 0, 0, 0)
|
|
308
|
+
user32.keybd_event(0x12, 0, 2, 0)
|
|
309
|
+
if not user32.SetForegroundWindow(hwnd):
|
|
310
|
+
raise RuntimeError(f"could not focus window: {matches[0][2]}")
|
|
311
|
+
return matches[0][0], matches[0][2]
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def click_in_window(hwnd: int, click: str) -> None:
|
|
315
|
+
import ctypes
|
|
316
|
+
from ctypes import wintypes
|
|
317
|
+
|
|
318
|
+
rect = wintypes.RECT()
|
|
319
|
+
user32 = ctypes.windll.user32
|
|
320
|
+
if not user32.GetWindowRect(wintypes.HWND(hwnd), ctypes.byref(rect)):
|
|
321
|
+
raise RuntimeError("could not read window bounds")
|
|
322
|
+
|
|
323
|
+
width = rect.right - rect.left
|
|
324
|
+
height = rect.bottom - rect.top
|
|
325
|
+
if click == "bottom":
|
|
326
|
+
x = rect.left + width // 2
|
|
327
|
+
y = rect.top + int(height * 0.88)
|
|
328
|
+
else:
|
|
329
|
+
raw_x, raw_y = click.split(",", 1)
|
|
330
|
+
x = rect.left + int(raw_x)
|
|
331
|
+
y = rect.top + int(raw_y)
|
|
332
|
+
|
|
333
|
+
user32.SetCursorPos(x, y)
|
|
334
|
+
user32.mouse_event(0x0002, 0, 0, 0, 0)
|
|
335
|
+
user32.mouse_event(0x0004, 0, 0, 0, 0)
|
|
336
|
+
time.sleep(0.15)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def send_keys(keys: str) -> None:
|
|
340
|
+
if platform.system() != "Windows":
|
|
341
|
+
raise RuntimeError("window paste/submit is Windows-only")
|
|
342
|
+
|
|
343
|
+
script = f"$w=New-Object -ComObject WScript.Shell; $w.SendKeys({json.dumps(keys)});"
|
|
344
|
+
subprocess.run(
|
|
345
|
+
["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script],
|
|
346
|
+
check=True,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def paste_into_active_window(
|
|
351
|
+
submit: bool,
|
|
352
|
+
window: str | None = None,
|
|
353
|
+
click: str | None = None,
|
|
354
|
+
pre_keys: str | None = None,
|
|
355
|
+
) -> None:
|
|
356
|
+
hwnd = None
|
|
357
|
+
if window:
|
|
358
|
+
hwnd, _title = focus_window(window)
|
|
359
|
+
if click:
|
|
360
|
+
if hwnd is None:
|
|
361
|
+
raise RuntimeError("--click needs --window or a target with a window hint")
|
|
362
|
+
click_in_window(hwnd, click)
|
|
363
|
+
if pre_keys:
|
|
364
|
+
send_keys(pre_keys)
|
|
365
|
+
|
|
366
|
+
send_keys("^v")
|
|
367
|
+
if submit:
|
|
368
|
+
time.sleep(0.15)
|
|
369
|
+
send_keys("{ENTER}")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def send_job(job: dict) -> None:
|
|
373
|
+
template = job.get("command_template") or COMMAND_TARGETS.get(job["target"])
|
|
374
|
+
if template:
|
|
375
|
+
run_command_template(template, job["prompt"])
|
|
376
|
+
return
|
|
377
|
+
|
|
378
|
+
copy_clipboard(job["prompt"])
|
|
379
|
+
launch_target(job["target"])
|
|
380
|
+
|
|
381
|
+
target = job["target"].lower()
|
|
382
|
+
window = job.get("window") or WINDOW_HINTS.get(target)
|
|
383
|
+
click = job.get("click") or CLICK_HINTS.get(target)
|
|
384
|
+
if window or click or job.get("pre_keys"):
|
|
385
|
+
time.sleep(float(job.get("delay", 5)))
|
|
386
|
+
paste_into_active_window(
|
|
387
|
+
job.get("submit", True),
|
|
388
|
+
window,
|
|
389
|
+
click,
|
|
390
|
+
job.get("pre_keys"),
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def run_due(
|
|
395
|
+
path: Path = QUEUE_FILE,
|
|
396
|
+
now: datetime | None = None,
|
|
397
|
+
send=send_job,
|
|
398
|
+
) -> int:
|
|
399
|
+
now = now or datetime.now()
|
|
400
|
+
queue = load_queue(path)
|
|
401
|
+
sent = 0
|
|
402
|
+
changed = False
|
|
403
|
+
|
|
404
|
+
for job in queue["jobs"]:
|
|
405
|
+
if not job_ready(job, now=now):
|
|
406
|
+
continue
|
|
407
|
+
|
|
408
|
+
try:
|
|
409
|
+
send(job)
|
|
410
|
+
except Exception as exc:
|
|
411
|
+
mark_failed(job, str(exc), now)
|
|
412
|
+
changed = True
|
|
413
|
+
print(f"failed {job['id']} -> {job['target']}: {exc}")
|
|
414
|
+
else:
|
|
415
|
+
mark_sent(job, now)
|
|
416
|
+
sent += 1
|
|
417
|
+
changed = True
|
|
418
|
+
print(f"sent {job['id']} -> {job['target']}")
|
|
419
|
+
|
|
420
|
+
if changed:
|
|
421
|
+
save_queue(queue, path)
|
|
422
|
+
return sent
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def read_prompt(args: argparse.Namespace) -> str:
|
|
426
|
+
parts = []
|
|
427
|
+
if args.file:
|
|
428
|
+
parts.append(Path(args.file).read_text(encoding="utf-8-sig"))
|
|
429
|
+
if args.stdin:
|
|
430
|
+
parts.append(sys.stdin.read())
|
|
431
|
+
if args.prompt:
|
|
432
|
+
parts.append(" ".join(args.prompt))
|
|
433
|
+
return "\n".join(part.strip("\n") for part in parts if part.strip()).strip()
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def add_job(args: argparse.Namespace) -> None:
|
|
437
|
+
prompt = read_prompt(args)
|
|
438
|
+
if not prompt:
|
|
439
|
+
raise SystemExit("prompt required")
|
|
440
|
+
|
|
441
|
+
queue = load_queue()
|
|
442
|
+
job = {
|
|
443
|
+
"id": uuid.uuid4().hex[:8],
|
|
444
|
+
"at": parse_when(args.when).isoformat(timespec="seconds"),
|
|
445
|
+
"target": args.target,
|
|
446
|
+
"prompt": prompt,
|
|
447
|
+
"delay": args.delay,
|
|
448
|
+
"submit": not args.no_submit,
|
|
449
|
+
"attempts": 0,
|
|
450
|
+
"max_attempts": args.max_attempts,
|
|
451
|
+
"retry_base": args.retry_base,
|
|
452
|
+
"window": args.window,
|
|
453
|
+
"click": args.click,
|
|
454
|
+
"pre_keys": args.pre_keys,
|
|
455
|
+
"command_template": args.command_template,
|
|
456
|
+
"created_at": datetime.now().isoformat(timespec="seconds"),
|
|
457
|
+
}
|
|
458
|
+
queue["jobs"].append(job)
|
|
459
|
+
save_queue(queue)
|
|
460
|
+
print(f"queued {job['id']} for {job['at']} -> {job['target']}")
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def list_jobs(args: argparse.Namespace) -> None:
|
|
464
|
+
jobs = load_queue()["jobs"]
|
|
465
|
+
rows = []
|
|
466
|
+
for job in sorted(jobs, key=lambda item: item["at"]):
|
|
467
|
+
if job.get("sent_at") and not args.all:
|
|
468
|
+
continue
|
|
469
|
+
status = job_status(job)
|
|
470
|
+
preview = job["prompt"].replace("\n", " ")[:70]
|
|
471
|
+
attempts = f" attempts={job.get('attempts', 0)}/{job.get('max_attempts', 5)}"
|
|
472
|
+
next_try = f" next={job['next_attempt_at']}" if job.get("next_attempt_at") else ""
|
|
473
|
+
error = f" error={job['last_error']}" if job.get("last_error") else ""
|
|
474
|
+
rows.append(f"{job['id']} {job['at']} {status:6} {job['target']} {preview}{attempts}{next_try}{error}")
|
|
475
|
+
if args.full:
|
|
476
|
+
rows.append(job["prompt"])
|
|
477
|
+
|
|
478
|
+
echo("\n".join(rows) if rows else "queue empty")
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def show_job(args: argparse.Namespace) -> None:
|
|
482
|
+
for job in load_queue()["jobs"]:
|
|
483
|
+
if job["id"] == args.id:
|
|
484
|
+
echo(json.dumps(job, indent=2))
|
|
485
|
+
return
|
|
486
|
+
raise SystemExit("not found")
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def remove_job(args: argparse.Namespace) -> None:
|
|
490
|
+
queue = load_queue()
|
|
491
|
+
before = len(queue["jobs"])
|
|
492
|
+
queue["jobs"] = [job for job in queue["jobs"] if job["id"] != args.id]
|
|
493
|
+
save_queue(queue)
|
|
494
|
+
print("removed" if len(queue["jobs"]) != before else "not found")
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def list_windows(_args: argparse.Namespace) -> None:
|
|
498
|
+
rows = [f"{hwnd} {pid} {title}" for hwnd, pid, title in visible_windows()]
|
|
499
|
+
echo("\n".join(rows) if rows else "no windows found")
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def list_targets(_args: argparse.Namespace) -> None:
|
|
503
|
+
rows = []
|
|
504
|
+
for name in sorted(set(TARGETS) | set(COMMAND_TARGETS)):
|
|
505
|
+
launch = COMMAND_TARGETS.get(name) or TARGETS.get(name)
|
|
506
|
+
window = WINDOW_HINTS.get(name, "")
|
|
507
|
+
click = CLICK_HINTS.get(name, "")
|
|
508
|
+
rows.append(f"{name:12} launch={launch} window={window} click={click} status={launch_status(launch)}")
|
|
509
|
+
echo("\n".join(rows))
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def run_loop(args: argparse.Namespace) -> None:
|
|
513
|
+
print(f"promptqueue running; queue file: {QUEUE_FILE}")
|
|
514
|
+
while True:
|
|
515
|
+
run_due()
|
|
516
|
+
if args.once:
|
|
517
|
+
return
|
|
518
|
+
time.sleep(args.poll)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def selftest() -> None:
|
|
522
|
+
now = datetime(2026, 1, 1, 10, 0)
|
|
523
|
+
assert parse_when("10:01", now) == datetime(2026, 1, 1, 10, 1)
|
|
524
|
+
assert parse_when("09:59", now) == datetime(2026, 1, 2, 9, 59)
|
|
525
|
+
assert parse_when("2026-01-02 03:04") == datetime(2026, 1, 2, 3, 4)
|
|
526
|
+
assert command_args("tool {prompt}", "hello world") == ["tool", "hello world"]
|
|
527
|
+
assert command_args("tool", "hi") == ["tool", "hi"]
|
|
528
|
+
assert launch_status("copy", which=lambda _name: None) == "internal"
|
|
529
|
+
assert launch_status("https://example.com/", which=lambda _name: None) == "url"
|
|
530
|
+
assert launch_status("app:Example.App!App", which=lambda _name: None) == "app"
|
|
531
|
+
assert launch_status("missing --flag", which=lambda _name: None) == "missing"
|
|
532
|
+
assert launch_status("tool --flag", which=lambda name: f"C:/bin/{name}") == "found"
|
|
533
|
+
assert command_issue(["missing"], which=lambda _name: None) == "command not found: missing"
|
|
534
|
+
assert command_issue(["tool"], which=lambda name: f"C:/bin/{name}") is None
|
|
535
|
+
protected_path = "C:/Program Files/WindowsApps/Vendor.App_1.0.0_x64__abc/app/resources/tool.exe"
|
|
536
|
+
assert protected_windowsapps_path(protected_path, system=lambda: "Windows")
|
|
537
|
+
assert command_issue(["tool"], which=lambda _name: protected_path, system=lambda: "Windows") == (
|
|
538
|
+
"command not runnable: tool (protected WindowsApps package)"
|
|
539
|
+
)
|
|
540
|
+
assert launch_status("tool --flag", which=lambda _name: protected_path, system=lambda: "Windows") == "blocked"
|
|
541
|
+
try:
|
|
542
|
+
run_command_template(
|
|
543
|
+
"tool {prompt}",
|
|
544
|
+
"hi",
|
|
545
|
+
runner=lambda *_args, **_kwargs: (_ for _ in ()).throw(PermissionError("locked")),
|
|
546
|
+
which=lambda name: f"C:/bin/{name}",
|
|
547
|
+
)
|
|
548
|
+
except RuntimeError as exc:
|
|
549
|
+
assert "command not runnable: tool" in str(exc)
|
|
550
|
+
else:
|
|
551
|
+
raise AssertionError("PermissionError should become a useful RuntimeError")
|
|
552
|
+
try:
|
|
553
|
+
copy_clipboard(
|
|
554
|
+
"hi",
|
|
555
|
+
runner=lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
|
556
|
+
subprocess.CalledProcessError(1, ["clip"], stderr="denied")
|
|
557
|
+
),
|
|
558
|
+
fallback=lambda _text: (_ for _ in ()).throw(AssertionError("no Windows fallback")),
|
|
559
|
+
system=lambda: "Windows",
|
|
560
|
+
)
|
|
561
|
+
except RuntimeError as exc:
|
|
562
|
+
assert "clipboard unavailable" in str(exc)
|
|
563
|
+
else:
|
|
564
|
+
raise AssertionError("failed Windows clipboard writes should not be marked sent")
|
|
565
|
+
fallback_seen = []
|
|
566
|
+
copy_clipboard("hi", fallback=fallback_seen.append, system=lambda: "Linux")
|
|
567
|
+
assert fallback_seen == ["hi"]
|
|
568
|
+
|
|
569
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
570
|
+
path = Path(temp_dir) / "queue.json"
|
|
571
|
+
prompt_path = Path(temp_dir) / "prompt.txt"
|
|
572
|
+
prompt_path.write_text("from file", encoding="utf-8-sig")
|
|
573
|
+
prompt_args = argparse.Namespace(file=str(prompt_path), stdin=False, prompt=[])
|
|
574
|
+
assert read_prompt(prompt_args) == "from file"
|
|
575
|
+
|
|
576
|
+
save_queue(
|
|
577
|
+
{"jobs": [{"id": "abc", "at": "2026-01-01T09:00:00", "target": "copy", "prompt": "hi"}]},
|
|
578
|
+
path,
|
|
579
|
+
)
|
|
580
|
+
seen = []
|
|
581
|
+
assert run_due(path, now, send=lambda job: seen.append(job["prompt"])) == 1
|
|
582
|
+
assert seen == ["hi"]
|
|
583
|
+
assert load_queue(path)["jobs"][0]["sent_at"] == "2026-01-01T10:00:00"
|
|
584
|
+
|
|
585
|
+
save_queue(
|
|
586
|
+
{"jobs": [{"id": "bad", "at": "2026-01-01T09:00:00", "target": "copy", "prompt": "hi"}]},
|
|
587
|
+
path,
|
|
588
|
+
)
|
|
589
|
+
assert run_due(path, now, send=lambda _job: (_ for _ in ()).throw(RuntimeError("boom"))) == 0
|
|
590
|
+
failed = load_queue(path)["jobs"][0]
|
|
591
|
+
assert failed["last_error"] == "boom"
|
|
592
|
+
assert "sent_at" not in failed
|
|
593
|
+
assert failed["attempts"] == 1
|
|
594
|
+
assert failed["next_attempt_at"]
|
|
595
|
+
assert failed["history"][-1]["event"] == "failed"
|
|
596
|
+
|
|
597
|
+
events = []
|
|
598
|
+
originals = copy_clipboard, launch_target, paste_into_active_window
|
|
599
|
+
try:
|
|
600
|
+
globals()["copy_clipboard"] = lambda text: events.append(("copy", text))
|
|
601
|
+
globals()["launch_target"] = lambda target: events.append(("launch", target))
|
|
602
|
+
globals()["paste_into_active_window"] = (
|
|
603
|
+
lambda submit, window=None, click=None, pre_keys=None: events.append(
|
|
604
|
+
("paste", submit, window, click, pre_keys)
|
|
605
|
+
)
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
send_job({"target": "claude", "prompt": "hi", "delay": 0})
|
|
609
|
+
assert events == [("copy", "hi"), ("launch", "claude"), ("paste", True, "Claude", "bottom", None)]
|
|
610
|
+
|
|
611
|
+
events.clear()
|
|
612
|
+
send_job({"target": "copy", "prompt": "hi", "delay": 0})
|
|
613
|
+
assert events == [("copy", "hi"), ("launch", "copy")]
|
|
614
|
+
|
|
615
|
+
events.clear()
|
|
616
|
+
send_job({"target": "copy", "prompt": "hi", "delay": 0, "window": "Codex", "submit": False})
|
|
617
|
+
assert events == [("copy", "hi"), ("launch", "copy"), ("paste", False, "Codex", None, None)]
|
|
618
|
+
finally:
|
|
619
|
+
globals()["copy_clipboard"], globals()["launch_target"], globals()["paste_into_active_window"] = originals
|
|
620
|
+
|
|
621
|
+
print("selftest ok")
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
625
|
+
parser = argparse.ArgumentParser(
|
|
626
|
+
description="Dependency-free prompt scheduler. Run `targets` to see built-in aliases."
|
|
627
|
+
)
|
|
628
|
+
sub = parser.add_subparsers(required=True)
|
|
629
|
+
|
|
630
|
+
add = sub.add_parser("add")
|
|
631
|
+
add.add_argument("--click", help='Click before paste: "bottom" or "x,y" pixels from the target window top-left.')
|
|
632
|
+
add.add_argument("--command-template", help='Run a command instead of UI paste. Use {prompt}, e.g. "claude -p {prompt}".')
|
|
633
|
+
add.add_argument("--delay", type=float, default=5, help="Seconds to wait before paste/submit.")
|
|
634
|
+
add.add_argument("--file", help="Read the prompt from a UTF-8 text file.")
|
|
635
|
+
add.add_argument("--max-attempts", type=int, default=5, help="Attempts before marking the job failed. Default: 5.")
|
|
636
|
+
add.add_argument("--no-submit", action="store_true", help="Paste only; do not press Enter.")
|
|
637
|
+
add.add_argument("--pre-keys", help='Windows SendKeys string to send before paste, e.g. "{ESC}".')
|
|
638
|
+
add.add_argument("--retry-base", type=float, default=30, help="Initial retry delay in seconds. Default: 30.")
|
|
639
|
+
add.add_argument("--stdin", action="store_true", help="Read the prompt from stdin.")
|
|
640
|
+
add.add_argument("--window", help='Window title substring to focus before paste, like "Codex" or "Claude".')
|
|
641
|
+
add.add_argument("when", help='Local time, like "23:30" or "2026-06-24 01:15".')
|
|
642
|
+
add.add_argument("target", help="copy, claude, chatgpt, copilot, gemini, cursor, codex, URL, or command.")
|
|
643
|
+
add.add_argument("prompt", nargs=argparse.REMAINDER)
|
|
644
|
+
add.set_defaults(func=add_job)
|
|
645
|
+
|
|
646
|
+
show = sub.add_parser("list")
|
|
647
|
+
show.add_argument("--all", action="store_true")
|
|
648
|
+
show.add_argument("--full", action="store_true")
|
|
649
|
+
show.set_defaults(func=list_jobs)
|
|
650
|
+
|
|
651
|
+
show_one = sub.add_parser("show")
|
|
652
|
+
show_one.add_argument("id")
|
|
653
|
+
show_one.set_defaults(func=show_job)
|
|
654
|
+
|
|
655
|
+
remove = sub.add_parser("remove")
|
|
656
|
+
remove.add_argument("id")
|
|
657
|
+
remove.set_defaults(func=remove_job)
|
|
658
|
+
|
|
659
|
+
windows = sub.add_parser("windows")
|
|
660
|
+
windows.set_defaults(func=list_windows)
|
|
661
|
+
|
|
662
|
+
targets = sub.add_parser("targets")
|
|
663
|
+
targets.set_defaults(func=list_targets)
|
|
664
|
+
|
|
665
|
+
run = sub.add_parser("run")
|
|
666
|
+
run.add_argument("--once", action="store_true")
|
|
667
|
+
run.add_argument("--poll", type=float, default=15)
|
|
668
|
+
run.set_defaults(func=run_loop)
|
|
669
|
+
|
|
670
|
+
check = sub.add_parser("selftest")
|
|
671
|
+
check.set_defaults(func=lambda _args: selftest())
|
|
672
|
+
|
|
673
|
+
return parser
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def main(argv: list[str] | None = None) -> None:
|
|
677
|
+
args = build_parser().parse_args(argv)
|
|
678
|
+
args.func(args)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
if __name__ == "__main__":
|
|
682
|
+
main(sys.argv[1:])
|