codewake 0.0.1
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.
- package/LICENSE +21 -0
- package/README.md +159 -0
- package/bin/codewake.js +7 -0
- package/lib/agents.js +119 -0
- package/lib/cli.js +245 -0
- package/lib/jobs.js +327 -0
- package/lib/proc.js +37 -0
- package/lib/registry.js +151 -0
- package/lib/sessions.js +156 -0
- package/lib/time.js +88 -0
- package/lib/tui.js +187 -0
- package/lib/wizard.js +264 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Luis Obz
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# codewake
|
|
2
|
+
|
|
3
|
+
**Schedule your coding agents to pick up where they left off.**
|
|
4
|
+
|
|
5
|
+
`codewake` is an interactive CLI that detects the coding agents installed on your machine — Claude Code, OpenAI Codex CLI, GitHub Copilot CLI, Gemini CLI, Cursor CLI, OpenCode, Aider, Goose, Amp, Qwen Code — lets you pick a session (or start a new one), choose a prompt, and launches the agent at the time you choose. Hit a usage limit at 2 AM? Schedule the resume for when it resets and go to sleep.
|
|
6
|
+
|
|
7
|
+
- **Interactive console** — arrow keys, sensible defaults, a summary before anything is scheduled.
|
|
8
|
+
- **Agent auto-detection** — scans your `PATH` for every supported agent; extend or override the list with a small JSON config.
|
|
9
|
+
- **Session discovery** — lists your existing Claude Code and Codex sessions (with titles and timestamps) for the current project; other agents accept a session id or start fresh.
|
|
10
|
+
- **Job management** — list, inspect logs, cancel, and clean up scheduled jobs from the console or with subcommands.
|
|
11
|
+
- **Suspend-safe scheduling** — timers track the wall clock, so a laptop that sleeps through the launch time fires the job right after waking instead of drifting.
|
|
12
|
+
- **Cross-platform** — Linux (systemd user timers), macOS, and Windows (detached waiter, `.cmd`-shim aware); CI runs on all three.
|
|
13
|
+
- **Zero dependencies** — plain Node.js ≥ 18. Prompts are never passed through a shell, so no quoting surprises.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install --global codewake
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or from a checkout of this repository:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm link
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
codewake
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The console walks you through it:
|
|
34
|
+
|
|
35
|
+
1. **Pick an agent** — only agents actually found on your `PATH` are offered.
|
|
36
|
+
2. **Pick a session** — existing sessions for the current project, enter a session id manually, or start a new session.
|
|
37
|
+
3. **Prompt** — optional when resuming (defaults to `continue`), required for new sessions.
|
|
38
|
+
4. **Permissions** — choose whether the agent runs unattended (its "skip permissions" / "yolo" flag) or interactively.
|
|
39
|
+
5. **When** — right now, after a delay (`2h 30m`), or at a clock time (`06:00`).
|
|
40
|
+
6. Review the exact command in the summary and confirm.
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
✔ Which agent do you want to launch? Claude Code
|
|
44
|
+
✔ Which session do you want to resume? Ship the new API
|
|
45
|
+
✔ Prompt to send (continue)
|
|
46
|
+
✔ Skip permission prompts? [y/N] y
|
|
47
|
+
✔ When should it launch? After a delay
|
|
48
|
+
✔ Delay 2h 30m
|
|
49
|
+
|
|
50
|
+
Summary
|
|
51
|
+
Agent Claude Code
|
|
52
|
+
Session 550e8400-e29b-41d4-a716-446655440000
|
|
53
|
+
Prompt continue
|
|
54
|
+
Permissions skipped (unattended)
|
|
55
|
+
Launch in 2h 30m (Jul 16, 04:10 PM)
|
|
56
|
+
Project ~/work/my-app
|
|
57
|
+
Command claude --resume 550e8400-… -p continue --dangerously-skip-permissions
|
|
58
|
+
|
|
59
|
+
✔ Job k2f9qz-ab12 scheduled in 2h 30m.
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Managing jobs
|
|
63
|
+
|
|
64
|
+
From the console (`codewake`) or directly:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
codewake jobs # list scheduled and finished jobs
|
|
68
|
+
codewake logs <job-id> # output captured from the agent run
|
|
69
|
+
codewake cancel <job-id> # cancel a pending job
|
|
70
|
+
codewake clear # drop finished/cancelled jobs from the list
|
|
71
|
+
codewake agents # which agents were detected, and where
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Non-interactive scheduling
|
|
75
|
+
|
|
76
|
+
Everything the console does is available as flags — handy for scripts:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
codewake schedule --agent claude --session 550e8400 --in 2h30m
|
|
80
|
+
codewake schedule --agent codex --new --prompt "run the test suite and fix failures" --at 06:00 --skip-permissions
|
|
81
|
+
codewake schedule --agent claude --session refactor-api --now --dry-run
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`--dry-run` prints the exact command without scheduling anything.
|
|
85
|
+
|
|
86
|
+
## Supported agents
|
|
87
|
+
|
|
88
|
+
| Agent | Binary | Resume | New session | Session discovery |
|
|
89
|
+
| --- | --- | --- | --- | --- |
|
|
90
|
+
| Claude Code | `claude` | ✔ | ✔ | `~/.claude/projects` (per project, with titles) |
|
|
91
|
+
| OpenAI Codex CLI | `codex` | ✔ | ✔ | `~/.codex/sessions` rollouts (filtered by cwd) |
|
|
92
|
+
| GitHub Copilot CLI | `copilot` | ✔ | ✔ | `~/.copilot/history-session-state` |
|
|
93
|
+
| Cursor CLI | `cursor-agent` | ✔ | ✔ | manual session id |
|
|
94
|
+
| OpenCode | `opencode` | ✔ | ✔ | manual session id |
|
|
95
|
+
| Aider | `aider` | ✔ (chat history) | ✔ | `.aider.chat.history.md` in the project |
|
|
96
|
+
| Goose | `goose` | ✔ | ✔ | `~/.local/share/goose/sessions` |
|
|
97
|
+
| Amp | `amp` | ✔ | ✔ | manual session id |
|
|
98
|
+
| Gemini CLI | `gemini` | – | ✔ | – |
|
|
99
|
+
| Qwen Code | `qwen` | – | ✔ | – |
|
|
100
|
+
|
|
101
|
+
Agent CLIs move fast. If a flag changes upstream, or you use an agent we don't know about, you don't have to wait for a release — see the next section.
|
|
102
|
+
|
|
103
|
+
## Custom agents
|
|
104
|
+
|
|
105
|
+
Add or override agents in `~/.config/codewake/config.json` (or point `CODEWAKE_CONFIG` somewhere else). Entries have the same shape as the built-in registry (`lib/registry.js`):
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"agents": [
|
|
110
|
+
{
|
|
111
|
+
"id": "mycli",
|
|
112
|
+
"name": "My CLI",
|
|
113
|
+
"bins": ["mycli"],
|
|
114
|
+
"resume": { "args": ["--continue", "{session}", "--ask", "{prompt}"], "skip": ["--yes"] },
|
|
115
|
+
"create": { "args": ["--ask", "{prompt}"], "skip": ["--yes"] }
|
|
116
|
+
},
|
|
117
|
+
{ "id": "claude", "bins": ["claude-nightly"] }
|
|
118
|
+
],
|
|
119
|
+
"disabled": ["amp"]
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`{session}` and `{prompt}` are substituted as whole argv elements — commands are spawned directly, never through a shell. `CODEWAKE_BIN_<ID>` (e.g. `CODEWAKE_BIN_CLAUDE=/opt/claude`) overrides detection for a single agent.
|
|
124
|
+
|
|
125
|
+
## How scheduling works
|
|
126
|
+
|
|
127
|
+
- On **Linux with systemd**, jobs are armed with a transient user timer (`systemd-run --user --on-calendar=…`). Wall-clock timers keep ticking through laptop suspend and fire on resume if their time passed while the lid was closed. They survive the terminal closing; if your machine logs you out completely, enable lingering once: `loginctl enable-linger "$USER"`.
|
|
128
|
+
- On **macOS, Windows, and Linux without systemd**, a small detached Node process waits for the launch time. It re-checks the wall clock every minute rather than sleeping once, so a machine that suspends overnight launches the job right after waking instead of drifting by the suspended time. It survives the terminal closing, but not a reboot.
|
|
129
|
+
|
|
130
|
+
A session can only have **one pending job** at a time — scheduling a second resume for the same session is refused until you cancel the first, so two agents never race each other over one transcript. Jobs whose launch time passed without running (e.g. after a reboot) show up as `overdue` in `codewake jobs`, with how late they are — reschedule them.
|
|
131
|
+
|
|
132
|
+
### Where state and logs live
|
|
133
|
+
|
|
134
|
+
| OS | Job state | Execution logs |
|
|
135
|
+
| --- | --- | --- |
|
|
136
|
+
| Linux | `~/.local/state/codewake/` | `~/.local/state/codewake/logs/` |
|
|
137
|
+
| macOS | `~/Library/Application Support/codewake/` | `~/Library/Logs/codewake/` |
|
|
138
|
+
| Windows | `%LOCALAPPDATA%\codewake\` | `%LOCALAPPDATA%\codewake\Logs\` |
|
|
139
|
+
|
|
140
|
+
Every job run captures the agent's stdout/stderr to `<job-id>.log`, readable with `codewake logs <job-id>`. `CODEWAKE_STATE_DIR` overrides the state location (logs move under `<dir>/logs`). On Windows, npm-installed agents (`.cmd` shims) are launched through `cmd.exe` with every argument escaped — never an interpolated shell string.
|
|
141
|
+
|
|
142
|
+
## A word about unattended runs
|
|
143
|
+
|
|
144
|
+
Choosing *skip permissions* passes the agent's own bypass flag (for example `--dangerously-skip-permissions` for Claude Code). The agent will edit files and run commands without asking. Only schedule projects you trust, review the prompt before confirming, and keep your work under version control. `codewake` always shows the exact command in the summary and defaults to **not** skipping permissions.
|
|
145
|
+
|
|
146
|
+
## Development
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
npm test # zero-dependency test suite (node --test)
|
|
150
|
+
node bin/codewake.js # run the console from the checkout
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The test suite covers argument parsing, duration/time parsing, the agent registry, command building, session discovery (with fixtures), the job store and both scheduling backends, and the interactive console end to end (driven through fake terminals).
|
|
154
|
+
|
|
155
|
+
## License
|
|
156
|
+
|
|
157
|
+
MIT
|
|
158
|
+
|
|
159
|
+
This is a new line for testing purposes.
|
package/bin/codewake.js
ADDED
package/lib/agents.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { accessSync, constants, existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { delimiter, isAbsolute, join, resolve } from 'node:path';
|
|
4
|
+
import { BUILTIN_AGENTS } from './registry.js';
|
|
5
|
+
|
|
6
|
+
export function configPath(env = process.env) {
|
|
7
|
+
if (env.CODEWAKE_CONFIG) return resolve(env.CODEWAKE_CONFIG);
|
|
8
|
+
const configHome = env.XDG_CONFIG_HOME || resolve(homedir(), '.config');
|
|
9
|
+
return resolve(configHome, 'codewake', 'config.json');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Built-in registry merged with the user's config file. The config may
|
|
14
|
+
* override built-ins by id, add brand new agents, and disable ids:
|
|
15
|
+
* { "agents": [{ "id": "mycli", ... }], "disabled": ["amp"] }
|
|
16
|
+
*/
|
|
17
|
+
export function loadAgents({ env = process.env } = {}) {
|
|
18
|
+
const agents = new Map(BUILTIN_AGENTS.map((agent) => [agent.id, agent]));
|
|
19
|
+
const path = configPath(env);
|
|
20
|
+
if (!existsSync(path)) return [...agents.values()];
|
|
21
|
+
let config;
|
|
22
|
+
try {
|
|
23
|
+
config = JSON.parse(readFileSync(path, 'utf8'));
|
|
24
|
+
} catch (error) {
|
|
25
|
+
throw new Error(`Could not parse ${path}: ${error.message}`);
|
|
26
|
+
}
|
|
27
|
+
for (const entry of config.agents ?? []) {
|
|
28
|
+
if (!entry || typeof entry.id !== 'string' || !entry.id) {
|
|
29
|
+
throw new Error(`Every agent in ${path} needs a string "id".`);
|
|
30
|
+
}
|
|
31
|
+
const merged = { ...agents.get(entry.id), ...entry };
|
|
32
|
+
validateAgent(merged, path);
|
|
33
|
+
agents.set(merged.id, merged);
|
|
34
|
+
}
|
|
35
|
+
for (const id of config.disabled ?? []) agents.delete(id);
|
|
36
|
+
return [...agents.values()];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validateAgent(agent, source) {
|
|
40
|
+
const problem = (message) => new Error(`Invalid agent "${agent.id}" in ${source}: ${message}`);
|
|
41
|
+
if (!agent.name || typeof agent.name !== 'string') throw problem('missing "name".');
|
|
42
|
+
if (!Array.isArray(agent.bins) || !agent.bins.length) throw problem('"bins" must be a non-empty array.');
|
|
43
|
+
if (!agent.resume && !agent.create) throw problem('needs a "resume" or "create" command spec.');
|
|
44
|
+
for (const key of ['resume', 'create']) {
|
|
45
|
+
const spec = agent[key];
|
|
46
|
+
if (spec && (!Array.isArray(spec.args) || !spec.args.length)) {
|
|
47
|
+
throw problem(`"${key}.args" must be a non-empty array.`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Resolve a command on PATH without spawning a shell. */
|
|
53
|
+
export function which(command, env = process.env) {
|
|
54
|
+
const tryPath = (candidate) => {
|
|
55
|
+
try {
|
|
56
|
+
accessSync(candidate, constants.X_OK);
|
|
57
|
+
return statSync(candidate).isFile() ? candidate : null;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
if (command.includes('/') || command.includes('\\')) {
|
|
63
|
+
return tryPath(isAbsolute(command) ? command : resolve(command));
|
|
64
|
+
}
|
|
65
|
+
const extensions = process.platform === 'win32'
|
|
66
|
+
? (env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';').map((ext) => ext.toLowerCase())
|
|
67
|
+
: [''];
|
|
68
|
+
for (const dir of (env.PATH || '').split(delimiter)) {
|
|
69
|
+
if (!dir) continue;
|
|
70
|
+
for (const ext of extensions) {
|
|
71
|
+
const found = tryPath(join(dir, command + ext));
|
|
72
|
+
if (found) return found;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Probe which agents are installed. Returns [{ agent, bin }] for every
|
|
80
|
+
* registered agent; `bin` is the resolved executable path or null.
|
|
81
|
+
* CODEWAKE_BIN_<ID> overrides detection for a single agent.
|
|
82
|
+
*/
|
|
83
|
+
export function detectAgents(agents, { env = process.env } = {}) {
|
|
84
|
+
return agents.map((agent) => {
|
|
85
|
+
const override = env[`CODEWAKE_BIN_${agent.id.toUpperCase().replaceAll('-', '_')}`];
|
|
86
|
+
const bin = override
|
|
87
|
+
? which(override, env)
|
|
88
|
+
: agent.bins.map((candidate) => which(candidate, env)).find(Boolean) ?? null;
|
|
89
|
+
return { agent, bin };
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Build the argv for a job. Placeholders are substituted inside argv
|
|
95
|
+
* elements — the command is spawned directly, never through a shell.
|
|
96
|
+
*/
|
|
97
|
+
export function buildCommand(agent, { mode, sessionId, prompt, skipPermissions = false }) {
|
|
98
|
+
const spec = mode === 'resume' ? agent.resume : agent.create;
|
|
99
|
+
if (!spec) {
|
|
100
|
+
const action = mode === 'resume' ? 'resuming sessions' : 'starting new sessions';
|
|
101
|
+
throw new Error(`${agent.name} does not support ${action} from codewake.`);
|
|
102
|
+
}
|
|
103
|
+
// Single-pass substitution: substituted values are never re-scanned, so a
|
|
104
|
+
// session id containing the literal text "{prompt}" (or vice versa) cannot
|
|
105
|
+
// be re-expanded.
|
|
106
|
+
const values = { session: sessionId ?? '', prompt: prompt ?? '' };
|
|
107
|
+
const args = spec.args.map((part) =>
|
|
108
|
+
part.replace(/\{(session|prompt)\}/g, (_match, token) => values[token]),
|
|
109
|
+
);
|
|
110
|
+
if (skipPermissions && spec.skip) args.push(...spec.skip);
|
|
111
|
+
return args;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Shell-quoted preview of a command, for display only. */
|
|
115
|
+
export function commandPreview(bin, args) {
|
|
116
|
+
return [bin, ...args]
|
|
117
|
+
.map((part) => (/[^\w@%+=:,./-]/.test(part) || part === '' ? `'${part.replaceAll("'", "'\\''")}'` : part))
|
|
118
|
+
.join(' ');
|
|
119
|
+
}
|
package/lib/cli.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { buildCommand, commandPreview, detectAgents, loadAgents } from './agents.js';
|
|
5
|
+
import {
|
|
6
|
+
cancelJob,
|
|
7
|
+
clearJobs,
|
|
8
|
+
listJobs,
|
|
9
|
+
logFile,
|
|
10
|
+
runJob,
|
|
11
|
+
scheduleJob,
|
|
12
|
+
waitJob,
|
|
13
|
+
} from './jobs.js';
|
|
14
|
+
import { describeRunAt, parseClockTime, parseDuration } from './time.js';
|
|
15
|
+
import { color } from './tui.js';
|
|
16
|
+
import { runWizard, viewJobs } from './wizard.js';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_RESUME_PROMPT = 'continue';
|
|
19
|
+
const PACKAGE_JSON = fileURLToPath(new URL('../package.json', import.meta.url));
|
|
20
|
+
|
|
21
|
+
export function version() {
|
|
22
|
+
return JSON.parse(readFileSync(PACKAGE_JSON, 'utf8')).version;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function usage() {
|
|
26
|
+
return `${color.bold('codewake')} — schedule your coding agents to pick up where they left off
|
|
27
|
+
|
|
28
|
+
Usage:
|
|
29
|
+
codewake Interactive console (detect agents, pick a
|
|
30
|
+
session, schedule the resume job)
|
|
31
|
+
codewake jobs List scheduled and finished jobs
|
|
32
|
+
codewake logs <job-id> Show the output of a job
|
|
33
|
+
codewake cancel <job-id> Cancel a pending job
|
|
34
|
+
codewake clear Remove finished jobs from the list
|
|
35
|
+
codewake agents Show which agents are installed
|
|
36
|
+
codewake schedule [options] Non-interactive scheduling
|
|
37
|
+
|
|
38
|
+
Schedule options:
|
|
39
|
+
--agent <id> Agent to launch (see "codewake agents")
|
|
40
|
+
--session <id> Session to resume
|
|
41
|
+
--new Start a new session instead of resuming
|
|
42
|
+
--prompt <text> Prompt to send (resume default: "${DEFAULT_RESUME_PROMPT}";
|
|
43
|
+
required with --new)
|
|
44
|
+
--skip-permissions Let the agent run unattended (its "yolo" flag)
|
|
45
|
+
--project <dir> Working directory (default: current directory)
|
|
46
|
+
--in <duration> Launch after a delay, e.g. "2h 30m"
|
|
47
|
+
--at <HH:MM> Launch at a time (today, or tomorrow if past)
|
|
48
|
+
--now Launch immediately (default)
|
|
49
|
+
--bin <path> Override the detected executable
|
|
50
|
+
--dry-run Print what would run without scheduling
|
|
51
|
+
|
|
52
|
+
Examples:
|
|
53
|
+
codewake
|
|
54
|
+
codewake schedule --agent claude --session 550e8400 --in 2h30m
|
|
55
|
+
codewake schedule --agent codex --new --prompt "run the test suite" --at 06:00`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function parseArgs(argv) {
|
|
59
|
+
const [first, ...rest] = argv;
|
|
60
|
+
if (first === undefined) return { command: 'wizard' };
|
|
61
|
+
switch (first) {
|
|
62
|
+
case 'help':
|
|
63
|
+
case '--help':
|
|
64
|
+
case '-h':
|
|
65
|
+
return { command: 'help' };
|
|
66
|
+
case '--version':
|
|
67
|
+
case '-v':
|
|
68
|
+
return { command: 'version' };
|
|
69
|
+
case 'jobs':
|
|
70
|
+
case 'list':
|
|
71
|
+
return { command: 'jobs' };
|
|
72
|
+
case 'agents':
|
|
73
|
+
return { command: 'agents' };
|
|
74
|
+
case 'clear':
|
|
75
|
+
return { command: 'clear' };
|
|
76
|
+
case 'logs':
|
|
77
|
+
if (!rest[0]) throw new Error('Usage: codewake logs <job-id>');
|
|
78
|
+
return { command: 'logs', jobId: rest[0] };
|
|
79
|
+
case 'cancel':
|
|
80
|
+
if (!rest[0]) throw new Error('Usage: codewake cancel <job-id>');
|
|
81
|
+
return { command: 'cancel', jobId: rest[0] };
|
|
82
|
+
case 'schedule':
|
|
83
|
+
return parseScheduleArgs(rest);
|
|
84
|
+
case '--run':
|
|
85
|
+
return { command: 'run', jobId: rest[0] };
|
|
86
|
+
case '--wait':
|
|
87
|
+
return { command: 'wait', jobId: rest[0] };
|
|
88
|
+
default:
|
|
89
|
+
throw new Error(`Unknown command: ${first}. Run "codewake --help".`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parseScheduleArgs(argv) {
|
|
94
|
+
const result = {
|
|
95
|
+
command: 'schedule',
|
|
96
|
+
agentId: null,
|
|
97
|
+
sessionId: null,
|
|
98
|
+
isNew: false,
|
|
99
|
+
prompt: null,
|
|
100
|
+
skipPermissions: false,
|
|
101
|
+
project: process.cwd(),
|
|
102
|
+
when: { type: 'now' },
|
|
103
|
+
bin: null,
|
|
104
|
+
dryRun: false,
|
|
105
|
+
};
|
|
106
|
+
const value = (i, flag) => {
|
|
107
|
+
if (argv[i + 1] === undefined) throw new Error(`${flag} needs a value.`);
|
|
108
|
+
return argv[i + 1];
|
|
109
|
+
};
|
|
110
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
111
|
+
const flag = argv[i];
|
|
112
|
+
if (flag === '--agent') result.agentId = value(i, flag), i += 1;
|
|
113
|
+
else if (flag === '--session') result.sessionId = value(i, flag), i += 1;
|
|
114
|
+
else if (flag === '--new') result.isNew = true;
|
|
115
|
+
else if (flag === '--prompt') result.prompt = value(i, flag), i += 1;
|
|
116
|
+
else if (flag === '--skip-permissions') result.skipPermissions = true;
|
|
117
|
+
else if (flag === '--project') result.project = resolve(value(i, flag)), i += 1;
|
|
118
|
+
else if (flag === '--in') result.when = { type: 'in', value: value(i, flag) }, i += 1;
|
|
119
|
+
else if (flag === '--at') result.when = { type: 'at', value: value(i, flag) }, i += 1;
|
|
120
|
+
else if (flag === '--now') result.when = { type: 'now' };
|
|
121
|
+
else if (flag === '--bin') result.bin = value(i, flag), i += 1;
|
|
122
|
+
else if (flag === '--dry-run') result.dryRun = true;
|
|
123
|
+
else throw new Error(`Unknown option: ${flag}. Run "codewake --help".`);
|
|
124
|
+
}
|
|
125
|
+
if (!result.agentId) throw new Error('--agent is required. See "codewake agents" for ids.');
|
|
126
|
+
if (result.isNew && result.sessionId) throw new Error('Use either --session or --new, not both.');
|
|
127
|
+
if (!result.isNew && !result.sessionId) throw new Error('Pass --session <id> to resume, or --new to start fresh.');
|
|
128
|
+
if (result.isNew && !result.prompt?.trim()) throw new Error('--prompt is required when starting a new session.');
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function resolveRunAt(when, now = new Date()) {
|
|
133
|
+
if (when.type === 'in') return new Date(now.getTime() + parseDuration(when.value) * 1000);
|
|
134
|
+
if (when.type === 'at') return parseClockTime(when.value, now);
|
|
135
|
+
return now;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function scheduleFromArgs(args, env = process.env) {
|
|
139
|
+
const detected = detectAgents(loadAgents({ env }), { env });
|
|
140
|
+
const entry = detected.find((candidate) => candidate.agent.id === args.agentId);
|
|
141
|
+
if (!entry) {
|
|
142
|
+
const known = detected.map((candidate) => candidate.agent.id).join(', ');
|
|
143
|
+
throw new Error(`Unknown agent "${args.agentId}". Known agents: ${known}.`);
|
|
144
|
+
}
|
|
145
|
+
const bin = args.bin ?? entry.bin;
|
|
146
|
+
if (!bin) {
|
|
147
|
+
throw new Error(`${entry.agent.name} is not installed (looked for: ${entry.agent.bins.join(', ')}). Use --bin to point at it.`);
|
|
148
|
+
}
|
|
149
|
+
const mode = args.isNew ? 'create' : 'resume';
|
|
150
|
+
const prompt = args.prompt?.trim() || DEFAULT_RESUME_PROMPT;
|
|
151
|
+
const commandArgs = buildCommand(entry.agent, {
|
|
152
|
+
mode,
|
|
153
|
+
sessionId: args.sessionId,
|
|
154
|
+
prompt,
|
|
155
|
+
skipPermissions: args.skipPermissions,
|
|
156
|
+
});
|
|
157
|
+
const job = scheduleJob({
|
|
158
|
+
agent: entry.agent,
|
|
159
|
+
bin,
|
|
160
|
+
args: commandArgs,
|
|
161
|
+
mode,
|
|
162
|
+
sessionId: args.sessionId,
|
|
163
|
+
prompt,
|
|
164
|
+
skipPermissions: args.skipPermissions,
|
|
165
|
+
project: args.project,
|
|
166
|
+
runAt: resolveRunAt(args.when),
|
|
167
|
+
}, { dryRun: args.dryRun, env });
|
|
168
|
+
return { job, preview: commandPreview(bin, commandArgs) };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function renderAgentsReport(detected) {
|
|
172
|
+
const nameWidth = Math.max(...detected.map(({ agent }) => `${agent.name} (${agent.id})`.length));
|
|
173
|
+
return detected
|
|
174
|
+
.map(({ agent, bin }) => {
|
|
175
|
+
const mark = bin ? color.green('✔') : color.dim('✘');
|
|
176
|
+
const location = bin ? color.dim(bin) : color.dim('not found');
|
|
177
|
+
const caps = [agent.resume ? 'resume' : null, agent.create ? 'new' : null].filter(Boolean).join(' + ');
|
|
178
|
+
const name = `${agent.name} (${agent.id})`.padEnd(nameWidth);
|
|
179
|
+
return `${mark} ${name} ${caps.padEnd(12)} ${location}`;
|
|
180
|
+
})
|
|
181
|
+
.join('\n');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function main(argv) {
|
|
185
|
+
const args = parseArgs(argv);
|
|
186
|
+
switch (args.command) {
|
|
187
|
+
case 'help':
|
|
188
|
+
console.log(usage());
|
|
189
|
+
return;
|
|
190
|
+
case 'version':
|
|
191
|
+
console.log(version());
|
|
192
|
+
return;
|
|
193
|
+
case 'wizard':
|
|
194
|
+
if (!process.stdin.isTTY) {
|
|
195
|
+
throw new Error('The interactive console needs a terminal. Use "codewake schedule" with flags instead.');
|
|
196
|
+
}
|
|
197
|
+
await runWizard({ version: version() });
|
|
198
|
+
return;
|
|
199
|
+
case 'jobs':
|
|
200
|
+
viewJobs(process.stdout);
|
|
201
|
+
return;
|
|
202
|
+
case 'logs': {
|
|
203
|
+
const path = logFile(args.jobId);
|
|
204
|
+
if (!existsSync(path)) {
|
|
205
|
+
throw new Error(`No log for job ${args.jobId} yet (it appears at ${path} once the job runs).`);
|
|
206
|
+
}
|
|
207
|
+
process.stdout.write(readFileSync(path, 'utf8'));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
case 'cancel': {
|
|
211
|
+
const { warning } = cancelJob(args.jobId);
|
|
212
|
+
console.log(`${color.green('✔')} Cancelled ${args.jobId}.`);
|
|
213
|
+
if (warning) console.log(`${color.yellow('!')} ${warning}`);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
case 'clear': {
|
|
217
|
+
const removed = clearJobs();
|
|
218
|
+
console.log(removed.length ? `Removed ${removed.length} finished job(s).` : 'Nothing to clean up.');
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
case 'agents':
|
|
222
|
+
console.log(renderAgentsReport(detectAgents(loadAgents())));
|
|
223
|
+
return;
|
|
224
|
+
case 'run':
|
|
225
|
+
await runJob(args.jobId);
|
|
226
|
+
return;
|
|
227
|
+
case 'wait':
|
|
228
|
+
await waitJob(args.jobId);
|
|
229
|
+
return;
|
|
230
|
+
case 'schedule': {
|
|
231
|
+
const { job, preview } = scheduleFromArgs(args);
|
|
232
|
+
if (args.dryRun) {
|
|
233
|
+
console.log(`Would launch ${describeRunAt(job.runAt)} in ${job.project}:`);
|
|
234
|
+
console.log(` ${preview}`);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
console.log(`${color.green('✔')} Job ${color.bold(job.id)} scheduled ${describeRunAt(job.runAt)}.`);
|
|
238
|
+
console.log(color.dim(` ${preview}`));
|
|
239
|
+
console.log(color.dim(` Manage it with "codewake jobs" or "codewake cancel ${job.id}".`));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
default:
|
|
243
|
+
throw new Error(`Unhandled command: ${args.command}`);
|
|
244
|
+
}
|
|
245
|
+
}
|