clavarium-tether 0.0.0-stage → 0.1.0-next.2
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 +152 -2
- package/bin/cli.mjs +137 -0
- package/docs/protocol.md +404 -0
- package/host/approvals.mjs +71 -0
- package/host/connectors/browser.mjs +183 -0
- package/host/connectors/graph.mjs +262 -0
- package/host/connectors/local.mjs +209 -0
- package/host/connectors/screen.mjs +209 -0
- package/host/connectors/ssh.mjs +339 -0
- package/host/connectors/webdav.mjs +246 -0
- package/host/journal.mjs +66 -0
- package/host/keeper.mjs +271 -0
- package/host/serve.mjs +334 -0
- package/host/ws.mjs +101 -0
- package/package.json +56 -4
- package/server/mcp.mjs +140 -0
- package/server/name.mjs +14 -0
- package/server/rights.mjs +148 -0
- package/server/store.mjs +114 -0
- package/server/verbs.mjs +155 -0
- package/ui/texts/en.json +64 -0
- package/ui/texts/fr.json +64 -0
- package/ui/ui.css +95 -0
- package/ui/ui.js +216 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The Clavarium Tether authors
|
|
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
CHANGED
|
@@ -1,3 +1,153 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Clavarium Tether
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An AI agent reaches outside its own machine, kept on a tether: a level per
|
|
4
|
+
target, a person's approval, a journal, and a stop.
|
|
5
|
+
|
|
6
|
+
A target is one access given to an agent: a computer, a machine reached by
|
|
7
|
+
SSH, browser tabs, an online folder. On each, the agent has a level:
|
|
8
|
+
|
|
9
|
+
| Level | The agent... |
|
|
10
|
+
|---|---|
|
|
11
|
+
| Watch | reads only |
|
|
12
|
+
| Propose | reads; each action waits for a person |
|
|
13
|
+
| Frame | reads, acts where a rule allows (`git status`, under `~/Projects`); the rest waits |
|
|
14
|
+
| Free | does everything; the journal says it all |
|
|
15
|
+
|
|
16
|
+
tether your targets, your level on each
|
|
17
|
+
tether run web "sudo apt upgrade -y" --why "Installs 14 updates."
|
|
18
|
+
tether files web read ~/site/index.html
|
|
19
|
+
tether open web; tether type web "top" --enter; tether read web
|
|
20
|
+
|
|
21
|
+
A call that waits shows the person the exact command and the agent's one
|
|
22
|
+
line why: **Accept**, **Refuse**, or **Always** (a rule from the command,
|
|
23
|
+
and it is not asked again). Not answered in time, it is refused.
|
|
24
|
+
|
|
25
|
+
An MCP server gives the same verbs to an AI agent. A person sees the
|
|
26
|
+
requests, the levels and the journal on its screen (`ui/`), drawn by Tether
|
|
27
|
+
in the page of the application that runs it.
|
|
28
|
+
|
|
29
|
+
A sibling of [Clavarium Parley](https://github.com/clavarium/clavarium-parley),
|
|
30
|
+
[Clavarium Docket](https://github.com/clavarium/clavarium-docket) and
|
|
31
|
+
[Clavarium Binder](https://github.com/clavarium/clavarium-binder), built the
|
|
32
|
+
same way, universal (nothing in it is specific to Clavarium).
|
|
33
|
+
|
|
34
|
+
## What an agent cannot do
|
|
35
|
+
|
|
36
|
+
- **Hold a key.** Passwords and keys stay with the keeper (the application,
|
|
37
|
+
or `tether serve`), from the application's secret store. The agent asks
|
|
38
|
+
through its own folder; it never connects, and never sees a secret.
|
|
39
|
+
- **Approve.** Only a person does, on the screen or wherever the
|
|
40
|
+
application asks them.
|
|
41
|
+
- **Hide.** Each call is a line of the journal: who, where, what exactly,
|
|
42
|
+
the decision and by whom, when, the size of the result and its exit code.
|
|
43
|
+
Never a secret, never a file's content.
|
|
44
|
+
- **Go on after Stop.** One button cuts every target of an agent: what
|
|
45
|
+
waits is refused, terminals are closed, every call after is refused until
|
|
46
|
+
a person resumes.
|
|
47
|
+
|
|
48
|
+
## Reach a server over SSH
|
|
49
|
+
|
|
50
|
+
A target of kind `ssh` is a machine that already runs an SSH server. The
|
|
51
|
+
keeper connects to it as an ordinary SSH client — like ansible or a deploy
|
|
52
|
+
job — through the `ssh2` library (pure JS; no system `ssh`, and **nothing is
|
|
53
|
+
installed on the far machine**). The agent gets the same verbs as on any
|
|
54
|
+
other target: `tether run`, `tether read`, `tether files …`, each through its
|
|
55
|
+
level and a person's approval.
|
|
56
|
+
|
|
57
|
+
The target says `host`, `port`, `user`, optionally a host-key `pin` to check
|
|
58
|
+
the server's identity and a `root` folder that files may not leave. The
|
|
59
|
+
private key is held by the keeper, from the application's secret store, and
|
|
60
|
+
named in the target — never the key itself; it never reaches the agent, the
|
|
61
|
+
journal or any log.
|
|
62
|
+
|
|
63
|
+
tether serve --side ./.tether=Assistant \
|
|
64
|
+
--ssh deploy@example.com --ssh-key ~/.ssh/deploy_ed25519 \
|
|
65
|
+
--ssh-root /srv/app --ssh-pin SHA256:… --ssh-title "App server"
|
|
66
|
+
|
|
67
|
+
declares one SSH target without an application. `--ssh-key FILE` reads the key
|
|
68
|
+
from that file (its content is the secret; the path is not stored); the target
|
|
69
|
+
starts at level Propose. A host-key `--ssh-pin` refuses a server whose key does
|
|
70
|
+
not match.
|
|
71
|
+
|
|
72
|
+
## Reach an online folder
|
|
73
|
+
|
|
74
|
+
A target of kind `files` is an online folder on a server the person runs or
|
|
75
|
+
owns. The keeper talks to it as an ordinary file-sync client over HTTPS (no
|
|
76
|
+
extra dependency — Node's own `fetch`); **nothing is installed anywhere**, and
|
|
77
|
+
the agent never connects. The agent gets the `files.*` verbs — `tether files …
|
|
78
|
+
list / read / search / write / move / delete` — each through its level and a
|
|
79
|
+
person's approval. Two kinds of server are reached:
|
|
80
|
+
|
|
81
|
+
**Nextcloud (and any WebDAV).** The target says the WebDAV `url`, a Basic-auth
|
|
82
|
+
`user`, and optionally a `root` folder files may not leave. The password — a
|
|
83
|
+
Nextcloud **app password** is ideal — is held by the keeper, from the
|
|
84
|
+
application's secret store, and only named in the target.
|
|
85
|
+
|
|
86
|
+
tether serve --side ./.tether=Assistant \
|
|
87
|
+
--webdav https://cloud.example.com/remote.php/dav/files/alice \
|
|
88
|
+
--webdav-user alice --webdav-pass-env NEXTCLOUD_APP_PASSWORD \
|
|
89
|
+
--webdav-root /Projects --webdav-title "Cloud"
|
|
90
|
+
|
|
91
|
+
**SharePoint / OneDrive (Microsoft Graph).** The target says the `drive` id and
|
|
92
|
+
optionally a `root`. V1 takes a bearer token the host keeps fresh (named in the
|
|
93
|
+
target, read here from an environment variable); a client-credentials flow with
|
|
94
|
+
a client secret is also supported (see the protocol). Large-file upload sessions
|
|
95
|
+
are left for a later version.
|
|
96
|
+
|
|
97
|
+
tether serve --side ./.tether=Assistant \
|
|
98
|
+
--graph-drive <drive-id> --graph-token-env GRAPH_TOKEN \
|
|
99
|
+
--graph-root /Shared --graph-title "Team drive"
|
|
100
|
+
|
|
101
|
+
`--webdav-pass-env` and `--graph-token-env` name an environment variable; the
|
|
102
|
+
secret is never passed on the command line, written to the store or journalled.
|
|
103
|
+
Both targets start at level Propose.
|
|
104
|
+
## Reach a browser
|
|
105
|
+
|
|
106
|
+
A target of kind `browser` is a set of tabs a person grants to an agent through
|
|
107
|
+
the **Tether extension** (Chrome and Firefox, one codebase; `extension/`). The
|
|
108
|
+
extension connects OUT to the keeper over a WebSocket — nothing is installed as a
|
|
109
|
+
server, and the browser may sit behind any firewall. The agent gets browser
|
|
110
|
+
verbs scoped to the granted tabs only: `tabs`, `open`, `shot`, `html`,
|
|
111
|
+
`console`, `eval`, `click`, `type`, `key`, `scroll`, `drag`.
|
|
112
|
+
|
|
113
|
+
tether serve --browser --port 8787 --side ./.tether=Assistant
|
|
114
|
+
|
|
115
|
+
serves the browser endpoint at `ws://127.0.0.1:8787/browser` (a browser-safe
|
|
116
|
+
port — browsers refuse 4190). The person opens the popup, which shows a short
|
|
117
|
+
code; they type it in the keeper page to pair. A long-lived token is then kept
|
|
118
|
+
in the extension's storage — never logged, never shown to the agent. In the
|
|
119
|
+
popup the person turns tabs on one by one, or allows a whole site.
|
|
120
|
+
|
|
121
|
+
- **Granted tabs only.** A verb on a tab that was not granted is refused, on both
|
|
122
|
+
the extension's side and the keeper's. `browser.open` of a new tab grants it.
|
|
123
|
+
- **Propose by default.** Each action waits for the person; sites are allowed one
|
|
124
|
+
at a time. Always ask before a payment, a post or a send.
|
|
125
|
+
- **A visible indicator.** While the agent acts in a tab, the extension shows a
|
|
126
|
+
badge on its icon and a red border around the page.
|
|
127
|
+
|
|
128
|
+
Build the extension with WXT (`cd extension && npm install && npm run build`
|
|
129
|
+
for Chrome, `npm run build:firefox` for Firefox); load the unpacked build from
|
|
130
|
+
`extension/.output/`.
|
|
131
|
+
|
|
132
|
+
## Without an application
|
|
133
|
+
|
|
134
|
+
tether serve --store ~/tether-data --side ./.tether=Assistant --local ~/Projects
|
|
135
|
+
|
|
136
|
+
keeps the targets and shows their page on `http://127.0.0.1:4190/`.
|
|
137
|
+
`--local FOLDER` gives the agents this machine (a shell, and that folder),
|
|
138
|
+
at level Propose; it is off unless asked for. Put the store where the agents
|
|
139
|
+
cannot write.
|
|
140
|
+
|
|
141
|
+
See [docs/protocol.md](docs/protocol.md). One dependency, `ssh2` (the SSH
|
|
142
|
+
connector); Node 22 or later; tmux for the local connector's terminals.
|
|
143
|
+
|
|
144
|
+
**Status:** first version (0.1), not yet published. The `local`, `ssh`,
|
|
145
|
+
`webdav` (Nextcloud/WebDAV) and `graph` (SharePoint/OneDrive) connectors are
|
|
146
|
+
built. A `screen` slot is ready for a host to plug an engine into (see docs).
|
|
147
|
+
The `computer` and `browser` connectors come next.
|
|
148
|
+
**Status:** first version (0.1), not yet published. The `local`, `ssh` and
|
|
149
|
+
`browser` connectors are built (the browser one with its WebExtension in
|
|
150
|
+
`extension/`). A `screen` slot is ready for a host to plug an engine into (see
|
|
151
|
+
docs). The `computer` connector and online folders come next.
|
|
152
|
+
|
|
153
|
+
License: MIT.
|
package/bin/cli.mjs
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* THE COMMAND: the verbs for any agent or script that can run a command. It
|
|
3
|
+
* asks through the same folder as the MCP server (server/mcp.mjs), and prints
|
|
4
|
+
* the keeper's answer. `serve` IS a keeper: the targets, their approvals and
|
|
5
|
+
* their page, for use without an application.
|
|
6
|
+
*/
|
|
7
|
+
import { realpathSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { ask, findFolder } from '../server/mcp.mjs';
|
|
10
|
+
import { NAME, VERSION, FOLDER, ENV_DIR } from '../server/name.mjs';
|
|
11
|
+
|
|
12
|
+
export const HELP = `${NAME}: act on the targets you were given (machines, folders, browsers).
|
|
13
|
+
|
|
14
|
+
${NAME} your targets, your level on each, its rules
|
|
15
|
+
${NAME} run TARGET "command" [--timeout S] run a command: its output and exit code
|
|
16
|
+
${NAME} open TARGET open a terminal
|
|
17
|
+
${NAME} read TARGET [--lines N] read the terminal's screen
|
|
18
|
+
${NAME} type TARGET "keys" [--enter] type in the terminal
|
|
19
|
+
${NAME} files TARGET list [PATH]
|
|
20
|
+
${NAME} files TARGET read PATH
|
|
21
|
+
${NAME} files TARGET search "text" [--in PATH]
|
|
22
|
+
${NAME} files TARGET write PATH --text "..." | --from LOCAL_FILE
|
|
23
|
+
${NAME} files TARGET move FROM TO
|
|
24
|
+
${NAME} files TARGET delete PATH
|
|
25
|
+
${NAME} browser TARGET tabs the tabs a person granted you
|
|
26
|
+
${NAME} browser TARGET open URL [--tab N] open a URL (a granted tab, or a new one)
|
|
27
|
+
${NAME} browser TARGET shot [--tab N] a screenshot of a granted tab
|
|
28
|
+
${NAME} browser TARGET html [--tab N] [--selector S] [--readable]
|
|
29
|
+
${NAME} browser TARGET console [--tab N] [--lines N]
|
|
30
|
+
${NAME} browser TARGET eval "script" [--tab N]
|
|
31
|
+
${NAME} browser TARGET click [SELECTOR | --x N --y N] [--tab N]
|
|
32
|
+
${NAME} browser TARGET type "text" [--selector S] [--tab N]
|
|
33
|
+
${NAME} browser TARGET key KEY [--selector S] [--tab N]
|
|
34
|
+
${NAME} browser TARGET scroll [--dx N] [--dy N] [--selector S] [--tab N]
|
|
35
|
+
${NAME} browser TARGET drag FROM TO [--tab N]
|
|
36
|
+
${NAME} serve [--store DIR] [--port N] [--side FOLDER=NAME] [--local FOLDER[=TITLE]]
|
|
37
|
+
keep targets yourself, with their page (no application)
|
|
38
|
+
|
|
39
|
+
Add --why "one short line" when you act: a person may read it before deciding.
|
|
40
|
+
Depending on your level a person approves first; the command waits for them.
|
|
41
|
+
Exit: 0 done, 1 refused, 2 no folder, 3 nobody keeps the targets, 4 not answered in time, 64 not understood.
|
|
42
|
+
The folder used is --dir FOLDER, else $${ENV_DIR}, else the nearest ${FOLDER} folder from here up.
|
|
43
|
+
${NAME} ${VERSION}`;
|
|
44
|
+
|
|
45
|
+
const FILE_VERBS = ['list', 'read', 'search', 'write', 'move', 'delete'];
|
|
46
|
+
const BROWSER_VERBS = ['tabs', 'open', 'shot', 'html', 'console', 'eval', 'click', 'type', 'key', 'scroll', 'drag'];
|
|
47
|
+
|
|
48
|
+
/* A command line, read: [verb, args] or { refused }. A --from file is read by run(). */
|
|
49
|
+
export function read(argv) {
|
|
50
|
+
const flags = {}; const words = [];
|
|
51
|
+
for (let i = 0; i < argv.length; i++) {
|
|
52
|
+
const a = argv[i];
|
|
53
|
+
if (a === '--enter') { flags.enter = true; continue; }
|
|
54
|
+
if (a === '--readable') { flags.readable = true; continue; }
|
|
55
|
+
if (a.startsWith('--')) { if (argv[i + 1] === undefined) return { refused: `${a} needs a value` }; flags[a.slice(2)] = argv[++i]; continue; }
|
|
56
|
+
words.push(a);
|
|
57
|
+
}
|
|
58
|
+
const [verb = 'targets', target, ...rest] = words;
|
|
59
|
+
const why = flags.why !== undefined ? { why: flags.why } : {};
|
|
60
|
+
const need = (ok, usage) => (ok ? null : { refused: `${NAME} ${usage}` });
|
|
61
|
+
switch (verb) {
|
|
62
|
+
case 'targets': return ['targets', {}];
|
|
63
|
+
case 'run': return need(target && rest.length, 'run TARGET "command"') ?? ['terminal.run', { target, command: rest.join(' '), ...(flags.timeout !== undefined ? { timeout: Number(flags.timeout) } : {}), ...why }];
|
|
64
|
+
case 'open': return need(target, 'open TARGET') ?? ['terminal.open', { target, ...why }];
|
|
65
|
+
case 'read': return need(target, 'read TARGET') ?? ['terminal.read', { target, ...(flags.lines !== undefined ? { lines: Number(flags.lines) } : {}) }];
|
|
66
|
+
case 'type': return need(target && rest.length, 'type TARGET "keys"') ?? ['terminal.type', { target, text: rest.join(' '), enter: !!flags.enter, ...why }];
|
|
67
|
+
case 'files': {
|
|
68
|
+
const [what, ...p] = rest;
|
|
69
|
+
if (!target || !FILE_VERBS.includes(what)) return { refused: `${NAME} files TARGET ${FILE_VERBS.join('|')} ...` };
|
|
70
|
+
switch (what) {
|
|
71
|
+
case 'list': return ['files.list', { target, path: p[0] ?? '.' }];
|
|
72
|
+
case 'read': return need(p[0], 'files TARGET read PATH') ?? ['files.read', { target, path: p[0] }];
|
|
73
|
+
case 'search': return need(p.length, 'files TARGET search "text"') ?? ['files.search', { target, text: p.join(' '), path: flags.in ?? '.' }];
|
|
74
|
+
case 'write':
|
|
75
|
+
if (!p[0] || (flags.text === undefined) === (flags.from === undefined)) return { refused: `${NAME} files TARGET write PATH --text "..." | --from LOCAL_FILE` };
|
|
76
|
+
return ['files.write', { target, path: p[0], ...(flags.text !== undefined ? { text: flags.text } : { from: flags.from }), ...why }];
|
|
77
|
+
case 'move': return need(p.length === 2, 'files TARGET move FROM TO') ?? ['files.move', { target, from: p[0], to: p[1], ...why }];
|
|
78
|
+
default: return need(p[0], 'files TARGET delete PATH') ?? ['files.delete', { target, path: p[0], ...why }];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
case 'browser': {
|
|
82
|
+
const [what, ...p] = rest;
|
|
83
|
+
if (!target || !BROWSER_VERBS.includes(what)) return { refused: `${NAME} browser TARGET ${BROWSER_VERBS.join('|')} ...` };
|
|
84
|
+
const tab = flags.tab !== undefined ? { tab: Number(flags.tab) } : {};
|
|
85
|
+
const sel = flags.selector !== undefined ? { selector: flags.selector } : {};
|
|
86
|
+
switch (what) {
|
|
87
|
+
case 'tabs': return ['browser.tabs', { target }];
|
|
88
|
+
case 'open': return need(p[0], 'browser TARGET open URL') ?? ['browser.open', { target, url: p[0], ...tab, ...why }];
|
|
89
|
+
case 'shot': return ['browser.shot', { target, ...tab }];
|
|
90
|
+
case 'html': return ['browser.html', { target, ...tab, ...sel, ...(flags.readable ? { readable: true } : {}) }];
|
|
91
|
+
case 'console': return ['browser.console', { target, ...tab, ...(flags.lines !== undefined ? { lines: Number(flags.lines) } : {}) }];
|
|
92
|
+
case 'eval': return need(p.length, 'browser TARGET eval "script"') ?? ['browser.eval', { target, script: p.join(' '), ...tab, ...why }];
|
|
93
|
+
case 'click': return ['browser.click', { target, ...tab, ...(p[0] ? { selector: p[0] } : sel), ...(flags.x !== undefined ? { x: Number(flags.x) } : {}), ...(flags.y !== undefined ? { y: Number(flags.y) } : {}), ...(flags.button ? { button: flags.button } : {}), ...why }];
|
|
94
|
+
case 'type': return need(p.length, 'browser TARGET type "text"') ?? ['browser.type', { target, text: p.join(' '), ...tab, ...sel, ...why }];
|
|
95
|
+
case 'key': return need(p[0], 'browser TARGET key KEY') ?? ['browser.key', { target, key: p[0], ...tab, ...sel, ...why }];
|
|
96
|
+
case 'scroll': return ['browser.scroll', { target, ...tab, ...sel, ...(flags.dx !== undefined ? { dx: Number(flags.dx) } : {}), ...(flags.dy !== undefined ? { dy: Number(flags.dy) } : {}), ...why }];
|
|
97
|
+
default: return need(p.length === 2, 'browser TARGET drag FROM TO') ?? ['browser.drag', { target, from: p[0], to: p[1], ...tab, ...why }];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
default: return { refused: `Unknown: ${verb}. Run "${NAME} --help".` };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* Runs one command line. Resolves with { code, out, err }: nothing is printed here. */
|
|
105
|
+
export async function run(argv, { env = process.env, cwd = process.cwd(), wait, onHeld } = {}) {
|
|
106
|
+
const args = [...argv];
|
|
107
|
+
let dir;
|
|
108
|
+
const at = args.indexOf('--dir');
|
|
109
|
+
if (at >= 0) { dir = args[at + 1]; if (!dir) return { code: 64, out: '', err: '--dir needs a folder.\n' }; args.splice(at, 2); }
|
|
110
|
+
if (['--help', '-h', 'help'].includes(args[0])) return { code: 0, out: `${HELP}\n`, err: '' };
|
|
111
|
+
if (args[0] === '--version') return { code: 0, out: `${VERSION}\n`, err: '' };
|
|
112
|
+
const what = read(args);
|
|
113
|
+
if (what.refused) return { code: 64, out: '', err: `${what.refused}\n` };
|
|
114
|
+
if (what[1].from !== undefined) {
|
|
115
|
+
try { what[1].text = readFileSync(what[1].from, 'utf8'); } catch (e) { return { code: 64, out: '', err: `cannot read ${what[1].from}: ${e.code ?? e.message}\n` }; }
|
|
116
|
+
delete what[1].from;
|
|
117
|
+
}
|
|
118
|
+
const folder = findFolder({ dir, env, from: cwd });
|
|
119
|
+
if (!folder) return { code: 2, out: '', err: `No ${FOLDER} folder here or above. Run it from your work folder, or give --dir FOLDER.\n` };
|
|
120
|
+
const r = await ask(folder, what[0], what[1], { ...(wait ? { wait } : {}), onHeld });
|
|
121
|
+
if (r.ok) return { code: 0, out: `${r.text}\n`, err: '' };
|
|
122
|
+
return { code: r.nokeeper ? 3 : r.late ? 4 : 1, out: '', err: `Not done: ${r.text}\n` };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const main = (() => { try { return realpathSync(process.argv[1] ?? '') === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })();
|
|
126
|
+
if (main) {
|
|
127
|
+
const argv = process.argv.slice(2);
|
|
128
|
+
if (argv[0] === 'serve') {
|
|
129
|
+
const { serveMain } = await import('../host/serve.mjs');
|
|
130
|
+
await serveMain(argv.slice(1));
|
|
131
|
+
} else {
|
|
132
|
+
const r = await run(argv, { onHeld: (until) => process.stderr.write(`Waiting for a person (until ${new Date(until).toLocaleTimeString()})...\n`) });
|
|
133
|
+
if (r.out) process.stdout.write(r.out);
|
|
134
|
+
if (r.err) process.stderr.write(r.err);
|
|
135
|
+
process.exitCode = r.code;
|
|
136
|
+
}
|
|
137
|
+
}
|