clickroom 0.3.3 → 0.3.5
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/cli/agents.js +2 -2
- package/cli/gaps.js +17 -7
- package/cli/note.js +2 -2
- package/cli/process.js +3 -3
- package/cli/push.js +64 -5
- package/cli/run.js +24 -0
- package/cli/share.js +24 -3
- package/package.json +2 -1
package/cli/agents.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { execFile } from 'node:child_process';
|
|
5
4
|
import { HOME, ROOT } from './config.js';
|
|
5
|
+
import { execCmd } from './run.js';
|
|
6
6
|
|
|
7
7
|
// `npx clickroom` also connects the coding agents on the computer to clickroom's feedback (the MCP
|
|
8
8
|
// server, `clickroom mcp`, signed in with this computer's key): Claude Code for the project being
|
|
@@ -24,7 +24,7 @@ const SERVER = ['npx', '-y', VERSION ? `clickroom@${VERSION}` : 'clickroom', 'mc
|
|
|
24
24
|
const DONE = path.join(HOME, 'agents.json');
|
|
25
25
|
|
|
26
26
|
const run = (cmd, args, cwd) =>
|
|
27
|
-
new Promise((resolve) =>
|
|
27
|
+
new Promise((resolve) => execCmd(cmd, args, { cwd, timeout: 20000 }, (err, stdout) => resolve({ ok: !err, missing: err?.code === 'ENOENT', out: String(stdout || '') })));
|
|
28
28
|
|
|
29
29
|
const AGENTS = [
|
|
30
30
|
{
|
package/cli/gaps.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// "Find gaps with Claude" (Clarify): the project's clickroom terminal runs Claude Code headless in
|
|
2
2
|
// the project folder. It may only read files and ask through clickroom's MCP server, so each gap it
|
|
3
3
|
// finds lands in Clarify as a draft, live, while nothing in the folder changes.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
4
6
|
import path from 'node:path';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
+
import { ROOT, credentials, isTeammate } from './config.js';
|
|
8
|
+
import { onPath, spawnCmd } from './run.js';
|
|
7
9
|
import { call } from './client.js';
|
|
8
10
|
import { gapsPrompt } from '../worker/handoff.js';
|
|
9
11
|
|
|
@@ -11,25 +13,33 @@ const TOOLS = ['Read', 'Glob', 'Grep', 'mcp__clickroom__clickroom_ask', 'mcp__cl
|
|
|
11
13
|
const NEVER = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash', 'WebFetch', 'WebSearch'];
|
|
12
14
|
const LIMIT = 10 * 60 * 1000; // a run that hangs is stopped
|
|
13
15
|
|
|
14
|
-
export const hasClaude = () =>
|
|
16
|
+
export const hasClaude = () => onPath('claude');
|
|
17
|
+
|
|
18
|
+
// Whose computer this is, for the review: the run is asked for from someone else's browser.
|
|
19
|
+
const computer = () => (isTeammate() && credentials()?.name ? `${credentials().name}’s computer` : 'the computer');
|
|
15
20
|
|
|
16
21
|
const questionCount = async (name) =>
|
|
17
22
|
(await call('GET', `/__ps/api/projects/${encodeURIComponent(name)}/threads`)).threads.filter((t) => t.kind === 'question').length;
|
|
18
23
|
|
|
19
24
|
// Resolves to the number of questions it added (drafts), or throws.
|
|
20
25
|
export async function findGaps(name, root) {
|
|
21
|
-
if (!hasClaude()) throw new Error(
|
|
26
|
+
if (!hasClaude()) throw new Error(`Claude Code (\`claude\`) isn’t installed on ${computer()}, which runs \`clickroom\` for this project.`);
|
|
22
27
|
const before = await questionCount(name);
|
|
23
|
-
|
|
24
|
-
const
|
|
28
|
+
// The MCP config as a file and the prompt through stdin: both survive Windows' shell.
|
|
29
|
+
const mcp = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'clickroom-gaps-')), 'mcp.json');
|
|
30
|
+
fs.writeFileSync(mcp, JSON.stringify({ mcpServers: { clickroom: { command: process.execPath, args: [path.join(ROOT, 'bin', 'clickroom.js'), 'mcp'] } } }));
|
|
31
|
+
const args = ['-p', '--mcp-config', mcp, '--strict-mcp-config', '--tools', 'Read,Glob,Grep', '--allowedTools', TOOLS.join(','), '--disallowedTools', NEVER.join(','), '--no-session-persistence'];
|
|
25
32
|
await new Promise((resolve, reject) => {
|
|
26
|
-
const child =
|
|
33
|
+
const child = spawnCmd('claude', args, { cwd: root, stdio: ['pipe', 'ignore', 'pipe'] });
|
|
34
|
+
child.stdin.on('error', () => {});
|
|
35
|
+
child.stdin.end(gapsPrompt(name));
|
|
27
36
|
let err = '';
|
|
28
37
|
child.stderr.on('data', (d) => (err = (err + d).slice(-2000)));
|
|
29
38
|
const timer = setTimeout(() => child.kill('SIGTERM'), LIMIT);
|
|
30
39
|
child.on('error', reject);
|
|
31
40
|
child.on('exit', (code) => {
|
|
32
41
|
clearTimeout(timer);
|
|
42
|
+
fs.rmSync(path.dirname(mcp), { recursive: true, force: true });
|
|
33
43
|
code === 0 ? resolve() : reject(new Error(err.trim().split('\n').pop() || `claude exited with ${code}`));
|
|
34
44
|
});
|
|
35
45
|
});
|
package/cli/note.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnCmd } from './run.js';
|
|
2
2
|
import { call } from './client.js';
|
|
3
3
|
import { changedSource, folderChanges } from './changes.js';
|
|
4
4
|
|
|
@@ -55,7 +55,7 @@ function askClaude(input) {
|
|
|
55
55
|
let out = '';
|
|
56
56
|
let child;
|
|
57
57
|
try {
|
|
58
|
-
child =
|
|
58
|
+
child = spawnCmd('claude', ['-p', '--model', 'haiku', '--tools', '', '--no-session-persistence', '--setting-sources', '', '--system-prompt', PROMPT], { stdio: ['pipe', 'pipe', 'ignore'] });
|
|
59
59
|
} catch {
|
|
60
60
|
return resolve(null);
|
|
61
61
|
}
|
package/cli/process.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// answers to questions), writes one brief into the project and starts Claude Code on it.
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import {
|
|
5
|
+
import { onPath, spawnCmd } from './run.js';
|
|
6
6
|
import { call } from './client.js';
|
|
7
7
|
import { buildBrief } from '../worker/handoff.js';
|
|
8
8
|
|
|
@@ -33,14 +33,14 @@ export function writeBrief(root, text) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export function hasClaude() {
|
|
36
|
-
return
|
|
36
|
+
return onPath('claude');
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
// Interactive Claude Code in the project, so the person watches and approves the changes.
|
|
40
40
|
export function startClaude(root, name) {
|
|
41
41
|
const prompt = `Process the clickroom feedback in ${BRIEF} for project "${name}". Read it first, then work through every item.`;
|
|
42
42
|
return new Promise((resolve, reject) => {
|
|
43
|
-
const child =
|
|
43
|
+
const child = spawnCmd('claude', [prompt], { cwd: root, stdio: 'inherit' });
|
|
44
44
|
child.on('error', reject);
|
|
45
45
|
child.on('exit', (code) => resolve(code ?? 0));
|
|
46
46
|
});
|
package/cli/push.js
CHANGED
|
@@ -7,6 +7,7 @@ import { BACKUP_DIR, copyBuild, cloudUrl, buildPages, baseUrl, localUrl, isTeamm
|
|
|
7
7
|
import { call } from './client.js';
|
|
8
8
|
import { dim } from './dev.js';
|
|
9
9
|
import { rememberFolder } from './changes.js';
|
|
10
|
+
import { serversInProject, probe } from './detect.js';
|
|
10
11
|
|
|
11
12
|
const OUT_DIRS = ['dist', 'build', 'out', '.output/public'];
|
|
12
13
|
|
|
@@ -98,11 +99,16 @@ export async function push(name, local, { note, live = false, build, out, quiet
|
|
|
98
99
|
} else {
|
|
99
100
|
const pkgDir = packageDir(dir, local.cwd);
|
|
100
101
|
const cmd = command || buildScript(pkgDir);
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
|
|
102
|
+
// No build step: a folder of plain web files (served by any little server) is its own snapshot.
|
|
103
|
+
const plain = !cmd && [pkgDir || dir, ...OUT_DIRS.map((d) => path.join(pkgDir || dir, d))].find((d) => fs.existsSync(path.join(d, 'index.html')));
|
|
104
|
+
if (plain) source = plain;
|
|
105
|
+
else if (!cmd) throw new NoSnapshot(`No build script in ${path.join(pkgDir || dir, 'package.json')}, so there's nothing to snapshot.`);
|
|
106
|
+
else {
|
|
107
|
+
const started = Date.now();
|
|
108
|
+
if (!quiet) console.log(dim(` Building v${next}: ${cmd}`));
|
|
109
|
+
await run(cmd, pkgDir || dir);
|
|
110
|
+
source = out || local.out ? path.resolve(pkgDir || dir, out || local.out) : findOutput(pkgDir || dir, started);
|
|
111
|
+
}
|
|
106
112
|
if (!source) throw new NoSnapshot(`The build finished, but no folder with an index.html was produced (looked in ${OUT_DIRS.join(', ')}).`);
|
|
107
113
|
}
|
|
108
114
|
if (!fs.existsSync(path.join(source, 'index.html'))) throw new NoSnapshot(`${source} has no index.html.`);
|
|
@@ -213,6 +219,59 @@ export async function backup(name) {
|
|
|
213
219
|
}
|
|
214
220
|
|
|
215
221
|
// Nearest folder with a package.json, from where clickroom was run up to the project root.
|
|
222
|
+
// No dev server running (the owner's Live view): start the project's own (`npm run dev`, else
|
|
223
|
+
// `start`) for as long as the session runs. { target, how, stop } once it answers in this project,
|
|
224
|
+
// null without such a script.
|
|
225
|
+
export async function startDevServer(root, { exclude = [] } = {}) {
|
|
226
|
+
const dir = packageDir(root, root);
|
|
227
|
+
if (!dir) return null;
|
|
228
|
+
let pkg;
|
|
229
|
+
try {
|
|
230
|
+
pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
231
|
+
} catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
const script = ['dev', 'start'].find((s) => pkg.scripts?.[s]);
|
|
235
|
+
if (!script) return null;
|
|
236
|
+
const pm = packageManager(dir);
|
|
237
|
+
const cmd = `${pm} run ${script}`;
|
|
238
|
+
if (Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).length && !fs.existsSync(path.join(dir, 'node_modules'))) {
|
|
239
|
+
console.log(dim(` Installing the project's packages (${pm} install; the first time only, this can take a minute)…`));
|
|
240
|
+
await run(`${pm} install`, dir).catch((err) => {
|
|
241
|
+
throw new FolderProblem(`Installing the project's packages failed.\n${err.message.replace(/^.*\n/, '')}`, `${pm} install fails in this project; please fix it, then run npx clickroom again.`);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
console.log(dim(` No dev server running, so clickroom starts it: ${cmd}`));
|
|
245
|
+
// Its own process group, so stopping it also stops what it started (vite, next, …).
|
|
246
|
+
const child = spawn(cmd, { cwd: dir, shell: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, BROWSER: 'none', FORCE_COLOR: '0' } });
|
|
247
|
+
let log = '';
|
|
248
|
+
const keep = (d) => (log = (log + d).slice(-4000));
|
|
249
|
+
child.stdout.on('data', keep);
|
|
250
|
+
child.stderr.on('data', keep);
|
|
251
|
+
let exited = false;
|
|
252
|
+
child.on('exit', () => (exited = true));
|
|
253
|
+
child.on('error', () => (exited = true));
|
|
254
|
+
const stop = () => {
|
|
255
|
+
try {
|
|
256
|
+
if (process.platform === 'win32') child.kill();
|
|
257
|
+
else process.kill(-child.pid, 'SIGTERM');
|
|
258
|
+
} catch {}
|
|
259
|
+
};
|
|
260
|
+
for (let i = 0; i < 90 && !exited; i++) {
|
|
261
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
262
|
+
const own = await serversInProject(root, { exclude });
|
|
263
|
+
for (const s of own) {
|
|
264
|
+
const p = await probe(s.port);
|
|
265
|
+
if (p.ok && p.html) return { target: `http://localhost:${s.port}`, how: `${cmd}, started by clickroom`, stop };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
stop();
|
|
269
|
+
throw new FolderProblem(
|
|
270
|
+
`${exited ? `\`${cmd}\` stopped` : `\`${cmd}\` didn't start a web server within 90 seconds`}:\n${log.trim().split('\n').slice(-8).map((l) => ' ' + l).join('\n')}`,
|
|
271
|
+
`\`${cmd}\` doesn't start the dev server in this project; please fix it, then run npx clickroom again.`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
216
275
|
function packageDir(dir, root) {
|
|
217
276
|
let d = path.resolve(dir);
|
|
218
277
|
const stop = path.resolve(root || dir);
|
package/cli/run.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { spawn, execFile, spawnSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
// Starting the coding agents' commands (claude, codex) on every OS. On Windows they are often
|
|
4
|
+
// npm's `.cmd` launchers, which Node only starts through the shell (cmd.exe): there each argument
|
|
5
|
+
// is quoted, double quotes become single ones and line breaks spaces (cmd can't take them), so
|
|
6
|
+
// anything long or multi-line goes in through stdin or a file instead.
|
|
7
|
+
|
|
8
|
+
const WIN = process.platform === 'win32';
|
|
9
|
+
const quote = (a) => {
|
|
10
|
+
const s = String(a).replace(/\r?\n/g, ' ').replace(/"/g, "'");
|
|
11
|
+
return s === '' || /[\s&|<>^()%!,;=]/.test(s) ? `"${s}"` : s;
|
|
12
|
+
};
|
|
13
|
+
const shellArgs = (args) => (WIN ? args.map(quote) : args);
|
|
14
|
+
|
|
15
|
+
// Is the command there? (On Windows the shell can't tell us: a missing command just fails.)
|
|
16
|
+
export const onPath = (cmd) => spawnSync(WIN ? 'where' : 'which', [cmd], { stdio: 'ignore', windowsHide: true }).status === 0;
|
|
17
|
+
|
|
18
|
+
export const spawnCmd = (cmd, args, opts = {}) => spawn(cmd, shellArgs(args), { windowsHide: true, ...opts, shell: WIN });
|
|
19
|
+
|
|
20
|
+
// execFile's callback, with `missing` when the command isn't installed (ENOENT elsewhere).
|
|
21
|
+
export function execCmd(cmd, args, opts, cb) {
|
|
22
|
+
if (WIN && !onPath(cmd)) return cb(Object.assign(new Error(`${cmd} not found`), { code: 'ENOENT' }), '');
|
|
23
|
+
return execFile(cmd, shellArgs(args), { windowsHide: true, ...opts, shell: WIN }, cb);
|
|
24
|
+
}
|
package/cli/share.js
CHANGED
|
@@ -6,7 +6,7 @@ import { readLocal, writeLocal, copyBuild, cloudUrl, baseUrl, isTeammate, adminT
|
|
|
6
6
|
import { call, isUp, signinLink } from './client.js';
|
|
7
7
|
import { projectRoot, projectName, parseTarget, detectTarget } from './detect.js';
|
|
8
8
|
import { startServer, bold, dim } from './dev.js';
|
|
9
|
-
import { push, pushHint, backup, NoSnapshot, FolderProblem, prepareFolder, projectFolder } from './push.js';
|
|
9
|
+
import { push, pushHint, backup, NoSnapshot, FolderProblem, prepareFolder, projectFolder, startDevServer } from './push.js';
|
|
10
10
|
import { folderChanges } from './changes.js';
|
|
11
11
|
import { connectAgents, agentsReport } from './agents.js';
|
|
12
12
|
import { autoNote } from './note.js';
|
|
@@ -57,6 +57,17 @@ export async function share(arg, opts) {
|
|
|
57
57
|
const what = await prepareFolder(cwd, root).catch(explain);
|
|
58
58
|
target = what.kind === 'static' ? { kind: 'static', target: null, source: what.source, how: 'the files as they are' } : { kind: 'build', target: null, source: null, how: 'each push runs its build' };
|
|
59
59
|
}
|
|
60
|
+
// Nothing running in this project: clickroom starts the project's own dev server (until q), and
|
|
61
|
+
// a folder of plain web files needs none: its files are the versions.
|
|
62
|
+
let dev = null;
|
|
63
|
+
if (!teammate && !arg && (!target || target.uncertain)) {
|
|
64
|
+
dev = await startDevServer(root, { exclude: [port, 9229, 9230] }).catch(explain);
|
|
65
|
+
if (dev) {
|
|
66
|
+
target = { kind: 'url', target: dev.target, how: dev.how };
|
|
67
|
+
process.on('exit', dev.stop); // also when the session ends with an error
|
|
68
|
+
}
|
|
69
|
+
else if (fs.existsSync(path.join(root, 'index.html'))) target = { kind: 'static', target: null, source: root, how: 'the files as they are' };
|
|
70
|
+
}
|
|
60
71
|
if (!target) {
|
|
61
72
|
throw new Error(
|
|
62
73
|
'No running dev server found for this project.\n' +
|
|
@@ -79,8 +90,17 @@ export async function share(arg, opts) {
|
|
|
79
90
|
console.log(dim(` Sharing through ${cloud} as you`));
|
|
80
91
|
} else if (cloud) {
|
|
81
92
|
console.log(dim(` Sharing through ${cloud}`));
|
|
93
|
+
// The Live view reaches this Mac through the local clickroom server (the relay). Not running
|
|
94
|
+
// (no `clickroom service`): this session runs it, until q.
|
|
82
95
|
const relay = await fetch(`http://localhost:${port}/__ps/api/health`).then((r) => r.ok, () => false);
|
|
83
|
-
if (!relay
|
|
96
|
+
if (!relay && target.kind === 'url') {
|
|
97
|
+
console.log(dim(' Starting the local clickroom server for the Live view…'));
|
|
98
|
+
server = await startServer({ ...opts, port, tunnel: true }).catch((e) => {
|
|
99
|
+
console.log(dim(` Couldn't start it (${e.message}), so the Live view is offline; pushed versions work.`));
|
|
100
|
+
return null;
|
|
101
|
+
});
|
|
102
|
+
delete process.env.CLICKROOM_URL; // startServer points the CLI at it; this session talks to the cloud
|
|
103
|
+
}
|
|
84
104
|
} else if (await isUp()) {
|
|
85
105
|
console.log(dim(` Attaching to the clickroom server already running on :${port}`));
|
|
86
106
|
} else {
|
|
@@ -253,10 +273,11 @@ export async function share(arg, opts) {
|
|
|
253
273
|
watcher?.close();
|
|
254
274
|
await call('DELETE', `/__ps/api/projects/${encodeURIComponent(name)}/session`).catch(() => {});
|
|
255
275
|
await backup(name);
|
|
276
|
+
dev?.stop();
|
|
256
277
|
if (server) {
|
|
257
278
|
server.stop();
|
|
258
279
|
await Promise.race([server.exited, new Promise((r) => setTimeout(r, 3000))]);
|
|
259
|
-
console.log(dim('\n Tunnel closed.'));
|
|
280
|
+
console.log(dim(cloud ? '\n Live view stopped (pushed versions stay online).' : '\n Tunnel closed.'));
|
|
260
281
|
}
|
|
261
282
|
process.exit(0);
|
|
262
283
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clickroom",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "Share prototype builds for review: pins, region comments, threads, versions, and agent hand-off (MCP). Teammates push builds to their team’s clickroom with `clickroom login`.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"test": "node --test test/*.test.js",
|
|
14
14
|
"screens": "node examples/screens/build.js",
|
|
15
15
|
"demo:seed": "node examples/demo/seed.js",
|
|
16
|
+
"release": "npm version patch --no-git-tag-version && npm publish && git commit -am \"$(node -p 'require(\"./package.json\").version')\"",
|
|
16
17
|
"prepublishOnly": "node --test test/detect.test.js test/changes.test.js test/webpush.test.js"
|
|
17
18
|
},
|
|
18
19
|
"engines": {
|