@pirol/nido 0.3.0 → 0.5.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 CHANGED
@@ -76,6 +76,7 @@ Instead of an agent, run any command. It needs no key:
76
76
  | `secrets` | Names of stored secrets, never values |
77
77
  | `network` | Hosts the sandbox may reach on port 443; everything else is blocked |
78
78
  | `storage` | Storage volumes, mounted at `/data/<name>` |
79
+ | `from` | Template a new sandbox starts from (`nido save`) |
79
80
  | `output` | Folder that comes back after the run; default `output/` |
80
81
 
81
82
  ## Storage
@@ -83,7 +84,7 @@ Instead of an agent, run any command. It needs no key:
83
84
  Keep large data in storage, independently of sandboxes:
84
85
 
85
86
  ```bash
86
- nido storage put ./data my-data # upload straight to Cloud Storage in Frankfurt
87
+ nido storage put ./data my-data # upload straight to storage in Frankfurt
87
88
  nido storage ls # volumes with file count and size
88
89
  nido storage ls my-data # files in one volume
89
90
  nido storage rm my-data # only once no sandbox uses it
@@ -93,6 +94,27 @@ Add `"storage": ["my-data"]` to `nido.json` (or `nido create shop --storage my-d
93
94
  sandbox reads and writes the data at `/data/my-data`, without copying. Volumes are attached when a
94
95
  sandbox is created; after changing `storage`, run `nido run --fresh`.
95
96
 
97
+ ## Backups and templates
98
+
99
+ Storage is safe: it lives in Frankfurt, independently of any sandbox. A sandbox's own folder
100
+ `/sandbox` is fast but local, and can be lost, for example when the machine it runs on is
101
+ replaced. nido backs it up to your storage whenever the sandbox stops.
102
+
103
+ ```bash
104
+ nido stop shop # backs up /sandbox, then stops (--no-backup to skip)
105
+ nido restore shop # last backup back into a running sandbox
106
+ nido save shop base # save the work as template "base"
107
+ nido create other --from base # or "from": "base" in nido.json
108
+ nido fork shop experiment # new sandbox with the work of a running one
109
+ nido save ls
110
+ nido save rm base
111
+ ```
112
+
113
+ A backup holds only what changed since the sandbox was created. If the folder was lost, `nido start`
114
+ restores the last backup by itself. Backups count as storage.
115
+
116
+ `nido rm` keeps the backup, and `nido ls` lists it. `nido rm <name> --backup` deletes it too.
117
+
96
118
  ## Secrets
97
119
 
