@rfany/rf 0.0.0-stage → 0.2.0

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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -2
  3. package/package.json +11 -4
  4. package/rf.mjs +187 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 spoj
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,8 @@
1
- # Temporary Holding Version
1
+ # @rfany/rf
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ People's command line for [rollforward](https://github.com/spoj/rollforward): what its page does, from a shell or a desktop agent, as you.
4
+
5
+ npm i -g @rfany/rf
6
+ rf login https://rollforward.example.com TOKEN
7
+
8
+ Make the token on the page, under Tokens. `rf` alone lists the commands.
package/package.json CHANGED
@@ -1,6 +1,13 @@
1
1
  {
2
2
  "name": "@rfany/rf",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "rollforward's command line for people: what its page does, from a shell or a desktop agent, as you",
5
+ "license": "MIT",
6
+ "repository": { "type": "git", "url": "git+https://github.com/spoj/rollforward.git", "directory": "cli" },
7
+ "type": "module",
8
+ "bin": { "rf": "rf.mjs" },
9
+ "files": ["rf.mjs"],
10
+ "engines": { "node": ">=22" },
11
+ "scripts": { "prepack": "node --no-warnings build.mjs" },
12
+ "publishConfig": { "access": "public" }
13
+ }
package/rf.mjs ADDED
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env node
2
+ // rf for people: what the page does, from a shell or a desktop agent, as you, with a personal token made on the page (Tokens).
3
+ // Published to npm as @rfany/rf, from cli/. PATH is workspace/series/label, as `rf ls` prints it.
4
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
7
+
8
+ const USAGE = `rf login URL TOKEN
9
+ rf ls
10
+ rf pull DIR a read-only mirror of what you can see, in the layout agents see
11
+ rf comment PATH TEXT [--item ID] [--ref REF] [--thread T] [--wait] a new thread, or a reply in T, as to an agent's ask
12
+ rf upload PATH FILE [--onto ITEM] [--output]
13
+ rf wake PATH AT REASON [--label LABEL] AT now, +<n>m|h|d or a date; PATH an open instance, or a series with LABEL
14
+ rf agents [WORKSPACE] [--set FILE|-] the company's AGENTS.md, or a workspace's; setting the company's is for admins
15
+ rf skills
16
+ rf websites
17
+
18
+ Publishers and admins:
19
+ rf skill push DIR DIR/SKILL.md and the rest become the newest version of the skill named DIR's name, live at once
20
+ rf skill versions ID
21
+ rf skill rollback ID N pushes version N again, as the newest
22
+
23
+ Admins:
24
+ rf users
25
+ rf user add EMAIL [admin|publisher] | role EMAIL admin|publisher|user | disable EMAIL | enable EMAIL | signout EMAIL | remove EMAIL
26
+ rf website set ID URL [--name NAME] [--hosts HOST,...] a website people sign in to at URL, and the other hosts its pages need,
27
+ such as sso.example.com or *.example.com; a run's browser on it reaches only those
28
+ rf website remove ID`;
29
+
30
+ const config = join(homedir(), '.config', 'rollforward.json');
31
+ const [cmd, ...argv] = process.argv.slice(2);
32
+ const flags = {}, args = [];
33
+ for (let i = 0; i < argv.length; i++) {
34
+ const flag = argv[i].match(/^--(.+)$/)?.[1];
35
+ if (!flag) args.push(argv[i]);
36
+ else flags[flag] = flag === 'wait' || flag === 'output' ? true : argv[++i];
37
+ }
38
+ if (cmd !== 'login' && !existsSync(config)) {
39
+ console.error(`Sign in first, with a token made on the page under Tokens: rf login URL TOKEN\n\n${USAGE}`);
40
+ process.exit(2);
41
+ }
42
+ const { url, token } = cmd === 'login' ? { url: args[0], token: args[1] } : JSON.parse(readFileSync(config, 'utf8'));
43
+
44
+ async function api(path , init = {}) {
45
+ const r = await fetch(url + path, { ...init, headers: { authorization: `Bearer ${token}` } });
46
+ if (!r.ok) throw new Error(`${init.method ?? 'GET'} ${path}: ${r.status} ${await r.text()}`);
47
+ return r;
48
+ }
49
+ const get = async (path ) => (await api(path)).json();
50
+ const post = (path , body , method = 'POST') => api(path, { method, body: JSON.stringify(body) });
51
+
52
+ async function find(path ) {
53
+ const [w, s, l] = path.split('/'), series = (await get('/api')).find(x => x.name === w)?.series.find(x => x.name === s);
54
+ const inst = l === undefined ? undefined : series?.instances.find(i => i.label === l);
55
+ if (!series || (l !== undefined && !inst)) throw new Error(`nothing you can see at ${path}`);
56
+ return { series, inst, at: inst ? `/api/instances/${inst.id}` : '' };
57
+ }
58
+
59
+ function write(path , data ) {
60
+ mkdirSync(dirname(path), { recursive: true });
61
+ rmSync(path, { force: true });
62
+ writeFileSync(path, data);
63
+ chmodSync(path, 0o444);
64
+ }
65
+
66
+ // A path the server named, under `base`, or undefined: on Windows a file's name may hold \ and lead out of it.
67
+ const under = (base , rel ) => resolve(base, rel).startsWith(resolve(base) + sep) ? resolve(base, rel) : undefined;
68
+
69
+ // Pulls what changed since the last pull, by each instance's files' sizes and times, and removes what moved or went. A file that fails
70
+ // is said and fetched again next time.
71
+ async function pull(dir ) {
72
+ const manifest = join(dir, '.rf-pull.json'), had = existsSync(manifest) ? JSON.parse(readFileSync(manifest, 'utf8')) : {};
73
+ const now = {}, tree = await get('/api');
74
+ write(join(dir, 'AGENTS.md'), (await get('/agents')).text);
75
+ for (const w of tree) {
76
+ write(join(dir, w.name, 'AGENTS.md'), w.agents);
77
+ for (const s of w.series) for (const i of s.instances) now[i.id] = { path: join(w.name, s.name, i.label), files: {} };
78
+ }
79
+ for (const [id, old] of Object.entries (had)) if (now[id]?.path !== old.path) rmSync(join(dir, old.path), { recursive: true, force: true });
80
+ let fetched = 0;
81
+ for (const [id, mine] of Object.entries(now)) {
82
+ const at = `/api/instances/${id}`, d = await get(at), was = had[id]?.path === mine.path ? had[id].files : {};
83
+ write(join(dir, mine.path, 'screen.json'), JSON.stringify(d.doc, null, 1));
84
+ write(join(dir, mine.path, 'log.jsonl'), d.log.map(e => JSON.stringify(e)).join('\n') + '\n');
85
+ write(join(dir, mine.path, 'trace.json'), JSON.stringify(await get(`${at}/trace`), null, 1));
86
+ for (const f of await get(`${at}/files`)) {
87
+ const path = under(join(dir, mine.path), f.path);
88
+ if (!path) {
89
+ console.error(`skipped ${mine.path}/${f.path}: it leads out of its folder`);
90
+ continue;
91
+ }
92
+ mine.files[f.path] = `${f.size} ${f.mtime}`;
93
+ if (was[f.path] === mine.files[f.path]) continue;
94
+ try {
95
+ write(path, new Uint8Array(await (await api(`/files/${id}/${f.path.split('/').map(encodeURIComponent).join('/')}`)).arrayBuffer()));
96
+ fetched++;
97
+ } catch (e) {
98
+ mine.files[f.path] = '';
99
+ console.error(`${mine.path}/${f.path}: ${(e ).message}`);
100
+ }
101
+ }
102
+ for (const gone of Object.keys(was).filter(p => !(p in mine.files))) {
103
+ const path = under(join(dir, mine.path), gone);
104
+ if (path) rmSync(path, { force: true });
105
+ }
106
+ }
107
+ writeFileSync(manifest, JSON.stringify(now));
108
+ console.log(`${Object.keys(now).length} instances, ${fetched} files fetched, into ${dir}`);
109
+ }
110
+
111
+ if (cmd === 'login' && url && token) {
112
+ const me = await get('/me');
113
+ mkdirSync(dirname(config), { recursive: true });
114
+ writeFileSync(config, JSON.stringify({ url, token }), { mode: 0o600 });
115
+ console.log(`signed in as ${me.email}`);
116
+ } else if (cmd === 'ls') {
117
+ for (const w of await get('/api')) for (const s of w.series) {
118
+ console.log(`${w.name}/${s.name}${s.wake ? ` wakes ${s.wake.at} to start ${s.wake.label}: ${s.wake.reason}` : ''}${s.requests.length ? ` ${s.requests.length} asking to start` : ''}`);
119
+ for (const i of s.instances) console.log(`${w.name}/${s.name}/${i.label} ${i.status}${i.running ? ', running' : ''}${i.asks.length ? `, ${i.asks.length} asks waiting` : ''}${i.wake ? `, wakes ${i.wake}` : ''}, runner ${i.runner}`);
120
+ }
121
+ } else if (cmd === 'pull' && args[0]) await pull(args[0]);
122
+ else if (cmd === 'comment' && args.length === 2) {
123
+ const { at } = await find(args[0]);
124
+ await post(`${at}/comment`, { text: args[1], item: flags.item, ref: flags.ref, thread: flags.thread });
125
+ const seq = (await get(at)).log.at(-1).seq;
126
+ if (!flags.wait) console.log(`commented, seq ${seq}`);
127
+ else for (;;) {
128
+ // The run that saw the comment, and what it said from then on: a run ending as it came may not have.
129
+ await new Promise(r => setTimeout(r, 5000));
130
+ const log = (await get(at)).log, done = log.find(e => e.op === 'done' && e.seen >= seq);
131
+ if (!done) continue;
132
+ const from = Math.max(seq, log.find(e => e.op === 'run' && e.run === done.run).seq);
133
+ for (const e of log.filter(e => e.seq > from && e.seq < done.seq && ((e.op === 'comment' && e.by === 'agent') || e.op === 'report'))) console.log(`${e.op === 'report' ? 'Report' : `${e.ask ? 'Asks' : 'Says'} in ${e.thread}`}:\n${e.text}\n`);
134
+ if (done.error) console.log(`The run failed: ${done.error}`);
135
+ break;
136
+ }
137
+ } else if (cmd === 'upload' && args.length === 2) {
138
+ const q = new URLSearchParams({ name: basename(args[1]), pane: flags.output ? 'out' : 'in', ...(typeof flags.onto === 'string' && { onto: flags.onto }) });
139
+ await api(`${(await find(args[0])).at}/upload?${q}`, { method: 'POST', body: readFileSync(args[1]) });
140
+ console.log('uploaded');
141
+ } else if (cmd === 'wake' && args.length >= 3) {
142
+ const { series, inst } = await find(args[0]);
143
+ const at = args[1] === 'now' ? Date.now() : args[1].startsWith('+') ? Date.now() + Number(args[1].slice(1, -1)) * { m: 60e3, h: 3600e3, d: 86400e3 }[args[1].at(-1) ] : Date.parse(args[1]);
144
+ const w = await (await post(`/api/wakes/${inst?.id ?? series.id}`, { at: new Date(at).toISOString(), reason: args.slice(2).join(' '), label: flags.label }, 'PUT')).json();
145
+ console.log(`wakes at ${w.at}`);
146
+ } else if (cmd === 'agents' && args.length <= 1) {
147
+ const ws = args[0] === undefined ? undefined : (await get('/api')).find(w => w.name === args[0]);
148
+ if (args[0] !== undefined && !ws) throw new Error(`no workspace ${args[0]}`);
149
+ if (typeof flags.set !== 'string') console.log(ws ? ws.agents : (await get('/agents')).text);
150
+ else {
151
+ const text = readFileSync(flags.set === '-' ? 0 : flags.set, 'utf8');
152
+ await (ws ? post(`/api/workspaces/${ws.id}`, { agents: text }, 'PUT') : api('/agents', { method: 'PUT', body: text }));
153
+ console.log('saved');
154
+ }
155
+ } else if (cmd === 'skill' && args[0] === 'push' && args[1]) {
156
+ const files = Object.fromEntries(readdirSync(args[1], { recursive: true, withFileTypes: true }).filter(d => d.isFile())
157
+ .map(d => [relative(args[1], join(d.parentPath, d.name)).split(sep).join('/'), readFileSync(join(d.parentPath, d.name)).toString('base64')]));
158
+ console.log(await (await post(`/skills/${encodeURIComponent(basename(resolve(args[1])))}/versions`, { files })).text());
159
+ } else if (cmd === 'skill' && args[0] === 'versions' && args[1]) {
160
+ for (const v of await get(`/skills/${encodeURIComponent(args[1])}/versions`)) console.log(`${v.version} ${v.at} ${v.by}`);
161
+ } else if (cmd === 'skill' && args[0] === 'rollback' && args.length === 3) {
162
+ console.log(await (await post(`/skills/${encodeURIComponent(args[1])}/versions`, { from: Number(args[2]) })).text());
163
+ } else if (cmd === 'skills') {
164
+ for (const s of await get('/skills')) console.log(`${s.id} v${s.pushed.version} by ${s.pushed.by} ${s.pushed.at} ${s.description}`);
165
+ } else if (cmd === 'websites') {
166
+ for (const w of await get('/websites')) console.log(`${w.id} ${w.url} ${w.hosts.join(' ') || '-'} ${w.name}`);
167
+ } else if (cmd === 'website' && args[0] === 'set' && args.length === 3) {
168
+ await post(`/websites/${encodeURIComponent(args[1])}`, { url: args[2], name: flags.name, hosts: typeof flags.hosts === 'string' ? flags.hosts.split(',') : [] }, 'PUT');
169
+ console.log('saved');
170
+ } else if (cmd === 'website' && args[0] === 'remove' && args[1]) {
171
+ await api(`/websites/${encodeURIComponent(args[1])}`, { method: 'DELETE' });
172
+ console.log('removed');
173
+ } else if (cmd === 'users') {
174
+ for (const u of await get('/users')) console.log([u.email, u.role, u.disabled ? 'disabled' : '', u.last_login ? `last in ${u.last_login}` : 'never in', u.workspaces.join(', ')].filter(Boolean).join('\t'));
175
+ } else if (cmd === 'user' && args.length >= 2) {
176
+ const [what, email, role] = args, at = `/users/${encodeURIComponent(email)}`;
177
+ if (what === 'add') await post('/users', { email, role: role ?? 'user' });
178
+ else if (what === 'role' && role) await post(at, { role }, 'PUT');
179
+ else if (what === 'disable' || what === 'enable') await post(at, { disabled: what === 'disable' }, 'PUT');
180
+ else if (what === 'signout') await post(at, { signout: true }, 'PUT');
181
+ else if (what === 'remove') await api(at, { method: 'DELETE' });
182
+ else throw new Error(USAGE);
183
+ console.log('done');
184
+ } else {
185
+ console.error(USAGE);
186
+ process.exitCode = 2;
187
+ }