spool.page 0.0.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/README.md +7 -0
- package/dist/cli.js +231 -0
- package/package.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# spool
|
|
2
|
+
|
|
3
|
+
A canvas where the frames are alive.
|
|
4
|
+
|
|
5
|
+
Agent-authored TSX frames on an infinite canvas: arrange them spatially, link them into walkable flows, and feel an app (interactions, motion, state, real inputs) before it exists. Code is the document; the canvas is a projection of it. A design space is just a `design/` folder inside your product repo: local-first, git-tracked, no cloud.
|
|
6
|
+
|
|
7
|
+
Home: [spool.page](https://spool.page). Status: wayfinding. The map lives in this repo's issues (`wayfinder:map`); exploration research lives in `research/`.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
5
|
+
import { homedir } from "os";
|
|
6
|
+
import { basename, join as join4 } from "path";
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
|
|
9
|
+
// src/errors.ts
|
|
10
|
+
var SpoolError = class extends Error {
|
|
11
|
+
name = "SpoolError";
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// src/init.ts
|
|
15
|
+
import { existsSync, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
16
|
+
import { dirname, join as join2 } from "path";
|
|
17
|
+
|
|
18
|
+
// src/paths.ts
|
|
19
|
+
import { realpathSync } from "fs";
|
|
20
|
+
import { resolve } from "path";
|
|
21
|
+
function realDir(dir) {
|
|
22
|
+
try {
|
|
23
|
+
return realpathSync(resolve(dir));
|
|
24
|
+
} catch {
|
|
25
|
+
throw new SpoolError(`no such directory: ${dir}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// src/registry.ts
|
|
30
|
+
import { mkdirSync, readFileSync, realpathSync as realpathSync2, renameSync, writeFileSync } from "fs";
|
|
31
|
+
import { join } from "path";
|
|
32
|
+
function readRegistry(spoolDir2) {
|
|
33
|
+
const file = join(spoolDir2, "registry.json");
|
|
34
|
+
let raw;
|
|
35
|
+
try {
|
|
36
|
+
raw = readFileSync(file, "utf8");
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (error.code === "ENOENT") {
|
|
39
|
+
return { version: 1, projects: [] };
|
|
40
|
+
}
|
|
41
|
+
throw new SpoolError(`cannot read registry at ${file}: ${error.message}`);
|
|
42
|
+
}
|
|
43
|
+
let parsed;
|
|
44
|
+
try {
|
|
45
|
+
parsed = JSON.parse(raw);
|
|
46
|
+
} catch {
|
|
47
|
+
parsed = void 0;
|
|
48
|
+
}
|
|
49
|
+
if (!isRegistry(parsed)) {
|
|
50
|
+
throw new SpoolError(`corrupt registry at ${file}, fix or remove it`);
|
|
51
|
+
}
|
|
52
|
+
return parsed;
|
|
53
|
+
}
|
|
54
|
+
function registerProject(spoolDir2, root) {
|
|
55
|
+
const real = realpathSync2(root);
|
|
56
|
+
const registry = readRegistry(spoolDir2);
|
|
57
|
+
const openedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
58
|
+
const existing = registry.projects.find((p) => p.root === real);
|
|
59
|
+
if (existing) {
|
|
60
|
+
existing.openedAt = openedAt;
|
|
61
|
+
} else {
|
|
62
|
+
registry.projects.push({ root: real, openedAt });
|
|
63
|
+
}
|
|
64
|
+
writeRegistry(spoolDir2, registry);
|
|
65
|
+
}
|
|
66
|
+
function isRegistry(value) {
|
|
67
|
+
if (typeof value !== "object" || value === null) return false;
|
|
68
|
+
const record = value;
|
|
69
|
+
if (record.version !== 1 || !Array.isArray(record.projects)) return false;
|
|
70
|
+
return record.projects.every((project) => {
|
|
71
|
+
if (typeof project !== "object" || project === null) return false;
|
|
72
|
+
const p = project;
|
|
73
|
+
return typeof p.root === "string" && typeof p.openedAt === "string";
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function writeRegistry(spoolDir2, registry) {
|
|
77
|
+
mkdirSync(spoolDir2, { recursive: true });
|
|
78
|
+
const file = join(spoolDir2, "registry.json");
|
|
79
|
+
const tmp = `${file}.tmp`;
|
|
80
|
+
writeFileSync(tmp, `${JSON.stringify(registry, null, " ")}
|
|
81
|
+
`);
|
|
82
|
+
renameSync(tmp, file);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// src/templates.ts
|
|
86
|
+
var FORMAT_VERSION = 1;
|
|
87
|
+
var canvasJson = `${JSON.stringify({ format: FORMAT_VERSION }, null, " ")}
|
|
88
|
+
`;
|
|
89
|
+
var gitignore = ".spool/\n";
|
|
90
|
+
var claudeMd = "@AGENTS.md\n";
|
|
91
|
+
var agentsMd = `# spool canvas
|
|
92
|
+
|
|
93
|
+
This folder is a [spool](https://spool.page) project: live TSX frames on an infinite canvas, authored by agents, arranged and played by humans.
|
|
94
|
+
|
|
95
|
+
- A frame is born by writing \`frames/<name>/frame.tsx\` default-exporting one React component. No registration step, no \`spool new\`.
|
|
96
|
+
- Variants are sibling frames named with \`--\`, like \`checkout--empty/\`.
|
|
97
|
+
- Shared code lives in \`shared/\`: \`ui/\` components (props only, never import \`"spool"\`), \`lib/utils.ts\` with \`cn()\`, \`tokens.css\`, \`scenarios/\`, \`fixtures/\`.
|
|
98
|
+
- Run \`spool skill\` for the complete contract. If it is not in there, spool does not do it.
|
|
99
|
+
- \`canvas.json\` and \`.spool/\` are app-owned. Never edit them.
|
|
100
|
+
`;
|
|
101
|
+
var tokensCss = `/*
|
|
102
|
+
* spool tokens, the single token file for this canvas.
|
|
103
|
+
* Distilling from an existing product: paste its variables into :root verbatim.
|
|
104
|
+
* Spool-born tokens: use @theme (Tailwind v4, shadcn v4 shape).
|
|
105
|
+
* Starts empty on purpose: tokens arrive per change, carried by the agent.
|
|
106
|
+
*/
|
|
107
|
+
:root {}
|
|
108
|
+
`;
|
|
109
|
+
var transitionsCss = `/*
|
|
110
|
+
* Player transition styling, plain CSS (no Tailwind here).
|
|
111
|
+
* Crossfade is the default. For morphs, give elements a shared
|
|
112
|
+
* view-transition-name in their frames and style ::view-transition-* here.
|
|
113
|
+
*/
|
|
114
|
+
`;
|
|
115
|
+
var fontsCss = `/*
|
|
116
|
+
* Project fonts, plain CSS: @font-face rules or a Google Fonts @import.
|
|
117
|
+
* Font files live in shared/assets/fonts/. Loaded in every frame document.
|
|
118
|
+
*/
|
|
119
|
+
`;
|
|
120
|
+
var importmapJson = `{
|
|
121
|
+
"imports": {
|
|
122
|
+
"class-variance-authority": "https://esm.sh/class-variance-authority@0.7.1",
|
|
123
|
+
"clsx": "https://esm.sh/clsx@2.1.1",
|
|
124
|
+
"motion": "https://esm.sh/motion@12.42.2?external=react,react-dom",
|
|
125
|
+
"motion/react": "https://esm.sh/motion@12.42.2/react?external=react,react-dom",
|
|
126
|
+
"tailwind-merge": "https://esm.sh/tailwind-merge@3.6.0"
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
`;
|
|
130
|
+
var defaultScenario = `{
|
|
131
|
+
"state": {},
|
|
132
|
+
"mock": {}
|
|
133
|
+
}
|
|
134
|
+
`;
|
|
135
|
+
var utilsTs = `import { clsx, type ClassValue } from "clsx";
|
|
136
|
+
import { twMerge } from "tailwind-merge";
|
|
137
|
+
|
|
138
|
+
export function cn(...inputs: ClassValue[]) {
|
|
139
|
+
return twMerge(clsx(inputs));
|
|
140
|
+
}
|
|
141
|
+
`;
|
|
142
|
+
var scaffoldFiles = {
|
|
143
|
+
".gitignore": gitignore,
|
|
144
|
+
"AGENTS.md": agentsMd,
|
|
145
|
+
"CLAUDE.md": claudeMd,
|
|
146
|
+
"canvas.json": canvasJson,
|
|
147
|
+
"shared/tokens.css": tokensCss,
|
|
148
|
+
"shared/transitions.css": transitionsCss,
|
|
149
|
+
"shared/fonts.css": fontsCss,
|
|
150
|
+
"shared/importmap.json": importmapJson,
|
|
151
|
+
"shared/scenarios/default.json": defaultScenario,
|
|
152
|
+
"shared/lib/utils.ts": utilsTs
|
|
153
|
+
};
|
|
154
|
+
var scaffoldDirs = ["frames", "shared/ui", "shared/fixtures", "shared/assets/fonts"];
|
|
155
|
+
|
|
156
|
+
// src/init.ts
|
|
157
|
+
function initProject(targetDir, spoolDir2) {
|
|
158
|
+
const root = realDir(targetDir);
|
|
159
|
+
const design = join2(root, "design");
|
|
160
|
+
if (existsSync(join2(design, "canvas.json"))) {
|
|
161
|
+
throw new SpoolError(`already a spool project: ${root} (run \`spool open\` instead)`);
|
|
162
|
+
}
|
|
163
|
+
if (existsSync(design)) {
|
|
164
|
+
throw new SpoolError(`design/ already exists at ${root} and is not a spool project, move it aside first`);
|
|
165
|
+
}
|
|
166
|
+
for (const dir of scaffoldDirs) {
|
|
167
|
+
mkdirSync2(join2(design, dir), { recursive: true });
|
|
168
|
+
}
|
|
169
|
+
for (const [rel, content] of Object.entries(scaffoldFiles)) {
|
|
170
|
+
const file = join2(design, rel);
|
|
171
|
+
mkdirSync2(dirname(file), { recursive: true });
|
|
172
|
+
writeFileSync2(file, content);
|
|
173
|
+
}
|
|
174
|
+
registerProject(spoolDir2, root);
|
|
175
|
+
return { root };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// src/resolve.ts
|
|
179
|
+
import { existsSync as existsSync2 } from "fs";
|
|
180
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
181
|
+
function resolveProjectRoot(startDir) {
|
|
182
|
+
let dir = realDir(startDir);
|
|
183
|
+
let prev = "";
|
|
184
|
+
while (dir !== prev) {
|
|
185
|
+
if (existsSync2(join3(dir, "design", "canvas.json"))) return dir;
|
|
186
|
+
prev = dir;
|
|
187
|
+
dir = dirname2(dir);
|
|
188
|
+
}
|
|
189
|
+
return void 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// src/open.ts
|
|
193
|
+
function openProject(startDir, spoolDir2) {
|
|
194
|
+
const root = resolveProjectRoot(startDir);
|
|
195
|
+
if (root === void 0) {
|
|
196
|
+
throw new SpoolError(`no spool project found from ${startDir}, run \`spool init\` in your product root`);
|
|
197
|
+
}
|
|
198
|
+
registerProject(spoolDir2, root);
|
|
199
|
+
return { root };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// src/cli.ts
|
|
203
|
+
var pkg = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf8"));
|
|
204
|
+
var spoolDir = join4(homedir(), ".spool");
|
|
205
|
+
var rootConfigPointer = `add this line to the repo's root CLAUDE.md or AGENTS.md so future sessions find the canvas:
|
|
206
|
+
|
|
207
|
+
design/ is a spool canvas (see design/AGENTS.md; run \`spool skill\` to learn it)
|
|
208
|
+
`;
|
|
209
|
+
var program = new Command("spool").description("the live prototyping canvas").version(pkg.version, "-v, --version");
|
|
210
|
+
program.command("init").description("scaffold design/ in a product root and register it").argument("[path]", "product root", ".").action((path) => {
|
|
211
|
+
const { root } = initProject(path, spoolDir);
|
|
212
|
+
process.stdout.write(`initialized spool project at ${root}
|
|
213
|
+
|
|
214
|
+
${rootConfigPointer}`);
|
|
215
|
+
});
|
|
216
|
+
program.command("open").description("resolve the project by walk-up and register it").argument("[path]", "where the walk-up starts", ".").action((path) => {
|
|
217
|
+
const { root } = openProject(path, spoolDir);
|
|
218
|
+
process.stdout.write(`registered ${basename(root)} (${root})
|
|
219
|
+
`);
|
|
220
|
+
});
|
|
221
|
+
try {
|
|
222
|
+
program.parse();
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (error instanceof SpoolError) {
|
|
225
|
+
process.stderr.write(`spool: ${error.message}
|
|
226
|
+
`);
|
|
227
|
+
process.exitCode = 1;
|
|
228
|
+
} else {
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "spool.page",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Live prototyping canvas: agent-authored TSX frames on an infinite canvas, repo-native.",
|
|
5
|
+
"repository": "github:devosurf/spool",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"spool": "./dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22"
|
|
15
|
+
},
|
|
16
|
+
"packageManager": "pnpm@11.5.3",
|
|
17
|
+
"scripts": {
|
|
18
|
+
"dev": "tsx src/cli.ts",
|
|
19
|
+
"build": "tsup",
|
|
20
|
+
"prepack": "tsup",
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"check": "biome check .",
|
|
23
|
+
"test": "vitest run"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@biomejs/biome": "^2.5.5",
|
|
27
|
+
"@types/node": "^22.20.1",
|
|
28
|
+
"tsup": "^8.5.1",
|
|
29
|
+
"tsx": "^4.23.1",
|
|
30
|
+
"typescript": "^7.0.2",
|
|
31
|
+
"vitest": "^4.1.10"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"commander": "^15.0.0"
|
|
35
|
+
}
|
|
36
|
+
}
|