98
120
  ```bash
@@ -129,7 +151,7 @@ Paths inside a sandbox must be below `/sandbox`.
129
151
  - No interactive shell yet; use `nido exec`.
130
152
  - The agent runs in the foreground. If the connection drops, `nido pull` fetches what is already
131
153
  in `output/`.
132
- - No backups or templates of a sandbox yet. Keep anything important in `output/` or in storage.
154
+ - A backup holds at most 5 GB, and an account keeps up to 20 templates. Keep larger data in storage.
133
155
 
134
156
  ## Development
135
157
 
package/dist/api.js CHANGED
@@ -69,11 +69,33 @@ export class Api {
69
69
  create(spec) {
70
70
  return this.json("POST", "/v1/sandboxes", spec);
71
71
  }
72
- remove(name) {
73
- return this.json("DELETE", `/v1/sandboxes/${encodeURIComponent(name)}`);
72
+ /** The backup stays in storage and comes back, unless deleteBackup is set. */
73
+ async remove(name, opts = {}) {
74
+ const query = opts.deleteBackup ? "?backup=delete" : "";
75
+ const res = await this.json("DELETE", `/v1/sandboxes/${encodeURIComponent(name)}${query}`);
76
+ return res?.backup ?? null;
74
77
  }
75
- stop(name) {
76
- return this.json("POST", `/v1/sandboxes/${encodeURIComponent(name)}/stop`);
78
+ /** All backups, those of removed sandboxes included. */
79
+ async backups() {
80
+ return (await this.json("GET", "/v1/backups")).backups;
81
+ }
82
+ /** Backs /sandbox up into storage first, unless backup is false. */
83
+ async stop(name, opts = {}) {
84
+ const query = opts.backup === false ? "?backup=false" : "";
85
+ const res = await this.json("POST", `/v1/sandboxes/${encodeURIComponent(name)}/stop${query}`);
86
+ return res?.backup ?? null;
87
+ }
88
+ async restore(name) {
89
+ return (await this.json("POST", `/v1/sandboxes/${encodeURIComponent(name)}/restore`)).restored;
90
+ }
91
+ async templates() {
92
+ return (await this.json("GET", "/v1/templates")).templates;
93
+ }
94
+ saveTemplate(name, sandbox) {
95
+ return this.json("PUT", `/v1/templates/${encodeURIComponent(name)}`, { sandbox });
96
+ }
97
+ removeTemplate(name) {
98
+ return this.json("DELETE", `/v1/templates/${encodeURIComponent(name)}`);
77
99
  }
78
100
  start(name) {
79
101
  return this.json("POST", `/v1/sandboxes/${encodeURIComponent(name)}/start`);
@@ -171,7 +193,7 @@ export class Api {
171
193
  return this.json("DELETE", `/v1/storage/${encodeURIComponent(name)}`);
172
194
  }
173
195
  }
174
- /** Sends a local file to an upload URL from storageUploads (straight to Cloud Storage). */
196
+ /** Sends a local file to an upload URL from storageUploads (straight to the storage). */
175
197
  export function putFile(url, file, size) {
176
198
  return new Promise((resolve, reject) => {
177
199
  const req = request(url, { method: "PUT", headers: { "content-length": size } }, (res) => {
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync, realpathSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
- import { copy, createSandbox, exec, init, list, login, logout, pull, runFolder, secretList, secretRemove, secretSet, simple, whoami, } from "./commands.js";
4
+ import { copy, createSandbox, exec, fork, init, list, login, logout, pull, remove, restore, runFolder, save, secretList, secretRemove, secretSet, simple, whoami, } from "./commands.js";
5
5
  import { storageLs, storagePut, storageRm } from "./storage.js";
6
6
  const HELP = `nido — run agents in isolated sandboxes (closed beta)
7
7
 
@@ -15,15 +15,23 @@ Folder workflow
15
15
  nido pull bring output/ back again
16
16
 
17
17
  Sandboxes
18
- nido create <name> [--image <ref>] [--secret <name>]… [--network <host>]… [--storage <name>]…
18
+ nido create <name> [--image <ref>] [--secret <name>]… [--network <host>]… [--storage <name>]… [--gpu l4|h100] [--from <template>]
19
19
  nido ls list your sandboxes
20
20
  nido exec <name> -- <cmd>… run a command
21
21
  nido cp <src> <dst> copy files; write the sandbox side as <name>:<path>
22
22
  nido logs <name> sandbox logs
23
- nido stop|start|rm <name>
23
+ nido stop <name> [--no-backup] stop; backs /sandbox up to your storage first
24
+ nido start <name>
25
+ nido rm <name> [--backup] remove; the backup stays unless --backup
26
+ nido restore <name> put the last backup back into a running sandbox
27
+ nido fork <from> <new> new sandbox with the work of a running one
28
+
29
+ Templates (a sandbox's work, saved in your storage)
30
+ nido save <sandbox> <template>
31
+ nido save ls | nido save rm <template>
24
32
 
25
33
  Storage (lives on without sandboxes, mounted at /data/<name>)
26
- nido storage put <folder|file> <name> upload, straight to Cloud Storage
34
+ nido storage put <folder|file> <name> upload, straight to storage
27
35
  nido storage ls [name]
28
36
  nido storage rm <name>
29
37
 
@@ -76,6 +84,8 @@ async function main(argv) {
76
84
  secrets: flags.get("secret") ?? [],
77
85
  network: flags.get("network") ?? [],
78
86
  storage: flags.get("storage") ?? [],
87
+ gpu: flags.get("gpu")?.[0]?.toLowerCase(),
88
+ from: flags.get("from")?.[0],
79
89
  });
80
90
  return 0;
81
91
  case "ls":
