@foldspace_npm/harness 0.1.31 → 0.1.33
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/CLAUDE.md +27 -8
- package/README.md +25 -2
- package/bin/ask.mjs +16 -145
- package/bin/attach.mjs +2 -0
- package/bin/cli.mjs +22 -0
- package/bin/inject.mjs +2 -14
- package/bin/record.mjs +412 -0
- package/bin/share.mjs +63 -0
- package/package.json +1 -1
- package/src/attach-daemon.mjs +18 -0
- package/src/badge-auto.mjs +38 -1
- package/src/cdp-client.mjs +7 -1
- package/src/chrome-binary.mjs +28 -0
- package/src/cli-help.mjs +1 -0
- package/src/cli-registry.mjs +97 -3
- package/src/init.mjs +170 -16
- package/src/panel-driver.mjs +283 -0
- package/src/session-events.mjs +1 -0
- package/src/upgrade.mjs +87 -1
- package/src/video/README.md +29 -0
- package/src/video/assets/foldspace-mark.svg +27 -0
- package/src/video/assets/logo-dark.svg +68 -0
- package/src/video/assets/logo-light.svg +56 -0
- package/src/video/brand.mjs +46 -0
- package/src/video/compose.mjs +476 -0
- package/src/video/composer/composer.mjs +554 -0
- package/src/video/composer/index.html +13 -0
- package/src/video/fonts/OFL.txt +93 -0
- package/src/video/fonts/Poppins-ExtraBold.woff2 +0 -0
- package/src/video/fonts/Poppins-Regular.woff2 +0 -0
- package/src/video/fonts/Poppins-SemiBold.woff2 +0 -0
- package/src/video/recorder.mjs +331 -0
- package/src/video/scenes.mjs +253 -0
- package/src/video/share.mjs +284 -0
- package/src/video/timeline.mjs +726 -0
- package/src/video/vendor/mp4-muxer.LICENSE +21 -0
- package/src/video/vendor/mp4-muxer.min.mjs +5 -0
- package/templates/agent-starter/README.md +5 -0
- package/templates/agent-starter/claude-settings.json +1 -0
- package/templates/agent-starter/gitignore +3 -0
- package/templates/agent-starter/skills/share-a-video/SKILL.md +271 -0
package/src/cli-registry.mjs
CHANGED
|
@@ -32,7 +32,7 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
32
32
|
{
|
|
33
33
|
name: "directory",
|
|
34
34
|
required: "non-interactive",
|
|
35
|
-
description: "
|
|
35
|
+
description: "Project directory: a new one, or one that holds nothing but files the system or an editor put there",
|
|
36
36
|
},
|
|
37
37
|
],
|
|
38
38
|
options: [
|
|
@@ -59,10 +59,10 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
59
59
|
],
|
|
60
60
|
prerequisites: [
|
|
61
61
|
"Node 20 or newer",
|
|
62
|
-
"A target directory that does not
|
|
62
|
+
"A target directory that does not exist yet, or holds nothing but .DS_Store, .localized, Icon\\r, ._*, Thumbs.db, desktop.ini, .mcp.json, .git/, .vscode/, .cursor/, .idea/ or .claude/ without settings.json, and is not the home directory, its Desktop, Documents or Downloads, the disk's root, a Claude Code Desktop no-folder session, or a Codex chat's folder (~/Documents/Codex and the folders directly in it)",
|
|
63
63
|
"Non-interactive use requires directory, product ID, Agent Key, and app domain",
|
|
64
64
|
],
|
|
65
|
-
effects: ["Creates a new
|
|
65
|
+
effects: ["Creates a local project in a new folder, or in place in one that holds nothing but files the system or an editor put there; never overwrites a file and never initializes Git"],
|
|
66
66
|
next: [
|
|
67
67
|
"npm install --ignore-scripts",
|
|
68
68
|
"Before the first build, CLAUDE.md step 5 (\"Every session starts the same way\"): with a code project, its agent/ replaces the scaffold's",
|
|
@@ -107,6 +107,8 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
107
107
|
"With --yes, writes the latest version into package.json (exact) and runs npm install --ignore-scripts",
|
|
108
108
|
"Does not rebuild dist/index.js or publish anything",
|
|
109
109
|
"--refresh-instructions copies CLAUDE.md to CLAUDE.md.bak then writes the stub",
|
|
110
|
+
"With --yes or --refresh-instructions, writes the harness's skills (share-a-video) into .claude/skills/ from the installed version, keeping a copy that differs as <file>.bak; other skills there are left alone",
|
|
111
|
+
"Any foldspace command writes a harness skill the project is missing, so the first upgrade from a harness older than the skill still gets it",
|
|
110
112
|
],
|
|
111
113
|
next: [
|
|
112
114
|
"If outdated, ask the human, then run npx foldspace upgrade --yes",
|
|
@@ -440,6 +442,96 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
440
442
|
"To go back to the local build: npx foldspace attach --daemon",
|
|
441
443
|
],
|
|
442
444
|
}),
|
|
445
|
+
Object.freeze({
|
|
446
|
+
name: "record",
|
|
447
|
+
entry: "record.mjs",
|
|
448
|
+
group: "share",
|
|
449
|
+
summary: "Record a short video of the agent's key use cases, answered in the test window, for the human to share",
|
|
450
|
+
usage:
|
|
451
|
+
"foldspace record [--scenes <file>] [--preset landscape|square] [--no-end-card] [--from <record-dir>]",
|
|
452
|
+
risk: "browser-session",
|
|
453
|
+
environment: "local-chrome",
|
|
454
|
+
environmentVariables: ["FOLDSPACE_PROJECT_DIR", "CDP_PORT", "CHROME_PATH"],
|
|
455
|
+
capabilities: ["browser.cdp", "page.evaluate", "browser.launch"],
|
|
456
|
+
positionals: [],
|
|
457
|
+
options: [
|
|
458
|
+
value("--scenes", "file", "The scenes and their captions: { product, scenes: [{ question, capabilities, line }] }", {
|
|
459
|
+
default: "video/scenes.json",
|
|
460
|
+
}),
|
|
461
|
+
value("--preset", "landscape|square", "1920x1080 or 1080x1080; either way the whole product stays in the frame", {
|
|
462
|
+
default: "landscape",
|
|
463
|
+
choices: ["landscape", "square"],
|
|
464
|
+
}),
|
|
465
|
+
flag("--no-end-card", "Leave out the closing card"),
|
|
466
|
+
value(
|
|
467
|
+
"--from",
|
|
468
|
+
"record-dir",
|
|
469
|
+
"Make the video again from an earlier recording, with the capabilities and lines in the scenes file now; records nothing",
|
|
470
|
+
),
|
|
471
|
+
],
|
|
472
|
+
prerequisites: [
|
|
473
|
+
"The test window from `npm run inject`, signed in, with the agent on the page: `npx foldspace attach --daemon` running, or after `npx foldspace published`",
|
|
474
|
+
"attach without --test-mode",
|
|
475
|
+
"Each question already proven with `npx foldspace ask`",
|
|
476
|
+
"Google Chrome, which makes the video (some Chromium builds cannot encode H.264)",
|
|
477
|
+
],
|
|
478
|
+
effects: [
|
|
479
|
+
"Asks each question in a new conversation, typed key by key: real conversations, in their Foldspace dashboard",
|
|
480
|
+
"The video shows real data from the signed-in account; nothing is masked. Whether to share it is the human's call",
|
|
481
|
+
"Hides the pill and the corner tag while it records, and puts them back after - also when it is stopped (Ctrl-C, a timeout)",
|
|
482
|
+
"Runs for a few minutes: give it a 10-minute timeout",
|
|
483
|
+
"Records with Chrome's own screen capture (no screen-recording permission) into .foldspace-dev/record/, keeping the 3 latest finished recordings",
|
|
484
|
+
"Makes the video in a separate hidden Chrome; writes video/<product>-<date>.mp4 and a .jpg poster, and video/.gitignore so neither is saved with the code",
|
|
485
|
+
"Uploads nothing and posts nothing",
|
|
486
|
+
],
|
|
487
|
+
next: [
|
|
488
|
+
"Read the poster and each scene's lastFrame: every answer complete, none an error. The human watches the video",
|
|
489
|
+
"Hand over the MP4 and the poster: say plainly it shows real data from their account, and that sharing it is their call",
|
|
490
|
+
"To change a scene's capabilities or line: edit the scenes file, then `npx foldspace record --from <recordDir>`",
|
|
491
|
+
],
|
|
492
|
+
}),
|
|
493
|
+
Object.freeze({
|
|
494
|
+
name: "share",
|
|
495
|
+
entry: "share.mjs",
|
|
496
|
+
group: "share",
|
|
497
|
+
summary: "Size and check a video for a Foldspace share link, then upload it with the targets create_video_share returned",
|
|
498
|
+
usage: "foldspace share <inspect|upload> [<video.mp4>] [--poster <file.jpg>] [--targets '<json>']",
|
|
499
|
+
risk: "remote-upload",
|
|
500
|
+
environment: "node",
|
|
501
|
+
environmentVariables: ["FOLDSPACE_PROJECT_DIR"],
|
|
502
|
+
capabilities: ["video.upload"],
|
|
503
|
+
positionals: [
|
|
504
|
+
Object.freeze({
|
|
505
|
+
name: "action",
|
|
506
|
+
required: true,
|
|
507
|
+
description:
|
|
508
|
+
"inspect: size and check the MP4 and its poster here, uploading nothing | upload: send both to Foldspace's video storage with the targets create_video_share returned",
|
|
509
|
+
}),
|
|
510
|
+
Object.freeze({
|
|
511
|
+
name: "video",
|
|
512
|
+
required: false,
|
|
513
|
+
description: "The MP4; default the newest video/*.mp4 that `record` made",
|
|
514
|
+
}),
|
|
515
|
+
],
|
|
516
|
+
options: [
|
|
517
|
+
value("--poster", "file.jpg", "The JPEG poster", { default: "the MP4's name with .jpg" }),
|
|
518
|
+
value("--targets", "json", "upload only: the targets JSON exactly as the upload command from create_video_share has it"),
|
|
519
|
+
],
|
|
520
|
+
prerequisites: [
|
|
521
|
+
"An H.264 MP4 with its moov box first (what `record` makes) and a JPEG poster",
|
|
522
|
+
"upload: the human said yes to a public link, and the Foldspace MCP tool create_video_share returned the upload command (its targets last 30 minutes)",
|
|
523
|
+
],
|
|
524
|
+
effects: [
|
|
525
|
+
"inspect reads the two files and nothing else",
|
|
526
|
+
"upload sends the MP4 and the poster to Foldspace's video storage, and only there; it reads no Foldspace sign-in",
|
|
527
|
+
"Never prints the upload targets: each is a one-time credential for one file",
|
|
528
|
+
"Anyone with the link publish_video_share returns can watch the video, which shows real data from the account",
|
|
529
|
+
],
|
|
530
|
+
next: [
|
|
531
|
+
"After inspect, with the human's yes: create_video_share with a title, video_bytes and poster_bytes, then run its upload command",
|
|
532
|
+
"After upload: publish_video_share with the share_id; give the human the link",
|
|
533
|
+
],
|
|
534
|
+
}),
|
|
443
535
|
Object.freeze({
|
|
444
536
|
name: "deploy",
|
|
445
537
|
// Foldspace-internal: it uploads to Foldspace's own storage, which no
|
|
@@ -492,12 +584,14 @@ export const CAPABILITY_CATALOGUE = Object.freeze([
|
|
|
492
584
|
["page.evaluate", "Inspect bounded SDK and registration state"],
|
|
493
585
|
["page.mutate", "Prepare and restore a matching development page"],
|
|
494
586
|
["artifact.publish", "Write the built artifact to remote storage"],
|
|
587
|
+
["video.upload", "Upload a video and its poster for a Foldspace share link, with targets the Foldspace MCP issued"],
|
|
495
588
|
]);
|
|
496
589
|
|
|
497
590
|
export const RISK_CATALOGUE = Object.freeze([
|
|
498
591
|
["local-write", "Writes only inside the consuming project"],
|
|
499
592
|
["browser-session", "Launches or temporarily mutates a local browser session"],
|
|
500
593
|
["remote-publish", "Writes the action artifact to remote storage"],
|
|
594
|
+
["remote-upload", "Uploads files the human chose to share to Foldspace's video storage"],
|
|
501
595
|
]);
|
|
502
596
|
|
|
503
597
|
export const ENVIRONMENT_CATALOGUE = Object.freeze([
|
package/src/init.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import readline from "node:readline/promises";
|
|
5
6
|
import { stdin as input, stdout as output } from "node:process";
|
|
@@ -299,13 +300,11 @@ function createTemplateValues(config, harnessVersion) {
|
|
|
299
300
|
throw new Error("Display name must be a non-empty single line and cannot contain '{{'.");
|
|
300
301
|
}
|
|
301
302
|
|
|
302
|
-
const packageName = toPackageName(displayName);
|
|
303
|
-
if (!packageName) {
|
|
304
|
-
throw new Error("Display name must contain at least one letter or number.");
|
|
305
|
-
}
|
|
306
|
-
|
|
307
303
|
const productId = validateProductId(config.productId);
|
|
308
304
|
const agentApiName = validateIdentifier(config.agentApiName, "Agent Key");
|
|
305
|
+
// A name in another alphabet, such as a folder the human named, has no
|
|
306
|
+
// letters a package name can use; the Agent Key always has.
|
|
307
|
+
const packageName = toPackageName(displayName) || toPackageName(agentApiName);
|
|
309
308
|
const region = parseRegion(config.region || DEFAULT_REGION);
|
|
310
309
|
const target = normalizeTarget(config.domain);
|
|
311
310
|
const hosts =
|
|
@@ -362,14 +361,17 @@ function copyTemplate(templateRoot, targetRoot, values) {
|
|
|
362
361
|
const sourcePath = path.join(templateRoot, relativePath);
|
|
363
362
|
// npm excludes .gitignore files from package tarballs, so the bundled
|
|
364
363
|
// template uses a publish-safe name and restores it during generation.
|
|
365
|
-
// The permission allowlist
|
|
366
|
-
// same reason
|
|
364
|
+
// The permission allowlist and the skills live in .claude/, kept flat
|
|
365
|
+
// here for the same reason - and so a session working on the harness
|
|
366
|
+
// itself does not pick the skills up as its own.
|
|
367
367
|
const outputPath =
|
|
368
368
|
relativePath === "gitignore"
|
|
369
369
|
? ".gitignore"
|
|
370
370
|
: relativePath === "claude-settings.json"
|
|
371
371
|
? path.join(".claude", "settings.json")
|
|
372
|
-
: relativePath
|
|
372
|
+
: relativePath === "skills" || relativePath.startsWith(`skills${path.sep}`)
|
|
373
|
+
? path.join(".claude", relativePath)
|
|
374
|
+
: relativePath;
|
|
373
375
|
const targetPath = path.join(targetRoot, outputPath);
|
|
374
376
|
assertInside(targetRoot, targetPath);
|
|
375
377
|
|
|
@@ -401,10 +403,147 @@ function validateGeneratedProject(projectDir) {
|
|
|
401
403
|
}
|
|
402
404
|
}
|
|
403
405
|
|
|
406
|
+
// What a folder the human made for their agent may already hold: files the
|
|
407
|
+
// system, an editor or Claude Code put there. The template writes none of them
|
|
408
|
+
// except .claude/settings.json, and init leaves each one as it is.
|
|
409
|
+
const ignorableFiles = new Set([".ds_store", ".localized", "icon\r", "thumbs.db", "desktop.ini", ".mcp.json"]);
|
|
410
|
+
const ignorableDirectories = new Set([".claude", ".git", ".vscode", ".cursor", ".idea"]);
|
|
411
|
+
|
|
412
|
+
function isIgnorableEntry(entry) {
|
|
413
|
+
const name = entry.name.toLowerCase();
|
|
414
|
+
if (entry.isDirectory()) {
|
|
415
|
+
return ignorableDirectories.has(name);
|
|
416
|
+
}
|
|
417
|
+
// A .git file, not a folder, belongs to another repository's worktree or
|
|
418
|
+
// submodule, so it is not ignorable.
|
|
419
|
+
return entry.isFile() && (ignorableFiles.has(name) || name.startsWith("._"));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// What stops init from filling an existing folder; an empty list means nothing.
|
|
423
|
+
function existingTargetBlockers(targetDir) {
|
|
424
|
+
let entries;
|
|
425
|
+
try {
|
|
426
|
+
entries = fs.readdirSync(targetDir, { withFileTypes: true });
|
|
427
|
+
} catch (error) {
|
|
428
|
+
throw new Error(`Refusing to set up a project in ${targetDir}: it cannot be read (${error.code || error.message}).`);
|
|
429
|
+
}
|
|
430
|
+
const blockers = entries
|
|
431
|
+
.filter((entry) => !isIgnorableEntry(entry))
|
|
432
|
+
.map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name))
|
|
433
|
+
.sort((a, b) => a.localeCompare(b));
|
|
434
|
+
// The one template file that lands inside an ignorable folder. lstat, so a
|
|
435
|
+
// link there counts even when what it points to is missing.
|
|
436
|
+
if (fs.lstatSync(path.join(targetDir, ".claude", "settings.json"), { throwIfNoEntry: false })) {
|
|
437
|
+
blockers.push(path.join(".claude", "settings.json"));
|
|
438
|
+
}
|
|
439
|
+
return blockers;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function realPath(target) {
|
|
443
|
+
try {
|
|
444
|
+
return fs.realpathSync(target);
|
|
445
|
+
} catch {
|
|
446
|
+
return path.resolve(target);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Folders init never fills, even empty: the home folder and its own Desktop,
|
|
451
|
+
// Documents and Downloads, the disk's root, the folders Claude Code
|
|
452
|
+
// Desktop makes for a session started with no folder, which the human cannot
|
|
453
|
+
// find again, and Codex's folder for chats with no project
|
|
454
|
+
// (~/Documents/Codex) and each chat's own folder in it.
|
|
455
|
+
function personalFolderReason(targetDir) {
|
|
456
|
+
const target = realPath(targetDir);
|
|
457
|
+
const home = realPath(os.homedir());
|
|
458
|
+
if (path.dirname(target) === target) {
|
|
459
|
+
return "it is the disk's root";
|
|
460
|
+
}
|
|
461
|
+
if (target === home) {
|
|
462
|
+
return "it is the home folder";
|
|
463
|
+
}
|
|
464
|
+
if (path.dirname(target) === home && ["desktop", "documents", "downloads"].includes(path.basename(target).toLowerCase())) {
|
|
465
|
+
return `it is the home folder's ${path.basename(target)}`;
|
|
466
|
+
}
|
|
467
|
+
if (target.split(path.sep).includes("scratch-workspaces")) {
|
|
468
|
+
return "it is a temporary session folder";
|
|
469
|
+
}
|
|
470
|
+
const codexChats = path.join(home, "documents", "codex");
|
|
471
|
+
const lower = target.toLowerCase();
|
|
472
|
+
if (lower === codexChats.toLowerCase() || path.dirname(lower) === codexChats.toLowerCase()) {
|
|
473
|
+
return "it is Codex's folder for chats with no project; make the agent's folder next to the chat's, in ~/Documents/Codex";
|
|
474
|
+
}
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function assertFillableTarget(targetDir) {
|
|
479
|
+
if (!fs.statSync(targetDir).isDirectory()) {
|
|
480
|
+
throw new Error(`Refusing to overwrite existing path: ${targetDir}`);
|
|
481
|
+
}
|
|
482
|
+
if (fs.existsSync(path.join(targetDir, "foldspace.dev.json"))) {
|
|
483
|
+
throw new Error(`${targetDir} is already a Foldspace project. Open it instead of running init.`);
|
|
484
|
+
}
|
|
485
|
+
const reason = personalFolderReason(targetDir);
|
|
486
|
+
if (reason) {
|
|
487
|
+
throw new Error(`Refusing to set up a project in ${targetDir}: ${reason}. Pass a new folder instead.`);
|
|
488
|
+
}
|
|
489
|
+
const blockers = existingTargetBlockers(targetDir);
|
|
490
|
+
if (blockers.length > 0) {
|
|
491
|
+
const shown = blockers.slice(0, 5).join(", ") + (blockers.length > 5 ? `, and ${blockers.length - 5} more` : "");
|
|
492
|
+
throw new Error(
|
|
493
|
+
`Refusing to set up a project in ${targetDir}: it already holds ${shown}. ` +
|
|
494
|
+
"init sets up a new folder, or one that holds nothing but files the system or an editor put there.",
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Fill an existing folder without replacing it, so a session working in it
|
|
500
|
+
// stays in the same folder. A file that is already there is never
|
|
501
|
+
// overwritten, and on any error everything this call created is removed.
|
|
502
|
+
// foldspace.dev.json goes in last: init and the playbook take it to mean a
|
|
503
|
+
// finished project, so a fill that is cut short never looks like one.
|
|
504
|
+
function copyIntoExisting(sourceRoot, targetRoot) {
|
|
505
|
+
const created = [];
|
|
506
|
+
const lastAtRoot = (entries, relativeDir) =>
|
|
507
|
+
relativeDir === ""
|
|
508
|
+
? [...entries.filter((entry) => entry.name !== "foldspace.dev.json"), ...entries.filter((entry) => entry.name === "foldspace.dev.json")]
|
|
509
|
+
: entries;
|
|
510
|
+
const walk = (relativeDir) => {
|
|
511
|
+
for (const entry of lastAtRoot(fs.readdirSync(path.join(sourceRoot, relativeDir), { withFileTypes: true }), relativeDir)) {
|
|
512
|
+
const relativePath = path.join(relativeDir, entry.name);
|
|
513
|
+
const sourcePath = path.join(sourceRoot, relativePath);
|
|
514
|
+
const targetPath = path.join(targetRoot, relativePath);
|
|
515
|
+
if (entry.isDirectory()) {
|
|
516
|
+
try {
|
|
517
|
+
fs.mkdirSync(targetPath);
|
|
518
|
+
created.push(targetPath);
|
|
519
|
+
} catch (error) {
|
|
520
|
+
// .claude/ is the one template folder the human's folder may hold.
|
|
521
|
+
if (error?.code !== "EEXIST" || relativePath !== ".claude") {
|
|
522
|
+
throw error;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
walk(relativePath);
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
fs.copyFileSync(sourcePath, targetPath, fs.constants.COPYFILE_EXCL);
|
|
529
|
+
created.push(targetPath);
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
try {
|
|
533
|
+
walk("");
|
|
534
|
+
} catch (error) {
|
|
535
|
+
for (const createdPath of created.reverse()) {
|
|
536
|
+
fs.rmSync(createdPath, { recursive: true, force: true });
|
|
537
|
+
}
|
|
538
|
+
throw error;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
404
542
|
export function scaffoldProject(config, options = {}) {
|
|
405
543
|
const targetDir = config.targetDir;
|
|
406
|
-
|
|
407
|
-
|
|
544
|
+
const inPlace = fs.existsSync(targetDir);
|
|
545
|
+
if (inPlace) {
|
|
546
|
+
assertFillableTarget(targetDir);
|
|
408
547
|
}
|
|
409
548
|
|
|
410
549
|
const templateRoot = options.templateRoot || defaultTemplateRoot;
|
|
@@ -414,17 +553,29 @@ export function scaffoldProject(config, options = {}) {
|
|
|
414
553
|
|
|
415
554
|
const harnessVersion = options.harnessVersion || readHarnessVersion(options.packageRoot || packageRoot);
|
|
416
555
|
const values = createTemplateValues(config, harnessVersion);
|
|
556
|
+
// A new folder is rendered next to where it goes, so it can be renamed into
|
|
557
|
+
// place in one step. Filling a folder copies file by file, so it renders in
|
|
558
|
+
// the system's temporary folder and leaves nothing beside the human's folder.
|
|
417
559
|
const parentDir = path.dirname(targetDir);
|
|
418
560
|
fs.mkdirSync(parentDir, { recursive: true });
|
|
419
|
-
const temporaryDir = fs.mkdtempSync(
|
|
561
|
+
const temporaryDir = fs.mkdtempSync(
|
|
562
|
+
path.join(inPlace ? os.tmpdir() : parentDir, `.${path.basename(targetDir)}.foldspace-init-`),
|
|
563
|
+
);
|
|
420
564
|
|
|
421
565
|
try {
|
|
422
566
|
copyTemplate(templateRoot, temporaryDir, values);
|
|
423
567
|
validateGeneratedProject(temporaryDir);
|
|
424
|
-
if (
|
|
425
|
-
|
|
568
|
+
if (inPlace) {
|
|
569
|
+
// Checked again: something may have landed there while rendering.
|
|
570
|
+
assertFillableTarget(targetDir);
|
|
571
|
+
copyIntoExisting(temporaryDir, targetDir);
|
|
572
|
+
fs.rmSync(temporaryDir, { recursive: true, force: true });
|
|
573
|
+
} else {
|
|
574
|
+
if (fs.existsSync(targetDir)) {
|
|
575
|
+
throw new Error(`Target path appeared while initializing: ${targetDir}`);
|
|
576
|
+
}
|
|
577
|
+
fs.renameSync(temporaryDir, targetDir);
|
|
426
578
|
}
|
|
427
|
-
fs.renameSync(temporaryDir, targetDir);
|
|
428
579
|
} catch (error) {
|
|
429
580
|
fs.rmSync(temporaryDir, { recursive: true, force: true });
|
|
430
581
|
throw error;
|
|
@@ -434,6 +585,7 @@ export function scaffoldProject(config, options = {}) {
|
|
|
434
585
|
targetDir,
|
|
435
586
|
packageName: values.PACKAGE_NAME,
|
|
436
587
|
harnessVersion,
|
|
588
|
+
inPlace,
|
|
437
589
|
};
|
|
438
590
|
}
|
|
439
591
|
|
|
@@ -496,11 +648,13 @@ export async function runInit(argv, options = {}) {
|
|
|
496
648
|
const log = options.log || console.log;
|
|
497
649
|
const installed = await maybeInstallDependencies(result.targetDir, options);
|
|
498
650
|
|
|
499
|
-
log(
|
|
651
|
+
log(`${result.inPlace ? "Set up Foldspace project in" : "Created Foldspace project at"} ${result.targetDir}`);
|
|
500
652
|
log(`Using @foldspace_npm/harness ${result.harnessVersion}`);
|
|
501
653
|
log("");
|
|
502
654
|
log("Next steps:");
|
|
503
|
-
|
|
655
|
+
if (path.resolve(process.cwd()) !== result.targetDir) {
|
|
656
|
+
log(` cd ${result.targetDir}`);
|
|
657
|
+
}
|
|
504
658
|
if (!installed) {
|
|
505
659
|
log(" npm install --ignore-scripts");
|
|
506
660
|
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// Driving the agent's panel the way a person does: open it, type into the
|
|
2
|
+
// composer, press Enter, read the screen. Shared by `foldspace ask` and
|
|
3
|
+
// `foldspace record`.
|
|
4
|
+
//
|
|
5
|
+
// Nothing here reaches into the SDK's internals, patches fetch or injects into
|
|
6
|
+
// the panel. The panel is the SDK's iframe#eucera-agent-iframe; its document is
|
|
7
|
+
// readable from the page, so the reply is read from what is on screen.
|
|
8
|
+
|
|
9
|
+
import { createSettleTracker, replyAfter, squash } from "./ask-core.mjs";
|
|
10
|
+
import { sleep } from "./cdp-client.mjs";
|
|
11
|
+
|
|
12
|
+
export const PANEL_ID = "eucera-agent-iframe";
|
|
13
|
+
|
|
14
|
+
// Shared by every page snippet: the panel document, or null while it loads.
|
|
15
|
+
export const PANEL_DOC = `
|
|
16
|
+
const frame = document.getElementById(${JSON.stringify(PANEL_ID)});
|
|
17
|
+
let doc = null;
|
|
18
|
+
try { doc = frame && frame.contentDocument; } catch {}
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
// The agent handle: the one attach's bootstrap made, else the page's own SDK
|
|
22
|
+
// handle for this project's agent. Defines `sdk` and `agent` for the snippet
|
|
23
|
+
// that follows.
|
|
24
|
+
const AGENT_HANDLE = (agentApiName) => `
|
|
25
|
+
const wanted = ${JSON.stringify(agentApiName || "")};
|
|
26
|
+
const namespace = typeof window.__FOLD_SPACE__ === "string" && window.__FOLD_SPACE__
|
|
27
|
+
? window.__FOLD_SPACE__ : "foldspace";
|
|
28
|
+
const sdk = window[namespace];
|
|
29
|
+
let agent = window.__FOLDSPACE_AGENT__ || null;
|
|
30
|
+
if (!agent && typeof sdk?.agentIds === "function") {
|
|
31
|
+
const ids = sdk.agentIds().filter((id) => typeof id === "string" && id.indexOf("-") > 0);
|
|
32
|
+
const id = ids.find((item) => item.slice(item.indexOf("-") + 1) === wanted) || ids[0];
|
|
33
|
+
if (id) {
|
|
34
|
+
const cut = id.indexOf("-");
|
|
35
|
+
agent = sdk.agent({ apiName: id.slice(cut + 1), mode: id.slice(0, cut).toUpperCase() });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
export async function panelState(page) {
|
|
41
|
+
return page.evaluate(`${PANEL_DOC}
|
|
42
|
+
if (!doc) return { frame: !!frame, composer: false };
|
|
43
|
+
const box = doc.querySelector("textarea");
|
|
44
|
+
const rect = frame.getBoundingClientRect();
|
|
45
|
+
return { frame: true, composer: !!box, visible: rect.width > 50 && rect.height > 50 };`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// open() is the SDK's public "show the panel" call, the same thing the
|
|
49
|
+
// launcher button does. The handle comes from the bootstrap when attach made
|
|
50
|
+
// one, else from the page's own SDK.
|
|
51
|
+
export async function openPanel(page, agentApiName) {
|
|
52
|
+
return page.evaluate(`${AGENT_HANDLE(agentApiName)}
|
|
53
|
+
if (typeof agent?.open !== "function") return { sdk: !!sdk, opened: false };
|
|
54
|
+
try { agent.open(); return { sdk: true, opened: true }; }
|
|
55
|
+
catch (error) { return { sdk: true, opened: false, error: String(error?.message || error) }; }`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Open the panel until its composer shows. The composer does not exist until
|
|
60
|
+
* the panel has opened, and one open() is not always enough, so it is retried
|
|
61
|
+
* inside the poll. Gives up early when there is no panel and no SDK at all.
|
|
62
|
+
*/
|
|
63
|
+
export async function openComposer(page, agentApiName, { waitS = 30 } = {}) {
|
|
64
|
+
let state = await panelState(page);
|
|
65
|
+
let opened = { sdk: false, opened: false };
|
|
66
|
+
for (let tick = 0; tick < waitS && !(state.composer && state.visible); tick++) {
|
|
67
|
+
opened = await openPanel(page, agentApiName).catch(() => opened);
|
|
68
|
+
await sleep(1000);
|
|
69
|
+
state = await panelState(page).catch(() => state);
|
|
70
|
+
if (tick === 5 && !state.frame && !opened.sdk) break;
|
|
71
|
+
}
|
|
72
|
+
return { state, opened };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function readPanel(page, cardsBefore) {
|
|
76
|
+
return page.evaluate(`${PANEL_DOC}
|
|
77
|
+
if (!doc || !doc.body) return { panel: "", cards: [], cardFrames: 0, newCardFrames: 0 };
|
|
78
|
+
const squash = (text) => (text || "").replace(/\\s+/g, " ").trim();
|
|
79
|
+
const frames = Array.from(doc.querySelectorAll("iframe"));
|
|
80
|
+
const cards = [];
|
|
81
|
+
let painted = 0;
|
|
82
|
+
// Cards from earlier turns are still in the panel; only the new ones count.
|
|
83
|
+
for (const card of frames.slice(${Number(cardsBefore) || 0})) {
|
|
84
|
+
const rect = card.getBoundingClientRect();
|
|
85
|
+
if (rect.width > 20 && rect.height > 20) painted += 1;
|
|
86
|
+
try {
|
|
87
|
+
const body = card.contentDocument && card.contentDocument.body;
|
|
88
|
+
if (body) cards.push(squash(body.innerText || body.textContent));
|
|
89
|
+
} catch {}
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
panel: squash(doc.body.innerText || doc.body.textContent),
|
|
93
|
+
cards: cards.filter(Boolean),
|
|
94
|
+
cardFrames: frames.length,
|
|
95
|
+
newCardFrames: painted,
|
|
96
|
+
};`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Where the panel sits in the page, in CSS pixels, with the viewport size; null without one. */
|
|
100
|
+
export async function panelRect(page) {
|
|
101
|
+
return page.evaluate(`${PANEL_DOC}
|
|
102
|
+
if (!frame) return null;
|
|
103
|
+
const rect = frame.getBoundingClientRect();
|
|
104
|
+
return {
|
|
105
|
+
x: rect.left, y: rect.top, width: rect.width, height: rect.height,
|
|
106
|
+
viewportWidth: window.innerWidth, viewportHeight: window.innerHeight,
|
|
107
|
+
};`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function focusComposer(page) {
|
|
111
|
+
return page.evaluate(`${PANEL_DOC}
|
|
112
|
+
const box = doc && doc.querySelector("textarea");
|
|
113
|
+
if (!box) return false;
|
|
114
|
+
window.focus(); frame.contentWindow.focus(); box.focus();
|
|
115
|
+
return doc.activeElement === box;`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function composerValue(page) {
|
|
119
|
+
return page
|
|
120
|
+
.evaluate(`${PANEL_DOC}
|
|
121
|
+
const box = doc && doc.querySelector("textarea");
|
|
122
|
+
return box ? box.value : "";`)
|
|
123
|
+
.catch(() => "");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Enter as real key events through the browser's input pipeline. */
|
|
127
|
+
export async function pressEnter(page) {
|
|
128
|
+
const enter = {
|
|
129
|
+
key: "Enter",
|
|
130
|
+
code: "Enter",
|
|
131
|
+
windowsVirtualKeyCode: 13,
|
|
132
|
+
nativeVirtualKeyCode: 13,
|
|
133
|
+
};
|
|
134
|
+
await page.send("Input.dispatchKeyEvent", { type: "rawKeyDown", ...enter });
|
|
135
|
+
await page.send("Input.dispatchKeyEvent", { type: "char", ...enter, text: "\r" });
|
|
136
|
+
await page.send("Input.dispatchKeyEvent", { type: "keyUp", ...enter });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Real key events through the browser's input pipeline, like a person at the
|
|
140
|
+
// keyboard. Frameworks that ignore synthetic DOM events still hear these.
|
|
141
|
+
export async function typeLikeAPerson(page, text) {
|
|
142
|
+
const focused = await focusComposer(page);
|
|
143
|
+
if (!focused) return false;
|
|
144
|
+
await page.send("Input.insertText", { text });
|
|
145
|
+
const typed = await composerValue(page);
|
|
146
|
+
if (squash(typed) !== text) return false;
|
|
147
|
+
await pressEnter(page);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The same gesture delivered as DOM events on the composer; still only typing,
|
|
152
|
+
// still nothing injected. `submit` false fills the box and presses nothing.
|
|
153
|
+
async function setComposerWithDomEvents(page, text, { submit = true } = {}) {
|
|
154
|
+
return page.evaluate(`${PANEL_DOC}
|
|
155
|
+
const box = doc && doc.querySelector("textarea");
|
|
156
|
+
if (!box) return false;
|
|
157
|
+
const view = frame.contentWindow;
|
|
158
|
+
const setValue = Object.getOwnPropertyDescriptor(view.HTMLTextAreaElement.prototype, "value").set;
|
|
159
|
+
box.focus();
|
|
160
|
+
setValue.call(box, ${JSON.stringify(text)});
|
|
161
|
+
box.dispatchEvent(new view.Event("input", { bubbles: true }));
|
|
162
|
+
if (${submit ? "true" : "false"}) {
|
|
163
|
+
for (const type of ["keydown", "keypress", "keyup"]) {
|
|
164
|
+
box.dispatchEvent(new view.KeyboardEvent(type, {
|
|
165
|
+
key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true, cancelable: true,
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return true;`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// A background or unfocused tab can swallow CDP input. Same gesture, delivered
|
|
173
|
+
// as DOM events on the composer.
|
|
174
|
+
export async function typeWithDomEvents(page, text) {
|
|
175
|
+
return setComposerWithDomEvents(page, text, { submit: true });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Type the question and send it, the way `ask` always has: the keyboard
|
|
180
|
+
* first; if the question is still sitting in the composer afterwards, Enter
|
|
181
|
+
* never landed, so the same thing again as DOM events.
|
|
182
|
+
*/
|
|
183
|
+
export async function sendQuestion(page, question) {
|
|
184
|
+
let typedBy = "keyboard";
|
|
185
|
+
let sent = await typeLikeAPerson(page, question).catch(() => false);
|
|
186
|
+
await sleep(700);
|
|
187
|
+
// Still sitting in the composer means Enter never landed.
|
|
188
|
+
if (!sent || squash(await composerValue(page)) === question) {
|
|
189
|
+
typedBy = "dom-events";
|
|
190
|
+
sent = await typeWithDomEvents(page, question).catch(() => false);
|
|
191
|
+
}
|
|
192
|
+
return { sent, typedBy };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Type the question one character at a time, so a recording shows it being
|
|
197
|
+
* typed, then press Enter. Each character goes through the keyboard pipeline;
|
|
198
|
+
* if the window swallows CDP input, each one is set as a DOM input event
|
|
199
|
+
* instead - it still appears key by key. `delayMs` is the pause between keys.
|
|
200
|
+
* Returns { sent, typedBy, enteredAt }: enteredAt is when the Enter that sent
|
|
201
|
+
* it was pressed (ms), for a recording's timeline.
|
|
202
|
+
*/
|
|
203
|
+
export async function typeKeyByKey(page, question, { delayMs = 45 } = {}) {
|
|
204
|
+
const characters = [...question];
|
|
205
|
+
let typedBy = "keyboard";
|
|
206
|
+
if (!(await focusComposer(page).catch(() => false))) return { sent: false, typedBy };
|
|
207
|
+
let typed = "";
|
|
208
|
+
for (const character of characters) {
|
|
209
|
+
typed += character;
|
|
210
|
+
if (typedBy === "keyboard") {
|
|
211
|
+
await page.send("Input.insertText", { text: character }).catch(() => {});
|
|
212
|
+
// Checked on the first key only: the composer either hears CDP or not.
|
|
213
|
+
if (typed.length === 1 && (await composerValue(page)) !== typed) typedBy = "dom-events";
|
|
214
|
+
}
|
|
215
|
+
if (typedBy === "dom-events") {
|
|
216
|
+
await setComposerWithDomEvents(page, typed, { submit: false }).catch(() => false);
|
|
217
|
+
}
|
|
218
|
+
await sleep(delayMs);
|
|
219
|
+
}
|
|
220
|
+
if (squash(await composerValue(page)) !== squash(question)) return { sent: false, typedBy, enteredAt: null };
|
|
221
|
+
await sleep(250);
|
|
222
|
+
let enteredAt = Date.now();
|
|
223
|
+
await pressEnter(page).catch(() => {});
|
|
224
|
+
await sleep(500);
|
|
225
|
+
if (squash(await composerValue(page)) === squash(question)) {
|
|
226
|
+
// Enter never landed: the same key as DOM events.
|
|
227
|
+
enteredAt = Date.now();
|
|
228
|
+
await typeWithDomEvents(page, question).catch(() => false);
|
|
229
|
+
await sleep(400);
|
|
230
|
+
typedBy = "dom-events";
|
|
231
|
+
}
|
|
232
|
+
return { sent: squash(await composerValue(page)) !== squash(question), typedBy, enteredAt };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* A fresh conversation, the way a person starts one: the New Chat button in
|
|
237
|
+
* the panel's header (its class is in both header layouts of the panel).
|
|
238
|
+
* Returns false when this panel shows no such button.
|
|
239
|
+
*/
|
|
240
|
+
export async function clickNewChat(page) {
|
|
241
|
+
return page.evaluate(`${PANEL_DOC}
|
|
242
|
+
const button = doc && doc.querySelector(".eucera-copilot-header-new-chat-icon");
|
|
243
|
+
if (!button) return false;
|
|
244
|
+
button.click();
|
|
245
|
+
return true;`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The SDK's own way to put a question to the agent in a new conversation:
|
|
250
|
+
* agent.message(text, { newChat: true, autoSend: true }). The question is
|
|
251
|
+
* sent at once - nothing is typed - so it is only the fallback for a panel
|
|
252
|
+
* with no New Chat button.
|
|
253
|
+
*/
|
|
254
|
+
export async function messageInNewChat(page, agentApiName, question) {
|
|
255
|
+
return page.evaluate(`${AGENT_HANDLE(agentApiName)}
|
|
256
|
+
if (typeof agent?.message !== "function") return false;
|
|
257
|
+
agent.message(${JSON.stringify(question)}, { newChat: true, autoSend: true });
|
|
258
|
+
return true;`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Poll the panel until the reply to `question` settles or `timeoutS` passes.
|
|
263
|
+
* `onPoll` sees each observation (with its time), for a caller that wants to
|
|
264
|
+
* know when the reply first appeared and when it last changed.
|
|
265
|
+
*/
|
|
266
|
+
export async function waitForReply(
|
|
267
|
+
page,
|
|
268
|
+
question,
|
|
269
|
+
{ cardsBefore = 0, timeoutS = 90, pollMs = 1500, tracker = createSettleTracker(), onPoll } = {},
|
|
270
|
+
) {
|
|
271
|
+
const deadline = Date.now() + timeoutS * 1000;
|
|
272
|
+
let seen = { panel: "", cards: [], newCardFrames: 0 };
|
|
273
|
+
let reply = "";
|
|
274
|
+
let settled = false;
|
|
275
|
+
while (Date.now() < deadline && !settled) {
|
|
276
|
+
await sleep(pollMs);
|
|
277
|
+
seen = await readPanel(page, cardsBefore).catch(() => seen);
|
|
278
|
+
reply = replyAfter(seen.panel, question);
|
|
279
|
+
settled = tracker.observe({ reply, cards: seen.cards }).settled;
|
|
280
|
+
if (onPoll) onPoll({ at: Date.now(), reply, seen, settled });
|
|
281
|
+
}
|
|
282
|
+
return { settled, reply, seen };
|
|
283
|
+
}
|