@home-v0/cli 0.0.0-stage → 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/README.md +32 -2
- package/dist/home-v0.js +754 -0
- package/package.json +43 -4
package/README.md
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @home-v0/cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Build a house in 3D in [home-v0](https://home-v0.com) from a folder of photos and floor plans, by hand or with an AI agent (Claude Code, Codex, Cursor…). An agent in home-v0 builds the model; the CLI uploads the house, follows the build, and saves the agent's chat, renders and 3D model next to your files.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx @home-v0/cli login --token hv0_… # API key from home-v0 → API keys
|
|
7
|
+
npx @home-v0/cli init our-house --name "Our house" --place "Vrbnje 53, Radovljica"
|
|
8
|
+
# put outside photos (Street View screenshots work) in our-house/photos, plans in our-house/drawings
|
|
9
|
+
npx @home-v0/cli build our-house # 10 to 40 minutes; results in our-house/runs/<time>/
|
|
10
|
+
npx @home-v0/cli say our-house -m "The roof is steeper: the section shows 45 degrees"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
| Command | What it does |
|
|
14
|
+
| --------------------------------------- | -------------------------------------------------------------------- |
|
|
15
|
+
| `login [--token]`, `logout` | Save or forget an API key (or set `HOME_V0_API_KEY`) |
|
|
16
|
+
| `init [dir] --name … --place …` | Start a house folder: `house.json`, `photos/`, `drawings/` |
|
|
17
|
+
| `houses [dir]` | List the house folders below a folder |
|
|
18
|
+
| `build [house]` | Make the house in home-v0, upload its files and follow the build |
|
|
19
|
+
| `say [house] -m …` | Tell the agent what to change and follow its answer |
|
|
20
|
+
| `follow`, `pull`, `stop` `[house]` | Keep following, save the latest files once, or stop the agent's turn |
|
|
21
|
+
| `guide [--agents-md]` | The folder format, the workflow and how to review a build |
|
|
22
|
+
|
|
23
|
+
A house is a folder with a `house.json`, the current folder by default.
|
|
24
|
+
|
|
25
|
+
## With an AI agent
|
|
26
|
+
|
|
27
|
+
Tell your agent to run `npx @home-v0/cli guide` first: it explains the folder, the commands, how to review a build against the photos and drawings, and what each build costs. To make it stick, add the output of `npx @home-v0/cli guide --agents-md` to your project's `AGENTS.md` or `CLAUDE.md`, and give the agent your key as `HOME_V0_API_KEY`.
|
|
28
|
+
|
|
29
|
+
Exit codes: 0 done, 1 the build failed or is waiting on a key or limit, 2 a mistake in the command, 3 not signed in or the key was refused, 4 a mistake in the house folder.
|
|
30
|
+
|
|
31
|
+
## Developing
|
|
32
|
+
|
|
33
|
+
From the repo root: `npm run home -- <command>` runs the source against `--api-url http://localhost:3000` or any home-v0. A local API needs no key: the CLI makes its own account there. `npm run build -w @home-v0/cli` bundles `dist/home-v0.js`, which is what gets published. To release, bump the version here, then `npm publish` from this folder.
|
package/dist/home-v0.js
ADDED
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/main.ts
|
|
4
|
+
import { access, readdir as readdir3, rm as rm2, writeFile as writeFile4 } from "node:fs/promises";
|
|
5
|
+
import { join as join4, relative as relative2 } from "node:path";
|
|
6
|
+
import { createInterface } from "node:readline/promises";
|
|
7
|
+
import { parseArgs } from "node:util";
|
|
8
|
+
|
|
9
|
+
// src/api.ts
|
|
10
|
+
import { readFile } from "node:fs/promises";
|
|
11
|
+
import { basename, extname } from "node:path";
|
|
12
|
+
var CONTENT_TYPES = {
|
|
13
|
+
".jpg": "image/jpeg",
|
|
14
|
+
".jpeg": "image/jpeg",
|
|
15
|
+
".png": "image/png",
|
|
16
|
+
".webp": "image/webp",
|
|
17
|
+
".avif": "image/avif",
|
|
18
|
+
".heic": "image/heic",
|
|
19
|
+
".heif": "image/heif",
|
|
20
|
+
".pdf": "application/pdf"
|
|
21
|
+
};
|
|
22
|
+
var ApiError = class extends Error {
|
|
23
|
+
status;
|
|
24
|
+
constructor(status, message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.status = status;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
var HomeApi = class {
|
|
30
|
+
baseUrl;
|
|
31
|
+
auth;
|
|
32
|
+
cookies = /* @__PURE__ */ new Map();
|
|
33
|
+
constructor(baseUrl, auth) {
|
|
34
|
+
this.baseUrl = baseUrl;
|
|
35
|
+
this.auth = auth;
|
|
36
|
+
}
|
|
37
|
+
async signIn() {
|
|
38
|
+
if ("apiKey" in this.auth) return;
|
|
39
|
+
const { email, password } = this.auth.devAccount;
|
|
40
|
+
try {
|
|
41
|
+
await this.request("POST", "auth/login", { json: { email, password }, retry: false });
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (!(error instanceof ApiError) || ![401, 404].includes(error.status)) throw error;
|
|
44
|
+
await this.request("POST", "auth/register", { json: { name: "home-v0 CLI", email, password }, retry: false });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Fails with 401 if the API doesn't know the key. */
|
|
48
|
+
async checkKey() {
|
|
49
|
+
await this.request("GET", "projects");
|
|
50
|
+
}
|
|
51
|
+
searchPlaces(query) {
|
|
52
|
+
return this.request("GET", `places/search?${new URLSearchParams({ q: query })}`);
|
|
53
|
+
}
|
|
54
|
+
createProject(name, place) {
|
|
55
|
+
return this.request("POST", "projects", { json: { name, kind: "house", place } });
|
|
56
|
+
}
|
|
57
|
+
deleteProject(id) {
|
|
58
|
+
return this.request("DELETE", `projects/${id}`);
|
|
59
|
+
}
|
|
60
|
+
spaces(projectId) {
|
|
61
|
+
return this.request("GET", `projects/${projectId}/spaces`);
|
|
62
|
+
}
|
|
63
|
+
space(id) {
|
|
64
|
+
return this.request("GET", `spaces/${id}`);
|
|
65
|
+
}
|
|
66
|
+
addPhotos(spaceId, files) {
|
|
67
|
+
return this.upload(`spaces/${spaceId}/photos`, "photos", files);
|
|
68
|
+
}
|
|
69
|
+
addDrawings(spaceId, files) {
|
|
70
|
+
return this.upload(`spaces/${spaceId}/drawings`, "files", files);
|
|
71
|
+
}
|
|
72
|
+
capture(spaceId) {
|
|
73
|
+
return this.request("POST", `spaces/${spaceId}/capture`);
|
|
74
|
+
}
|
|
75
|
+
conversation(spaceId) {
|
|
76
|
+
return this.request("GET", `spaces/${spaceId}/agent`);
|
|
77
|
+
}
|
|
78
|
+
send(sessionId, text) {
|
|
79
|
+
return this.request("POST", `agent-sessions/${sessionId}/messages`, { json: { text } });
|
|
80
|
+
}
|
|
81
|
+
stop(sessionId) {
|
|
82
|
+
return this.request("POST", `agent-sessions/${sessionId}/stop`);
|
|
83
|
+
}
|
|
84
|
+
/** A render or model the agent made. Media on the API's own host gets the credentials too. */
|
|
85
|
+
async download(url) {
|
|
86
|
+
const sameHost = new URL(url).host === new URL(this.baseUrl).host;
|
|
87
|
+
const response = await fetch(url, { headers: sameHost ? this.headers() : {} });
|
|
88
|
+
if (!response.ok) throw new ApiError(response.status, `Downloading ${url} failed with ${response.status}`);
|
|
89
|
+
return { body: new Uint8Array(await response.arrayBuffer()), contentType: response.headers.get("content-type") };
|
|
90
|
+
}
|
|
91
|
+
async upload(path, field, files) {
|
|
92
|
+
const form = new FormData();
|
|
93
|
+
for (const file of files) {
|
|
94
|
+
const type = CONTENT_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream";
|
|
95
|
+
form.append(field, new Blob([await readFile(file)], { type }), basename(file));
|
|
96
|
+
}
|
|
97
|
+
return this.request("POST", path, { form });
|
|
98
|
+
}
|
|
99
|
+
async request(method, path, options = {}) {
|
|
100
|
+
const headers = this.headers();
|
|
101
|
+
let body = options.form;
|
|
102
|
+
if (options.json !== void 0) {
|
|
103
|
+
headers["Content-Type"] = "application/json";
|
|
104
|
+
body = JSON.stringify(options.json);
|
|
105
|
+
}
|
|
106
|
+
const response = await fetch(new URL(path, this.baseUrl.endsWith("/") ? this.baseUrl : `${this.baseUrl}/`), {
|
|
107
|
+
method,
|
|
108
|
+
headers,
|
|
109
|
+
body
|
|
110
|
+
});
|
|
111
|
+
this.keepCookies(response);
|
|
112
|
+
if (response.status === 401 && "devAccount" in this.auth && options.retry !== false) {
|
|
113
|
+
await this.signIn();
|
|
114
|
+
return this.request(method, path, { ...options, retry: false });
|
|
115
|
+
}
|
|
116
|
+
if (!response.ok) throw new ApiError(response.status, await errorMessage(method, path, response));
|
|
117
|
+
if (response.status === 204) return void 0;
|
|
118
|
+
return await response.json();
|
|
119
|
+
}
|
|
120
|
+
headers() {
|
|
121
|
+
if ("apiKey" in this.auth) return { Authorization: `Bearer ${this.auth.apiKey}` };
|
|
122
|
+
const cookie = [...this.cookies].map(([name, value]) => `${name}=${value}`).join("; ");
|
|
123
|
+
return { "X-Requested-With": "XMLHttpRequest", ...cookie ? { Cookie: cookie } : {} };
|
|
124
|
+
}
|
|
125
|
+
keepCookies(response) {
|
|
126
|
+
for (const header of response.headers.getSetCookie()) {
|
|
127
|
+
const [pair] = header.split(";");
|
|
128
|
+
const at = pair.indexOf("=");
|
|
129
|
+
if (at > 0) this.cookies.set(pair.slice(0, at).trim(), pair.slice(at + 1).trim());
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
async function errorMessage(method, path, response) {
|
|
134
|
+
const text = await response.text();
|
|
135
|
+
let message = text;
|
|
136
|
+
try {
|
|
137
|
+
const json = JSON.parse(text);
|
|
138
|
+
if (json.message) message = Array.isArray(json.message) ? json.message.join("; ") : String(json.message);
|
|
139
|
+
} catch {
|
|
140
|
+
}
|
|
141
|
+
return `${method} /${path.split("?")[0]} failed with ${response.status}: ${message.slice(0, 500)}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// src/config.ts
|
|
145
|
+
import { chmod, mkdir, readFile as readFile2, rm, writeFile } from "node:fs/promises";
|
|
146
|
+
import { homedir } from "node:os";
|
|
147
|
+
import { dirname, join } from "node:path";
|
|
148
|
+
var DEFAULT_API_URL = "https://api.home-v0.com";
|
|
149
|
+
var configPath = () => join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "home-v0", "config.json");
|
|
150
|
+
var readSavedConfig = async () => {
|
|
151
|
+
try {
|
|
152
|
+
return JSON.parse(await readFile2(configPath(), "utf8"));
|
|
153
|
+
} catch {
|
|
154
|
+
return {};
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
var saveConfig = async (config) => {
|
|
158
|
+
const path = configPath();
|
|
159
|
+
await mkdir(dirname(path), { recursive: true });
|
|
160
|
+
await writeFile(path, `${JSON.stringify(config, null, 2)}
|
|
161
|
+
`, { mode: 384 });
|
|
162
|
+
await chmod(path, 384);
|
|
163
|
+
return path;
|
|
164
|
+
};
|
|
165
|
+
var clearConfig = async () => rm(configPath(), { force: true });
|
|
166
|
+
var resolveConnection = async (flags) => {
|
|
167
|
+
const saved = await readSavedConfig();
|
|
168
|
+
return {
|
|
169
|
+
apiUrl: (flags.apiUrl || process.env.HOME_V0_API_URL || saved.apiUrl || DEFAULT_API_URL).replace(/\/$/, ""),
|
|
170
|
+
token: flags.token || process.env.HOME_V0_API_KEY || saved.token || ""
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
var isLocal = (url) => ["localhost", "127.0.0.1"].includes(new URL(url).hostname);
|
|
174
|
+
var webUrlFor = (apiUrl) => {
|
|
175
|
+
const url = new URL(apiUrl);
|
|
176
|
+
if (isLocal(apiUrl)) return `${url.protocol}//${url.hostname}:5173`;
|
|
177
|
+
return `${url.protocol}//${url.host.replace(/^api\./, "")}`;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// src/guide.ts
|
|
181
|
+
var guide = () => `# Building a house in home-v0 with the CLI
|
|
182
|
+
|
|
183
|
+
home-v0 turns a house's photos and floor plans into a 3D model built by an AI agent, and suggests what the house
|
|
184
|
+
could become. The \`home-v0\` CLI does what you'd do in the app: it makes the house, uploads its photos and drawings,
|
|
185
|
+
starts the build, and saves the agent's chat, renders and 3D model in the house's folder. You (a person or an AI
|
|
186
|
+
agent) prepare a folder; the CLI does the rest.
|
|
187
|
+
|
|
188
|
+
## Sign in
|
|
189
|
+
|
|
190
|
+
Create an API key in home-v0 (sidebar \u2192 API keys). It acts as you, in the workspace you made it in. Then either:
|
|
191
|
+
|
|
192
|
+
- \`home-v0 login --token hv0_\u2026\` saves it in ~/.config/home-v0/config.json, or
|
|
193
|
+
- set \`HOME_V0_API_KEY\` for each run.
|
|
194
|
+
|
|
195
|
+
A key can create houses and talk to the agent that builds them. It can't change your account or your team.
|
|
196
|
+
|
|
197
|
+
## The house folder
|
|
198
|
+
|
|
199
|
+
\`\`\`
|
|
200
|
+
my-house/
|
|
201
|
+
house.json
|
|
202
|
+
photos/ outside photos, up to 8 (JPEG, PNG, WebP, AVIF or HEIC, 15 MB each)
|
|
203
|
+
drawings/ floor plans, elevations, sections (PDF or images, 30 MB each, 12 pages in all)
|
|
204
|
+
runs/ written by the CLI: one folder per build
|
|
205
|
+
\`\`\`
|
|
206
|
+
|
|
207
|
+
\`home-v0 init my-house --name "Our house" --place "Vrbnje 53, Radovljica"\` starts one.
|
|
208
|
+
|
|
209
|
+
\`house.json\`:
|
|
210
|
+
|
|
211
|
+
\`\`\`json
|
|
212
|
+
{
|
|
213
|
+
"name": "Our house",
|
|
214
|
+
"place": "Vrbnje 53, Radovljica",
|
|
215
|
+
"notes": "Two floors and an attic, gable roof, built in 1978."
|
|
216
|
+
}
|
|
217
|
+
\`\`\`
|
|
218
|
+
|
|
219
|
+
- \`name\`: what the house is called in the app.
|
|
220
|
+
- \`place\`: an address, searched like the app's address field. An exact address gives the agent the building's
|
|
221
|
+
outline from the map (for its size) and the real sun. A town alone works, without the outline.
|
|
222
|
+
- \`photos\`, \`drawings\` (optional): lists of files, relative to the folder, when you don't want every file in
|
|
223
|
+
\`photos/\` or \`drawings/\` (which are taken in name order).
|
|
224
|
+
- \`notes\` (optional): what you know about the house. The CLI doesn't send it; it's for whoever reviews the build.
|
|
225
|
+
|
|
226
|
+
Photos: the whole outside of the house from eye level, each side if you can. Street View screenshots work well.
|
|
227
|
+
At least one photo is required: the app reads the house from its photos. Drawings are optional but make the model
|
|
228
|
+
much closer: plans decide wall lengths, rooms and openings on sides the photos don't show, and each floor with a
|
|
229
|
+
plan is built inside too.
|
|
230
|
+
|
|
231
|
+
## Workflow
|
|
232
|
+
|
|
233
|
+
1. \`home-v0 build my-house\` makes the house in home-v0, uploads everything and follows the build, printing the
|
|
234
|
+
agent's chat. A first build takes 10 to 40 minutes. The command ends when the agent's turn ends, and leaves
|
|
235
|
+
everything in \`my-house/runs/<time>/\`: \`chat.md\` (the conversation, renders inline), \`NNNN-render.*\` (pictures
|
|
236
|
+
the agent rendered), \`NNNN-model.glb\` (the 3D model), \`messages.json\` and \`run.json\` (with the house's link in
|
|
237
|
+
the app).
|
|
238
|
+
2. Look at the renders next to the photos and drawings (see Review below).
|
|
239
|
+
3. \`home-v0 say my-house -m "The roof is steeper: the section shows 45 degrees"\` tells the agent what's wrong and
|
|
240
|
+
follows its answer. Name what's wrong and where you see it, one or two things per message.
|
|
241
|
+
4. \`home-v0 follow my-house\` keeps watching if a command timed out; \`home-v0 pull my-house\` saves the latest files
|
|
242
|
+
without waiting; \`home-v0 stop my-house\` stops the agent's current turn.
|
|
243
|
+
|
|
244
|
+
Open the link in \`run.json\` to see the model in 3D, peel it floor by floor, and try new directions in the app.
|
|
245
|
+
|
|
246
|
+
## Review
|
|
247
|
+
|
|
248
|
+
Compare each render with the photos and drawings, element by element:
|
|
249
|
+
|
|
250
|
+
- the number of floors, and the footprint's shape and size
|
|
251
|
+
- the roof: type, pitch, overhangs, which way the ridge runs, dormers and chimneys
|
|
252
|
+
- openings on each side: how many, where, and how big
|
|
253
|
+
- materials and colours
|
|
254
|
+
- inside each floor that has a plan: walls, doors, stairs and room names
|
|
255
|
+
|
|
256
|
+
Say which render and which photo or drawing each point comes from, and how sure you are when a render's angle hides
|
|
257
|
+
something. Fix the biggest differences first.
|
|
258
|
+
|
|
259
|
+
## For AI agents
|
|
260
|
+
|
|
261
|
+
- Each build spends from the workspace's budget and counts against its daily house limit. Build each house once,
|
|
262
|
+
then correct it with \`say\`; don't build the same house again to fix it.
|
|
263
|
+
- Run \`build\`, \`say\` and \`follow\` in the background and wait for them to finish instead of polling: they print
|
|
264
|
+
as they go and exit when the agent's turn ends.
|
|
265
|
+
- Exit codes: 0 done, 1 the build failed or is waiting on a key or the plan's limit (the output says which),
|
|
266
|
+
2 a mistake in the command, 3 not signed in or the key was refused, 4 a mistake in the house folder.
|
|
267
|
+
- \`--timeout <minutes>\` (default 45) is how long to follow; \`--detach\` starts a build without following it.
|
|
268
|
+
- \`--api-url\` or \`HOME_V0_API_URL\` points the CLI at another home-v0 (default https://api.home-v0.com).
|
|
269
|
+
`;
|
|
270
|
+
var agentsSnippet = () => `## home-v0 houses
|
|
271
|
+
|
|
272
|
+
Folders with a \`house.json\` are houses for home-v0, which builds them in 3D with an AI agent. Use the
|
|
273
|
+
\`home-v0\` CLI (\`npx @home-v0/cli\`):
|
|
274
|
+
|
|
275
|
+
- Run \`npx @home-v0/cli guide\` once to learn the folder format, the workflow and how to review a build.
|
|
276
|
+
- \`npx @home-v0/cli build <folder>\` builds a house (10 to 40 minutes; run it in the background). Results land in
|
|
277
|
+
\`<folder>/runs/<time>/\`.
|
|
278
|
+
- Correct a build with \`npx @home-v0/cli say <folder> -m "\u2026"\` instead of building it again: each build costs money.
|
|
279
|
+
- Authenticate with the \`HOME_V0_API_KEY\` environment variable (an API key from home-v0 \u2192 API keys).
|
|
280
|
+
`;
|
|
281
|
+
|
|
282
|
+
// src/houses.ts
|
|
283
|
+
import { mkdir as mkdir2, readdir, readFile as readFile3, stat, writeFile as writeFile2 } from "node:fs/promises";
|
|
284
|
+
import { extname as extname2, join as join2, relative, resolve } from "node:path";
|
|
285
|
+
var MAX_PHOTOS = 8;
|
|
286
|
+
var MAX_DRAWING_FILES_PER_UPLOAD = 5;
|
|
287
|
+
var MAX_PHOTO_MB = 15;
|
|
288
|
+
var MAX_DRAWING_MB = 30;
|
|
289
|
+
var PHOTO_TYPES = [".jpg", ".jpeg", ".png", ".webp", ".avif", ".heic", ".heif"];
|
|
290
|
+
var DRAWING_TYPES = [...PHOTO_TYPES, ".pdf"];
|
|
291
|
+
var SKIP = /* @__PURE__ */ new Set(["node_modules", "runs", "dist", "photos", "drawings"]);
|
|
292
|
+
var HouseError = class extends Error {
|
|
293
|
+
};
|
|
294
|
+
function parseManifest(raw) {
|
|
295
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return { problems: ["it must be an object"] };
|
|
296
|
+
const value = raw;
|
|
297
|
+
const problems = [];
|
|
298
|
+
const text = (key, required) => {
|
|
299
|
+
const field = value[key];
|
|
300
|
+
if (field === void 0 && !required) return;
|
|
301
|
+
if (typeof field !== "string" || !field.trim())
|
|
302
|
+
problems.push(`${key}: ${required ? "is required" : "must be text"}`);
|
|
303
|
+
};
|
|
304
|
+
const files = (key) => {
|
|
305
|
+
const field = value[key];
|
|
306
|
+
if (field !== void 0 && (!Array.isArray(field) || field.some((f) => typeof f !== "string"))) {
|
|
307
|
+
problems.push(`${key}: must be a list of file paths`);
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
text("name", true);
|
|
311
|
+
text("place", true);
|
|
312
|
+
text("notes", false);
|
|
313
|
+
files("photos");
|
|
314
|
+
files("drawings");
|
|
315
|
+
if (problems.length) return { problems };
|
|
316
|
+
const manifest = value;
|
|
317
|
+
return { manifest: { ...manifest, name: manifest.name.trim(), place: manifest.place.trim() }, problems };
|
|
318
|
+
}
|
|
319
|
+
async function loadHouse(folder) {
|
|
320
|
+
const file = join2(folder, "house.json");
|
|
321
|
+
let raw;
|
|
322
|
+
try {
|
|
323
|
+
raw = JSON.parse(await readFile3(file, "utf8"));
|
|
324
|
+
} catch (error) {
|
|
325
|
+
if (error.code === "ENOENT") {
|
|
326
|
+
throw new HouseError(
|
|
327
|
+
`${folder} has no house.json. Start one with: home-v0 init ${folder} --name "\u2026" --place "\u2026"`
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
throw new HouseError(`${file} isn't valid JSON: ${error.message}`);
|
|
331
|
+
}
|
|
332
|
+
const { manifest, problems } = parseManifest(raw);
|
|
333
|
+
if (!manifest) throw new HouseError(`${file} has mistakes:
|
|
334
|
+
${problems.join("\n ")}`);
|
|
335
|
+
const photos = manifest.photos?.map((p) => join2(folder, p)) ?? await filesIn(join2(folder, "photos"), PHOTO_TYPES);
|
|
336
|
+
const drawings = manifest.drawings?.map((d) => join2(folder, d)) ?? await filesIn(join2(folder, "drawings"), DRAWING_TYPES);
|
|
337
|
+
if (photos.length > MAX_PHOTOS) {
|
|
338
|
+
throw new HouseError(
|
|
339
|
+
`${file}: a house takes up to ${MAX_PHOTOS} photos, and it has ${photos.length}. List the best ones in "photos".`
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
await checkFiles(photos, PHOTO_TYPES, MAX_PHOTO_MB, "photo");
|
|
343
|
+
await checkFiles(drawings, DRAWING_TYPES, MAX_DRAWING_MB, "drawing");
|
|
344
|
+
return { folder, name: manifest.name, place: manifest.place, photos, drawings, notes: manifest.notes ?? null };
|
|
345
|
+
}
|
|
346
|
+
async function checkFiles(files, types, maxMb, what) {
|
|
347
|
+
for (const file of files) {
|
|
348
|
+
if (!types.includes(extname2(file).toLowerCase())) {
|
|
349
|
+
throw new HouseError(`${file}: a ${what} must be one of ${types.join(", ")}`);
|
|
350
|
+
}
|
|
351
|
+
const info = await stat(file).catch(() => null);
|
|
352
|
+
if (!info?.isFile()) throw new HouseError(`${file} doesn't exist`);
|
|
353
|
+
if (info.size > maxMb * 1024 * 1024) throw new HouseError(`${file} is over ${maxMb} MB`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
async function filesIn(folder, types) {
|
|
357
|
+
const names = await readdir(folder).catch(() => []);
|
|
358
|
+
return names.filter((name) => types.includes(extname2(name).toLowerCase())).sort().map((name) => join2(folder, name));
|
|
359
|
+
}
|
|
360
|
+
async function findHouses(root) {
|
|
361
|
+
const folders = [];
|
|
362
|
+
const walk = async (dir, depth) => {
|
|
363
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
364
|
+
if (entries.some((e) => e.isFile() && e.name === "house.json")) folders.push(dir);
|
|
365
|
+
if (depth >= 6) return;
|
|
366
|
+
for (const entry of entries) {
|
|
367
|
+
if (entry.isDirectory() && !entry.name.startsWith(".") && !SKIP.has(entry.name)) {
|
|
368
|
+
await walk(join2(dir, entry.name), depth + 1);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
await walk(resolve(root), 0);
|
|
373
|
+
const houses = await Promise.all(folders.sort().map((folder) => loadHouse(folder)));
|
|
374
|
+
return houses;
|
|
375
|
+
}
|
|
376
|
+
var fold = (text) => text.normalize("NFD").replace(new RegExp("\\p{Diacritic}", "gu"), "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
377
|
+
async function pickHouse(query, root) {
|
|
378
|
+
const asFolder = await stat(query).catch(() => null);
|
|
379
|
+
if (asFolder?.isDirectory()) return loadHouse(query);
|
|
380
|
+
const houses = await findHouses(root);
|
|
381
|
+
const words = fold(query).split(" ").filter(Boolean);
|
|
382
|
+
const matches = houses.filter(
|
|
383
|
+
(h) => words.every((word) => fold(`${relative(root, h.folder)} ${h.name} ${h.place}`).includes(word))
|
|
384
|
+
);
|
|
385
|
+
if (matches.length === 1) return matches[0];
|
|
386
|
+
const list = (matches.length ? matches : houses).map((h) => ` ${relative(root, h.folder) || "."}`).join("\n");
|
|
387
|
+
if (matches.length) throw new HouseError(`"${query}" matches more than one house:
|
|
388
|
+
${list}`);
|
|
389
|
+
throw new HouseError(
|
|
390
|
+
houses.length ? `"${query}" isn't a folder, and no house here matches it. The houses are:
|
|
391
|
+
${list}` : `"${query}" isn't a folder with a house.json in it.`
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
async function initHouse(folder, name, place, force) {
|
|
395
|
+
const file = join2(folder, "house.json");
|
|
396
|
+
if (!force && await stat(file).catch(() => null)) {
|
|
397
|
+
throw new HouseError(`${file} already exists. Pass --force to start it again.`);
|
|
398
|
+
}
|
|
399
|
+
await mkdir2(join2(folder, "photos"), { recursive: true });
|
|
400
|
+
await mkdir2(join2(folder, "drawings"), { recursive: true });
|
|
401
|
+
await writeFile2(file, `${JSON.stringify({ name, place }, null, 2)}
|
|
402
|
+
`);
|
|
403
|
+
return file;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// src/runs.ts
|
|
407
|
+
import { mkdir as mkdir3, readdir as readdir2, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
|
|
408
|
+
import { extname as extname3, join as join3 } from "node:path";
|
|
409
|
+
var runId = (date) => date.toISOString().slice(0, 16).replace(":", "").replace("T", "-");
|
|
410
|
+
async function createRun(house, run) {
|
|
411
|
+
const folder = join3(house.folder, "runs", runId(new Date(run.startedAt)));
|
|
412
|
+
await mkdir3(folder, { recursive: true });
|
|
413
|
+
const saved = { folder, name: house.name, ...run };
|
|
414
|
+
await writeFile3(join3(folder, "run.json"), `${JSON.stringify({ ...saved, folder: void 0 }, null, 2)}
|
|
415
|
+
`);
|
|
416
|
+
return saved;
|
|
417
|
+
}
|
|
418
|
+
async function loadRun(house, name) {
|
|
419
|
+
const runs = join3(house.folder, "runs");
|
|
420
|
+
let picked = name;
|
|
421
|
+
if (!picked) {
|
|
422
|
+
const all = (await readdir2(runs).catch(() => [])).filter((entry) => !entry.startsWith(".")).sort();
|
|
423
|
+
if (!all.length)
|
|
424
|
+
throw new HouseError(`${house.folder} hasn't been built yet. Start with: home-v0 build ${house.folder}`);
|
|
425
|
+
picked = all[all.length - 1];
|
|
426
|
+
}
|
|
427
|
+
const folder = join3(runs, picked);
|
|
428
|
+
const run = JSON.parse(
|
|
429
|
+
await readFile4(join3(folder, "run.json"), "utf8").catch(() => {
|
|
430
|
+
throw new HouseError(`${folder} isn't a run of this house`);
|
|
431
|
+
})
|
|
432
|
+
);
|
|
433
|
+
return { ...run, folder };
|
|
434
|
+
}
|
|
435
|
+
function mediaFile(message) {
|
|
436
|
+
if (!message.url || message.kind !== "render" && message.kind !== "model") return null;
|
|
437
|
+
const ext = extname3(new URL(message.url).pathname).toLowerCase() || (message.kind === "model" ? ".glb" : ".png");
|
|
438
|
+
return `${String(message.seq).padStart(4, "0")}-${message.kind}${ext}`;
|
|
439
|
+
}
|
|
440
|
+
function describe(message) {
|
|
441
|
+
const who = message.role === "user" ? "you" : "agent";
|
|
442
|
+
switch (message.kind) {
|
|
443
|
+
case "step":
|
|
444
|
+
return ` \xB7 ${message.text ?? ""}${message.seconds ? ` (${message.seconds}s)` : ""}`;
|
|
445
|
+
case "render":
|
|
446
|
+
case "model":
|
|
447
|
+
return ` [${message.kind}] ${mediaFile(message) ?? message.url ?? ""}${message.text ? ` ${message.text}` : ""}`;
|
|
448
|
+
case "notice":
|
|
449
|
+
return ` [notice] ${message.text ?? ""}`;
|
|
450
|
+
case "design":
|
|
451
|
+
return ` [design] ${message.text ?? ""}`;
|
|
452
|
+
default:
|
|
453
|
+
return `${who}: ${message.text ?? ""}`;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
function chatMarkdown(run, messages) {
|
|
457
|
+
const lines = [
|
|
458
|
+
`# ${run.name}`,
|
|
459
|
+
"",
|
|
460
|
+
`Started ${run.startedAt} on ${run.apiUrl}. Open it in the app: ${run.webUrl}`,
|
|
461
|
+
""
|
|
462
|
+
];
|
|
463
|
+
for (const message of messages) {
|
|
464
|
+
const time = message.createdAt.slice(11, 19);
|
|
465
|
+
const file = mediaFile(message);
|
|
466
|
+
if (message.kind === "render" && file) lines.push(``, "");
|
|
467
|
+
else if (message.kind === "model" && file) lines.push(`*${time}* model: [${file}](${file})`, "");
|
|
468
|
+
else if (message.kind === "step") lines.push(`*${time}* \xB7 ${message.text ?? ""}`, "");
|
|
469
|
+
else if (message.kind === "text")
|
|
470
|
+
lines.push(`**${message.role === "user" ? "You" : "Agent"}** *${time}*`, "", message.text ?? "", "");
|
|
471
|
+
else lines.push(`*${time}* ${message.kind}: ${message.text ?? ""}`, "");
|
|
472
|
+
}
|
|
473
|
+
return `${lines.join("\n").trimEnd()}
|
|
474
|
+
`;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// src/version.ts
|
|
478
|
+
var VERSION = true ? "0.1.0" : JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
479
|
+
|
|
480
|
+
// src/main.ts
|
|
481
|
+
var POLL_MS = 3e3;
|
|
482
|
+
var HELD = ["failed", "needs-key", "plan-limit", "paused"];
|
|
483
|
+
var HELP = `home-v0 v${VERSION}: build a house in 3D in home-v0 from a folder of photos and drawings.
|
|
484
|
+
|
|
485
|
+
Usage
|
|
486
|
+
home-v0 login [--token hv0_\u2026] Save an API key (home-v0 \u2192 API keys)
|
|
487
|
+
home-v0 logout Forget the saved key
|
|
488
|
+
home-v0 init [dir] --name "\u2026" --place "\u2026"
|
|
489
|
+
Start a house folder: house.json, photos/, drawings/
|
|
490
|
+
home-v0 houses [dir] List the house folders in a folder
|
|
491
|
+
home-v0 build [house] Make the house in home-v0, upload its files and follow the build
|
|
492
|
+
home-v0 say [house] -m "\u2026" Tell the agent something and follow its answer
|
|
493
|
+
home-v0 follow [house] Keep following the latest build until the agent's turn ends
|
|
494
|
+
home-v0 pull [house] Save the latest build's chat, renders and model without waiting
|
|
495
|
+
home-v0 stop [house] Stop the agent's current turn
|
|
496
|
+
home-v0 guide [--agents-md] How it all works (start here, agents)
|
|
497
|
+
|
|
498
|
+
A house is a folder with a house.json, the current one by default, or words from the name of one below it.
|
|
499
|
+
Each build is saved in <house>/runs/<time>/.
|
|
500
|
+
|
|
501
|
+
Options
|
|
502
|
+
-m, --message <text> say: what to tell the agent
|
|
503
|
+
--run <name> Use this build (a folder in runs/) instead of the latest
|
|
504
|
+
--timeout <minutes> Stop following after this long (default 45)
|
|
505
|
+
--detach build: start it and exit without following
|
|
506
|
+
--api-url <url> The home-v0 API (default https://api.home-v0.com, or HOME_V0_API_URL)
|
|
507
|
+
--token <key> API key for this run (or HOME_V0_API_KEY)
|
|
508
|
+
--force init: start again over an existing house.json
|
|
509
|
+
-h, --help Show this help
|
|
510
|
+
-v, --version Show the version`;
|
|
511
|
+
var UsageError = class extends Error {
|
|
512
|
+
};
|
|
513
|
+
var AuthError = class extends Error {
|
|
514
|
+
};
|
|
515
|
+
async function main(argv) {
|
|
516
|
+
const { values, positionals } = parseArgs({
|
|
517
|
+
args: argv,
|
|
518
|
+
allowPositionals: true,
|
|
519
|
+
options: {
|
|
520
|
+
message: { type: "string", short: "m" },
|
|
521
|
+
run: { type: "string" },
|
|
522
|
+
timeout: { type: "string", default: "45" },
|
|
523
|
+
detach: { type: "boolean", default: false },
|
|
524
|
+
"api-url": { type: "string" },
|
|
525
|
+
token: { type: "string" },
|
|
526
|
+
name: { type: "string" },
|
|
527
|
+
place: { type: "string" },
|
|
528
|
+
force: { type: "boolean", default: false },
|
|
529
|
+
"agents-md": { type: "boolean", default: false },
|
|
530
|
+
help: { type: "boolean", short: "h", default: false },
|
|
531
|
+
version: { type: "boolean", short: "v", default: false }
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
const [command, target = "."] = positionals;
|
|
535
|
+
if (values.version) return console.log(VERSION);
|
|
536
|
+
if (!command || command === "help" || values.help) return console.log(HELP);
|
|
537
|
+
const connection = await resolveConnection({ apiUrl: values["api-url"], token: values.token });
|
|
538
|
+
const timeoutMs = Number(values.timeout) * 6e4;
|
|
539
|
+
if (!(timeoutMs > 0)) throw new UsageError("--timeout is a number of minutes");
|
|
540
|
+
switch (command) {
|
|
541
|
+
case "guide":
|
|
542
|
+
return console.log(values["agents-md"] ? agentsSnippet() : guide());
|
|
543
|
+
case "login":
|
|
544
|
+
return login(connection.apiUrl, values.token, values["api-url"]);
|
|
545
|
+
case "logout":
|
|
546
|
+
await clearConfig();
|
|
547
|
+
return console.log("Signed out: the saved key is gone.");
|
|
548
|
+
case "init": {
|
|
549
|
+
if (!values.name || !values.place) throw new UsageError('init needs --name "\u2026" and --place "\u2026"');
|
|
550
|
+
const file = await initHouse(target, values.name, values.place, values.force);
|
|
551
|
+
return console.log(
|
|
552
|
+
`Wrote ${file}. Add outside photos to photos/ and any plans to drawings/, then: home-v0 build ${target}`
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
case "houses":
|
|
556
|
+
return listHouses(target);
|
|
557
|
+
case "build": {
|
|
558
|
+
const house = await pickHouse(target, ".");
|
|
559
|
+
const api = await signIn(connection.apiUrl, connection.token);
|
|
560
|
+
const run = await startBuild(api, house);
|
|
561
|
+
if (values.detach) return console.log(`Started. Follow it with: home-v0 follow ${house.folder}`);
|
|
562
|
+
return finish(await follow(api, run, 0, timeoutMs), run);
|
|
563
|
+
}
|
|
564
|
+
case "say":
|
|
565
|
+
case "follow":
|
|
566
|
+
case "pull":
|
|
567
|
+
case "stop": {
|
|
568
|
+
const house = await pickHouse(target, ".");
|
|
569
|
+
const run = await loadRun(house, values.run);
|
|
570
|
+
const api = await signIn(run.apiUrl, connection.token);
|
|
571
|
+
return afterBuild(command, api, run, values.message, timeoutMs);
|
|
572
|
+
}
|
|
573
|
+
default:
|
|
574
|
+
throw new UsageError(`Unknown command "${command}". Run: home-v0 --help`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
async function afterBuild(command, api, run, message, timeoutMs) {
|
|
578
|
+
if (command === "follow") return finish(await follow(api, run, 0, timeoutMs), run);
|
|
579
|
+
if (command === "pull") {
|
|
580
|
+
const { messages: messages2 } = await save(api, run, /* @__PURE__ */ new Set());
|
|
581
|
+
return console.log(`Saved ${messages2.length} messages to ${run.folder}`);
|
|
582
|
+
}
|
|
583
|
+
const { session, messages } = await api.conversation(run.spaceId);
|
|
584
|
+
if (!session) throw new UsageError("This house's agent hasn't started yet. Wait for it with: home-v0 follow");
|
|
585
|
+
if (command === "stop") {
|
|
586
|
+
const stopped = await api.stop(session.id);
|
|
587
|
+
return console.log(`Stopped. The agent is ${stopped.status}.`);
|
|
588
|
+
}
|
|
589
|
+
const text = message?.trim();
|
|
590
|
+
if (!text) throw new UsageError('What should I tell the agent? home-v0 say -m "the roof is steeper"');
|
|
591
|
+
const since = messages.at(-1)?.seq ?? 0;
|
|
592
|
+
await api.send(session.id, text);
|
|
593
|
+
return finish(await follow(api, run, since, timeoutMs), run);
|
|
594
|
+
}
|
|
595
|
+
async function login(apiUrl, flagToken, flagApiUrl) {
|
|
596
|
+
let token = flagToken ?? "";
|
|
597
|
+
if (!token) {
|
|
598
|
+
if (!process.stdin.isTTY) throw new UsageError("Pass the key: home-v0 login --token hv0_\u2026");
|
|
599
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
600
|
+
token = (await rl.question(`API key (make one at ${webUrlFor(apiUrl)}/dashboard/api-keys): `)).trim();
|
|
601
|
+
rl.close();
|
|
602
|
+
}
|
|
603
|
+
if (!token.startsWith("hv0_")) throw new UsageError("That isn't a home-v0 API key: they start with hv0_");
|
|
604
|
+
const api = new HomeApi(apiUrl, { apiKey: token });
|
|
605
|
+
await api.checkKey().catch((error) => {
|
|
606
|
+
throw error instanceof ApiError && error.status === 401 ? new AuthError(`${apiUrl} doesn't accept that key: it may have been revoked. Make a new one.`) : error;
|
|
607
|
+
});
|
|
608
|
+
const path = await saveConfig({ token, ...flagApiUrl ? { apiUrl: flagApiUrl } : {} });
|
|
609
|
+
console.log(`Signed in to ${apiUrl}. The key is saved in ${path}.`);
|
|
610
|
+
}
|
|
611
|
+
async function listHouses(root) {
|
|
612
|
+
const houses = await findHouses(root);
|
|
613
|
+
if (!houses.length) {
|
|
614
|
+
return console.log(`No house folders in ${root === "." ? "this folder" : root}. Start one with: home-v0 init`);
|
|
615
|
+
}
|
|
616
|
+
for (const house of houses) {
|
|
617
|
+
const runs = (await readdir3(join4(house.folder, "runs")).catch(() => [])).filter((r) => !r.startsWith("."));
|
|
618
|
+
const files = house.photos.length ? `${count(house.photos.length, "photo")}, ${count(house.drawings.length, "drawing")}` : `${count(house.drawings.length, "drawing")}, needs photos`;
|
|
619
|
+
const built = runs.length ? `, ${count(runs.length, "build")}` : "";
|
|
620
|
+
console.log(`${relative2(root, house.folder) || "."}: ${house.name}, ${house.place} (${files}${built})`);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
async function signIn(apiUrl, token) {
|
|
624
|
+
let api;
|
|
625
|
+
if (token) api = new HomeApi(apiUrl, { apiKey: token });
|
|
626
|
+
else if (isLocal(apiUrl)) {
|
|
627
|
+
api = new HomeApi(apiUrl, { devAccount: { email: "cli@home-v0.local", password: "home-v0-cli" } });
|
|
628
|
+
} else {
|
|
629
|
+
throw new AuthError(
|
|
630
|
+
`Not signed in. Make an API key at ${webUrlFor(apiUrl)}/dashboard/api-keys, then run home-v0 login or set HOME_V0_API_KEY.`
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
await api.signIn();
|
|
634
|
+
return api;
|
|
635
|
+
}
|
|
636
|
+
async function startBuild(api, house) {
|
|
637
|
+
if (!house.photos.length) {
|
|
638
|
+
throw new HouseError(
|
|
639
|
+
`${house.folder} has no photos, and home-v0 reads a house from its photos. Add some to photos/.`
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
console.log(`Building ${house.name} on ${api.baseUrl}`);
|
|
643
|
+
const [place] = await api.searchPlaces(house.place);
|
|
644
|
+
if (place) console.log(` place: ${place.label}${place.footprint ? ", with its outline from the map" : ""}`);
|
|
645
|
+
else console.log(` place: nothing found for "${house.place}", so it has no location`);
|
|
646
|
+
const startedAt = /* @__PURE__ */ new Date();
|
|
647
|
+
const project = await api.createProject(house.name, place);
|
|
648
|
+
let run;
|
|
649
|
+
try {
|
|
650
|
+
const exterior = (await api.spaces(project.id)).find((space2) => space2.kind === "exterior");
|
|
651
|
+
if (!exterior) throw new Error(`The new house ${project.id} has no exterior`);
|
|
652
|
+
let space = await api.addPhotos(exterior.id, house.photos);
|
|
653
|
+
for (let i = 0; i < house.drawings.length; i += MAX_DRAWING_FILES_PER_UPLOAD) {
|
|
654
|
+
space = await api.addDrawings(exterior.id, house.drawings.slice(i, i + MAX_DRAWING_FILES_PER_UPLOAD));
|
|
655
|
+
}
|
|
656
|
+
console.log(
|
|
657
|
+
` uploaded ${count(space.photos.length, "photo")} and ${count(space.drawings.length, "page")} of drawings`
|
|
658
|
+
);
|
|
659
|
+
run = await createRun(house, {
|
|
660
|
+
apiUrl: api.baseUrl,
|
|
661
|
+
webUrl: `${webUrlFor(api.baseUrl)}/projects/${project.id}/exterior`,
|
|
662
|
+
projectId: project.id,
|
|
663
|
+
spaceId: exterior.id,
|
|
664
|
+
startedAt: startedAt.toISOString()
|
|
665
|
+
});
|
|
666
|
+
await api.capture(exterior.id);
|
|
667
|
+
} catch (error) {
|
|
668
|
+
await api.deleteProject(project.id).catch(() => void 0);
|
|
669
|
+
if (run) await rm2(run.folder, { recursive: true, force: true });
|
|
670
|
+
throw error;
|
|
671
|
+
}
|
|
672
|
+
console.log(` in the app: ${run.webUrl}`);
|
|
673
|
+
console.log(` results: ${run.folder}`);
|
|
674
|
+
return run;
|
|
675
|
+
}
|
|
676
|
+
async function follow(api, run, since, timeoutMs) {
|
|
677
|
+
const deadline = Date.now() + timeoutMs;
|
|
678
|
+
const seen = /* @__PURE__ */ new Set();
|
|
679
|
+
let capture = "";
|
|
680
|
+
let status = "";
|
|
681
|
+
for (; ; ) {
|
|
682
|
+
const { session, messages } = await save(api, run, seen, (message) => {
|
|
683
|
+
if (message.seq > since) console.log(describe(message));
|
|
684
|
+
});
|
|
685
|
+
if (!session) {
|
|
686
|
+
const space = await api.space(run.spaceId);
|
|
687
|
+
const state = space.capture ? `${space.capture.status}${space.capture.progress ? `: ${space.capture.progress}` : ""}` : "";
|
|
688
|
+
if (state && state !== capture) console.log(` reading photos: ${state}`);
|
|
689
|
+
capture = state;
|
|
690
|
+
if (space.capture?.status === "failed") throw new Error(`Reading the photos failed: ${space.capture.error}`);
|
|
691
|
+
if (space.capture?.status === "succeeded" && !messages.length) {
|
|
692
|
+
throw new Error("The photos were read but the agent didn't start. Try again later.");
|
|
693
|
+
}
|
|
694
|
+
} else {
|
|
695
|
+
if (session.status !== status) console.log(` status: ${session.status}`);
|
|
696
|
+
status = session.status;
|
|
697
|
+
if (HELD.includes(session.status)) return session;
|
|
698
|
+
if (session.status === "waiting" && messages.some((m) => m.role === "agent" && m.seq > since)) return session;
|
|
699
|
+
}
|
|
700
|
+
if (Date.now() > deadline) {
|
|
701
|
+
throw new Error(`Still going after ${timeoutMs / 6e4} minutes. Keep watching with: home-v0 follow`);
|
|
702
|
+
}
|
|
703
|
+
await new Promise((resolve2) => setTimeout(resolve2, POLL_MS));
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
async function save(api, run, seen, onNew) {
|
|
707
|
+
const { session, messages } = await api.conversation(run.spaceId);
|
|
708
|
+
const fresh = messages.filter((m) => !seen.has(m.seq));
|
|
709
|
+
for (const message of fresh) {
|
|
710
|
+
const file = mediaFile(message);
|
|
711
|
+
if (file && message.url && !await exists(join4(run.folder, file))) {
|
|
712
|
+
const { body } = await api.download(message.url);
|
|
713
|
+
await writeFile4(join4(run.folder, file), body);
|
|
714
|
+
}
|
|
715
|
+
seen.add(message.seq);
|
|
716
|
+
onNew?.(message);
|
|
717
|
+
}
|
|
718
|
+
if (fresh.length || !await exists(join4(run.folder, "chat.md"))) {
|
|
719
|
+
await writeFile4(join4(run.folder, "chat.md"), chatMarkdown(run, messages));
|
|
720
|
+
await writeFile4(join4(run.folder, "messages.json"), `${JSON.stringify({ session, messages }, null, 2)}
|
|
721
|
+
`);
|
|
722
|
+
}
|
|
723
|
+
return { session, messages };
|
|
724
|
+
}
|
|
725
|
+
function finish(session, run) {
|
|
726
|
+
console.log(
|
|
727
|
+
`
|
|
728
|
+
The agent is ${session.status}. Spent $${session.spentUsd.toFixed(2)} of $${session.budgetUsd.toFixed(2)}.`
|
|
729
|
+
);
|
|
730
|
+
console.log(`Chat, renders and model: ${run.folder}`);
|
|
731
|
+
console.log(`In the app: ${run.webUrl}`);
|
|
732
|
+
if (HELD.includes(session.status) && session.status !== "paused") process.exitCode = 1;
|
|
733
|
+
}
|
|
734
|
+
var count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
735
|
+
var exists = (path) => access(path).then(
|
|
736
|
+
() => true,
|
|
737
|
+
() => false
|
|
738
|
+
);
|
|
739
|
+
var exitCode = (error) => {
|
|
740
|
+
if (error instanceof UsageError || error.code?.startsWith("ERR_PARSE_ARGS")) return 2;
|
|
741
|
+
if (error instanceof AuthError || error instanceof ApiError && (error.status === 401 || error.status === 403)) {
|
|
742
|
+
return 3;
|
|
743
|
+
}
|
|
744
|
+
if (error instanceof HouseError) return 4;
|
|
745
|
+
return 1;
|
|
746
|
+
};
|
|
747
|
+
process.stdout.on("error", (error) => {
|
|
748
|
+
if (error.code === "EPIPE") process.exit(0);
|
|
749
|
+
throw error;
|
|
750
|
+
});
|
|
751
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
752
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
753
|
+
process.exitCode = exitCode(error);
|
|
754
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@home-v0/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Build a house in 3D in home-v0 from a folder of photos and floor plans. Made for people and AI agents.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/simenravnik/home-v0.git",
|
|
9
|
+
"directory": "apps/cli"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://home-v0.com",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"bin": {
|
|
14
|
+
"home-v0": "dist/home-v0.js"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"home-v0",
|
|
27
|
+
"house",
|
|
28
|
+
"renovation",
|
|
29
|
+
"floor-plan",
|
|
30
|
+
"3d",
|
|
31
|
+
"cli",
|
|
32
|
+
"ai-agents"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "node build.mjs",
|
|
36
|
+
"format": "prettier . --write --ignore-unknown",
|
|
37
|
+
"test": "node --test --disable-warning=ExperimentalWarning 'src/*.test.ts'",
|
|
38
|
+
"typecheck": "tsc -p tsconfig.json"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^24",
|
|
42
|
+
"esbuild": "^0.28.2",
|
|
43
|
+
"typescript": "~6.0.3"
|
|
44
|
+
}
|
|
45
|
+
}
|