@foldspace_npm/harness 0.1.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.
package/src/init.mjs ADDED
@@ -0,0 +1,301 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
6
+ const defaultTemplateRoot = path.join(packageRoot, "templates", "agent-starter");
7
+ const allowedFlags = new Set(["name", "product-id", "agent-api-name", "domain"]);
8
+ const tokenPattern = /\{\{([A-Z0-9_]+)\}\}/g;
9
+
10
+ export const initUsage =
11
+ "foldspace init <directory> --product-id <id> --agent-api-name <name> --domain <host> [--name <display-name>]";
12
+
13
+ function assertSupportedNode() {
14
+ const major = Number.parseInt(process.versions.node.split(".", 1)[0], 10);
15
+ if (!Number.isInteger(major) || major < 20) {
16
+ throw new Error(`Node 20 or newer is required; found ${process.versions.node}.`);
17
+ }
18
+ }
19
+
20
+ function readFlag(argv, index) {
21
+ const argument = argv[index];
22
+ const equalsIndex = argument.indexOf("=");
23
+ const name = argument.slice(2, equalsIndex === -1 ? undefined : equalsIndex);
24
+
25
+ if (!allowedFlags.has(name)) {
26
+ throw new Error(`Unknown option: --${name}`);
27
+ }
28
+
29
+ if (equalsIndex !== -1) {
30
+ const value = argument.slice(equalsIndex + 1);
31
+ if (!value) {
32
+ throw new Error(`Missing value for --${name}`);
33
+ }
34
+ return { name, value, consumed: 1 };
35
+ }
36
+
37
+ const value = argv[index + 1];
38
+ if (!value || value.startsWith("--")) {
39
+ throw new Error(`Missing value for --${name}`);
40
+ }
41
+ return { name, value, consumed: 2 };
42
+ }
43
+
44
+ export function parseInitArgs(argv) {
45
+ const positional = [];
46
+ const flags = {};
47
+
48
+ for (let index = 0; index < argv.length; ) {
49
+ const argument = argv[index];
50
+ if (argument.startsWith("--")) {
51
+ const parsed = readFlag(argv, index);
52
+ if (flags[parsed.name] !== undefined) {
53
+ throw new Error(`Option provided more than once: --${parsed.name}`);
54
+ }
55
+ flags[parsed.name] = parsed.value;
56
+ index += parsed.consumed;
57
+ continue;
58
+ }
59
+
60
+ positional.push(argument);
61
+ index += 1;
62
+ }
63
+
64
+ if (positional.length !== 1) {
65
+ throw new Error(`Usage: ${initUsage}`);
66
+ }
67
+
68
+ for (const required of ["product-id", "agent-api-name", "domain"]) {
69
+ if (!flags[required]) {
70
+ throw new Error(`Missing required option: --${required}\nUsage: ${initUsage}`);
71
+ }
72
+ }
73
+
74
+ return {
75
+ targetDir: path.resolve(positional[0]),
76
+ displayName: flags.name || path.basename(path.resolve(positional[0])),
77
+ productId: flags["product-id"],
78
+ agentApiName: flags["agent-api-name"],
79
+ domain: flags.domain,
80
+ };
81
+ }
82
+
83
+ function toPackageName(value) {
84
+ return value
85
+ .trim()
86
+ .toLowerCase()
87
+ .replace(/[^a-z0-9]+/g, "-")
88
+ .replace(/^-+|-+$/g, "");
89
+ }
90
+
91
+ function validateIdentifier(value, label) {
92
+ const normalized = value.trim();
93
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(normalized)) {
94
+ throw new Error(
95
+ `${label} must start with a letter or number and contain only letters, numbers, hyphens, or underscores.`,
96
+ );
97
+ }
98
+ return normalized;
99
+ }
100
+
101
+ function validateProductId(value) {
102
+ const productId = validateIdentifier(value, "Product ID");
103
+ if (/^EU-/i.test(productId)) {
104
+ throw new Error("Product ID must be the bare product ID, not an EU-… SDK key.");
105
+ }
106
+ return productId;
107
+ }
108
+
109
+ function normalizeTarget(value) {
110
+ const raw = value.trim();
111
+ if (!raw || raw.includes("*")) {
112
+ throw new Error("Domain must be a concrete hostname without a wildcard.");
113
+ }
114
+
115
+ let url;
116
+ try {
117
+ url = new URL(raw.includes("://") ? raw : `https://${raw}`);
118
+ } catch {
119
+ throw new Error(`Invalid domain: ${value}`);
120
+ }
121
+
122
+ if (
123
+ !["http:", "https:"].includes(url.protocol) ||
124
+ url.username ||
125
+ url.password ||
126
+ url.port ||
127
+ (url.pathname !== "/" && url.pathname !== "") ||
128
+ url.search ||
129
+ url.hash
130
+ ) {
131
+ throw new Error("Domain must contain only an HTTP(S) hostname, without credentials, a port, path, query, or fragment.");
132
+ }
133
+
134
+ const domain = url.hostname.toLowerCase().replace(/\.$/, "");
135
+ if (!domain || domain.includes("..")) {
136
+ throw new Error(`Invalid domain: ${value}`);
137
+ }
138
+
139
+ return {
140
+ domain,
141
+ startUrl: `${url.protocol}//${domain}/`,
142
+ };
143
+ }
144
+
145
+ function readHarnessVersion(root) {
146
+ const manifestPath = path.join(root, "package.json");
147
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
148
+ if (typeof manifest.version !== "string" || !manifest.version) {
149
+ throw new Error(`Harness package version is missing from ${manifestPath}`);
150
+ }
151
+ return manifest.version;
152
+ }
153
+
154
+ function createTemplateValues(config, harnessVersion) {
155
+ const displayName = config.displayName.trim();
156
+ if (!displayName || /[\r\n]/.test(displayName) || displayName.includes("{{")) {
157
+ throw new Error("Display name must be a non-empty single line and cannot contain '{{'.");
158
+ }
159
+
160
+ const packageName = toPackageName(displayName);
161
+ if (!packageName) {
162
+ throw new Error("Display name must contain at least one letter or number.");
163
+ }
164
+
165
+ const productId = validateProductId(config.productId);
166
+ const agentApiName = validateIdentifier(config.agentApiName, "Agent API name");
167
+ const target = normalizeTarget(config.domain);
168
+ const hosts =
169
+ target.domain === "localhost" || /^\d+(?:\.\d+){3}$/.test(target.domain)
170
+ ? [target.domain]
171
+ : [target.domain, `*.${target.domain}`];
172
+
173
+ return {
174
+ DISPLAY_NAME: displayName,
175
+ PACKAGE_NAME: packageName,
176
+ PACKAGE_DESCRIPTION_JSON: JSON.stringify(`Foldspace browser actions for ${displayName}.`),
177
+ PRODUCT_ID_JSON: JSON.stringify(productId),
178
+ AGENT_API_NAME_JSON: JSON.stringify(agentApiName),
179
+ APP_DOMAIN_JSON: JSON.stringify(target.domain),
180
+ START_URL_JSON: JSON.stringify(target.startUrl),
181
+ HOSTS_JSON: JSON.stringify(hosts),
182
+ HARNESS_VERSION: harnessVersion,
183
+ };
184
+ }
185
+
186
+ function renderTemplate(content, values, sourcePath) {
187
+ const rendered = content.replace(tokenPattern, (_match, key) => {
188
+ if (!(key in values)) {
189
+ throw new Error(`Missing value for {{${key}}} in ${sourcePath}`);
190
+ }
191
+ return values[key];
192
+ });
193
+
194
+ const unresolved = rendered.match(tokenPattern);
195
+ if (unresolved) {
196
+ throw new Error(`Unresolved template token ${unresolved[0]} in ${sourcePath}`);
197
+ }
198
+ return rendered;
199
+ }
200
+
201
+ function assertInside(root, candidate) {
202
+ const relative = path.relative(root, candidate);
203
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
204
+ throw new Error(`Template path escapes the project root: ${candidate}`);
205
+ }
206
+ }
207
+
208
+ function copyTemplate(templateRoot, targetRoot, values) {
209
+ const walk = (relativeDir) => {
210
+ const sourceDir = path.join(templateRoot, relativeDir);
211
+ for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
212
+ const relativePath = path.join(relativeDir, entry.name);
213
+ const sourcePath = path.join(templateRoot, relativePath);
214
+ // npm excludes .gitignore files from package tarballs, so the bundled
215
+ // template uses a publish-safe name and restores it during generation.
216
+ const outputPath = relativePath === "gitignore" ? ".gitignore" : relativePath;
217
+ const targetPath = path.join(targetRoot, outputPath);
218
+ assertInside(targetRoot, targetPath);
219
+
220
+ if (entry.isSymbolicLink()) {
221
+ throw new Error(`Template symlinks are not supported: ${sourcePath}`);
222
+ }
223
+ if (entry.isDirectory()) {
224
+ fs.mkdirSync(targetPath, { recursive: true });
225
+ walk(relativePath);
226
+ continue;
227
+ }
228
+ if (!entry.isFile()) {
229
+ throw new Error(`Unsupported template entry: ${sourcePath}`);
230
+ }
231
+
232
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
233
+ const content = fs.readFileSync(sourcePath, "utf8");
234
+ fs.writeFileSync(targetPath, renderTemplate(content, values, sourcePath), "utf8");
235
+ }
236
+ };
237
+
238
+ walk("");
239
+ }
240
+
241
+ function validateGeneratedProject(projectDir) {
242
+ for (const relativePath of ["package.json", "foldspace.dev.json"]) {
243
+ const filePath = path.join(projectDir, relativePath);
244
+ JSON.parse(fs.readFileSync(filePath, "utf8"));
245
+ }
246
+ }
247
+
248
+ export function scaffoldProject(config, options = {}) {
249
+ const targetDir = config.targetDir;
250
+ if (fs.existsSync(targetDir)) {
251
+ throw new Error(`Refusing to overwrite existing path: ${targetDir}`);
252
+ }
253
+
254
+ const templateRoot = options.templateRoot || defaultTemplateRoot;
255
+ if (!fs.existsSync(templateRoot)) {
256
+ throw new Error(`Bundled project template not found: ${templateRoot}`);
257
+ }
258
+
259
+ const harnessVersion = options.harnessVersion || readHarnessVersion(options.packageRoot || packageRoot);
260
+ const values = createTemplateValues(config, harnessVersion);
261
+ const parentDir = path.dirname(targetDir);
262
+ fs.mkdirSync(parentDir, { recursive: true });
263
+ const temporaryDir = fs.mkdtempSync(path.join(parentDir, `.${path.basename(targetDir)}.foldspace-init-`));
264
+
265
+ try {
266
+ copyTemplate(templateRoot, temporaryDir, values);
267
+ validateGeneratedProject(temporaryDir);
268
+ if (fs.existsSync(targetDir)) {
269
+ throw new Error(`Target path appeared while initializing: ${targetDir}`);
270
+ }
271
+ fs.renameSync(temporaryDir, targetDir);
272
+ } catch (error) {
273
+ fs.rmSync(temporaryDir, { recursive: true, force: true });
274
+ throw error;
275
+ }
276
+
277
+ return {
278
+ targetDir,
279
+ packageName: values.PACKAGE_NAME,
280
+ harnessVersion,
281
+ };
282
+ }
283
+
284
+ export function runInit(argv, options = {}) {
285
+ assertSupportedNode();
286
+ const config = parseInitArgs(argv);
287
+ const result = scaffoldProject(config, options);
288
+ const log = options.log || console.log;
289
+
290
+ log(`Created Foldspace project at ${result.targetDir}`);
291
+ log(`Using @foldspace_npm/harness ${result.harnessVersion}`);
292
+ log("");
293
+ log("Next steps:");
294
+ log(` cd ${result.targetDir}`);
295
+ log(" npm install --ignore-scripts");
296
+ log(" npm run build");
297
+ log(" npm run inject");
298
+ log(" npm run attach");
299
+
300
+ return result;
301
+ }
@@ -0,0 +1,24 @@
1
+ # Transports
2
+
3
+ The harness does the same three jobs regardless of where the browser is:
4
+
5
+ put the bundle in the page · run code in the page · look at the result
6
+
7
+ Track A (`local`) does them over CDP against a browser on this machine.
8
+ Track B (`extension`) will do them over a websocket to a Web Store extension in
9
+ the user's own Chrome.
10
+
11
+ `agent/`, `fixtures/` and the build are identical in both — only this layer
12
+ differs. Keep it that way: "export to repo" from a hosted build is a copy only
13
+ while the two tracks share an identical `agent/` directory.
14
+
15
+ | Verb | local (CDP) | extension (planned) |
16
+ |---|---|---|
17
+ | `loadBundle` | `addScriptToEvaluateOnNewDocument` / `Fetch.fulfillRequest` | `<script src>` at the deployed CDN path |
18
+ | `evaluate` | `Runtime.evaluate` | content-script message |
19
+ | `screenshot` | `Page.captureScreenshot` | `tabs.captureVisibleTab` |
20
+ | `relaxCSP` | `Page.setBypassCSP` | `declarativeNetRequest` header rules |
21
+ | `navigate` | `Page.navigate` | `tabs.update` |
22
+
23
+ `local` is implemented today as `bin/inject.mjs` + `bin/attach.mjs`.
24
+ `extension` is not built; see `architecture-hosted.md`.
@@ -0,0 +1,90 @@
1
+ # Foldspace browser actions
2
+
3
+ ## Scope
4
+
5
+ This project contains Foldspace action handlers. The customer website, SDK
6
+ snippet, `identify()` call, and layout are out of scope unless the user asks for
7
+ changes there.
8
+
9
+ ## Before building
10
+
11
+ 1. Connect Product MCP and verify that `list_agents` works.
12
+ 2. Inspect the selected agent with `get_agent_settings`, `list_actions`, and
13
+ `list_task_agents`.
14
+ 3. Use `discover_actions` to identify candidate experiences, then let the user
15
+ choose what to build.
16
+
17
+ ## Build workflow
18
+
19
+ An experience can require several reusable actions. Separate lookup actions
20
+ from actions that read or mutate a selected resource.
21
+
22
+ 1. Agree on the experience and how its actions compose.
23
+ 2. Create the action metadata as a draft.
24
+ 3. Ask before publishing when the agent has real users; publishing is a live
25
+ product change.
26
+ 4. Observe the real target workflow and capture its browser requests.
27
+ 5. Generate the typed handler skeleton from the published action schema.
28
+ 6. Implement the handler using the observed request and response shapes.
29
+ 7. Register the handler in `agent/actions/index.ts`.
30
+ 8. Complete the verification gates below.
31
+
32
+ ## API rules
33
+
34
+ Actions execute in the user's signed-in browser session.
35
+
36
+ - Use the same internal APIs as the product page, with
37
+ `credentials: "include"`.
38
+ - Never guess endpoints or schemas. Capture at least one real request and
39
+ response before implementing a parser.
40
+ - Verify that the user is signed in before observing a workflow.
41
+ - Do not substitute a public developer API when the browser session is missing;
42
+ ask the user to sign in.
43
+ - Validate parameters and return sanitized errors.
44
+
45
+ ## Local harness loop
46
+
47
+ ```bash
48
+ npm run dev
49
+ npm run inject
50
+ npm run attach
51
+ ```
52
+
53
+ `inject` launches an isolated Chrome profile and records its debug port.
54
+ `attach` loads the SDK and the local `dist/index.js` bundle. Use
55
+ `npm run attach -- --bootstrap` when the target page does not already embed the
56
+ Foldspace SDK.
57
+
58
+ The attach log must report `actions attached: N` before treating the page as
59
+ running local action code.
60
+
61
+ ## Verification gates
62
+
63
+ Do not report success without all six:
64
+
65
+ 1. TypeScript compiles with `npx tsc --noEmit -p tsconfig.json`.
66
+ 2. The expected handler appears in `dist/index.js`.
67
+ 3. The browser reports the expected number of attached actions.
68
+ 4. The action behaves correctly against the real target workflow.
69
+ 5. Existing neighbouring action fixtures still pass when fixtures exist.
70
+ 6. Browser evidence came from the live target, not from hand-authored examples.
71
+
72
+ ## Layout
73
+
74
+ - `agent/actions/` — one handler per action, registered in `index.ts`
75
+ - `agent/api/` — one HTTP helper per endpoint
76
+ - `agent/constants.ts` — agent, product, and domain identifiers
77
+ - `agent/utils.ts` — Foldspace agent lookup
78
+ - `foldspace.dev.json` — local harness target configuration
79
+
80
+ Do not introduce another bundler or bundle format.
81
+
82
+ ## Safety
83
+
84
+ - Do not commit secrets, cookies, HAR files, browser storage, or
85
+ `.foldspace-dev/`.
86
+ - Local handler changes are not cloud publication.
87
+ - Creating or publishing Foldspace resources requires explicit approval.
88
+ - Action keys and parameter names must match the published schema.
89
+ - Search Foldspace documentation before asserting unfamiliar platform
90
+ behaviour.
@@ -0,0 +1,54 @@
1
+ # {{DISPLAY_NAME}}
2
+
3
+ This project contains Foldspace browser action handlers. The handlers are built
4
+ locally and loaded into the configured product page by `@foldspace_npm/harness`.
5
+ The customer website and its Foldspace SDK installation are separate.
6
+
7
+ ## Setup
8
+
9
+ `foldspace init` generated the product, agent, and domain settings in:
10
+
11
+ - `agent/constants.ts`
12
+ - `foldspace.dev.json`
13
+
14
+ Install dependencies without running dependency lifecycle scripts, then build:
15
+
16
+ ```bash
17
+ npm install --ignore-scripts
18
+ npm run build
19
+ ```
20
+
21
+ ## Project layout
22
+
23
+ ```text
24
+ agent/
25
+ actions/ one file per action; register actions in index.ts
26
+ api/ one file per HTTP helper
27
+ constants.ts product, agent, and domain identifiers
28
+ utils.ts Foldspace agent lookup
29
+ foldspace.dev.json
30
+ CLAUDE.md
31
+ ```
32
+
33
+ ## Commands
34
+
35
+ ```bash
36
+ npm run build # create dist/index.js
37
+ npm run dev # rebuild dist/index.js on changes
38
+ npm run inject # launch the dedicated Chrome profile
39
+ npm run attach # load the SDK and local actions over CDP
40
+ ```
41
+
42
+ Run `inject` before `attach`. Sign in to the product in the Chrome window that
43
+ `inject` opens. For a product that does not already embed the Foldspace SDK, run
44
+ `npm run attach -- --bootstrap`.
45
+
46
+ ## Add an action
47
+
48
+ 1. Define and publish the action in Agent Studio or through Product MCP.
49
+ 2. Copy `agent/actions/_example.ts` to `agent/actions/<action_key>.ts`.
50
+ 3. Implement the handler from observed browser API evidence.
51
+ 4. Import and register the handler in `agent/actions/index.ts`.
52
+ 5. Type-check, rebuild, attach, and verify it in the target product.
53
+
54
+ Action keys and parameter names must exactly match the published action schema.
@@ -0,0 +1,18 @@
1
+ // Copy this file to <action_key>.ts and register it in index.ts.
2
+ // Do not register _example — it is not a real Agent Studio action.
3
+
4
+ export const example_action = {
5
+ execute: async (params: { message?: string }) => {
6
+ const message = typeof params?.message === "string" ? params.message.trim() : "";
7
+ if (!message) {
8
+ return { ok: false, error: "message is required" };
9
+ }
10
+
11
+ try {
12
+ return { ok: true, echo: message };
13
+ } catch (error) {
14
+ const detail = error instanceof Error ? error.message : "unknown error";
15
+ return { ok: false, error: detail };
16
+ }
17
+ },
18
+ };
@@ -0,0 +1,10 @@
1
+ // Entry point for the agent actions. One file per action in this directory.
2
+ //
3
+ // Registration here is the switch: the SDK transmits these to the server, and
4
+ // the server excludes any active action it does not receive. Comment an entry
5
+ // out and the copilot can no longer see it — no unpublishing required.
6
+
7
+ const actions = {};
8
+ (window as any).__FOLDSPACE_REMOTE_ACTIONS__ = actions;
9
+
10
+ export default actions;
@@ -0,0 +1 @@
1
+ # API helpers belong in this directory.
@@ -0,0 +1,3 @@
1
+ export const AGENT_API_NAME = {{AGENT_API_NAME_JSON}};
2
+ export const PRODUCT_ID = {{PRODUCT_ID_JSON}};
3
+ export const APP_DOMAIN = {{APP_DOMAIN_JSON}};
@@ -0,0 +1,15 @@
1
+ import { AGENT_API_NAME } from "./constants";
2
+
3
+ let agent: any | null = null;
4
+
5
+ export function getAgent(): any | null {
6
+ if (agent) {
7
+ return agent;
8
+ }
9
+
10
+ agent = (window as any).foldspace?.agent({
11
+ apiName: AGENT_API_NAME,
12
+ });
13
+
14
+ return agent;
15
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "$comment": "Generated by foldspace init. Use the bare product ID, not the EU-… SDK key.",
3
+ "defaultTarget": "app",
4
+ "sdkUrl": "https://script.eucerahive.io/web/sdk/foldspace.js",
5
+ "localActionsUrl": "http://localhost:3007/dist/index.js",
6
+ "targets": {
7
+ "app": {
8
+ "name": "App",
9
+ "startUrl": {{START_URL_JSON}},
10
+ "productId": {{PRODUCT_ID_JSON}},
11
+ "agentApiName": {{AGENT_API_NAME_JSON}},
12
+ "hosts": {{HOSTS_JSON}},
13
+ "mode": "OVERLAY",
14
+ "loadLocally": true
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,6 @@
1
+ node_modules/
2
+ dist/
3
+ .foldspace-dev/
4
+ .DS_Store
5
+ *.log
6
+ *.zip
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "{{PACKAGE_NAME}}",
3
+ "version": "1.0.0",
4
+ "description": {{PACKAGE_DESCRIPTION_JSON}},
5
+ "private": true,
6
+ "type": "module",
7
+ "scripts": {
8
+ "dev": "foldspace-build --watch",
9
+ "build": "foldspace-build",
10
+ "inject": "foldspace-inject",
11
+ "attach": "foldspace-attach"
12
+ },
13
+ "devDependencies": {
14
+ "@foldspace_npm/harness": "{{HARNESS_VERSION}}",
15
+ "typescript": "^5.3.3"
16
+ }
17
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "esModuleInterop": true,
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "declaration": false,
10
+ "outDir": "./dist",
11
+ "rootDir": ".",
12
+ "resolveJsonModule": true,
13
+ "allowSyntheticDefaultImports": true
14
+ },
15
+ "include": ["**/*.ts"],
16
+ "exclude": ["node_modules", "dist"]
17
+ }