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 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.
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../lib/cli.js';
3
+
4
+ main(process.argv.slice(2)).catch((error) => {
5
+ console.error(`codewake: ${error.message}`);
6
+ process.exitCode = 1;
7
+ });
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
+ }