@ardenthq/airc 0.1.0 → 0.1.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 (2) hide show
  1. package/dist/cli.js +546 -0
  2. package/package.json +11 -17
package/dist/cli.js ADDED
@@ -0,0 +1,546 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/run.ts
4
+ import { parseArgs } from "util";
5
+
6
+ // src/guidelines-repository.ts
7
+ import { existsSync, readdirSync, readFileSync, statSync } from "fs";
8
+ import { fileURLToPath } from "url";
9
+ import { join } from "path";
10
+ import { parse } from "yaml";
11
+ var GuidelinesRepository = class _GuidelinesRepository {
12
+ constructor(guidelinesPath, version = "dev") {
13
+ this.guidelinesPath = guidelinesPath;
14
+ this.version = version;
15
+ if (!existsSync(guidelinesPath) || !statSync(guidelinesPath).isDirectory()) {
16
+ throw new Error(`Guidelines directory not found: ${guidelinesPath}`);
17
+ }
18
+ }
19
+ guidelinesPath;
20
+ version;
21
+ /** Resolve the guidelines directory bundled with the package. */
22
+ static default() {
23
+ return new _GuidelinesRepository(
24
+ fileURLToPath(new URL("../guidelines", import.meta.url)),
25
+ readPackageVersion()
26
+ );
27
+ }
28
+ /** Guideline names without extension, sorted, e.g. ["core", "js", "php"]. */
29
+ names() {
30
+ return readdirSync(this.guidelinesPath).filter((file) => file.endsWith(".md")).map((file) => file.slice(0, -".md".length)).sort();
31
+ }
32
+ has(name) {
33
+ return existsSync(this.path(name));
34
+ }
35
+ read(name) {
36
+ if (!this.has(name)) {
37
+ throw new Error(`Guideline not found: ${name}`);
38
+ }
39
+ return readFileSync(this.path(name), "utf8").replaceAll("{{VERSION}}", this.version);
40
+ }
41
+ path(name) {
42
+ return join(this.guidelinesPath, `${name}.md`);
43
+ }
44
+ recommendations() {
45
+ const file = join(this.guidelinesPath, "recommended.yaml");
46
+ if (!existsSync(file)) {
47
+ return { skills: [], composer: [], npm: [] };
48
+ }
49
+ const parsed = parse(readFileSync(file, "utf8")) ?? {};
50
+ return {
51
+ skills: parsed.skills ?? [],
52
+ composer: parsed.composer ?? [],
53
+ npm: parsed.npm ?? []
54
+ };
55
+ }
56
+ };
57
+ function readPackageVersion() {
58
+ try {
59
+ const pkg = JSON.parse(
60
+ readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")
61
+ );
62
+ return typeof pkg.version === "string" ? pkg.version : "dev";
63
+ } catch {
64
+ return "dev";
65
+ }
66
+ }
67
+
68
+ // src/claude-md-editor.ts
69
+ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync } from "fs";
70
+ import { join as join2 } from "path";
71
+ var ClaudeMdEditor = class _ClaudeMdEditor {
72
+ constructor(repoPath) {
73
+ this.repoPath = repoPath;
74
+ }
75
+ repoPath;
76
+ static POINTER = "<!-- Project-specific instructions go below. -->";
77
+ path() {
78
+ return join2(this.repoPath, "CLAUDE.md");
79
+ }
80
+ exists() {
81
+ return existsSync2(this.path());
82
+ }
83
+ importLine(name) {
84
+ return `@.claude/ardenthq/${name}.md`;
85
+ }
86
+ /** Ensure an import exists for each guideline. Returns the names newly added. */
87
+ ensureImports(names) {
88
+ if (!this.exists()) {
89
+ this.create(names);
90
+ return names;
91
+ }
92
+ const contents = readFileSync2(this.path(), "utf8");
93
+ const missing = names.filter((name) => !this.hasImport(contents, name));
94
+ if (missing.length === 0) {
95
+ return [];
96
+ }
97
+ const block = missing.map((name) => this.importLine(name)).join("\n");
98
+ writeFileSync(this.path(), `${block}
99
+ ${contents}`);
100
+ return missing;
101
+ }
102
+ /** Names whose import line is absent (all of them when CLAUDE.md is missing). */
103
+ missingImports(names) {
104
+ if (!this.exists()) {
105
+ return names;
106
+ }
107
+ const contents = readFileSync2(this.path(), "utf8");
108
+ return names.filter((name) => !this.hasImport(contents, name));
109
+ }
110
+ hasImport(contents, name) {
111
+ const line = this.importLine(name);
112
+ return contents.split(/\r?\n/).some((row) => row.trim() === line);
113
+ }
114
+ create(names) {
115
+ const imports = names.map((name) => this.importLine(name)).join("\n");
116
+ writeFileSync(this.path(), `${imports}
117
+
118
+ ${_ClaudeMdEditor.POINTER}
119
+ `);
120
+ }
121
+ };
122
+
123
+ // src/gitignore-editor.ts
124
+ import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
125
+ import { join as join3 } from "path";
126
+ var GitignoreEditor = class _GitignoreEditor {
127
+ constructor(repoPath) {
128
+ this.repoPath = repoPath;
129
+ }
130
+ repoPath;
131
+ static HEADER = "# airc";
132
+ path() {
133
+ return join3(this.repoPath, ".gitignore");
134
+ }
135
+ /** Add the entry if missing. Returns true when the file was modified. */
136
+ ensure(entry) {
137
+ if (!existsSync3(this.path())) {
138
+ writeFileSync2(this.path(), `${_GitignoreEditor.HEADER}
139
+ ${entry}
140
+ `);
141
+ return true;
142
+ }
143
+ const contents = readFileSync3(this.path(), "utf8");
144
+ if (this.has(contents, entry)) {
145
+ return false;
146
+ }
147
+ const separator = contents === "" || contents.endsWith("\n") ? "" : "\n";
148
+ writeFileSync2(this.path(), `${contents}${separator}
149
+ ${_GitignoreEditor.HEADER}
150
+ ${entry}
151
+ `);
152
+ return true;
153
+ }
154
+ has(contents, entry) {
155
+ return contents.split(/\r?\n/).some((row) => row.trim() === entry);
156
+ }
157
+ };
158
+
159
+ // src/guideline-selector.ts
160
+ var GuidelineSelector = class _GuidelineSelector {
161
+ constructor(guidelines) {
162
+ this.guidelines = guidelines;
163
+ }
164
+ guidelines;
165
+ static STACK_GUIDELINES = {
166
+ php: ["php"],
167
+ js: ["js"]
168
+ };
169
+ /** Guideline names that exist, `core` first. */
170
+ for(stacks) {
171
+ const selected = ["core"];
172
+ for (const stack of stacks) {
173
+ selected.push(..._GuidelineSelector.STACK_GUIDELINES[stack] ?? []);
174
+ }
175
+ return [...new Set(selected)].filter((name) => this.guidelines.has(name));
176
+ }
177
+ };
178
+
179
+ // src/guideline-writer.ts
180
+ import { existsSync as existsSync4, mkdirSync, readFileSync as readFileSync4, readdirSync as readdirSync2, writeFileSync as writeFileSync3 } from "fs";
181
+ import { basename, join as join4 } from "path";
182
+ var GuidelineWriter = class _GuidelineWriter {
183
+ constructor(guidelines, repoPath) {
184
+ this.guidelines = guidelines;
185
+ this.repoPath = repoPath;
186
+ }
187
+ guidelines;
188
+ repoPath;
189
+ static RELATIVE_DIR = ".claude/ardenthq";
190
+ directory() {
191
+ return join4(this.repoPath, _GuidelineWriter.RELATIVE_DIR);
192
+ }
193
+ write(names) {
194
+ mkdirSync(this.directory(), { recursive: true });
195
+ const result2 = { created: [], updated: [], unchanged: [], stale: [] };
196
+ for (const name of names) {
197
+ result2[this.writeOne(name)].push(name);
198
+ }
199
+ result2.stale = this.staleFiles(names);
200
+ return result2;
201
+ }
202
+ /** Read-only comparison of selected guidelines against what's on disk. */
203
+ diff(names) {
204
+ const missing = [];
205
+ const outdated = [];
206
+ for (const name of names) {
207
+ const target = join4(this.directory(), `${name}.md`);
208
+ if (!existsSync4(target)) {
209
+ missing.push(name);
210
+ } else if (readFileSync4(target, "utf8") !== this.guidelines.read(name)) {
211
+ outdated.push(name);
212
+ }
213
+ }
214
+ return { missing, outdated };
215
+ }
216
+ writeOne(name) {
217
+ const target = join4(this.directory(), `${name}.md`);
218
+ const contents = this.guidelines.read(name);
219
+ if (!existsSync4(target)) {
220
+ writeFileSync3(target, contents);
221
+ return "created";
222
+ }
223
+ if (readFileSync4(target, "utf8") === contents) {
224
+ return "unchanged";
225
+ }
226
+ writeFileSync3(target, contents);
227
+ return "updated";
228
+ }
229
+ staleFiles(written) {
230
+ return readdirSync2(this.directory()).filter((file) => file.endsWith(".md")).map((file) => basename(file, ".md")).filter((name) => !written.includes(name)).sort();
231
+ }
232
+ };
233
+
234
+ // src/recommendations.ts
235
+ import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
236
+ import { homedir } from "os";
237
+ import { join as join5 } from "path";
238
+ var CHANNELS = ["skills", "composer", "npm"];
239
+ var Recommendations = class {
240
+ constructor(guidelines, repoPath) {
241
+ this.guidelines = guidelines;
242
+ this.repoPath = repoPath;
243
+ }
244
+ guidelines;
245
+ repoPath;
246
+ for(stacks) {
247
+ const all = this.guidelines.recommendations();
248
+ const result2 = { skills: [], composer: [], npm: [] };
249
+ for (const channel of CHANNELS) {
250
+ result2[channel] = all[channel].filter((entry) => this.shouldSuggest(entry, stacks));
251
+ }
252
+ return result2;
253
+ }
254
+ flat(stacks) {
255
+ const grouped = this.for(stacks);
256
+ return [...grouped.skills, ...grouped.composer, ...grouped.npm];
257
+ }
258
+ shouldSuggest(entry, stacks) {
259
+ return this.matchesStacks(entry, stacks) && !this.isInstalled(entry);
260
+ }
261
+ matchesStacks(entry, stacks) {
262
+ const required = entry.stacks ?? [];
263
+ return required.length === 0 || required.some((stack) => stacks.includes(stack));
264
+ }
265
+ isInstalled(entry) {
266
+ const detect = entry.detect;
267
+ if (!detect) {
268
+ return false;
269
+ }
270
+ if (detect.paths && this.anyPathExists(toList(detect.paths))) {
271
+ return true;
272
+ }
273
+ if (detect.composerJson && this.manifestHas("composer.json", toList(detect.composerJson))) {
274
+ return true;
275
+ }
276
+ return Boolean(detect.npmJson) && this.manifestHas("package.json", toList(detect.npmJson));
277
+ }
278
+ anyPathExists(paths) {
279
+ return paths.some((path) => existsSync5(expandHome(path)));
280
+ }
281
+ manifestHas(file, packages) {
282
+ const path = join5(this.repoPath, file);
283
+ if (!existsSync5(path)) {
284
+ return false;
285
+ }
286
+ let manifest;
287
+ try {
288
+ manifest = JSON.parse(readFileSync5(path, "utf8"));
289
+ } catch {
290
+ return false;
291
+ }
292
+ const installed = /* @__PURE__ */ new Set();
293
+ for (const section of ["require", "require-dev", "dependencies", "devDependencies"]) {
294
+ const deps = manifest[section];
295
+ if (typeof deps === "object" && deps !== null) {
296
+ for (const name of Object.keys(deps)) {
297
+ installed.add(name);
298
+ }
299
+ }
300
+ }
301
+ return packages.some((pkg) => installed.has(pkg));
302
+ }
303
+ };
304
+ function toList(value) {
305
+ if (typeof value === "string") {
306
+ return [value];
307
+ }
308
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
309
+ }
310
+ function expandHome(path) {
311
+ if (!path.startsWith("~/")) {
312
+ return path;
313
+ }
314
+ return join5(process.env.HOME ?? homedir(), path.slice(2));
315
+ }
316
+
317
+ // src/stack-detector.ts
318
+ import { existsSync as existsSync6, readFileSync as readFileSync6 } from "fs";
319
+ import { join as join6 } from "path";
320
+ var StackDetector = class {
321
+ constructor(repoPath) {
322
+ this.repoPath = repoPath;
323
+ }
324
+ repoPath;
325
+ /** e.g. ["php", "laravel"] or ["js"]. */
326
+ stacks() {
327
+ return [...this.phpStacks(), ...this.jsStacks()];
328
+ }
329
+ has(stack) {
330
+ return this.stacks().includes(stack);
331
+ }
332
+ phpStacks() {
333
+ const composer = this.readJson("composer.json");
334
+ if (composer === null) {
335
+ return [];
336
+ }
337
+ const stacks = ["php"];
338
+ if (this.requires(composer, "laravel/framework")) {
339
+ stacks.push("laravel");
340
+ }
341
+ return stacks;
342
+ }
343
+ jsStacks() {
344
+ return this.readJson("package.json") === null ? [] : ["js"];
345
+ }
346
+ requires(manifest, pkg) {
347
+ return ["require", "require-dev", "dependencies", "devDependencies"].some((section) => {
348
+ const deps = manifest[section];
349
+ return typeof deps === "object" && deps !== null && pkg in deps;
350
+ });
351
+ }
352
+ readJson(file) {
353
+ const path = join6(this.repoPath, file);
354
+ if (!existsSync6(path)) {
355
+ return null;
356
+ }
357
+ try {
358
+ const parsed = JSON.parse(readFileSync6(path, "utf8"));
359
+ return typeof parsed === "object" && parsed !== null ? parsed : null;
360
+ } catch {
361
+ return null;
362
+ }
363
+ }
364
+ };
365
+
366
+ // src/installer.ts
367
+ var NO_RECOMMENDATIONS = { skills: [], composer: [], npm: [] };
368
+ var Installer = class {
369
+ detector;
370
+ selector;
371
+ writer;
372
+ claudeMd;
373
+ gitignore;
374
+ recommendations;
375
+ constructor(guidelines, repoPath) {
376
+ this.detector = new StackDetector(repoPath);
377
+ this.selector = new GuidelineSelector(guidelines);
378
+ this.writer = new GuidelineWriter(guidelines, repoPath);
379
+ this.claudeMd = new ClaudeMdEditor(repoPath);
380
+ this.gitignore = new GitignoreEditor(repoPath);
381
+ this.recommendations = new Recommendations(guidelines, repoPath);
382
+ }
383
+ install(manageGitignore = true) {
384
+ const stacks = this.detector.stacks();
385
+ const names = this.selector.for(stacks);
386
+ const guidelines = this.writer.write(names);
387
+ const claudeMdExisted = this.claudeMd.exists();
388
+ const importsAdded = this.claudeMd.ensureImports(names);
389
+ const gitignoreModified = manageGitignore && this.gitignore.ensure("CLAUDE.local.md");
390
+ return {
391
+ stacks,
392
+ guidelines,
393
+ importsAdded,
394
+ claudeMdCreated: !claudeMdExisted,
395
+ gitignoreModified,
396
+ recommendations: this.recommendations.for(stacks)
397
+ };
398
+ }
399
+ /** Read-only drift check, for CI. Reports what install/update would change. */
400
+ check() {
401
+ const stacks = this.detector.stacks();
402
+ const names = this.selector.for(stacks);
403
+ const { missing, outdated } = this.writer.diff(names);
404
+ const missingImports = this.claudeMd.missingImports(names);
405
+ return {
406
+ stacks,
407
+ missing,
408
+ outdated,
409
+ missingImports,
410
+ inSync: missing.length === 0 && outdated.length === 0 && missingImports.length === 0
411
+ };
412
+ }
413
+ /** Recurring sync. Rewrites managed guideline files only. */
414
+ update() {
415
+ const stacks = this.detector.stacks();
416
+ const names = this.selector.for(stacks);
417
+ return {
418
+ stacks,
419
+ guidelines: this.writer.write(names),
420
+ importsAdded: [],
421
+ claudeMdCreated: false,
422
+ gitignoreModified: false,
423
+ recommendations: NO_RECOMMENDATIONS
424
+ };
425
+ }
426
+ };
427
+ function flatRecommendations(result2) {
428
+ return [
429
+ ...result2.recommendations.skills,
430
+ ...result2.recommendations.composer,
431
+ ...result2.recommendations.npm
432
+ ];
433
+ }
434
+
435
+ // src/output-renderer.ts
436
+ function render(result2, showRecommendations = true) {
437
+ const lines = [...summary(result2), ...showRecommendations ? recommendations(result2) : []];
438
+ return `${lines.join("\n")}
439
+ `;
440
+ }
441
+ function renderCheck(result2) {
442
+ if (result2.inSync) {
443
+ return "\u2713 airc: guidelines in sync\n";
444
+ }
445
+ const lines = ["\u2717 airc: out of sync"];
446
+ if (result2.missing.length > 0) {
447
+ lines.push(` missing: ${fileList(result2.missing)}`);
448
+ }
449
+ if (result2.outdated.length > 0) {
450
+ lines.push(` outdated: ${fileList(result2.outdated)}`);
451
+ }
452
+ if (result2.missingImports.length > 0) {
453
+ lines.push(` missing imports: ${result2.missingImports.join(", ")}`);
454
+ }
455
+ lines.push("", "Run `npx @ardenthq/airc install` to fix.");
456
+ return `${lines.join("\n")}
457
+ `;
458
+ }
459
+ function summary(result2) {
460
+ const lines = ["\u2713 airc"];
461
+ const written = [...result2.guidelines.created, ...result2.guidelines.updated];
462
+ if (written.length > 0) {
463
+ lines.push(` wrote ${fileList(written)}`);
464
+ } else if (result2.guidelines.unchanged.length > 0) {
465
+ lines.push(` up to date (${fileList(result2.guidelines.unchanged)})`);
466
+ }
467
+ if (result2.claudeMdCreated) {
468
+ lines.push(" created CLAUDE.md with imports");
469
+ } else if (result2.importsAdded.length > 0) {
470
+ lines.push(` added ${result2.importsAdded.length} import(s) to CLAUDE.md`);
471
+ }
472
+ if (result2.gitignoreModified) {
473
+ lines.push(" added CLAUDE.local.md to .gitignore");
474
+ }
475
+ if (result2.guidelines.stale.length > 0) {
476
+ lines.push(` stale (no longer selected, not removed): ${fileList(result2.guidelines.stale)}`);
477
+ }
478
+ return lines;
479
+ }
480
+ function recommendations(result2) {
481
+ const entries = flatRecommendations(result2);
482
+ if (entries.length === 0) {
483
+ return [];
484
+ }
485
+ const lines = ["", "Recommended (not yet installed):"];
486
+ for (const entry of entries) {
487
+ lines.push(` ${entry.name}${entry.description ? ` \u2014 ${entry.description}` : ""}`);
488
+ if (entry.install) {
489
+ lines.push(` ${entry.install}`);
490
+ }
491
+ }
492
+ lines.push("", "Skip recommendations: export AIRC_QUIET=1");
493
+ return lines;
494
+ }
495
+ function fileList(names) {
496
+ return names.map((name) => `${name}.md`).join(", ");
497
+ }
498
+
499
+ // src/run.ts
500
+ var USAGE = `airc \u2014 shared Claude conventions
501
+
502
+ Usage:
503
+ airc install [--path <dir>] [--no-gitignore]
504
+ airc update [--path <dir>]
505
+ airc check [--path <dir>]
506
+ `;
507
+ function run(argv, options = {}) {
508
+ let parsed;
509
+ try {
510
+ parsed = parseArgs({
511
+ args: argv,
512
+ allowPositionals: true,
513
+ options: {
514
+ path: { type: "string" },
515
+ "no-gitignore": { type: "boolean", default: false }
516
+ }
517
+ });
518
+ } catch (error) {
519
+ return { code: 1, stdout: `${error.message}
520
+
521
+ ${USAGE}` };
522
+ }
523
+ const command = parsed.positionals[0];
524
+ const repoPath = parsed.values.path ?? options.cwd ?? process.cwd();
525
+ const guidelines = options.guidelines ?? GuidelinesRepository.default();
526
+ const installer = new Installer(guidelines, repoPath);
527
+ if (command === "install") {
528
+ const result2 = installer.install(!parsed.values["no-gitignore"]);
529
+ return { code: 0, stdout: render(result2, !options.quiet) };
530
+ }
531
+ if (command === "update") {
532
+ return { code: 0, stdout: render(installer.update(), false) };
533
+ }
534
+ if (command === "check") {
535
+ const result2 = installer.check();
536
+ return { code: result2.inSync ? 0 : 1, stdout: renderCheck(result2) };
537
+ }
538
+ return { code: command ? 1 : 0, stdout: USAGE };
539
+ }
540
+
541
+ // src/cli.ts
542
+ var result = run(process.argv.slice(2), {
543
+ quiet: Boolean(process.env.AIRC_QUIET)
544
+ });
545
+ process.stdout.write(result.stdout);
546
+ process.exit(result.code);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ardenthq/airc",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Shared Claude conventions for Ark/ArdentHQ repos — writes guideline files into a consumer repo's .claude/ardenthq/ and wires up the CLAUDE.md imports.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,24 +14,9 @@
14
14
  "engines": {
15
15
  "node": ">=18"
16
16
  },