@@ -87,12 +97,27 @@ async function main(argv) {
87
97
  return exec(one("name"), passthrough);
88
98
  case "shell":
89
99
  throw new Error(`nido shell is not in the beta yet. Use: nido exec ${args[0] ?? "<name>"} -- <command>`);
90
- case "logs":
91
100
  case "stop":
101
+ await simple("stop", one("name"), { backup: !flags.has("no-backup") });
102
+ return 0;
103
+ case "logs":
92
104
  case "start":
93
- case "rm":
94
105
  await simple(cmd, one("name"));
95
106
  return 0;
107
+ case "rm":
108
+ await remove(one("name"), { backup: flags.has("backup") });
109
+ return 0;
110
+ case "restore":
111
+ await restore(one("name"));
112
+ return 0;
113
+ case "fork":
114
+ if (args.length !== 2)
115
+ throw new Error("Usage: nido fork <from> <new>");
116
+ await fork(args[0], args[1]);
117
+ return 0;
118
+ case "save":
119
+ await save(args);
120
+ return 0;
96
121
  case "cp":
97
122
  if (args.length !== 2)
98
123
  throw new Error("Usage: nido cp <src> <dst> (sandbox side as <name>:<path>)");
@@ -130,7 +155,7 @@ async function storage(args) {
130
155
  throw new Error("Usage: nido storage put <folder|file> <name> | nido storage ls [name] | nido storage rm <name>");
131
156
  return 0;
132
157
  }
133
- const VALUE_FLAGS = new Set(["image", "secret", "network", "storage"]);
158
+ const VALUE_FLAGS = new Set(["image", "secret", "network", "storage", "gpu", "from"]);
134
159
  export function parse(argv) {
135
160
  const flags = new Map();
136
161
  const args = [];
package/dist/commands.js CHANGED
@@ -51,18 +51,106 @@ export function init(dir, kind) {
51
51
  // ---------------------------------------------------------------- sandboxes
52
52
  export async function createSandbox(name, spec) {
53
53
  const api = Api.fromConfig();
54
- say(`Creating sandbox ${name}…`);
54
+ const origin = spec.from ? ` from template ${spec.from}` : spec.fork ? ` as a copy of ${spec.fork}` : "";
55
+ say(`Creating sandbox ${name}${spec.gpu ? ` with a GPU (${spec.gpu})` : ""}${origin}…`);
56
+ const started = Date.now();
55
57
  const info = await api.create({ name, ...spec });
56
- say(`Sandbox ${name} ready in ${(info.ready_ms / 1000).toFixed(1)} s`);
58
+ if (info.phase !== "Ready")
59
+ await waitForGpu(api, name, info.gpu);
60
+ say(`Sandbox ${name} ready in ${((Date.now() - started) / 1000).toFixed(1)} s`);
61
+ if (info.backup) {
62
+ say(`A removed sandbox ${name} left a backup from ${when(info.backup.at)} (${size(info.backup.bytes)}).`);
63
+ say(`Put it in with: nido restore ${name}. The next nido stop replaces it.`);
64
+ }
65
+ }
66
+ /** nido fork: a new sandbox with the changes of a running one. */
67
+ export async function fork(from, to) {
68
+ await createSandbox(to, { secrets: [], network: [], storage: [], fork: from });
69
+ }
70
+ /** How long nido waits for a GPU node before it gives up (the sandbox stays queued). */
71
+ export const GPU_WAIT_MS = 30 * 60_000;
72
+ /**
73
+ * Waits until a sandbox is ready. GPU sandboxes need a node of their own,
74
+ * which takes minutes; an H100 may wait for free capacity. The API answers
75
+ * at once, so nido asks every few seconds and shows the time.
76
+ */
77
+ export async function waitUntilReady(get, opts) {
78
+ const now = opts.now ?? Date.now;
79
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
80
+ const start = now();
81
+ for (;;) {
82
+ const info = await get();
83
+ if (!info)
84
+ throw new Error("The sandbox is gone");
85
+ if (info.phase === "Ready")
86
+ return info;
87
+ if (info.phase === "Error" || info.phase === "Failed")
88
+ throw new Error(`Sandbox ${info.name} failed to start`);
89
+ const elapsed = now() - start;
90
+ if (elapsed >= opts.timeoutMs)
91
+ throw new TimeoutError(info);
92
+ opts.onTick?.(elapsed);
93
+ await sleep(opts.intervalMs ?? 3000);
94
+ }
95
+ }
96
+ class TimeoutError extends Error {
97
+ info;
98
+ constructor(info) {
99
+ super(`Sandbox ${info.name} is still ${info.phase}`);
100
+ this.info = info;
101
+ }
102
+ }
103
+ async function waitForGpu(api, name, gpu = "GPU") {
104
+ const tty = process.stderr.isTTY;
105
+ try {
106
+ await waitUntilReady(() => api.get(name), {
107
+ timeoutMs: GPU_WAIT_MS,
108
+ onTick: (ms) => {
109
+ const line = `Waiting for a free ${gpu.toUpperCase()} node… ${clock(ms)}`;
110
+ if (tty)
111
+ process.stderr.write(`\r${line}`);
112
+ else if (ms % 60_000 < 3000)
113
+ say(line);
114
+ },
115
+ });
116
+ }
117
+ catch (err) {
118
+ if (err instanceof TimeoutError) {
119
+ throw new Error(`No ${gpu.toUpperCase()} free after ${GPU_WAIT_MS / 60_000} minutes. ${name} stays queued and starts when one is free. ` +
120
+ `Check with: nido ls. Remove it with: nido rm ${name}`);
121
+ }
122
+ throw err;
123
+ }
124
+ finally {
125
+ if (tty)
126
+ process.stderr.write("\r\x1b[K");
127
+ }
128
+ }
129
+ function clock(ms) {
130
+ const s = Math.floor(ms / 1000);
131
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
57
132
  }
58
133
  export async function list() {
59
- const sandboxes = await Api.fromConfig().list();
134
+ const api = Api.fromConfig();
135
+ const [sandboxes, backups] = await Promise.all([api.list(), api.backups()]);
136
+ // Backups stay after nido rm. They cost storage, so they show up here.
137
+ const left = backups.filter((b) => !sandboxes.some((s) => s.name === b.name));
60
138
  if (!sandboxes.length)
61
- return say("No sandboxes. Create one with: nido run or nido create <name>");
62
- const width = Math.max(4, ...sandboxes.map((s) => s.name.length));
63
- process.stdout.write(`${"NAME".padEnd(width)} ${"CREATED".padEnd(19)} PHASE\n`);
64
- for (const s of sandboxes)
65
- process.stdout.write(`${s.name.padEnd(width)} ${s.created.padEnd(19)} ${s.phase}\n`);
139
+ say("No sandboxes. Create one with: nido run or nido create <name>");
140
+ else {
141
+ const width = Math.max(4, ...sandboxes.map((s) => s.name.length));
142
+ process.stdout.write(`${"NAME".padEnd(width)} ${"GPU".padEnd(4)} ${"CREATED".padEnd(19)} ${"PHASE".padEnd(9)} LAST BACKUP\n`);
143
+ for (const s of sandboxes) {
144
+ const backup = s.backup ? `${when(s.backup.at)}, ${size(s.backup.bytes)}` : "-";
145
+ process.stdout.write(`${s.name.padEnd(width)} ${(s.gpu ?? "-").padEnd(4)} ${s.created.padEnd(19)} ${s.phase.padEnd(9)} ${backup}\n`);
146
+ }
147
+ }
148
+ if (!left.length)
149
+ return;
150
+ process.stdout.write(`\nBackups of removed sandboxes (delete with: nido rm <name> --backup)\n`);
151
+ const width = Math.max(4, ...left.map((b) => b.name.length));
152
+ for (const b of left)
153
+ process.stdout.write(`${b.name.padEnd(width)} ${when(b.at)}, ${size(b.bytes)}\n`);
66
154
  }
67
155
  export async function exec(name, command) {
68
156
  return Api.fromConfig().exec(name, command, {
@@ -70,17 +158,90 @@ export async function exec(name, command) {
70
158
  onErr: (text) => process.stderr.write(text),
71
159
  });
72
160
  }
73
- export async function simple(action, name) {
161
+ export async function simple(action, name, opts = {}) {
74
162
  const api = Api.fromConfig();
75
- if (action === "rm")
76
- await api.remove(name);
77
- if (action === "stop")
78
- await api.stop(name);
79
- if (action === "start")
80
- await api.start(name);
163
+ if (action === "stop") {
164
+ if (opts.backup !== false)
165
+ say(`Backing up ${name}…`);
166
+ const backup = await api.stop(name, opts);
167
+ return say(stopMessage(name, backup, opts.backup !== false));
168
+ }
169
+ if (action === "start") {
170
+ const info = await api.start(name);
171
+ if (info.phase !== "Ready")
172
+ await waitForGpu(api, name, info.gpu);
173
+ if (info.restored) {
174
+ say(`The workspace of ${name} was lost: the machine it ran on was replaced.`);
175
+ say(`Restored the backup from ${when(info.restored.at)} (${size(info.restored.bytes)}).`);
176
+ }
177
+ }
81
178
  if (action === "logs")
82
179
  return void process.stdout.write(await api.logs(name));
83
- say(`${action === "rm" ? "Removed" : action === "stop" ? "Stopped" : "Started"} ${name}`);
180
+ say(`Started ${name}`);
181
+ }
182
+ /** nido rm: the backup stays in storage, unless --backup. */
183
+ export async function remove(name, opts) {
184
+ const kept = await Api.fromConfig().remove(name, { deleteBackup: opts.backup });
185
+ say(removeMessage(name, kept, opts.backup));
186
+ }
187
+ export function removeMessage(name, kept, deleteBackup) {
188
+ if (deleteBackup)
189
+ return `Removed ${name} and its backup`;
190
+ if (!kept)
191
+ return `Removed ${name}`;
192
+ return `Removed ${name}. Its backup from ${when(kept.at)} (${size(kept.bytes)}) stays in your storage. Delete it with: nido rm ${name} --backup`;
193
+ }
194
+ export function stopMessage(name, backup, wanted) {
195
+ if (!wanted)
196
+ return `Stopped ${name}, without a backup`;
197
+ if (!backup)
198
+ return `Stopped ${name}. Nothing changed since the last backup.`;
199
+ return `Stopped ${name}. Backed up ${size(backup.bytes)} to your storage.`;
200
+ }
201
+ /** nido restore: the last backup back into a running sandbox. */
202
+ export async function restore(name) {
203
+ const backup = await Api.fromConfig().restore(name);
204
+ say(`Restored the backup of ${name} from ${when(backup.at)} (${size(backup.bytes)})`);
205
+ }
206
+ /** nido save: templates. */
207
+ export async function save(args) {
208
+ const api = Api.fromConfig();
209
+ const [a, b] = args;
210
+ if (a === "ls" && !b) {
211
+ const templates = await api.templates();
212
+ if (!templates.length)
213
+ return say("No templates. Save one with: nido save <sandbox> <template>");
214
+ const width = Math.max(8, ...templates.map((t) => t.name.length));
215
+ process.stdout.write(`${"TEMPLATE".padEnd(width)} ${"SAVED".padEnd(16)} SIZE\n`);
216
+ for (const t of templates)
217
+ process.stdout.write(`${t.name.padEnd(width)} ${when(t.at).padEnd(16)} ${size(t.bytes)}\n`);
218
+ return;
219
+ }
220
+ if (a === "rm" && b) {
221
+ await api.removeTemplate(b);
222
+ return say(`Removed template ${b}`);
223
+ }
224
+ if (a && b && a !== "ls" && a !== "rm") {
225
+ say(`Saving ${a} as template ${b}…`);
226
+ const t = await api.saveTemplate(b, a);
227
+ return say(`Saved template ${b} (${size(t.bytes)}). Start from it with: nido create <name> --from ${b}`);
228
+ }
229
+ throw new Error("Usage: nido save <sandbox> <template> | nido save ls | nido save rm <template>");
230
+ }
231
+ /** "2026-11-06T14:05:12Z" as "2026-11-06 14:05" in local time. */
232
+ export function when(iso) {
233
+ const d = new Date(iso);
234
+ if (Number.isNaN(d.getTime()))
235
+ return iso;
236
+ const p = (n) => String(n).padStart(2, "0");
237
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
238
+ }
239
+ export function size(bytes) {
240
+ if (bytes < 1024 ** 2)
241
+ return `${Math.max(bytes / 1024, bytes ? 0.1 : 0).toFixed(1)} KB`;
242
+ if (bytes < 1024 ** 3)
243
+ return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
244
+ return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
84
245
  }
85
246
  /** nido cp: "shop:/sandbox/x" is in the sandbox, anything else is local. */
86
247
  export async function copy(src, dst) {
@@ -163,19 +324,18 @@ async function ensureSandbox(api, m, fresh) {
163
324
  info = undefined;
164
325
  }
165
326
  if (!info) {
166
- say(`Creating sandbox ${m.name}…`);
167
- const created = await api.create({
168
- name: m.name,
327
+ await createSandbox(m.name, {
169
328
  image: m.imageSet ? m.image : undefined,
170
329
  secrets: m.secrets,
171
330
  network: m.network,
172
331
  storage: m.storage,
332
+ gpu: m.gpu,
333
+ from: m.from,
173
334
  });
174
- say(`Sandbox ${m.name} ready in ${(created.ready_ms / 1000).toFixed(1)} s`);
175
335
  return true;
176
336
  }
177
337
  if (info.phase !== "Ready")
178
- await api.start(m.name);
338
+ await simple("start", m.name);
179
339
  return false;
180
340
  }
181
341
  async function upload(api, dir, m, uploaded) {
package/dist/manifest.js CHANGED
@@ -25,7 +25,7 @@ export function parseManifest(raw, folderName) {
25
25
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
26
26
  throw new Error(`${MANIFEST} must be an object`);
27
27
  const m = raw;
28
- const known = ["name", "agent", "task", "command", "image", "files", "secrets", "network", "storage", "output"];
28
+ const known = ["name", "agent", "task", "command", "image", "files", "secrets", "network", "storage", "gpu", "from", "output"];
29
29
  const unknown = Object.keys(m).filter((key) => !known.includes(key) && !key.startsWith("$"));
30
30
  if (unknown.length)
31
31
  throw new Error(`${MANIFEST}: unknown field ${unknown.map((k) => `"${k}"`).join(", ")}`);
@@ -70,6 +70,8 @@ export function parseManifest(raw, folderName) {
70
70
  throw new Error(`${MANIFEST}: "${name}" in "storage" is not a valid name`);
71
71
  return name;
72
72
  }),
73
+ gpu: optionalString(m, "gpu")?.toLowerCase(),
74
+ from: checkFrom(optionalString(m, "from")),
73
75
  output,
74
76
  };
75
77
  }
@@ -83,6 +85,11 @@ export function sandboxName(input) {
83
85
  .replace(/-+$/, "");
84
86
  return name || "nest";
85
87
  }
88
+ function checkFrom(name) {
89
+ if (name !== undefined && !isVolumeName(name))
90
+ throw new Error(`${MANIFEST}: "${name}" in "from" is not a valid template name`);
91
+ return name;
92
+ }
86
93
  /** Storage names, like sandbox names: lowercase letters, digits and dashes, at most 40. */
87
94
  export function isVolumeName(name) {
88
95
  return /^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$/.test(name);
package/dist/storage.js CHANGED
@@ -1,5 +1,5 @@
1
- // nido storage: volumes in Cloud Storage that live independently of
2
- // sandboxes. Files go straight to Cloud Storage; the API only hands out an
1
+ // nido storage: volumes in object storage that live independently of
2
+ // sandboxes. Files go straight to the storage; the API only hands out an
3
3
  // upload URL per file.
4
4
  import { existsSync, statSync } from "node:fs";
5
5
  import { basename, dirname, join, resolve } from "node:path";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pirol/nido",
3
- "version": "0.3.0",
4
- "description": "Run agents in isolated sandboxes. Give it a folder, get results back.",
3
+ "version": "0.5.0",
4
+ "description": "Run coding agents and commands in isolated sandboxes in Frankfurt, with the nido CLI.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "nido": "dist/cli.js"