@amenophis1er/foreman 0.1.6 → 0.1.7
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/bin/foreman.mjs +2 -1
- package/package.json +1 -1
- package/src/cli.ts +13 -0
- package/src/completion.test.ts +30 -0
- package/src/completion.ts +113 -0
- package/src/server.ts +21 -0
- package/ui/dist/assets/index-BPFc7O5V.js +67 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-btSPOnoZ.js +0 -67
package/bin/foreman.mjs
CHANGED
|
@@ -33,6 +33,7 @@ const USAGE = `foreman ${pkg.version}
|
|
|
33
33
|
|
|
34
34
|
foreman uninstall Remove the service and the background server; keeps ~/.foreman
|
|
35
35
|
foreman uninstall --purge --yes …and delete ~/.foreman (every run's history) too
|
|
36
|
+
foreman completion install Tab-completion for these commands (zsh, bash, fish); or "completion zsh" to print it
|
|
36
37
|
foreman --version | --help
|
|
37
38
|
|
|
38
39
|
Environment:
|
|
@@ -55,7 +56,7 @@ if (command === '--help' || command === '-h' || command === 'help') {
|
|
|
55
56
|
} else if (command === 'start') {
|
|
56
57
|
register();
|
|
57
58
|
await import(new URL('../src/server.ts', import.meta.url).href);
|
|
58
|
-
} else if (['doctor', 'open', 'service', 'up', 'down', 'stop', 'restart', 'status', 'logs', 'uninstall', 'update'].includes(command)) {
|
|
59
|
+
} else if (['doctor', 'open', 'service', 'up', 'down', 'stop', 'restart', 'status', 'logs', 'uninstall', 'update', 'completion'].includes(command)) {
|
|
59
60
|
register();
|
|
60
61
|
const { runCli } = await import(new URL('../src/cli.ts', import.meta.url).href);
|
|
61
62
|
process.exitCode = await runCli(command, rest, { version: pkg.version, bin: new URL(import.meta.url) });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amenophis1er/foreman",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Autonomous mission runner on the Claude Agent SDK: a director plans, delegates to workers, verifies, and reports — from one dashboard, your phone, or the CLI.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/src/cli.ts
CHANGED
|
@@ -493,6 +493,19 @@ export async function runCli(command: string, rest: string[], ctx: { version: st
|
|
|
493
493
|
case 'uninstall': return uninstall(rest);
|
|
494
494
|
case 'status': return status();
|
|
495
495
|
case 'doctor': return doctor();
|
|
496
|
+
case 'completion': {
|
|
497
|
+
const { completionScript, detectShell, installCompletion } = await import('./completion.js');
|
|
498
|
+
const arg = rest[0];
|
|
499
|
+
if (arg === 'zsh' || arg === 'bash' || arg === 'fish') { process.stdout.write(completionScript(arg)); return 0; }
|
|
500
|
+
if (arg === 'install') {
|
|
501
|
+
const shell = (rest[1] === 'zsh' || rest[1] === 'bash' || rest[1] === 'fish') ? rest[1] : detectShell();
|
|
502
|
+
if (!shell) { console.error('Could not tell your shell from $SHELL. Say which: foreman completion install zsh|bash|fish'); return 1; }
|
|
503
|
+
console.log(await installCompletion(shell));
|
|
504
|
+
return 0;
|
|
505
|
+
}
|
|
506
|
+
console.error('Usage: foreman completion zsh|bash|fish (prints the script)\n foreman completion install [zsh|bash|fish] (adds it to your shell rc, once)');
|
|
507
|
+
return 1;
|
|
508
|
+
}
|
|
496
509
|
case 'open': return open();
|
|
497
510
|
case 'service': {
|
|
498
511
|
const sub = rest[0];
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { COMMANDS, SERVICE_COMMANDS, completionScript, detectShell, installTarget } from './completion.js';
|
|
4
|
+
|
|
5
|
+
test('every shell script names every command and the service verbs', () => {
|
|
6
|
+
for (const shell of ['zsh', 'bash', 'fish'] as const) {
|
|
7
|
+
const s = completionScript(shell);
|
|
8
|
+
for (const [c] of COMMANDS) assert.ok(s.includes(c), `${shell} lacks ${c}`);
|
|
9
|
+
for (const c of SERVICE_COMMANDS) assert.ok(s.includes(c), `${shell} lacks service ${c}`);
|
|
10
|
+
const flags = shell === 'fish' ? ['-l force', '-l purge'] : ['--force', '--purge'];
|
|
11
|
+
for (const f of flags) assert.ok(s.includes(f), `${shell} lacks flag ${f}`);
|
|
12
|
+
}
|
|
13
|
+
assert.match(completionScript('zsh'), /^#compdef foreman\n/);
|
|
14
|
+
assert.match(completionScript('zsh'), /compdef _foreman foreman\n$/);
|
|
15
|
+
assert.match(completionScript('bash'), /complete -F _foreman foreman\n$/);
|
|
16
|
+
assert.match(completionScript('fish'), /^complete -c foreman -f\n/);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('detectShell reads $SHELL and ignores anything else', () => {
|
|
20
|
+
assert.equal(detectShell({ SHELL: '/bin/zsh' }), 'zsh');
|
|
21
|
+
assert.equal(detectShell({ SHELL: '/opt/homebrew/bin/fish' }), 'fish');
|
|
22
|
+
assert.equal(detectShell({ SHELL: '/bin/tcsh' }), null);
|
|
23
|
+
assert.equal(detectShell({}), null);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('installTarget: rc line for zsh and bash, a completions file for fish', () => {
|
|
27
|
+
assert.deepEqual(installTarget('zsh', '/home/u'), { file: '/home/u/.zshrc', line: 'eval "$(foreman completion zsh)" # foreman completion' });
|
|
28
|
+
assert.equal(installTarget('bash', '/home/u').file, '/home/u/.bashrc');
|
|
29
|
+
assert.deepEqual(installTarget('fish', '/home/u'), { file: '/home/u/.config/fish/completions/foreman.fish' });
|
|
30
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell completion for the `foreman` command.
|
|
3
|
+
*
|
|
4
|
+
* `foreman completion <shell>` prints a script; `foreman completion install`
|
|
5
|
+
* wires it into the shell's rc file, once, behind a marker — the only file
|
|
6
|
+
* outside ~/.foreman the CLI ever writes, and only when asked by name.
|
|
7
|
+
*/
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
11
|
+
|
|
12
|
+
export type Shell = 'zsh' | 'bash' | 'fish';
|
|
13
|
+
|
|
14
|
+
/** Every top-level command with the one line the menu shows for it. Keep in step with bin/foreman.mjs USAGE. */
|
|
15
|
+
export const COMMANDS: Array<[string, string]> = [
|
|
16
|
+
['start', 'Start in this terminal'],
|
|
17
|
+
['up', 'Start in the background'],
|
|
18
|
+
['stop', 'Stop it, however it was started'],
|
|
19
|
+
['restart', 'Stop and start it again the same way'],
|
|
20
|
+
['status', 'Is a server up, on which port, started how'],
|
|
21
|
+
['logs', 'Tail the log'],
|
|
22
|
+
['open', 'Open the dashboard in your browser'],
|
|
23
|
+
['doctor', 'Check credentials, providers, browser, port, Tailscale'],
|
|
24
|
+
['update', 'Install the latest version and restart'],
|
|
25
|
+
['service', 'Keep Foreman running at login'],
|
|
26
|
+
['uninstall', 'Remove the service and the background server'],
|
|
27
|
+
['completion', 'Shell completion: zsh, bash, fish, or install'],
|
|
28
|
+
['help', 'Show usage'],
|
|
29
|
+
['version', 'Print the version'],
|
|
30
|
+
];
|
|
31
|
+
export const SERVICE_COMMANDS = ['install', 'uninstall', 'start', 'stop', 'restart', 'status', 'logs'];
|
|
32
|
+
export const COMPLETION_ARGS = ['zsh', 'bash', 'fish', 'install'];
|
|
33
|
+
const FLAGS: Record<string, string[]> = { update: ['--force'], uninstall: ['--purge', '--yes'] };
|
|
34
|
+
|
|
35
|
+
const q = (s: string) => `'${s.replace(/'/g, "'\\''")}'`;
|
|
36
|
+
|
|
37
|
+
export function completionScript(shell: Shell): string {
|
|
38
|
+
const names = COMMANDS.map(([c]) => c).join(' ');
|
|
39
|
+
if (shell === 'zsh') {
|
|
40
|
+
return [
|
|
41
|
+
'#compdef foreman',
|
|
42
|
+
'_foreman() {',
|
|
43
|
+
' local -a cmds',
|
|
44
|
+
` cmds=(${COMMANDS.map(([c, d]) => q(`${c}:${d}`)).join(' ')})`,
|
|
45
|
+
' if (( CURRENT == 2 )); then _describe -t commands "foreman command" cmds; return; fi',
|
|
46
|
+
' case "${words[2]}" in',
|
|
47
|
+
` service) local -a svc; svc=(${SERVICE_COMMANDS.map(q).join(' ')}); _describe -t commands "service command" svc ;;`,
|
|
48
|
+
` completion) local -a sh; sh=(${COMPLETION_ARGS.map(q).join(' ')}); _describe -t commands "shell" sh ;;`,
|
|
49
|
+
...Object.entries(FLAGS).map(([c, f]) => ` ${c}) local -a fl; fl=(${f.map(q).join(' ')}); _describe -t options "flag" fl ;;`),
|
|
50
|
+
' esac',
|
|
51
|
+
'}',
|
|
52
|
+
'compdef _foreman foreman',
|
|
53
|
+
'',
|
|
54
|
+
].join('\n');
|
|
55
|
+
}
|
|
56
|
+
if (shell === 'bash') {
|
|
57
|
+
return [
|
|
58
|
+
'_foreman() {',
|
|
59
|
+
' local cur prev',
|
|
60
|
+
' cur="${COMP_WORDS[COMP_CWORD]}"',
|
|
61
|
+
' prev="${COMP_WORDS[COMP_CWORD-1]}"',
|
|
62
|
+
` if [ "$COMP_CWORD" -eq 1 ]; then COMPREPLY=( $(compgen -W "${names}" -- "$cur") ); return; fi`,
|
|
63
|
+
' case "$prev" in',
|
|
64
|
+
` service) COMPREPLY=( $(compgen -W "${SERVICE_COMMANDS.join(' ')}" -- "$cur") ) ;;`,
|
|
65
|
+
` completion) COMPREPLY=( $(compgen -W "${COMPLETION_ARGS.join(' ')}" -- "$cur") ) ;;`,
|
|
66
|
+
...Object.entries(FLAGS).map(([c, f]) => ` ${c}) COMPREPLY=( $(compgen -W "${f.join(' ')}" -- "$cur") ) ;;`),
|
|
67
|
+
' esac',
|
|
68
|
+
'}',
|
|
69
|
+
'complete -F _foreman foreman',
|
|
70
|
+
'',
|
|
71
|
+
].join('\n');
|
|
72
|
+
}
|
|
73
|
+
return [
|
|
74
|
+
'complete -c foreman -f',
|
|
75
|
+
...COMMANDS.map(([c, d]) => `complete -c foreman -n __fish_use_subcommand -a ${c} -d ${q(d)}`),
|
|
76
|
+
`complete -c foreman -n '__fish_seen_subcommand_from service' -a '${SERVICE_COMMANDS.join(' ')}'`,
|
|
77
|
+
`complete -c foreman -n '__fish_seen_subcommand_from completion' -a '${COMPLETION_ARGS.join(' ')}'`,
|
|
78
|
+
...Object.entries(FLAGS).flatMap(([c, f]) => f.map((flag) => `complete -c foreman -n '__fish_seen_subcommand_from ${c}' -l ${flag.replace(/^--/, '')}`)),
|
|
79
|
+
'',
|
|
80
|
+
].join('\n');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The user's shell from $SHELL, or null when it is none of the three. */
|
|
84
|
+
export function detectShell(env: NodeJS.ProcessEnv = process.env): Shell | null {
|
|
85
|
+
const name = path.basename(env.SHELL ?? '');
|
|
86
|
+
return name === 'zsh' || name === 'bash' || name === 'fish' ? name : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const MARKER = '# foreman completion';
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Where the hook goes, and what it is. zsh and bash source the script at
|
|
93
|
+
* shell start; fish loads a file from its completions directory on demand.
|
|
94
|
+
*/
|
|
95
|
+
export function installTarget(shell: Shell, home = os.homedir()): { file: string; line?: string } {
|
|
96
|
+
if (shell === 'fish') return { file: path.join(home, '.config', 'fish', 'completions', 'foreman.fish') };
|
|
97
|
+
const file = shell === 'zsh' ? path.join(home, '.zshrc') : path.join(home, '.bashrc');
|
|
98
|
+
return { file, line: `eval "$(foreman completion ${shell})" ${MARKER}` };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Idempotent: a second install finds the marker and changes nothing. Returns what happened. */
|
|
102
|
+
export async function installCompletion(shell: Shell, home = os.homedir()): Promise<string> {
|
|
103
|
+
const target = installTarget(shell, home);
|
|
104
|
+
if (!target.line) {
|
|
105
|
+
await mkdir(path.dirname(target.file), { recursive: true });
|
|
106
|
+
await writeFile(target.file, completionScript('fish'));
|
|
107
|
+
return `Wrote ${target.file}. Open a new fish shell.`;
|
|
108
|
+
}
|
|
109
|
+
const current = await readFile(target.file, 'utf8').catch(() => '');
|
|
110
|
+
if (current.includes(MARKER)) return `Already installed in ${target.file}.`;
|
|
111
|
+
await appendFile(target.file, `${current.endsWith('\n') || !current ? '' : '\n'}${target.line}\n`);
|
|
112
|
+
return `Added one line to ${target.file}. Open a new shell, or run: source ${target.file}`;
|
|
113
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* POST /runs/{id}/resume Resume an interrupted/failed run
|
|
25
25
|
* POST /permission Resolve an approval {id, behavior, message?}
|
|
26
26
|
* POST /answer Answer a director question {id, text}
|
|
27
|
+
* GET /search?q= Runs across the fleet matching title, brief, project or folder
|
|
27
28
|
* POST /fleet/chat One turn with the fleet planner {text} → {text, costUsd}
|
|
28
29
|
* POST /fleet/stop Stop the fleet planner reply in flight
|
|
29
30
|
* DELETE /fleet/chat Forget the fleet conversation
|
|
@@ -2193,6 +2194,26 @@ const server = http.createServer(async (req, res) => {
|
|
|
2193
2194
|
if (!ok) return json(res, 404, { error: 'no pending question with that id' });
|
|
2194
2195
|
json(res, 200, { ok: true });
|
|
2195
2196
|
|
|
2197
|
+
} else if (req.method === 'GET' && url.pathname === '/search') {
|
|
2198
|
+
// Every run across the fleet whose title, brief, project or folder
|
|
2199
|
+
// says the words. Run records are small and already on disk; no index.
|
|
2200
|
+
const q = (url.searchParams.get('q') ?? '').trim().toLowerCase();
|
|
2201
|
+
if (q.length < 2) return json(res, 200, { runs: [] });
|
|
2202
|
+
const [runs, projects] = await Promise.all([store.listRuns(), store.listProjects()]);
|
|
2203
|
+
const byFolder = new Map(projects.map((p) => [p.folder, p]));
|
|
2204
|
+
const hits = runs.filter((r) => {
|
|
2205
|
+
const project = byFolder.get(r.folder);
|
|
2206
|
+
return [r.title, r.mission, r.folder, project?.name].some((f) => f?.toLowerCase().includes(q));
|
|
2207
|
+
}).sort((a, b) => b.createdAt - a.createdAt).slice(0, 30).map((r) => {
|
|
2208
|
+
const project = byFolder.get(r.folder);
|
|
2209
|
+
return {
|
|
2210
|
+
id: r.id, projectId: r.projectId ?? project?.id ?? null, projectName: project?.name ?? path.basename(r.folder),
|
|
2211
|
+
folder: r.folder, title: r.title, mission: firstLine(r.mission), status: r.status,
|
|
2212
|
+
createdAt: r.createdAt, endedAt: r.endedAt, costUsd: r.costUsd, costBasis: costBasisOf(r),
|
|
2213
|
+
};
|
|
2214
|
+
});
|
|
2215
|
+
json(res, 200, { runs: hits });
|
|
2216
|
+
|
|
2196
2217
|
} else if (req.method === 'POST' && url.pathname === '/fleet/chat') {
|
|
2197
2218
|
// One turn at the front desk, answered in the response. The same
|
|
2198
2219
|
// session the phone uses, so a conversation can move between them.
|