@airig/cli 0.0.1

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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +51 -0
  3. package/dist/index.js +882 -0
  4. package/package.json +53 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 JD Solanki
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # airig
2
+
3
+ Distribute and manage AI setups across coding agents from one project-local `.ai/` directory.
4
+
5
+ ## Usage
6
+
7
+ Install the CLI globally to use the short `airig` command:
8
+
9
+ ```sh
10
+ npm install --global airig
11
+ ```
12
+
13
+ ```sh
14
+ airig add <owner/repo>[@version]
15
+ airig add .
16
+ airig update <owner/repo>@<version>
17
+ airig remove [owner/repo|.]
18
+ airig publish [tag]
19
+ ```
20
+
21
+ For one-off usage without a global install, run the npm package directly:
22
+
23
+ ```sh
24
+ npx airig add <owner/repo>[@version]
25
+ npx airig add .
26
+ npx airig update <owner/repo>@<version>
27
+ npx airig remove [owner/repo|.]
28
+ npx airig publish [tag]
29
+ ```
30
+
31
+ The package is named `airig`; the installed binary is `airig`.
32
+
33
+ ## What It Does
34
+
35
+ airig installs selected AI setup artifacts from immutable GitHub releases into `.ai/`, then links them into provider-specific config paths. It supports local author dogfooding with `add .`, explicit version updates, interactive removal, and publishing `.ai/` as an `ai.zip` release asset.
36
+
37
+ Remote setup releases are pinned to exact versions in `.ai/ai.json`. `add` and `update` verify GitHub release immutability before writing remote content.
38
+
39
+ ## Author Workflow
40
+
41
+ 1. Create setup artifacts under `.ai/`.
42
+ 2. Run `airig add .` to wire local artifacts into your repo.
43
+ 3. Tag a release with your normal git tooling.
44
+ 4. Run `airig publish` to upload `ai.zip` to an immutable GitHub release.
45
+ 5. Share `airig add yourname/repo`.
46
+
47
+ ## Requirements
48
+
49
+ - Node.js `24.11.0` or newer in the Node 24 release line.
50
+ - GitHub immutable releases enabled for repositories that publish setup releases.
51
+ - `GITHUB_TOKEN` with repository write access when running `publish`.
package/dist/index.js ADDED
@@ -0,0 +1,882 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { execSync } from "node:child_process";
4
+ import { cp, lstat, mkdir, mkdtemp, readFile, readdir, readlink, rm, symlink, unlink, writeFile } from "node:fs/promises";
5
+ import path, { join } from "node:path";
6
+ import { parseEnv } from "node:util";
7
+ import archiver from "archiver";
8
+ import { createWriteStream, existsSync } from "node:fs";
9
+ import { Octokit } from "@octokit/rest";
10
+ import { checkbox } from "@inquirer/prompts";
11
+ import os from "node:os";
12
+ import extractZip from "extract-zip";
13
+ //#region src/lib/zip.ts
14
+ function create(sourceDir, outputPath) {
15
+ return new Promise((resolve, reject) => {
16
+ const output = createWriteStream(outputPath);
17
+ const archive = archiver("zip", { zlib: { level: 9 } });
18
+ const sourceBaseName = path.basename(sourceDir);
19
+ output.on("close", resolve);
20
+ archive.on("error", reject);
21
+ archive.pipe(output);
22
+ archive.directory(sourceDir, sourceBaseName, (entry) => {
23
+ if (entry.name === "ai.json" && entry.prefix === sourceBaseName) return false;
24
+ return entry;
25
+ });
26
+ archive.finalize();
27
+ });
28
+ }
29
+ //#endregion
30
+ //#region src/lib/github.ts
31
+ async function fetchReleaseInfo(owner, repo, tag, octokit) {
32
+ let releaseTag;
33
+ let immutable;
34
+ let assets;
35
+ if (tag) {
36
+ const { data } = await octokit.repos.getReleaseByTag({
37
+ owner,
38
+ repo,
39
+ tag
40
+ });
41
+ releaseTag = data.tag_name;
42
+ immutable = data.immutable === true;
43
+ assets = data.assets;
44
+ } else {
45
+ const { data } = await octokit.repos.getLatestRelease({
46
+ owner,
47
+ repo
48
+ });
49
+ releaseTag = data.tag_name;
50
+ immutable = data.immutable === true;
51
+ assets = data.assets;
52
+ }
53
+ const asset = assets.find((a) => a.name === "ai.zip");
54
+ if (!asset) throw new Error(`No ai.zip asset found in release "${tag ?? "latest"}" of ${owner}/${repo}`);
55
+ return {
56
+ tag: releaseTag,
57
+ assetDownloadUrl: asset.browser_download_url,
58
+ immutable
59
+ };
60
+ }
61
+ async function downloadAsset(url) {
62
+ const response = await fetch(url);
63
+ if (!response.ok) throw new Error(`Failed to download asset: HTTP ${response.status} ${response.statusText}`);
64
+ return Buffer.from(await response.arrayBuffer());
65
+ }
66
+ function createOctokit(token) {
67
+ return new Octokit({ auth: token });
68
+ }
69
+ async function getImmutableReleasesStatus(owner, repo, octokit) {
70
+ try {
71
+ const { data } = await octokit.request("GET /repos/{owner}/{repo}/immutable-releases", {
72
+ owner,
73
+ repo
74
+ });
75
+ return data;
76
+ } catch (err) {
77
+ if (err && typeof err === "object" && "status" in err) {
78
+ const status = err.status;
79
+ if (status === 404) return {
80
+ enabled: false,
81
+ enforced_by_owner: false
82
+ };
83
+ if (status === 401) throw new Error("Verifying release immutability requires a GitHub token (even for public repos).\n Set GITHUB_TOKEN and retry: export GITHUB_TOKEN=ghp_...\n Generate a token at: https://github.com/settings/tokens");
84
+ }
85
+ throw err;
86
+ }
87
+ }
88
+ function isOctokitError(err) {
89
+ return err != null && typeof err === "object" && "status" in err;
90
+ }
91
+ function interpretError(err, step, ctx) {
92
+ if (!isOctokitError(err)) return err instanceof Error ? err : new Error(String(err));
93
+ const { status } = err;
94
+ const firstValidationError = err.response?.data?.errors?.[0];
95
+ if (status === 401) return /* @__PURE__ */ new Error("GITHUB_TOKEN is invalid or expired.\n Generate a new token at: https://github.com/settings/tokens");
96
+ if (status === 403) return /* @__PURE__ */ new Error(`Token lacks write access to ${ctx.owner}/${ctx.repo}.\n Classic PAT needs the "repo" scope.
97
+ Fine-grained PAT needs "Contents: Read and write".`);
98
+ if (status === 404 && step === "create-release") return /* @__PURE__ */ new Error(`Repository ${ctx.owner}/${ctx.repo} not found or the token has no access to it.\n Check the git remote URL and token permissions.`);
99
+ if (status === 422 && firstValidationError?.code === "already_exists" && firstValidationError?.field === "tag_name") return /* @__PURE__ */ new Error(`A release for tag ${ctx.tag} already exists in ${ctx.owner}/${ctx.repo}.\n Immutable releases cannot be deleted or have their tag reused.
100
+ Bump the version, push a new tag, and retry.`);
101
+ if (status === 422 && step === "upload-asset" && firstValidationError?.code === "already_exists") return /* @__PURE__ */ new Error(`An asset named ai.zip already exists on a stale draft release (leftover from a previous failed publish).
102
+ Delete stale drafts at: https://github.com/${ctx.owner}/${ctx.repo}/releases`);
103
+ const apiMessage = err.response?.data?.message ?? String(err);
104
+ return /* @__PURE__ */ new Error(`GitHub API error (HTTP ${status}): ${apiMessage}`);
105
+ }
106
+ async function deleteDraft(octokit, owner, repo, releaseId) {
107
+ await octokit.repos.deleteRelease({
108
+ owner,
109
+ repo,
110
+ release_id: releaseId
111
+ }).catch(() => {});
112
+ }
113
+ async function publishRelease(opts) {
114
+ const { owner, repo, tag, assetPath, octokit } = opts;
115
+ const ctx = {
116
+ owner,
117
+ repo,
118
+ tag
119
+ };
120
+ let draftId;
121
+ try {
122
+ const { data: draft } = await octokit.repos.createRelease({
123
+ owner,
124
+ repo,
125
+ tag_name: tag,
126
+ draft: true
127
+ });
128
+ draftId = draft.id;
129
+ } catch (err) {
130
+ throw interpretError(err, "create-release", ctx);
131
+ }
132
+ try {
133
+ const assetData = await readFile(assetPath);
134
+ await octokit.repos.uploadReleaseAsset({
135
+ owner,
136
+ repo,
137
+ release_id: draftId,
138
+ name: path.basename(assetPath),
139
+ data: assetData
140
+ });
141
+ } catch (err) {
142
+ await deleteDraft(octokit, owner, repo, draftId);
143
+ throw interpretError(err, "upload-asset", ctx);
144
+ }
145
+ try {
146
+ const { data: published } = await octokit.repos.updateRelease({
147
+ owner,
148
+ repo,
149
+ release_id: draftId,
150
+ draft: false
151
+ });
152
+ return published.html_url;
153
+ } catch (err) {
154
+ await deleteDraft(octokit, owner, repo, draftId);
155
+ throw interpretError(err, "publish-release", ctx);
156
+ }
157
+ }
158
+ //#endregion
159
+ //#region src/commands/publish.ts
160
+ function resolveTag(tagArg) {
161
+ if (tagArg) return tagArg;
162
+ try {
163
+ return execSync("git describe --tags --abbrev=0", { encoding: "utf8" }).trim();
164
+ } catch {
165
+ console.error("✖ No tag found. Pass a tag argument or create a git tag first.");
166
+ process.exit(1);
167
+ }
168
+ }
169
+ function parseRemoteUrl(remote) {
170
+ const match = remote.match(/[:/]([^/:]+)\/([^/]+?)(?:\.git)?$/);
171
+ if (!match) return null;
172
+ return {
173
+ owner: match[1],
174
+ repo: match[2]
175
+ };
176
+ }
177
+ function resolveOwnerRepo() {
178
+ let remote;
179
+ try {
180
+ remote = execSync("git remote get-url origin", { encoding: "utf8" }).trim();
181
+ } catch {
182
+ console.error("✖ Could not read git remote origin. Is this a git repository with a remote?");
183
+ process.exit(1);
184
+ }
185
+ const parsed = parseRemoteUrl(remote);
186
+ if (!parsed) {
187
+ console.error(`✖ Could not parse owner/repo from remote: ${remote}`);
188
+ process.exit(1);
189
+ }
190
+ return parsed;
191
+ }
192
+ function createPublishZip(zipPath = path.join(process.cwd(), "ai.zip")) {
193
+ return create(".ai", zipPath);
194
+ }
195
+ function isNodeErrorWithCode(err, code) {
196
+ return err instanceof Error && "code" in err && err.code === code;
197
+ }
198
+ async function loadPublishGithubTokenFromCwd() {
199
+ if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
200
+ try {
201
+ const token = parseEnv(await readFile(path.join(process.cwd(), ".env"), "utf8")).GITHUB_TOKEN;
202
+ if (token) process.env.GITHUB_TOKEN = token;
203
+ return token;
204
+ } catch (err) {
205
+ if (isNodeErrorWithCode(err, "ENOENT")) return void 0;
206
+ throw err;
207
+ }
208
+ }
209
+ const publishCommand = new Command("publish").description("Publish project .ai artifacts as an immutable ai.zip release").argument("[tag]", "Git tag to release (defaults to latest local tag)").action(async (tagArg) => {
210
+ try {
211
+ const token = await loadPublishGithubTokenFromCwd();
212
+ if (!token) {
213
+ console.error("✖ GITHUB_TOKEN is not set. Add it to .env in this directory or export it before running publish.");
214
+ console.error(" GITHUB_TOKEN=ghp_...");
215
+ process.exit(1);
216
+ }
217
+ const tag = resolveTag(tagArg);
218
+ const { owner, repo } = resolveOwnerRepo();
219
+ const octokit = createOctokit(token);
220
+ if (!(await getImmutableReleasesStatus(owner, repo, octokit)).enabled) {
221
+ console.error(`✖ Immutable releases are not enabled for ${owner}/${repo}.`);
222
+ console.error(` Enable it at: https://github.com/${owner}/${repo}/settings`);
223
+ console.error(" Docs: https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository");
224
+ process.exit(1);
225
+ }
226
+ const zipPath = path.join(process.cwd(), "ai.zip");
227
+ await createPublishZip(zipPath);
228
+ const url = await publishRelease({
229
+ owner,
230
+ repo,
231
+ tag,
232
+ assetPath: zipPath,
233
+ octokit
234
+ });
235
+ await rm(zipPath, { force: true });
236
+ console.log(`✔ Published: ${url}`);
237
+ } catch (err) {
238
+ console.error(`✖ ${err instanceof Error ? err.message : String(err)}`);
239
+ process.exit(1);
240
+ }
241
+ });
242
+ //#endregion
243
+ //#region src/lib/ai-json.ts
244
+ const AI_JSON_PATH = ".ai/ai.json";
245
+ function validate(data) {
246
+ if (typeof data !== "object" || data === null || typeof data.packages !== "object" || data.packages === null) throw new Error(`${AI_JSON_PATH} is malformed: expected { "packages": {} }\n Fix: restore the missing top-level keys, or delete ${AI_JSON_PATH} to reset it.`);
247
+ const packages = {};
248
+ for (const [key, rawEntry] of Object.entries(data.packages)) {
249
+ if (typeof rawEntry !== "object" || rawEntry === null) throw new Error(`${AI_JSON_PATH} is malformed: package "${key}" must be an object.`);
250
+ const entry = rawEntry;
251
+ if (typeof entry.version !== "string" || entry.version.length === 0) throw new Error(`${AI_JSON_PATH} is malformed: package "${key}" must have a version string.`);
252
+ if (key === "." && entry.version !== "*") throw new Error(`${AI_JSON_PATH} is malformed: local package "." must use version "*".`);
253
+ if (key !== "." && entry.version === "*") throw new Error(`${AI_JSON_PATH} is malformed: remote package "${key}" must use an exact version.`);
254
+ if (entry.linked !== void 0 && (!Array.isArray(entry.linked) || entry.linked.some((label) => typeof label !== "string" || label.length === 0))) throw new Error(`${AI_JSON_PATH} is malformed: package "${key}" linked must be a string array.`);
255
+ packages[key] = {
256
+ version: entry.version,
257
+ linked: entry.linked === void 0 ? [] : [...entry.linked]
258
+ };
259
+ }
260
+ return { packages };
261
+ }
262
+ async function readAiJson() {
263
+ if (!existsSync(AI_JSON_PATH)) return { packages: {} };
264
+ const raw = await readFile(AI_JSON_PATH, "utf-8");
265
+ return validate(JSON.parse(raw));
266
+ }
267
+ async function writeAiJson(data) {
268
+ await mkdir(path.dirname(AI_JSON_PATH), { recursive: true });
269
+ await writeFile(AI_JSON_PATH, JSON.stringify(data, null, 2) + "\n", "utf-8");
270
+ }
271
+ function addPackage(data, key, entry) {
272
+ data.packages[key] = entry;
273
+ }
274
+ function removePackage(data, key) {
275
+ delete data.packages[key];
276
+ }
277
+ //#endregion
278
+ //#region src/lib/package-ref.ts
279
+ function parsePackageRef(pkg) {
280
+ const atIdx = pkg.lastIndexOf("@");
281
+ let ref = pkg;
282
+ let tag;
283
+ if (atIdx > 0) {
284
+ tag = pkg.slice(atIdx + 1);
285
+ ref = pkg.slice(0, atIdx);
286
+ }
287
+ const slashIdx = ref.indexOf("/");
288
+ if (slashIdx < 1 || slashIdx === ref.length - 1) throw new Error(`Invalid package reference "${pkg}". Expected: owner/repo or owner/repo@version`);
289
+ return {
290
+ owner: ref.slice(0, slashIdx),
291
+ repo: ref.slice(slashIdx + 1),
292
+ tag
293
+ };
294
+ }
295
+ function parseExactPackageRef(pkg) {
296
+ const parsed = parsePackageRef(pkg);
297
+ if (!parsed.tag) throw new Error(`Invalid package reference "${pkg}". Expected exact version: owner/repo@version`);
298
+ return parsed;
299
+ }
300
+ //#endregion
301
+ //#region src/lib/setup-release.ts
302
+ async function findSkillDirs(dir) {
303
+ let entries;
304
+ try {
305
+ entries = await readdir(dir, { withFileTypes: true });
306
+ } catch {
307
+ return [];
308
+ }
309
+ if (entries.some((e) => e.isFile() && e.name === "SKILL.md")) return [dir];
310
+ const results = [];
311
+ for (const entry of entries) if (entry.isDirectory()) results.push(...await findSkillDirs(path.join(dir, entry.name)));
312
+ return results;
313
+ }
314
+ async function flattenSkills(skillsDir) {
315
+ const skillDirs = await findSkillDirs(skillsDir);
316
+ const names = /* @__PURE__ */ new Map();
317
+ for (const dir of skillDirs) {
318
+ const name = path.basename(dir);
319
+ if (names.has(name)) throw new Error(`Skill name collision: "${name}" appears at "${names.get(name)}" and "${dir}" in the package`);
320
+ names.set(name, dir);
321
+ }
322
+ for (const dir of skillDirs) {
323
+ const dest = path.join(skillsDir, path.basename(dir));
324
+ if (dir !== dest) await cp(dir, dest, { recursive: true });
325
+ }
326
+ const topEntries = await readdir(skillsDir, { withFileTypes: true });
327
+ for (const entry of topEntries) if (entry.isDirectory() && !names.has(entry.name)) await rm(path.join(skillsDir, entry.name), {
328
+ recursive: true,
329
+ force: true
330
+ });
331
+ }
332
+ async function withExtractedReleaseAi(assetBuffer, tmpPrefix, fn) {
333
+ const tmpDir = await mkdtemp(path.join(os.tmpdir(), tmpPrefix));
334
+ try {
335
+ const zipPath = path.join(tmpDir, "ai.zip");
336
+ await writeFile(zipPath, assetBuffer);
337
+ const extractDir = path.join(tmpDir, "extracted");
338
+ await mkdir(extractDir);
339
+ await extractZip(zipPath, { dir: extractDir });
340
+ const extractedAiDir = path.join(extractDir, ".ai");
341
+ if (!existsSync(extractedAiDir)) throw new Error("The release zip does not contain an .ai/ directory");
342
+ const skillsSrc = path.join(extractedAiDir, "skills");
343
+ if (existsSync(skillsSrc)) await flattenSkills(skillsSrc);
344
+ return await fn(extractedAiDir);
345
+ } finally {
346
+ await rm(tmpDir, {
347
+ recursive: true,
348
+ force: true
349
+ });
350
+ }
351
+ }
352
+ async function copyReleaseArtifactsToLocal(extractedAiDir, artifacts) {
353
+ await mkdir(".ai", { recursive: true });
354
+ const artifactsToCopy = await expandReleaseArtifactsWithSymlinkDependencies(extractedAiDir, artifacts);
355
+ for (const artifact of artifactsToCopy) {
356
+ const sourcePath = path.join(extractedAiDir, artifact);
357
+ const targetPath = path.join(".ai", artifact);
358
+ await mkdir(path.dirname(targetPath), { recursive: true });
359
+ await rm(targetPath, {
360
+ recursive: true,
361
+ force: true
362
+ });
363
+ await cp(sourcePath, targetPath, {
364
+ recursive: true,
365
+ force: true,
366
+ verbatimSymlinks: true
367
+ });
368
+ }
369
+ }
370
+ async function expandReleaseArtifactsWithSymlinkDependencies(extractedAiDir, artifacts) {
371
+ const expanded = /* @__PURE__ */ new Set();
372
+ async function visit(artifact) {
373
+ if (expanded.has(artifact)) return;
374
+ expanded.add(artifact);
375
+ const sourcePath = path.join(extractedAiDir, artifact);
376
+ if (!(await lstatIfExists(sourcePath))?.isSymbolicLink()) return;
377
+ const linkTarget = await readlink(sourcePath);
378
+ if (path.isAbsolute(linkTarget)) return;
379
+ const dependency = path.relative(extractedAiDir, path.resolve(path.dirname(sourcePath), linkTarget));
380
+ if (dependency.startsWith("..") || dependency === "") return;
381
+ if (existsSync(path.join(extractedAiDir, dependency))) await visit(dependency);
382
+ }
383
+ for (const artifact of artifacts) await visit(artifact);
384
+ return [...expanded];
385
+ }
386
+ async function lstatIfExists(filePath) {
387
+ try {
388
+ return await lstat(filePath);
389
+ } catch {
390
+ return;
391
+ }
392
+ }
393
+ //#endregion
394
+ //#region src/lib/provider-registry.ts
395
+ const PROVIDER_REGISTRY = {
396
+ claude: {
397
+ name: "claude",
398
+ rules: [
399
+ {
400
+ source: ".ai/CLAUDE.md",
401
+ target: "CLAUDE.md"
402
+ },
403
+ {
404
+ source: ".ai/.claude/agents",
405
+ target: ".claude/agents"
406
+ },
407
+ {
408
+ source: ".ai/.claude/commands",
409
+ target: ".claude/commands"
410
+ }
411
+ ]
412
+ },
413
+ codex: {
414
+ name: "codex",
415
+ rules: [
416
+ {
417
+ source: ".ai/AGENTS.md",
418
+ target: "AGENTS.md"
419
+ },
420
+ {
421
+ source: ".ai/.codex/agents",
422
+ target: ".codex/agents"
423
+ },
424
+ {
425
+ source: ".ai/.codex/commands",
426
+ target: ".codex/prompts"
427
+ }
428
+ ]
429
+ }
430
+ };
431
+ const SKILLS_RULE = {
432
+ source: ".ai/skills",
433
+ target: ".agents/skills"
434
+ };
435
+ function rulesFor(providers) {
436
+ return [...providers.flatMap((p) => PROVIDER_REGISTRY[p].rules), SKILLS_RULE];
437
+ }
438
+ function targetPathsForArtifact(artifact, providers = Object.keys(PROVIDER_REGISTRY)) {
439
+ const targets = /* @__PURE__ */ new Set();
440
+ for (const rule of rulesFor(providers)) {
441
+ const relSource = rule.source.startsWith(".ai/") ? rule.source.slice(4) : rule.source;
442
+ if (artifact === relSource) targets.add(rule.target);
443
+ else if (artifact.startsWith(relSource + "/")) targets.add(join(rule.target, artifact.slice(relSource.length + 1)));
444
+ }
445
+ return [...targets];
446
+ }
447
+ async function listArtifacts(rootDir, providers = Object.keys(PROVIDER_REGISTRY)) {
448
+ const artifacts = /* @__PURE__ */ new Set();
449
+ for (const rule of rulesFor(providers)) {
450
+ const relSource = rule.source.startsWith(".ai/") ? rule.source.slice(4) : rule.source;
451
+ const sourcePath = join(rootDir, relSource);
452
+ let sourceStat;
453
+ try {
454
+ sourceStat = await lstat(sourcePath);
455
+ } catch {
456
+ continue;
457
+ }
458
+ if (!sourceStat.isDirectory()) {
459
+ artifacts.add(relSource);
460
+ continue;
461
+ }
462
+ const entries = await readdir(sourcePath);
463
+ for (const e of entries) artifacts.add(`${relSource}/${e}`);
464
+ }
465
+ return [...artifacts];
466
+ }
467
+ //#endregion
468
+ //#region src/lib/linker.ts
469
+ function deriveTargetOwnership(aiJson) {
470
+ const ownership = /* @__PURE__ */ new Map();
471
+ for (const [packageKey, entry] of Object.entries(aiJson.packages)) for (const artifact of entry.linked) for (const targetPath of targetPathsForArtifact(artifact)) {
472
+ const owners = ownership.get(targetPath) ?? [];
473
+ owners.push({
474
+ packageKey,
475
+ version: entry.version,
476
+ artifact,
477
+ targetPath
478
+ });
479
+ ownership.set(targetPath, owners);
480
+ }
481
+ return ownership;
482
+ }
483
+ async function unlinkFiles(targetPaths) {
484
+ for (const targetPath of targetPaths) try {
485
+ if ((await lstat(targetPath)).isSymbolicLink()) await unlink(targetPath);
486
+ } catch {}
487
+ }
488
+ async function createSymlink(sourcePath, targetPath, result) {
489
+ let targetStat;
490
+ try {
491
+ targetStat = await lstat(targetPath);
492
+ } catch {}
493
+ if (targetStat) {
494
+ if (targetStat.isSymbolicLink()) {
495
+ const existing = await readlink(targetPath);
496
+ if (path.resolve(path.dirname(targetPath), existing) === path.resolve(sourcePath)) {
497
+ result.skipped.push({
498
+ path: targetPath,
499
+ reason: "already-linked"
500
+ });
501
+ return;
502
+ }
503
+ result.skipped.push({
504
+ path: targetPath,
505
+ reason: "conflict-wrong-symlink"
506
+ });
507
+ } else result.skipped.push({
508
+ path: targetPath,
509
+ reason: "conflict-real-file"
510
+ });
511
+ return;
512
+ }
513
+ await symlink(path.relative(path.dirname(targetPath), sourcePath), targetPath);
514
+ result.linked.push(targetPath);
515
+ }
516
+ async function linkPackageArtifacts(providers, artifactLabels) {
517
+ const result = {
518
+ linked: [],
519
+ skipped: []
520
+ };
521
+ const allowedTargets = /* @__PURE__ */ new Map();
522
+ for (const artifact of artifactLabels) for (const targetPath of targetPathsForArtifact(artifact, providers)) allowedTargets.set(targetPath, `.ai/${artifact}`);
523
+ for (const [targetPath, sourcePath] of allowedTargets) {
524
+ await mkdir(path.dirname(targetPath), { recursive: true });
525
+ await createSymlink(sourcePath, targetPath, result);
526
+ }
527
+ return result;
528
+ }
529
+ async function targetConflictFor$1(sourcePath, targetPath) {
530
+ let targetStat;
531
+ try {
532
+ targetStat = await lstat(targetPath);
533
+ } catch {
534
+ return;
535
+ }
536
+ if (!targetStat.isSymbolicLink()) return {
537
+ targetPath,
538
+ reason: "real-file"
539
+ };
540
+ const existing = await readlink(targetPath);
541
+ if (path.resolve(path.dirname(targetPath), existing) === path.resolve(sourcePath)) return void 0;
542
+ return {
543
+ targetPath,
544
+ reason: "wrong-symlink"
545
+ };
546
+ }
547
+ async function assertNoTargetConflicts$1(providers, artifactLabels) {
548
+ const conflicts = [];
549
+ const allowedTargets = /* @__PURE__ */ new Map();
550
+ for (const artifact of artifactLabels) for (const targetPath of targetPathsForArtifact(artifact, providers)) allowedTargets.set(targetPath, `.ai/${artifact}`);
551
+ for (const [targetPath, sourcePath] of allowedTargets) {
552
+ const conflict = await targetConflictFor$1(sourcePath, targetPath);
553
+ if (conflict) conflicts.push(conflict);
554
+ }
555
+ if (conflicts.length === 0) return;
556
+ throw new Error(`Conflicts detected — the following target paths are already occupied:\n` + conflicts.map((conflict) => ` ${conflict.targetPath} (${conflict.reason})`).join("\n") + "\n Remove or move the conflicting files, then run the command again.");
557
+ }
558
+ function assertNoBlockingSkips(skipped) {
559
+ const conflicts = skipped.filter((entry) => entry.reason !== "already-linked");
560
+ if (conflicts.length === 0) return;
561
+ throw new Error(`Conflicts detected — the following target paths could not be linked:\n` + conflicts.map((conflict) => ` ${conflict.path} (${conflict.reason})`).join("\n") + "\n Remove or move the conflicting files, then run the command again.");
562
+ }
563
+ function findRemotePackageConflicts(aiJson, packageKey, providers, artifactLabels) {
564
+ const ownership = deriveTargetOwnership(aiJson);
565
+ const conflicts = [];
566
+ for (const artifact of artifactLabels) for (const targetPath of targetPathsForArtifact(artifact, providers)) for (const owner of ownership.get(targetPath) ?? []) {
567
+ if (owner.packageKey === packageKey || owner.packageKey === ".") continue;
568
+ if (packageKey === ".") continue;
569
+ conflicts.push({
570
+ targetPath,
571
+ owner
572
+ });
573
+ }
574
+ return conflicts;
575
+ }
576
+ async function reconcilePackageLinks(aiJson, packageKey, providers, selectedLabels, scopedLabels = selectedLabels) {
577
+ if (!aiJson.packages[packageKey]) throw new Error(`Package "${packageKey}" is not installed.`);
578
+ const selected = [...new Set(selectedLabels)];
579
+ const selectedSet = new Set(selected);
580
+ const scopedSet = new Set(scopedLabels);
581
+ const currentLinked = aiJson.packages[packageKey].linked;
582
+ const preserved = currentLinked.filter((label) => !scopedSet.has(label));
583
+ const removed = currentLinked.filter((label) => scopedSet.has(label) && !selectedSet.has(label));
584
+ const conflicts = findRemotePackageConflicts(aiJson, packageKey, providers, selected);
585
+ if (conflicts.length > 0) throw new Error(`Conflicts detected — the following symlinks are already owned by another package:\n` + conflicts.map(({ targetPath, owner }) => ` ${targetPath} (owned by ${owner.packageKey}@${owner.version})`).join("\n") + "\n Remove the conflicting package first with: airig remove <owner/repo>");
586
+ await assertNoTargetConflicts$1(providers, selected);
587
+ const ownership = deriveTargetOwnership(aiJson);
588
+ const targetPathsToUnlink = /* @__PURE__ */ new Set();
589
+ for (const artifact of removed) for (const targetPath of targetPathsForArtifact(artifact, providers)) if ((ownership.get(targetPath) ?? []).filter((owner) => owner.packageKey !== packageKey).length === 0) targetPathsToUnlink.add(targetPath);
590
+ await unlinkFiles([...targetPathsToUnlink]);
591
+ const localOverrides = /* @__PURE__ */ new Set();
592
+ const localOverrideArtifacts = /* @__PURE__ */ new Set();
593
+ if (packageKey !== ".") {
594
+ for (const artifact of selected) for (const targetPath of targetPathsForArtifact(artifact, providers)) for (const owner of ownership.get(targetPath) ?? []) if (owner.packageKey === ".") {
595
+ localOverrides.add(targetPath);
596
+ localOverrideArtifacts.add(owner.artifact);
597
+ }
598
+ }
599
+ if (localOverrideArtifacts.size > 0 && aiJson.packages["."]) aiJson.packages["."].linked = aiJson.packages["."].linked.filter((artifact) => !localOverrideArtifacts.has(artifact));
600
+ const { linked, skipped } = await linkPackageArtifacts(providers, selected);
601
+ assertNoBlockingSkips(skipped);
602
+ aiJson.packages[packageKey].linked = [...new Set([...preserved, ...selected])];
603
+ return {
604
+ linked,
605
+ skipped,
606
+ unlinked: [...targetPathsToUnlink],
607
+ localOverrides: [...localOverrides]
608
+ };
609
+ }
610
+ //#endregion
611
+ //#region src/commands/add.ts
612
+ async function runAdd(pkg) {
613
+ if (pkg === ".") {
614
+ await runAddLocal();
615
+ return;
616
+ }
617
+ const { owner, repo, tag: inputTag } = parsePackageRef(pkg);
618
+ const packageKey = `${owner}/${repo}`;
619
+ const aiJson = await readAiJson();
620
+ const existingEntry = aiJson.packages[packageKey];
621
+ if (existingEntry && inputTag && inputTag !== existingEntry.version) throw new Error(`${packageKey} is already installed at ${existingEntry.version}.\n Use airig update <owner/repo>@<version> to move versions.`);
622
+ const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
623
+ const { tag: resolvedTag, assetDownloadUrl, immutable } = await fetchReleaseInfo(owner, repo, existingEntry ? existingEntry.version : inputTag, octokit);
624
+ if (!immutable) throw new Error(`Security restriction: release ${resolvedTag} of ${owner}/${repo} is not immutable.\n Installing mutable releases is unsafe — assets can be swapped after you review them.
625
+ Ask the package author to enable immutable releases in their repo settings.`);
626
+ console.log(` Downloading ${owner}/${repo}@${resolvedTag}...`);
627
+ await withExtractedReleaseAi(await downloadAsset(assetDownloadUrl), "airig-add-", async (extractedAiDir) => {
628
+ const providers = await promptProviders();
629
+ if (providers.length === 0) {
630
+ console.log("No providers selected.");
631
+ return;
632
+ }
633
+ const currentLinked = existingEntry?.linked ?? [];
634
+ const selectable = (await listArtifacts(extractedAiDir, providers)).filter((artifact) => !currentLinked.includes(artifact));
635
+ if (selectable.length === 0) {
636
+ console.log(`No new files found for ${packageKey}@${resolvedTag}.`);
637
+ return;
638
+ }
639
+ const selectedNew = await checkbox({
640
+ message: "Select files to add:",
641
+ choices: selectable.map((label) => ({
642
+ value: label,
643
+ name: label,
644
+ checked: currentLinked.length === 0
645
+ }))
646
+ });
647
+ if (selectedNew.length === 0) {
648
+ console.log("No files selected.");
649
+ return;
650
+ }
651
+ const artifactsToCopy = await expandReleaseArtifactsWithSymlinkDependencies(extractedAiDir, selectedNew);
652
+ assertNoRemoteConflicts(aiJson, packageKey, providers, selectedNew);
653
+ assertNoSourceConflicts(packageKey, currentLinked, artifactsToCopy);
654
+ await assertNoTargetConflicts(selectedNew, providers);
655
+ await copyReleaseArtifactsToLocal(extractedAiDir, selectedNew);
656
+ const entry = {
657
+ version: resolvedTag,
658
+ linked: []
659
+ };
660
+ if (!existingEntry) addPackage(aiJson, packageKey, entry);
661
+ const selected = [...new Set([...currentLinked, ...selectedNew])];
662
+ await reconcilePackageLinks(aiJson, packageKey, providers, selected, selected);
663
+ await writeAiJson(aiJson);
664
+ console.log(`\nAdded ${selectedNew.length} file(s) from ${owner}/${repo}@${resolvedTag}.`);
665
+ });
666
+ }
667
+ function assertNoSourceConflicts(packageKey, currentLinked, artifactsToCopy) {
668
+ const conflicts = artifactsToCopy.filter((artifact) => !currentLinked.includes(artifact)).filter((artifact) => existsSync(path.join(".ai", artifact)));
669
+ if (conflicts.length === 0) return;
670
+ throw new Error(`Conflicts detected — ${packageKey} would overwrite existing .ai source files:\n` + conflicts.map((artifact) => ` .ai/${artifact}`).join("\n") + "\n Remove the conflicting files, then run add again.");
671
+ }
672
+ async function runAddLocal() {
673
+ const aiJson = await readAiJson();
674
+ aiJson.packages["."] ??= {
675
+ version: "*",
676
+ linked: []
677
+ };
678
+ const providers = await promptProviders();
679
+ if (providers.length === 0) {
680
+ console.log("No providers selected.");
681
+ return;
682
+ }
683
+ const currentLinked = aiJson.packages["."].linked;
684
+ const selectable = (await listArtifacts(".ai", providers)).filter((artifact) => !currentLinked.includes(artifact));
685
+ if (selectable.length === 0) {
686
+ console.log("No new local files found.");
687
+ return;
688
+ }
689
+ const selectedNew = await checkbox({
690
+ message: "Select local files to add:",
691
+ choices: selectable.map((label) => ({
692
+ value: label,
693
+ name: label,
694
+ checked: true
695
+ }))
696
+ });
697
+ if (selectedNew.length === 0) {
698
+ console.log("No files selected.");
699
+ return;
700
+ }
701
+ await assertNoTargetConflicts(selectedNew, providers);
702
+ const selected = [...new Set([...currentLinked, ...selectedNew])];
703
+ await reconcilePackageLinks(aiJson, ".", providers, selected, selected);
704
+ await writeAiJson(aiJson);
705
+ console.log(`\nAdded ${selectedNew.length} local file(s).`);
706
+ }
707
+ async function promptProviders() {
708
+ return checkbox({
709
+ message: "Select providers to add:",
710
+ choices: Object.keys(PROVIDER_REGISTRY).map((p) => ({
711
+ value: p,
712
+ name: p
713
+ }))
714
+ });
715
+ }
716
+ function assertNoRemoteConflicts(aiJson, packageKey, providers, artifacts) {
717
+ const conflicts = findRemotePackageConflicts(aiJson, packageKey, providers, artifacts);
718
+ if (conflicts.length === 0) return;
719
+ throw new Error(`Conflicts detected — the following symlinks are already owned by another package:\n` + conflicts.map(({ targetPath, owner }) => ` ${targetPath} (owned by ${owner.packageKey}@${owner.version})`).join("\n") + "\n Remove the conflicting files first with: airig remove");
720
+ }
721
+ async function assertNoTargetConflicts(artifacts, providers) {
722
+ const conflicts = [];
723
+ for (const artifact of artifacts) for (const targetPath of targetPathsForArtifact(artifact, providers)) {
724
+ const conflict = await targetConflictFor(artifact, targetPath);
725
+ if (conflict) conflicts.push(conflict);
726
+ }
727
+ if (conflicts.length === 0) return;
728
+ throw new Error(`Conflicts detected — the following target paths are already occupied:\n` + conflicts.map((conflict) => ` ${conflict.targetPath} (${conflict.reason})`).join("\n") + "\n Remove or move the conflicting files, then run add again.");
729
+ }
730
+ async function targetConflictFor(artifact, targetPath) {
731
+ let targetStat;
732
+ try {
733
+ targetStat = await lstat(targetPath);
734
+ } catch {
735
+ return;
736
+ }
737
+ if (!targetStat.isSymbolicLink()) return {
738
+ targetPath,
739
+ reason: "real-file"
740
+ };
741
+ const existing = await readlink(targetPath);
742
+ if (path.resolve(path.dirname(targetPath), existing) === path.resolve(`.ai/${artifact}`)) return void 0;
743
+ return {
744
+ targetPath,
745
+ reason: "wrong-symlink"
746
+ };
747
+ }
748
+ const addCommand = new Command("add").description("Interactively add active AI Setup artifacts").argument("<package>", "Package to add, e.g. owner/repo, owner/repo@1.2.0, or .").action(async (pkg) => {
749
+ try {
750
+ await runAdd(pkg);
751
+ } catch (err) {
752
+ console.error(`✖ ${err.message}`);
753
+ process.exit(1);
754
+ }
755
+ });
756
+ //#endregion
757
+ //#region src/commands/remove.ts
758
+ async function runRemove(pkg) {
759
+ const aiJson = await readAiJson();
760
+ const packageKeys = pkg ? [pkg] : Object.keys(aiJson.packages);
761
+ if (packageKeys.length === 0) throw new Error("No AI Setup artifacts are installed.");
762
+ for (const packageKey of packageKeys) if (!aiJson.packages[packageKey]) throw new Error(`Package "${packageKey}" is not installed.\n Check installed packages in .ai/ai.json`);
763
+ const choices = packageKeys.flatMap((packageKey) => aiJson.packages[packageKey].linked.map((artifact) => ({
764
+ value: {
765
+ packageKey,
766
+ artifact
767
+ },
768
+ name: `${packageKey} / ${categoryForArtifact(artifact)} / ${artifact}`,
769
+ checked: false
770
+ })));
771
+ if (choices.length === 0) {
772
+ for (const packageKey of packageKeys) removePackage(aiJson, packageKey);
773
+ await writeAiJson(aiJson);
774
+ console.log("No linked files found.");
775
+ return;
776
+ }
777
+ const selected = await checkbox({
778
+ message: "Select files to remove:",
779
+ choices
780
+ });
781
+ if (selected.length === 0) {
782
+ console.log("No files selected.");
783
+ return;
784
+ }
785
+ const selectedByPackage = /* @__PURE__ */ new Map();
786
+ for (const { packageKey, artifact } of selected) {
787
+ const artifacts = selectedByPackage.get(packageKey) ?? /* @__PURE__ */ new Set();
788
+ artifacts.add(artifact);
789
+ selectedByPackage.set(packageKey, artifacts);
790
+ }
791
+ let symlinkCount = 0;
792
+ let sourceCount = 0;
793
+ for (const [packageKey, artifacts] of selectedByPackage) {
794
+ const isLocal = packageKey === ".";
795
+ const targetPaths = [...artifacts].flatMap((artifact) => targetPathsForArtifact(artifact));
796
+ await unlinkFiles([...new Set(targetPaths)]);
797
+ symlinkCount += targetPaths.length;
798
+ if (!isLocal) for (const artifact of artifacts) {
799
+ await rm(`.ai/${artifact}`, {
800
+ recursive: true,
801
+ force: true
802
+ });
803
+ sourceCount += 1;
804
+ }
805
+ const entry = aiJson.packages[packageKey];
806
+ entry.linked = entry.linked.filter((artifact) => !artifacts.has(artifact));
807
+ if (entry.linked.length === 0) removePackage(aiJson, packageKey);
808
+ }
809
+ await writeAiJson(aiJson);
810
+ console.log(`\nRemoved ${selected.length} file(s), ${symlinkCount} symlink target(s), and ${sourceCount} source file(s).`);
811
+ }
812
+ function categoryForArtifact(artifact) {
813
+ if (artifact === "AGENTS.md" || artifact === "CLAUDE.md") return "Project Instruction Files";
814
+ if (artifact.startsWith("skills/")) return "Skills";
815
+ if (artifact.includes("/commands/")) return "Custom Commands";
816
+ if (artifact.includes("/agents/")) return "Agents";
817
+ if (artifact.includes("/hooks/")) return "Hooks";
818
+ return "Other";
819
+ }
820
+ const removeCommand = new Command("remove").description("Interactively remove active AI Setup artifacts").argument("[package]", "Optional package to remove from, e.g. owner/repo or .").action(async (pkg) => {
821
+ try {
822
+ await runRemove(pkg);
823
+ } catch (err) {
824
+ console.error(`✖ ${err.message}`);
825
+ process.exit(1);
826
+ }
827
+ });
828
+ //#endregion
829
+ //#region src/commands/update.ts
830
+ async function runUpdate(pkg) {
831
+ const { owner, repo, tag } = parseExactPackageRef(pkg);
832
+ const packageKey = `${owner}/${repo}`;
833
+ const providers = Object.keys(PROVIDER_REGISTRY);
834
+ const aiJson = await readAiJson();
835
+ const entry = aiJson.packages[packageKey];
836
+ if (!entry) throw new Error(`Package "${packageKey}" is not installed.\n Install it first with: airig add <owner/repo>[@version]`);
837
+ const { tag: resolvedTag, assetDownloadUrl, immutable } = await fetchReleaseInfo(owner, repo, tag, new Octokit({ auth: process.env.GITHUB_TOKEN }));
838
+ if (!immutable) throw new Error(`Security restriction: release ${resolvedTag} of ${owner}/${repo} is not immutable.\n Updating from mutable releases is unsafe — assets can be swapped after you review them.
839
+ Ask the package author to enable immutable releases in their repo settings.`);
840
+ console.log(` Downloading ${owner}/${repo}@${resolvedTag}...`);
841
+ await withExtractedReleaseAi(await downloadAsset(assetDownloadUrl), "airig-update-", async (extractedAiDir) => {
842
+ const newArtifacts = await listArtifacts(extractedAiDir, providers);
843
+ const newArtifactSet = new Set(newArtifacts);
844
+ const previousVersion = entry.version;
845
+ const previousLinked = [...entry.linked];
846
+ const prunedLinked = previousLinked.filter((artifact) => newArtifactSet.has(artifact));
847
+ const deletedLinked = previousLinked.filter((artifact) => !newArtifactSet.has(artifact));
848
+ await copyReleaseArtifactsToLocal(extractedAiDir, prunedLinked);
849
+ const targetsToUnlink = /* @__PURE__ */ new Set();
850
+ for (const artifact of deletedLinked) {
851
+ await rm(`.ai/${artifact}`, {
852
+ recursive: true,
853
+ force: true
854
+ });
855
+ for (const targetPath of targetPathsForArtifact(artifact, providers)) targetsToUnlink.add(targetPath);
856
+ }
857
+ await unlinkFiles([...targetsToUnlink]);
858
+ entry.version = resolvedTag;
859
+ entry.linked = prunedLinked;
860
+ await reconcilePackageLinks(aiJson, packageKey, providers, prunedLinked, prunedLinked);
861
+ await writeAiJson(aiJson);
862
+ console.log(`\nUpdated ${owner}/${repo} from ${previousVersion} to ${resolvedTag} (${prunedLinked.length} active file(s) refreshed, ${deletedLinked.length} pruned).`);
863
+ });
864
+ }
865
+ const updateCommand = new Command("update").description("Refresh an installed Setup Release at an exact immutable version").argument("<package>", "Package to update, e.g. owner/repo@1.2.0").action(async (pkg) => {
866
+ try {
867
+ await runUpdate(pkg);
868
+ } catch (err) {
869
+ console.error(`✖ ${err.message}`);
870
+ process.exit(1);
871
+ }
872
+ });
873
+ //#endregion
874
+ //#region src/index.ts
875
+ const program = new Command("airig").description("Manage project-scoped AI Setup artifacts");
876
+ program.addCommand(publishCommand);
877
+ program.addCommand(addCommand);
878
+ program.addCommand(updateCommand);
879
+ program.addCommand(removeCommand);
880
+ program.parse();
881
+ //#endregion
882
+ export {};
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@airig/cli",
3
+ "version": "0.0.1",
4
+ "description": "Distribute and manage AI setups across providers",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/jd-solanki/airig.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/jd-solanki/airig/issues"
13
+ },
14
+ "homepage": "https://github.com/jd-solanki/airig#readme",
15
+ "packageManager": "pnpm@10.19.0",
16
+ "engines": {
17
+ "node": "^24.11.0"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "bin": {
26
+ "airig": "./dist/index.js"
27
+ },
28
+ "main": "./dist/index.js",
29
+ "scripts": {
30
+ "build": "tsdown",
31
+ "dev": "tsdown --watch",
32
+ "release": "bumpp",
33
+ "test": "vitest run",
34
+ "test:watch": "vitest",
35
+ "pkg:link": "pnpm link --global",
36
+ "pkg:unlink": "pnpm unlink --global airig"
37
+ },
38
+ "dependencies": {
39
+ "@inquirer/prompts": "^7.5.2",
40
+ "@octokit/rest": "^21.1.1",
41
+ "archiver": "^7.0.1",
42
+ "commander": "^14.0.0",
43
+ "extract-zip": "^2.0.1"
44
+ },
45
+ "devDependencies": {
46
+ "@types/archiver": "^6.0.3",
47
+ "@types/node": "^22.15.21",
48
+ "bumpp": "^11.1.0",
49
+ "tsdown": "^0.12.6",
50
+ "typescript": "^5.8.3",
51
+ "vitest": "^3.2.2"
52
+ }
53
+ }