17
- "packageManager": "pnpm@10.0.0",
18
- "scripts": {
19
- "build": "tsup",
20
- "format": "prettier --write \"**/*.{md,yaml,yml,json}\"",
21
- "format:check": "prettier --check \"**/*.{md,yaml,yml,json}\"",
22
- "lint": "eslint .",
23
- "typecheck": "tsc --noEmit",
24
- "test": "vitest run",
25
- "test:coverage": "vitest run --coverage"
26
- },
27
17
  "dependencies": {
28
18
  "yaml": "^2.6.1"
29
19
  },
30
- "pnpm": {
31
- "onlyBuiltDependencies": [
32
- "esbuild"
33
- ]
34
- },
35
20
  "devDependencies": {
36
21
  "@eslint/js": "^9.17.0",
37
22
  "@types/node": "^22.10.2",
@@ -42,5 +27,14 @@
42
27
  "typescript": "^5.7.2",
43
28
  "typescript-eslint": "^8.18.1",
44
29
  "vitest": "^3.2.4"
30
+ },
31
+ "scripts": {
32
+ "build": "tsup",
33
+ "format": "prettier --write \"**/*.{md,yaml,yml,json}\"",
34
+ "format:check": "prettier --check \"**/*.{md,yaml,yml,json}\"",
35
+ "lint": "eslint .",
36
+ "typecheck": "tsc --noEmit",
37
+ "test": "vitest run",
38
+ "test:coverage": "vitest run --coverage"
45
39
  }
46
- }
40
+ }