@nomai/nomai 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/LICENSE +21 -0
- package/README.md +98 -0
- package/dist/cli.d.mts +912 -0
- package/dist/cli.mjs +3367 -0
- package/dist/dist-DQ6U1xGI.mjs +2462 -0
- package/dist/dist-DSXfsMP5.mjs +145 -0
- package/dist/index.d.mts +226 -0
- package/dist/index.mjs +2 -0
- package/dist/payload-DdBYzrl3.mjs +473 -0
- package/dist/rolldown-runtime-C7HZzL1F.mjs +26 -0
- package/package.json +52 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { mkdir, mkdtemp, readFile, readdir, rm, rmdir, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
//#region src/core/manifest.ts
|
|
7
|
+
const MANIFEST_FILENAME = "nomai-manifest.json";
|
|
8
|
+
function manifestPath(installDir) {
|
|
9
|
+
return path.join(installDir, MANIFEST_FILENAME);
|
|
10
|
+
}
|
|
11
|
+
function hashBytes(contents) {
|
|
12
|
+
return createHash("sha256").update(contents).digest("hex");
|
|
13
|
+
}
|
|
14
|
+
/** @returns null when no manifest exists; throws on a corrupt/foreign one. */
|
|
15
|
+
async function readManifest(installDir) {
|
|
16
|
+
const file = manifestPath(installDir);
|
|
17
|
+
let raw;
|
|
18
|
+
try {
|
|
19
|
+
raw = await readFile(file, "utf8");
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (error.code === "ENOENT") return null;
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
let parsed;
|
|
25
|
+
try {
|
|
26
|
+
parsed = JSON.parse(raw);
|
|
27
|
+
} catch {
|
|
28
|
+
throw new Error(`Corrupt manifest at ${file}: not valid JSON. Remove it and reinstall.`);
|
|
29
|
+
}
|
|
30
|
+
if (!isManifest(parsed)) throw new Error(`Unrecognized manifest at ${file}. Remove it and reinstall.`);
|
|
31
|
+
return parsed;
|
|
32
|
+
}
|
|
33
|
+
async function writeManifest(installDir, manifest) {
|
|
34
|
+
await mkdir(installDir, { recursive: true });
|
|
35
|
+
await writeFile(manifestPath(installDir), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
36
|
+
}
|
|
37
|
+
async function removeManifest(installDir) {
|
|
38
|
+
await rm(manifestPath(installDir), { force: true });
|
|
39
|
+
}
|
|
40
|
+
function isManifest(value) {
|
|
41
|
+
if (typeof value !== "object" || value === null) return false;
|
|
42
|
+
const m = value;
|
|
43
|
+
return m.schemaVersion === 1 && typeof m.nomaiVersion === "string" && typeof m.source === "string" && typeof m.harness === "string" && (m.scope === "project" || m.scope === "global") && typeof m.installedAt === "string" && Array.isArray(m.files) && m.files.every((f) => typeof f === "object" && f !== null && typeof f.path === "string" && typeof f.sha256 === "string");
|
|
44
|
+
}
|
|
45
|
+
function createManifest(options) {
|
|
46
|
+
return {
|
|
47
|
+
schemaVersion: 1,
|
|
48
|
+
nomaiVersion: options.nomaiVersion,
|
|
49
|
+
source: options.source,
|
|
50
|
+
harness: options.harness,
|
|
51
|
+
scope: options.scope,
|
|
52
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
53
|
+
files: options.files
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/core/installer.ts
|
|
58
|
+
/**
|
|
59
|
+
* Generic install/uninstall/status engine shared by all harness adapters.
|
|
60
|
+
* defineHarness() wires adapter methods to these functions by default;
|
|
61
|
+
* adapters with special needs override the methods instead.
|
|
62
|
+
*
|
|
63
|
+
* This module is the only place in the codebase that writes to disk.
|
|
64
|
+
*/
|
|
65
|
+
async function installHarness(adapter, ctx) {
|
|
66
|
+
if (!ctx.payload) throw new Error(`install(${adapter.id}) requires a payload source.`);
|
|
67
|
+
const installDir = adapter.resolveInstallDir(ctx.scope, ctx);
|
|
68
|
+
const merged = mergePayload(await ctx.payload.readSharedFiles(), await ctx.payload.readHarnessOverlay(adapter.id));
|
|
69
|
+
const files = await adapter.transformPayload(merged, {
|
|
70
|
+
projectDir: ctx.projectDir,
|
|
71
|
+
homeDir: ctx.homeDir,
|
|
72
|
+
scope: ctx.scope,
|
|
73
|
+
nomaiVersion: ctx.nomaiVersion
|
|
74
|
+
});
|
|
75
|
+
const nextEntries = files.map((file) => ({
|
|
76
|
+
path: toPosix(file.relPath),
|
|
77
|
+
sha256: hashBytes(file.contents)
|
|
78
|
+
})).sort((a, b) => comparePaths(a.path, b.path));
|
|
79
|
+
const existing = await readManifest(installDir);
|
|
80
|
+
if (existing && !ctx.force) {
|
|
81
|
+
if (sameEntries(existing.files, nextEntries) && await allFilesMatch(installDir, nextEntries)) return {
|
|
82
|
+
installDir,
|
|
83
|
+
filesWritten: [],
|
|
84
|
+
filesRemoved: [],
|
|
85
|
+
skipped: true
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const nextPaths = new Set(nextEntries.map((entry) => entry.path));
|
|
89
|
+
const orphans = (existing?.files ?? []).filter((entry) => !nextPaths.has(entry.path));
|
|
90
|
+
if (ctx.dryRun) return {
|
|
91
|
+
installDir,
|
|
92
|
+
filesWritten: nextEntries.map((entry) => entry.path),
|
|
93
|
+
filesRemoved: orphans.map((entry) => entry.path),
|
|
94
|
+
skipped: false
|
|
95
|
+
};
|
|
96
|
+
for (const file of files) {
|
|
97
|
+
const target = path.join(installDir, file.relPath);
|
|
98
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
99
|
+
await writeFile(target, file.contents);
|
|
100
|
+
}
|
|
101
|
+
const removed = await removeOwnedFiles(installDir, orphans, ctx.force);
|
|
102
|
+
await writeManifest(installDir, createManifest({
|
|
103
|
+
nomaiVersion: ctx.nomaiVersion,
|
|
104
|
+
source: ctx.payload.description,
|
|
105
|
+
harness: adapter.id,
|
|
106
|
+
scope: ctx.scope,
|
|
107
|
+
files: nextEntries
|
|
108
|
+
}));
|
|
109
|
+
return {
|
|
110
|
+
installDir,
|
|
111
|
+
filesWritten: nextEntries.map((entry) => entry.path),
|
|
112
|
+
filesRemoved: removed.removed,
|
|
113
|
+
skipped: false
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
async function uninstallHarness(adapter, ctx) {
|
|
117
|
+
const installDir = adapter.resolveInstallDir(ctx.scope, ctx);
|
|
118
|
+
const manifest = await readManifest(installDir);
|
|
119
|
+
if (!manifest) return {
|
|
120
|
+
installDir,
|
|
121
|
+
notInstalled: true,
|
|
122
|
+
filesRemoved: [],
|
|
123
|
+
filesKept: []
|
|
124
|
+
};
|
|
125
|
+
if (ctx.dryRun) return {
|
|
126
|
+
installDir,
|
|
127
|
+
notInstalled: false,
|
|
128
|
+
filesRemoved: manifest.files.map((entry) => entry.path),
|
|
129
|
+
filesKept: []
|
|
130
|
+
};
|
|
131
|
+
const { removed, kept } = await removeOwnedFiles(installDir, manifest.files, ctx.force);
|
|
132
|
+
await removeManifest(installDir);
|
|
133
|
+
await pruneEmptyDirs(installDir, removed);
|
|
134
|
+
return {
|
|
135
|
+
installDir,
|
|
136
|
+
notInstalled: false,
|
|
137
|
+
filesRemoved: removed,
|
|
138
|
+
filesKept: kept
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
async function statusHarness(adapter, ctx) {
|
|
142
|
+
const installDir = adapter.resolveInstallDir(ctx.scope, ctx);
|
|
143
|
+
const detected = ctx.scope === "project" ? await adapter.detectProject(ctx.projectDir) : await adapter.detectGlobal(ctx.homeDir);
|
|
144
|
+
const manifest = await readManifest(installDir);
|
|
145
|
+
if (!manifest) return {
|
|
146
|
+
harnessId: adapter.id,
|
|
147
|
+
scope: ctx.scope,
|
|
148
|
+
installDir,
|
|
149
|
+
detected,
|
|
150
|
+
installed: false,
|
|
151
|
+
modifiedFiles: [],
|
|
152
|
+
missingFiles: []
|
|
153
|
+
};
|
|
154
|
+
const modified = [];
|
|
155
|
+
const missing = [];
|
|
156
|
+
for (const entry of manifest.files) try {
|
|
157
|
+
if (hashBytes(await readFile(path.join(installDir, entry.path))) !== entry.sha256) modified.push(entry.path);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if (error.code === "ENOENT") missing.push(entry.path);
|
|
160
|
+
else throw error;
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
harnessId: adapter.id,
|
|
164
|
+
scope: ctx.scope,
|
|
165
|
+
installDir,
|
|
166
|
+
detected,
|
|
167
|
+
installed: true,
|
|
168
|
+
installedVersion: manifest.nomaiVersion,
|
|
169
|
+
installedAt: manifest.installedAt,
|
|
170
|
+
modifiedFiles: modified,
|
|
171
|
+
missingFiles: missing
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/** Overlay wins over shared on the same relPath. */
|
|
175
|
+
function mergePayload(shared, overlay) {
|
|
176
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
177
|
+
for (const file of shared) byPath.set(toPosix(file.relPath), file);
|
|
178
|
+
for (const file of overlay) byPath.set(toPosix(file.relPath), file);
|
|
179
|
+
return [...byPath.values()];
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Delete manifest-owned files. A file whose content no longer matches its
|
|
183
|
+
* manifest hash was modified by the user — keep it unless force.
|
|
184
|
+
* Missing files are treated as already removed.
|
|
185
|
+
*/
|
|
186
|
+
async function removeOwnedFiles(installDir, entries, force) {
|
|
187
|
+
const removed = [];
|
|
188
|
+
const kept = [];
|
|
189
|
+
for (const entry of entries) {
|
|
190
|
+
const target = path.join(installDir, entry.path);
|
|
191
|
+
let contents;
|
|
192
|
+
try {
|
|
193
|
+
contents = await readFile(target);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (error.code === "ENOENT") continue;
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
if (!force && hashBytes(contents) !== entry.sha256) {
|
|
199
|
+
kept.push(entry.path);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
await rm(target, { force: true });
|
|
203
|
+
removed.push(entry.path);
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
removed,
|
|
207
|
+
kept
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Remove directories left empty by deletions, walking up from each removed
|
|
212
|
+
* file. Never removes the install dir itself — nomai never deletes .claude/.
|
|
213
|
+
*/
|
|
214
|
+
async function pruneEmptyDirs(installDir, removedPaths) {
|
|
215
|
+
const root = path.resolve(installDir);
|
|
216
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
217
|
+
for (const relPath of removedPaths) {
|
|
218
|
+
let dir = path.dirname(path.resolve(installDir, relPath));
|
|
219
|
+
while (dir.startsWith(root) && dir !== root) {
|
|
220
|
+
candidates.add(dir);
|
|
221
|
+
dir = path.dirname(dir);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const ordered = [...candidates].sort((a, b) => b.length - a.length);
|
|
225
|
+
for (const dir of ordered) try {
|
|
226
|
+
if ((await readdir(dir)).length === 0) await rmdir(dir);
|
|
227
|
+
} catch {}
|
|
228
|
+
}
|
|
229
|
+
async function allFilesMatch(installDir, entries) {
|
|
230
|
+
for (const entry of entries) try {
|
|
231
|
+
if (hashBytes(await readFile(path.join(installDir, entry.path))) !== entry.sha256) return false;
|
|
232
|
+
} catch {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
function sameEntries(a, b) {
|
|
238
|
+
if (a.length !== b.length) return false;
|
|
239
|
+
const sortedA = [...a].sort((x, y) => comparePaths(x.path, y.path));
|
|
240
|
+
const sortedB = [...b].sort((x, y) => comparePaths(x.path, y.path));
|
|
241
|
+
return sortedA.every((entry, i) => entry.path === sortedB[i].path && entry.sha256 === sortedB[i].sha256);
|
|
242
|
+
}
|
|
243
|
+
/** Codepoint sort: stable across machines/locales (localeCompare is not). */
|
|
244
|
+
function comparePaths(a, b) {
|
|
245
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
246
|
+
}
|
|
247
|
+
function toPosix(relPath) {
|
|
248
|
+
return relPath.split(path.sep).join("/");
|
|
249
|
+
}
|
|
250
|
+
//#endregion
|
|
251
|
+
//#region src/harnesses/define.ts
|
|
252
|
+
/**
|
|
253
|
+
* Build a HarnessAdapter from declarative config. A typical adapter is:
|
|
254
|
+
*
|
|
255
|
+
* export const claudeCode = defineHarness({
|
|
256
|
+
* id: "claude-code",
|
|
257
|
+
* displayName: "Claude Code",
|
|
258
|
+
* configDirName: ".claude",
|
|
259
|
+
* });
|
|
260
|
+
*
|
|
261
|
+
* Harnesses that need per-harness compilation override transformPayload;
|
|
262
|
+
* harnesses with entirely custom mechanics (e.g. settings.json merges for
|
|
263
|
+
* hooks) can override install/uninstall/status wholesale.
|
|
264
|
+
*/
|
|
265
|
+
function defineHarness(config) {
|
|
266
|
+
const adapter = {
|
|
267
|
+
id: config.id,
|
|
268
|
+
displayName: config.displayName,
|
|
269
|
+
configDirName: config.configDirName,
|
|
270
|
+
detectProject: config.detectProject ?? ((projectDir) => dirExists(path.join(projectDir, config.configDirName))),
|
|
271
|
+
detectGlobal: config.detectGlobal ?? ((homeDir) => dirExists(path.join(homeDir, config.configDirName))),
|
|
272
|
+
resolveInstallDir: config.resolveInstallDir ?? ((scope, ctx) => path.join(scope === "project" ? ctx.projectDir : ctx.homeDir, config.configDirName)),
|
|
273
|
+
transformPayload: config.transformPayload ?? (async (files) => files),
|
|
274
|
+
install: config.install ?? ((ctx) => installHarness(adapter, ctx)),
|
|
275
|
+
uninstall: config.uninstall ?? ((ctx) => uninstallHarness(adapter, ctx)),
|
|
276
|
+
status: config.status ?? ((ctx) => statusHarness(adapter, ctx))
|
|
277
|
+
};
|
|
278
|
+
return adapter;
|
|
279
|
+
}
|
|
280
|
+
async function dirExists(target) {
|
|
281
|
+
try {
|
|
282
|
+
return (await stat(target)).isDirectory();
|
|
283
|
+
} catch {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
//#endregion
|
|
288
|
+
//#region src/harnesses/index.ts
|
|
289
|
+
/**
|
|
290
|
+
* Register new harnesses here. Adding one:
|
|
291
|
+
* 1. Create src/harnesses/<name>.ts with defineHarness({...}).
|
|
292
|
+
* 2. Add it to this array.
|
|
293
|
+
* 3. Create payload/<name>/ for harness-specific payload files (optional).
|
|
294
|
+
*/
|
|
295
|
+
const harnesses = [defineHarness({
|
|
296
|
+
id: "claude-code",
|
|
297
|
+
displayName: "Claude Code",
|
|
298
|
+
configDirName: ".claude"
|
|
299
|
+
})];
|
|
300
|
+
//#endregion
|
|
301
|
+
//#region src/core/registry.ts
|
|
302
|
+
function listHarnesses() {
|
|
303
|
+
return [...harnesses];
|
|
304
|
+
}
|
|
305
|
+
function getHarness(id) {
|
|
306
|
+
const adapter = harnesses.find((harness) => harness.id === id);
|
|
307
|
+
if (!adapter) {
|
|
308
|
+
const valid = harnesses.map((harness) => harness.id).join(", ");
|
|
309
|
+
throw new Error(`Unknown harness "${id}". Valid harnesses: ${valid}`);
|
|
310
|
+
}
|
|
311
|
+
return adapter;
|
|
312
|
+
}
|
|
313
|
+
//#endregion
|
|
314
|
+
//#region src/core/detect.ts
|
|
315
|
+
/**
|
|
316
|
+
* Ask every registered harness whether it is present at project and global
|
|
317
|
+
* scope. The report preseeds the wizard's harness multiselect and drives
|
|
318
|
+
* the defaults used by --yes.
|
|
319
|
+
*/
|
|
320
|
+
async function detectHarnesses(ctx) {
|
|
321
|
+
const results = await Promise.all(listHarnesses().map(async (adapter) => ({
|
|
322
|
+
harnessId: adapter.id,
|
|
323
|
+
project: await adapter.detectProject(ctx.projectDir),
|
|
324
|
+
global: await adapter.detectGlobal(ctx.homeDir)
|
|
325
|
+
})));
|
|
326
|
+
return {
|
|
327
|
+
results,
|
|
328
|
+
detectedIds: results.filter((r) => r.project || r.global).map((r) => r.harnessId),
|
|
329
|
+
projectDetectedIds: results.filter((r) => r.project).map((r) => r.harnessId)
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
//#endregion
|
|
333
|
+
//#region src/core/payload.ts
|
|
334
|
+
/**
|
|
335
|
+
* Default payload source: the Nomai GitHub repo, fetched as a codeload
|
|
336
|
+
* tarball of `main` at install time (same model as BuilderIO's
|
|
337
|
+
* `npx @agent-native/skills`). The npm package itself ships no payload.
|
|
338
|
+
*/
|
|
339
|
+
const DEFAULT_SOURCE = "A-MCode/nomai-installer";
|
|
340
|
+
/** Name of the payload directory inside the repo. */
|
|
341
|
+
const PAYLOAD_DIR = "payload";
|
|
342
|
+
/** Subdirectory holding the canonical payload shared by all harnesses. */
|
|
343
|
+
const SHARED_DIR = "shared";
|
|
344
|
+
/** Accepts "owner/repo", "owner/repo#ref", or a github.com URL (optionally /tree/<ref>). */
|
|
345
|
+
function parseGitHubSource(input) {
|
|
346
|
+
const shorthand = input.match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:#(.+))?$/);
|
|
347
|
+
if (shorthand) return {
|
|
348
|
+
owner: shorthand[1],
|
|
349
|
+
repo: shorthand[2].replace(/\.git$/, ""),
|
|
350
|
+
ref: shorthand[3]
|
|
351
|
+
};
|
|
352
|
+
let url;
|
|
353
|
+
try {
|
|
354
|
+
url = new URL(input);
|
|
355
|
+
} catch {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
if (url.hostname !== "github.com") return null;
|
|
359
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
360
|
+
const [owner, repo] = parts;
|
|
361
|
+
if (!owner || !repo) return null;
|
|
362
|
+
const treeIndex = parts.indexOf("tree");
|
|
363
|
+
return {
|
|
364
|
+
owner,
|
|
365
|
+
repo: repo.replace(/\.git$/, ""),
|
|
366
|
+
ref: treeIndex >= 0 && parts[treeIndex + 1] ? parts.slice(treeIndex + 1).join("/") : void 0
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Turn a --source value (or the default) into a live PayloadSource:
|
|
371
|
+
* an existing local path wins, otherwise a GitHub download.
|
|
372
|
+
*/
|
|
373
|
+
async function resolvePayloadSource(input) {
|
|
374
|
+
const source = input ?? "A-MCode/nomai-installer";
|
|
375
|
+
const localRoot = path.resolve(source);
|
|
376
|
+
if (await exists(localRoot)) {
|
|
377
|
+
await assertPayloadRoot(localRoot, source);
|
|
378
|
+
return new DirectoryPayloadSource(source, localRoot);
|
|
379
|
+
}
|
|
380
|
+
const parsed = parseGitHubSource(source);
|
|
381
|
+
if (!parsed) throw new Error(`Payload source not found: ${source}. Use a local path, GitHub owner/repo[#ref], or GitHub URL.`);
|
|
382
|
+
return materializeGitHubSource(parsed);
|
|
383
|
+
}
|
|
384
|
+
async function materializeGitHubSource(ref) {
|
|
385
|
+
const gitRef = ref.ref ?? "main";
|
|
386
|
+
const description = `${ref.owner}/${ref.repo}#${gitRef}`;
|
|
387
|
+
const url = `https://codeload.github.com/${ref.owner}/${ref.repo}/tar.gz/${encodeURIComponent(gitRef)}`;
|
|
388
|
+
const tmpRoot = await mkdtemp(path.join(os.tmpdir(), "nomai-payload-"));
|
|
389
|
+
try {
|
|
390
|
+
const response = await fetch(url, { headers: { "user-agent": "nomai" } });
|
|
391
|
+
if (!response.ok) throw new Error(`Could not download ${description}: HTTP ${response.status}.`);
|
|
392
|
+
const archive = path.join(tmpRoot, "source.tgz");
|
|
393
|
+
await writeFile(archive, Buffer.from(await response.arrayBuffer()));
|
|
394
|
+
const extracted = spawnSync("tar", [
|
|
395
|
+
"-xzf",
|
|
396
|
+
archive,
|
|
397
|
+
"-C",
|
|
398
|
+
tmpRoot
|
|
399
|
+
], {
|
|
400
|
+
stdio: "pipe",
|
|
401
|
+
encoding: "utf8"
|
|
402
|
+
});
|
|
403
|
+
if (extracted.status !== 0) throw new Error(`Could not extract ${description}: ${extracted.stderr || extracted.stdout}`);
|
|
404
|
+
const repoDir = (await readdir(tmpRoot, { withFileTypes: true })).find((entry) => entry.isDirectory());
|
|
405
|
+
if (!repoDir) throw new Error(`Downloaded archive for ${description} did not contain a repo directory.`);
|
|
406
|
+
const root = path.join(tmpRoot, repoDir.name);
|
|
407
|
+
await assertPayloadRoot(root, description);
|
|
408
|
+
return new DirectoryPayloadSource(description, root, () => rm(tmpRoot, {
|
|
409
|
+
recursive: true,
|
|
410
|
+
force: true
|
|
411
|
+
}));
|
|
412
|
+
} catch (error) {
|
|
413
|
+
await rm(tmpRoot, {
|
|
414
|
+
recursive: true,
|
|
415
|
+
force: true
|
|
416
|
+
});
|
|
417
|
+
throw error;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/** Reads payload files from a repo checkout/extract on disk. */
|
|
421
|
+
var DirectoryPayloadSource = class {
|
|
422
|
+
description;
|
|
423
|
+
rootDir;
|
|
424
|
+
cleanup;
|
|
425
|
+
constructor(description, rootDir, cleanup) {
|
|
426
|
+
this.description = description;
|
|
427
|
+
this.rootDir = rootDir;
|
|
428
|
+
this.cleanup = cleanup;
|
|
429
|
+
}
|
|
430
|
+
async readSharedFiles() {
|
|
431
|
+
return readTree(path.join(this.rootDir, PAYLOAD_DIR, SHARED_DIR));
|
|
432
|
+
}
|
|
433
|
+
async readHarnessOverlay(harnessId) {
|
|
434
|
+
if (harnessId === "shared") return [];
|
|
435
|
+
return readTree(path.join(this.rootDir, PAYLOAD_DIR, harnessId));
|
|
436
|
+
}
|
|
437
|
+
async dispose() {
|
|
438
|
+
await this.cleanup?.();
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
async function assertPayloadRoot(root, description) {
|
|
442
|
+
if (!await exists(path.join(root, "payload"))) throw new Error(`${description} does not contain a ${PAYLOAD_DIR}/ directory — not a Nomai payload source.`);
|
|
443
|
+
}
|
|
444
|
+
/** Recursively read a directory into PayloadFiles; missing dir → []. Skips .gitkeep placeholders. */
|
|
445
|
+
async function readTree(dir, prefix = "") {
|
|
446
|
+
let entries;
|
|
447
|
+
try {
|
|
448
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
449
|
+
} catch (error) {
|
|
450
|
+
if (error.code === "ENOENT") return [];
|
|
451
|
+
throw error;
|
|
452
|
+
}
|
|
453
|
+
const files = [];
|
|
454
|
+
for (const entry of entries) {
|
|
455
|
+
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
456
|
+
if (entry.isDirectory()) files.push(...await readTree(path.join(dir, entry.name), relPath));
|
|
457
|
+
else if (entry.isFile() && entry.name !== ".gitkeep") files.push({
|
|
458
|
+
relPath,
|
|
459
|
+
contents: await readFile(path.join(dir, entry.name))
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
return files.sort((a, b) => a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0);
|
|
463
|
+
}
|
|
464
|
+
async function exists(target) {
|
|
465
|
+
try {
|
|
466
|
+
await stat(target);
|
|
467
|
+
return true;
|
|
468
|
+
} catch {
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
//#endregion
|
|
473
|
+
export { getHarness as a, installHarness as c, MANIFEST_FILENAME as d, readManifest as f, detectHarnesses as i, statusHarness as l, parseGitHubSource as n, listHarnesses as o, resolvePayloadSource as r, defineHarness as s, DEFAULT_SOURCE as t, uninstallHarness as u };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
//#region \0rolldown/runtime.js
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
12
|
+
key = keys[i];
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
14
|
+
get: ((k) => from[k]).bind(null, key),
|
|
15
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
21
|
+
value: mod,
|
|
22
|
+
enumerable: true
|
|
23
|
+
}) : target, mod));
|
|
24
|
+
var __require = /* #__PURE__ */ (() => createRequire(import.meta.url))();
|
|
25
|
+
//#endregion
|
|
26
|
+
export { __require as n, __toESM as r, __commonJSMin as t };
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nomai/nomai",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Install Nomai skills, hooks, and commands into AI coding harnesses",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/A-MCode/nomai-installer.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/A-MCode/nomai-installer#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/A-MCode/nomai-installer/issues"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=20"
|
|
20
|
+
},
|
|
21
|
+
"bin": {
|
|
22
|
+
"nomai": "./dist/cli.mjs"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.mts",
|
|
30
|
+
"import": "./dist/index.mjs"
|
|
31
|
+
},
|
|
32
|
+
"./package.json": "./package.json"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"dev": "tsx src/cli.ts",
|
|
36
|
+
"build": "tsdown",
|
|
37
|
+
"test": "vitest run",
|
|
38
|
+
"test:watch": "vitest",
|
|
39
|
+
"typecheck": "tsc --noEmit",
|
|
40
|
+
"prepublishOnly": "npm run typecheck && npm run test && npm run build"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@clack/prompts": "^1.7.0",
|
|
44
|
+
"@types/node": "^22.10.0",
|
|
45
|
+
"@vercel/detect-agent": "^1.2.3",
|
|
46
|
+
"commander": "^15.0.0",
|
|
47
|
+
"tsdown": "^0.22.4",
|
|
48
|
+
"tsx": "^4.20.0",
|
|
49
|
+
"typescript": "^5.9.0",
|
|
50
|
+
"vitest": "^4.1.0"
|
|
51
|
+
}
|
|
52
|
+
}
|