@neat.is/core 0.7.9 → 0.7.10

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/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  resolveHost,
7
7
  resolveNeatVersion,
8
8
  writeDaemonRecord
9
- } from "./chunk-ILG3SMD5.js";
9
+ } from "./chunk-D4OX6MUX.js";
10
10
  import {
11
11
  buildSearchIndex
12
12
  } from "./chunk-BC53SCT7.js";
@@ -74,7 +74,7 @@ import {
74
74
  startStalenessLoop,
75
75
  upsertConnectorEntry,
76
76
  validateConnectorEntry
77
- } from "./chunk-N5TPODCX.js";
77
+ } from "./chunk-6T7ZHODF.js";
78
78
  import {
79
79
  startOtelGrpcReceiver
80
80
  } from "./chunk-FCO5Z3RW.js";
@@ -88,9 +88,9 @@ import {
88
88
  } from "./chunk-UUYCTH2E.js";
89
89
 
90
90
  // src/cli.ts
91
- import path13 from "path";
91
+ import path15 from "path";
92
92
  import os4 from "os";
93
- import { promises as fs11 } from "fs";
93
+ import { promises as fs13 } from "fs";
94
94
 
95
95
  // src/banner.ts
96
96
  import path from "path";
@@ -487,6 +487,9 @@ async function startWatch(graph, opts) {
487
487
  project: projectName,
488
488
  graph,
489
489
  projectDir: opts.scanPath,
490
+ // Incident ledger for an incident-emitting connector (ADR-185), same path
491
+ // `neat watch`'s own error-span writer already uses.
492
+ errorsPath: opts.errorsPath,
490
493
  ...opts.neatHome ? { home: opts.neatHome } : {},
491
494
  onSkip: (skipped, reason) => console.warn(
492
495
  `neat watch: connector "${skipped.id}" (${skipped.provider}) skipped for project "${projectName}" \u2014 ${reason}`
@@ -3107,13 +3110,516 @@ async function apply3(installPlan) {
3107
3110
  await fs6.writeFile(generated.file, generated.contents, "utf8");
3108
3111
  writtenFiles.push(generated.file);
3109
3112
  }
3110
- return { serviceDir: installPlan.serviceDir, outcome: writtenFiles.length ? "instrumented" : "already-instrumented", writtenFiles };
3113
+ const wroteManifest = writtenFiles.some((f) => f.split(/[\\/]/).pop() === "go.mod");
3114
+ return {
3115
+ serviceDir: installPlan.serviceDir,
3116
+ outcome: writtenFiles.length ? "instrumented" : "already-instrumented",
3117
+ writtenFiles,
3118
+ ...wroteManifest ? { followUpInstall: "go mod download" } : {}
3119
+ };
3111
3120
  }
3112
3121
  var goInstaller = { name: "go", detect: detect3, plan: plan3, apply: apply3 };
3113
3122
 
3123
+ // src/installers/ruby.ts
3124
+ import { promises as fs7 } from "fs";
3125
+ import path8 from "path";
3126
+ var RUBY_MARKERS = [
3127
+ "Gemfile",
3128
+ "Gemfile.lock"
3129
+ ];
3130
+ var RUBY_GEMS = [
3131
+ { name: "opentelemetry-sdk", version: "~> 1.5" },
3132
+ { name: "opentelemetry-exporter-otlp", version: "~> 0.29" },
3133
+ { name: "opentelemetry-instrumentation-all", version: "~> 0.62" }
3134
+ ];
3135
+ var NEAT_OTEL_STAMP2 = "neat-otel-init v1";
3136
+ var INITIALIZER_REL = path8.join("config", "initializers", "neat_otel.rb");
3137
+ function neatOtelRb(opts = {}) {
3138
+ const service = opts.project ?? "ruby-service";
3139
+ const endpoint = opts.project ? `http://localhost:4318/projects/${opts.project}/v1/traces` : "http://localhost:4318/v1/traces";
3140
+ return `# ${NEAT_OTEL_STAMP2} \u2014 generated by NEAT. Safe to re-generate; do not edit.
3141
+ # Rails auto-loads this at boot (config/initializers/*). It points the
3142
+ # OpenTelemetry SDK at your NEAT daemon, enables the Ruby auto-instrumentation
3143
+ # set, and installs a span processor that stamps code.file.path /
3144
+ # code.line.number / code.function.name on the CLIENT/PRODUCER spans your app
3145
+ # issues, so NEAT fuses each runtime span onto the source file that made the
3146
+ # call (docs/contracts/file-awareness.md). Absolute paths are emitted here;
3147
+ # ingest anchors them against the service root. If the OpenTelemetry gems are
3148
+ # not installed this file degrades to a no-op rather than breaking boot.
3149
+
3150
+ begin
3151
+ require 'opentelemetry/sdk'
3152
+ require 'opentelemetry/exporter/otlp'
3153
+ require 'opentelemetry/instrumentation/all'
3154
+ _neat_otel_loaded = true
3155
+ rescue LoadError
3156
+ _neat_otel_loaded = false
3157
+ end
3158
+
3159
+ if _neat_otel_loaded && ENV['NEAT_CALLSITE_DISABLED'] != '1'
3160
+ # Walk the Ruby call stack to the first application frame and stamp the stable
3161
+ # OTel source attributes on CLIENT/PRODUCER spans. SERVER spans are created
3162
+ # before the handler runs, so they stay route/service-grained, honestly.
3163
+ class NeatCallSiteSpanProcessor
3164
+ def initialize(root)
3165
+ @root = root.to_s.end_with?(File::SEPARATOR) ? root.to_s : root.to_s + File::SEPARATOR
3166
+ end
3167
+
3168
+ def on_start(span, _parent_context)
3169
+ kind = span.kind
3170
+ return unless kind == OpenTelemetry::Trace::SpanKind::CLIENT ||
3171
+ kind == OpenTelemetry::Trace::SpanKind::PRODUCER
3172
+ caller_locations(1).each do |loc|
3173
+ file = loc.absolute_path || loc.path
3174
+ next if file.nil?
3175
+ next unless file.start_with?(@root)
3176
+ next if file.include?('/vendor/') || file.include?('/.bundle/')
3177
+ next if file.end_with?('neat_otel.rb')
3178
+ span.set_attribute('code.file.path', file)
3179
+ span.set_attribute('code.line.number', loc.lineno)
3180
+ span.set_attribute('code.function.name', loc.label.to_s)
3181
+ break
3182
+ end
3183
+ rescue StandardError
3184
+ # never break the host application
3185
+ end
3186
+
3187
+ def on_finish(_span); end
3188
+
3189
+ def force_flush(timeout: nil)
3190
+ OpenTelemetry::SDK::Trace::Export::SUCCESS
3191
+ end
3192
+
3193
+ def shutdown(timeout: nil)
3194
+ OpenTelemetry::SDK::Trace::Export::SUCCESS
3195
+ end
3196
+ end
3197
+
3198
+ _neat_root = defined?(Rails) ? Rails.root.to_s : Dir.pwd
3199
+ _neat_service = ENV.fetch('OTEL_SERVICE_NAME', '${service}')
3200
+ _neat_endpoint = ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', '${endpoint}')
3201
+
3202
+ OpenTelemetry::SDK.configure do |c|
3203
+ c.service_name = _neat_service
3204
+ c.use_all
3205
+ c.add_span_processor(
3206
+ OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
3207
+ OpenTelemetry::Exporter::OTLP::Exporter.new(endpoint: _neat_endpoint)
3208
+ )
3209
+ )
3210
+ c.add_span_processor(NeatCallSiteSpanProcessor.new(_neat_root))
3211
+ end
3212
+ end
3213
+ `;
3214
+ }
3215
+ async function exists4(p) {
3216
+ try {
3217
+ await fs7.stat(p);
3218
+ return true;
3219
+ } catch {
3220
+ return false;
3221
+ }
3222
+ }
3223
+ async function detect4(serviceDir) {
3224
+ for (const marker of RUBY_MARKERS) {
3225
+ if (await exists4(path8.join(serviceDir, marker))) return true;
3226
+ }
3227
+ return false;
3228
+ }
3229
+ async function isRailsApp(serviceDir, gemfile) {
3230
+ if (gemfile && /^\s*gem\s+['"]rails['"]/m.test(gemfile)) return true;
3231
+ for (const marker of ["config/application.rb", "config/environment.rb", "bin/rails"]) {
3232
+ if (await exists4(path8.join(serviceDir, marker))) return true;
3233
+ }
3234
+ return false;
3235
+ }
3236
+ function gemPresent(gemfile, name) {
3237
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3238
+ return new RegExp(`^\\s*gem\\s+['"]${escaped}['"]`, "m").test(gemfile);
3239
+ }
3240
+ async function readGemfile(serviceDir) {
3241
+ const file = path8.join(serviceDir, "Gemfile");
3242
+ if (!await exists4(file)) return null;
3243
+ return { file, body: await fs7.readFile(file, "utf8") };
3244
+ }
3245
+ async function plan4(serviceDir, opts) {
3246
+ const empty = {
3247
+ language: "ruby",
3248
+ serviceDir,
3249
+ dependencyEdits: [],
3250
+ entrypointEdits: [],
3251
+ envEdits: []
3252
+ };
3253
+ const gemfile = await readGemfile(serviceDir);
3254
+ const dependencyEdits = [];
3255
+ if (gemfile) {
3256
+ for (const gem of RUBY_GEMS) {
3257
+ if (!gemPresent(gemfile.body, gem.name)) {
3258
+ dependencyEdits.push({ file: gemfile.file, kind: "add", name: gem.name, version: gem.version });
3259
+ }
3260
+ }
3261
+ }
3262
+ const rails = await isRailsApp(serviceDir, gemfile?.body ?? null);
3263
+ const initializer = path8.join(serviceDir, INITIALIZER_REL);
3264
+ const generatedFiles = [];
3265
+ if (rails && !await exists4(initializer)) {
3266
+ generatedFiles.push({
3267
+ file: initializer,
3268
+ contents: neatOtelRb({ project: opts?.project }),
3269
+ skipIfExists: true
3270
+ });
3271
+ }
3272
+ if (dependencyEdits.length === 0 && generatedFiles.length === 0) {
3273
+ return empty;
3274
+ }
3275
+ const envEdits = [
3276
+ { file: null, key: "OTEL_EXPORTER_OTLP_ENDPOINT", value: "http://localhost:4318" }
3277
+ ];
3278
+ return {
3279
+ language: "ruby",
3280
+ serviceDir,
3281
+ dependencyEdits,
3282
+ entrypointEdits: [],
3283
+ envEdits,
3284
+ ...generatedFiles.length > 0 ? { generatedFiles } : {}
3285
+ };
3286
+ }
3287
+ async function writeFileAtomic2(file, contents) {
3288
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
3289
+ await fs7.writeFile(tmp, contents, "utf8");
3290
+ await fs7.rename(tmp, file);
3291
+ }
3292
+ async function applyGemfile(file, edits, original) {
3293
+ const lines = edits.filter((e) => e.kind === "add").map((e) => `gem '${e.name}', '${e.version}'`);
3294
+ const banner = `
3295
+ # ${NEAT_OTEL_STAMP2} \u2014 OpenTelemetry gems added by NEAT
3296
+ `;
3297
+ const trailing = original.endsWith("\n") ? "" : "\n";
3298
+ await writeFileAtomic2(file, `${original}${trailing}${banner}${lines.join("\n")}
3299
+ `);
3300
+ }
3301
+ async function rollback3(serviceDir, language, originals, created) {
3302
+ const restored = [];
3303
+ for (const [file, raw] of originals.entries()) {
3304
+ try {
3305
+ await fs7.writeFile(file, raw, "utf8");
3306
+ restored.push(file);
3307
+ } catch {
3308
+ }
3309
+ }
3310
+ const removed = [];
3311
+ for (const file of created) {
3312
+ try {
3313
+ await fs7.rm(file, { force: true });
3314
+ removed.push(file);
3315
+ } catch {
3316
+ }
3317
+ }
3318
+ const body = [
3319
+ "# neat-rollback.patch",
3320
+ "",
3321
+ `# Generated after a partial apply failure in the ${language} installer.`,
3322
+ "# Files listed below were restored to their pre-apply contents.",
3323
+ "",
3324
+ ...restored.map((f) => `restored: ${f}`),
3325
+ ...removed.map((f) => `removed: ${f}`),
3326
+ ""
3327
+ ];
3328
+ await fs7.writeFile(path8.join(serviceDir, "neat-rollback.patch"), body.join("\n"), "utf8");
3329
+ }
3330
+ async function apply4(installPlan) {
3331
+ const { serviceDir } = installPlan;
3332
+ const generatedFiles = installPlan.generatedFiles ?? [];
3333
+ const manifests = new Set(installPlan.dependencyEdits.map((e) => e.file));
3334
+ if (manifests.size === 0 && generatedFiles.length === 0) {
3335
+ return { serviceDir, outcome: "already-instrumented", writtenFiles: [] };
3336
+ }
3337
+ const originals = /* @__PURE__ */ new Map();
3338
+ for (const file of manifests) {
3339
+ try {
3340
+ originals.set(file, await fs7.readFile(file, "utf8"));
3341
+ } catch {
3342
+ }
3343
+ }
3344
+ const writtenFiles = [];
3345
+ const created = [];
3346
+ try {
3347
+ for (const gf of generatedFiles) {
3348
+ if (await exists4(gf.file)) continue;
3349
+ await fs7.mkdir(path8.dirname(gf.file), { recursive: true });
3350
+ await writeFileAtomic2(gf.file, gf.contents);
3351
+ writtenFiles.push(gf.file);
3352
+ created.push(gf.file);
3353
+ }
3354
+ for (const file of manifests) {
3355
+ const raw = originals.get(file);
3356
+ if (raw === void 0) throw new Error(`ruby installer: cannot read ${file} during apply`);
3357
+ const edits = installPlan.dependencyEdits.filter((e) => e.file === file);
3358
+ if (edits.length > 0) {
3359
+ await applyGemfile(file, edits, raw);
3360
+ writtenFiles.push(file);
3361
+ }
3362
+ }
3363
+ } catch (err) {
3364
+ await rollback3(serviceDir, installPlan.language, originals, created);
3365
+ throw err;
3366
+ }
3367
+ const wroteManifest = writtenFiles.some((f) => path8.basename(f) === "Gemfile");
3368
+ return {
3369
+ serviceDir,
3370
+ outcome: writtenFiles.length > 0 ? "instrumented" : "already-instrumented",
3371
+ writtenFiles,
3372
+ ...wroteManifest ? { followUpInstall: "bundle install" } : {}
3373
+ };
3374
+ }
3375
+ var rubyInstaller = { name: "ruby", detect: detect4, plan: plan4, apply: apply4 };
3376
+
3377
+ // src/installers/php.ts
3378
+ import { promises as fs8 } from "fs";
3379
+ import path9 from "path";
3380
+ var PHP_MARKERS = [
3381
+ "composer.json",
3382
+ "composer.lock"
3383
+ ];
3384
+ var NEAT_OTEL_FILENAME2 = "neat_otel.php";
3385
+ var NEAT_OTEL_STAMP3 = "neat-otel-init v1";
3386
+ var LARAVEL_PACKAGE = "open-telemetry/opentelemetry-auto-laravel";
3387
+ var PHP_PACKAGES = [
3388
+ { name: "open-telemetry/sdk", version: "^1.0" },
3389
+ { name: "open-telemetry/exporter-otlp", version: "^1.0" },
3390
+ { name: "php-http/guzzle7-adapter", version: "^1.0" },
3391
+ { name: LARAVEL_PACKAGE, version: "^0.1" }
3392
+ ];
3393
+ var PHP_PECL_CAVEAT = "PHP auto-instrumentation requires the `opentelemetry` PECL extension (`pecl install opentelemetry`, then `extension=opentelemetry.so` in php.ini). NEAT cannot install a PECL extension via composer \u2014 until it is loaded no spans are produced. See neat_otel.php and ADR-186.";
3394
+ function neatOtelPhp(opts = {}) {
3395
+ const service = opts.project ?? "php-service";
3396
+ const endpoint = opts.project ? `http://localhost:4318/projects/${opts.project}/v1/traces` : "http://localhost:4318/v1/traces";
3397
+ return `<?php
3398
+ // ${NEAT_OTEL_STAMP3} \u2014 generated by NEAT. Safe to re-generate; do not edit.
3399
+ //
3400
+ // REQUIRED SYSTEM STEP \u2014 NEAT CANNOT DO THIS FOR YOU:
3401
+ // PHP OpenTelemetry auto-instrumentation needs the \`opentelemetry\` PECL
3402
+ // extension, a system-level install composer cannot provide:
3403
+ // pecl install opentelemetry
3404
+ // then enable it in your php.ini:
3405
+ // extension=opentelemetry.so
3406
+ // Verify with \`php -m | grep opentelemetry\`. Until the extension is loaded
3407
+ // the Laravel auto-instrumentation hooks never fire and no spans are emitted.
3408
+ //
3409
+ // Wire this file so it runs before the framework boots \u2014 either set
3410
+ // auto_prepend_file = /absolute/path/to/neat_otel.php
3411
+ // in php.ini / .user.ini, or require it at the very top of public/index.php and
3412
+ // artisan. It points the exporter at your NEAT daemon and turns on the SDK
3413
+ // autoloader; the auto-laravel instrumentation then produces route, DB, cache,
3414
+ // and queue spans that fuse onto your extracted routes and Eloquent tables.
3415
+ //
3416
+ // FILE-GRAIN (code.file.path call-site attribution) is a documented follow-up
3417
+ // for PHP \u2014 see ADR-186. Route, table, and service grain land now.
3418
+
3419
+ declare(strict_types=1);
3420
+
3421
+ // Degrade to a no-op when the extension isn't present, so a bare app still
3422
+ // boots (never break the host application).
3423
+ if (!extension_loaded('opentelemetry')) {
3424
+ return;
3425
+ }
3426
+
3427
+ // Point the exporter at NEAT unless the operator already set these. The
3428
+ // endpoint is NEAT's project-scoped traces path (ADR-183).
3429
+ $neat_defaults = [
3430
+ 'OTEL_PHP_AUTOLOAD_ENABLED' => 'true',
3431
+ 'OTEL_SERVICE_NAME' => '${service}',
3432
+ 'OTEL_TRACES_EXPORTER' => 'otlp',
3433
+ 'OTEL_EXPORTER_OTLP_PROTOCOL' => 'http/json',
3434
+ 'OTEL_EXPORTER_OTLP_TRACES_ENDPOINT' => '${endpoint}',
3435
+ 'OTEL_PROPAGATORS' => 'baggage,tracecontext',
3436
+ ];
3437
+ foreach ($neat_defaults as $neat_key => $neat_value) {
3438
+ if (getenv($neat_key) === false && !isset($_SERVER[$neat_key]) && !isset($_ENV[$neat_key])) {
3439
+ putenv($neat_key . '=' . $neat_value);
3440
+ $_SERVER[$neat_key] = $neat_value;
3441
+ $_ENV[$neat_key] = $neat_value;
3442
+ }
3443
+ }
3444
+ `;
3445
+ }
3446
+ async function exists5(p) {
3447
+ try {
3448
+ await fs8.stat(p);
3449
+ return true;
3450
+ } catch {
3451
+ return false;
3452
+ }
3453
+ }
3454
+ async function detect5(serviceDir) {
3455
+ for (const marker of PHP_MARKERS) {
3456
+ if (await exists5(path9.join(serviceDir, marker))) return true;
3457
+ }
3458
+ return false;
3459
+ }
3460
+ function readComposerObject(body) {
3461
+ let parsed = null;
3462
+ try {
3463
+ parsed = JSON.parse(body);
3464
+ } catch {
3465
+ parsed = null;
3466
+ }
3467
+ const obj = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
3468
+ const require2 = obj.require && typeof obj.require === "object" && !Array.isArray(obj.require) ? obj.require : {};
3469
+ const requireDev = obj["require-dev"] && typeof obj["require-dev"] === "object" && !Array.isArray(obj["require-dev"]) ? obj["require-dev"] : {};
3470
+ return { require: require2, requireDev };
3471
+ }
3472
+ async function plan5(serviceDir, opts) {
3473
+ const empty = {
3474
+ language: "php",
3475
+ serviceDir,
3476
+ dependencyEdits: [],
3477
+ entrypointEdits: [],
3478
+ envEdits: []
3479
+ };
3480
+ const composerPath = path9.join(serviceDir, "composer.json");
3481
+ const hasComposer = await exists5(composerPath);
3482
+ const dependencyEdits = [];
3483
+ if (hasComposer) {
3484
+ const body = await fs8.readFile(composerPath, "utf8");
3485
+ const { require: require2, requireDev } = readComposerObject(body);
3486
+ const laravel = "laravel/framework" in require2 || "laravel/framework" in requireDev || await exists5(path9.join(serviceDir, "artisan"));
3487
+ const wanted = laravel ? PHP_PACKAGES : PHP_PACKAGES.filter((p) => p.name !== LARAVEL_PACKAGE);
3488
+ for (const pkg of wanted) {
3489
+ if (!(pkg.name in require2)) {
3490
+ dependencyEdits.push({ file: composerPath, kind: "add", name: pkg.name, version: pkg.version });
3491
+ }
3492
+ }
3493
+ }
3494
+ const bootstrap = path9.join(serviceDir, NEAT_OTEL_FILENAME2);
3495
+ const generatedFiles = [];
3496
+ if (hasComposer && !await exists5(bootstrap)) {
3497
+ generatedFiles.push({ file: bootstrap, contents: neatOtelPhp({ project: opts?.project }), skipIfExists: true });
3498
+ }
3499
+ if (dependencyEdits.length === 0 && generatedFiles.length === 0) {
3500
+ return empty;
3501
+ }
3502
+ const envEdits = [
3503
+ { file: null, key: "OTEL_PHP_AUTOLOAD_ENABLED", value: "true" },
3504
+ { file: null, key: "OTEL_EXPORTER_OTLP_ENDPOINT", value: "http://localhost:4318" }
3505
+ ];
3506
+ return {
3507
+ language: "php",
3508
+ serviceDir,
3509
+ dependencyEdits,
3510
+ entrypointEdits: [],
3511
+ envEdits,
3512
+ ...generatedFiles.length > 0 ? { generatedFiles } : {}
3513
+ };
3514
+ }
3515
+ async function writeFileAtomic3(file, contents) {
3516
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
3517
+ await fs8.writeFile(tmp, contents, "utf8");
3518
+ await fs8.rename(tmp, file);
3519
+ }
3520
+ async function applyComposerJson(file, edits, original) {
3521
+ let parsed;
3522
+ try {
3523
+ parsed = JSON.parse(original);
3524
+ } catch {
3525
+ throw new Error(`php installer: composer.json at ${file} is not valid JSON`);
3526
+ }
3527
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
3528
+ throw new Error(`php installer: composer.json at ${file} is not a JSON object`);
3529
+ }
3530
+ const obj = parsed;
3531
+ const require2 = obj.require && typeof obj.require === "object" && !Array.isArray(obj.require) ? obj.require : {};
3532
+ for (const e of edits) {
3533
+ if (e.kind !== "add") continue;
3534
+ if (!(e.name in require2)) require2[e.name] = e.version;
3535
+ }
3536
+ obj.require = require2;
3537
+ await writeFileAtomic3(file, JSON.stringify(obj, null, 2) + "\n");
3538
+ }
3539
+ async function rollback4(serviceDir, language, originals, created) {
3540
+ const restored = [];
3541
+ for (const [file, raw] of originals.entries()) {
3542
+ try {
3543
+ await fs8.writeFile(file, raw, "utf8");
3544
+ restored.push(file);
3545
+ } catch {
3546
+ }
3547
+ }
3548
+ const removed = [];
3549
+ for (const file of created) {
3550
+ try {
3551
+ await fs8.rm(file, { force: true });
3552
+ removed.push(file);
3553
+ } catch {
3554
+ }
3555
+ }
3556
+ const body = [
3557
+ "# neat-rollback.patch",
3558
+ "",
3559
+ `# Generated after a partial apply failure in the ${language} installer.`,
3560
+ "# Files listed below were restored to their pre-apply contents.",
3561
+ "",
3562
+ ...restored.map((f) => `restored: ${f}`),
3563
+ ...removed.map((f) => `removed: ${f}`),
3564
+ ""
3565
+ ];
3566
+ await fs8.writeFile(path9.join(serviceDir, "neat-rollback.patch"), body.join("\n"), "utf8");
3567
+ }
3568
+ async function apply5(installPlan) {
3569
+ const { serviceDir } = installPlan;
3570
+ const generatedFiles = installPlan.generatedFiles ?? [];
3571
+ const manifests = new Set(installPlan.dependencyEdits.map((e) => e.file));
3572
+ if (manifests.size === 0 && generatedFiles.length === 0) {
3573
+ return { serviceDir, outcome: "already-instrumented", writtenFiles: [], reason: PHP_PECL_CAVEAT };
3574
+ }
3575
+ const originals = /* @__PURE__ */ new Map();
3576
+ for (const file of manifests) {
3577
+ try {
3578
+ originals.set(file, await fs8.readFile(file, "utf8"));
3579
+ } catch {
3580
+ }
3581
+ }
3582
+ const writtenFiles = [];
3583
+ const created = [];
3584
+ try {
3585
+ for (const gf of generatedFiles) {
3586
+ if (await exists5(gf.file)) continue;
3587
+ await fs8.mkdir(path9.dirname(gf.file), { recursive: true });
3588
+ await writeFileAtomic3(gf.file, gf.contents);
3589
+ writtenFiles.push(gf.file);
3590
+ created.push(gf.file);
3591
+ }
3592
+ for (const file of manifests) {
3593
+ const raw = originals.get(file);
3594
+ if (raw === void 0) throw new Error(`php installer: cannot read ${file} during apply`);
3595
+ const edits = installPlan.dependencyEdits.filter((e) => e.file === file);
3596
+ if (edits.length > 0) {
3597
+ await applyComposerJson(file, edits, raw);
3598
+ writtenFiles.push(file);
3599
+ }
3600
+ }
3601
+ } catch (err) {
3602
+ await rollback4(serviceDir, installPlan.language, originals, created);
3603
+ throw err;
3604
+ }
3605
+ if (writtenFiles.length > 0) {
3606
+ console.warn(`neat: PHP instrumentation staged in ${path9.basename(serviceDir)}, but a system step remains:
3607
+ ${PHP_PECL_CAVEAT}`);
3608
+ }
3609
+ const wroteManifest = writtenFiles.some((f) => path9.basename(f) === "composer.json");
3610
+ return {
3611
+ serviceDir,
3612
+ outcome: writtenFiles.length > 0 ? "instrumented" : "already-instrumented",
3613
+ writtenFiles,
3614
+ reason: PHP_PECL_CAVEAT,
3615
+ ...wroteManifest ? { followUpInstall: "composer install" } : {}
3616
+ };
3617
+ }
3618
+ var phpInstaller = { name: "php", detect: detect5, plan: plan5, apply: apply5 };
3619
+
3114
3620
  // src/installers/shared.ts
3115
- function isEmptyPlan(plan4) {
3116
- return plan4.dependencyEdits.length === 0 && plan4.entrypointEdits.length === 0 && plan4.envEdits.length === 0 && (plan4.generatedFiles?.length ?? 0) === 0 && plan4.nextConfigEdit === void 0;
3621
+ function isEmptyPlan(plan6) {
3622
+ return plan6.dependencyEdits.length === 0 && plan6.entrypointEdits.length === 0 && plan6.envEdits.length === 0 && (plan6.generatedFiles?.length ?? 0) === 0 && plan6.nextConfigEdit === void 0;
3117
3623
  }
3118
3624
 
3119
3625
  // src/installers/index.ts
@@ -3125,9 +3631,16 @@ var FORBIDDEN_LOCKFILES = /* @__PURE__ */ new Set([
3125
3631
  "Pipfile.lock",
3126
3632
  "Gemfile.lock",
3127
3633
  "Cargo.lock",
3128
- "go.sum"
3634
+ "go.sum",
3635
+ "composer.lock"
3129
3636
  ]);
3130
- var INSTALLERS = [javascriptInstaller, pythonInstaller, goInstaller];
3637
+ var INSTALLERS = [
3638
+ javascriptInstaller,
3639
+ pythonInstaller,
3640
+ goInstaller,
3641
+ rubyInstaller,
3642
+ phpInstaller
3643
+ ];
3131
3644
  async function pickInstaller(serviceDir) {
3132
3645
  for (const inst of INSTALLERS) {
3133
3646
  if (await inst.detect(serviceDir)) return inst;
@@ -3142,7 +3655,7 @@ function renderPatch(sections) {
3142
3655
  "No SDK installers matched the discovered services. Two reasons this",
3143
3656
  "normally happens:",
3144
3657
  " - the project uses a language NEAT does not yet instrument",
3145
- " (Java / Ruby / .NET / Rust are out of MVP scope per ADR-047);",
3658
+ " (Java / .NET / Rust are out of scope per ADR-047);",
3146
3659
  " - the SDK is already installed, so the installer returned an empty",
3147
3660
  " plan.",
3148
3661
  "",
@@ -3152,22 +3665,22 @@ function renderPatch(sections) {
3152
3665
  }
3153
3666
  const lines = ["# neat install plan", ""];
3154
3667
  for (const section of sections) {
3155
- const { installer, plan: plan4 } = section;
3156
- lines.push(`## ${installer} (${plan4.language}) \u2014 ${plan4.serviceDir}`);
3668
+ const { installer, plan: plan6 } = section;
3669
+ lines.push(`## ${installer} (${plan6.language}) \u2014 ${plan6.serviceDir}`);
3157
3670
  lines.push("");
3158
- if (plan4.libOnly) {
3671
+ if (plan6.libOnly) {
3159
3672
  lines.push("### skipped \u2014 no resolvable entry point (lib-only)");
3160
3673
  lines.push("");
3161
3674
  continue;
3162
3675
  }
3163
- if (plan4.entryFile) {
3164
- lines.push(`entry: ${plan4.entryFile}`);
3676
+ if (plan6.entryFile) {
3677
+ lines.push(`entry: ${plan6.entryFile}`);
3165
3678
  lines.push("");
3166
3679
  }
3167
- if (plan4.dependencyEdits.length > 0) {
3680
+ if (plan6.dependencyEdits.length > 0) {
3168
3681
  lines.push("### dependencies");
3169
3682
  const byFile = /* @__PURE__ */ new Map();
3170
- for (const dep of plan4.dependencyEdits) {
3683
+ for (const dep of plan6.dependencyEdits) {
3171
3684
  const base = dep.file.split(/[\\/]/).pop() ?? dep.file;
3172
3685
  if (FORBIDDEN_LOCKFILES.has(base)) {
3173
3686
  throw new Error(
@@ -3186,9 +3699,9 @@ function renderPatch(sections) {
3186
3699
  }
3187
3700
  lines.push("");
3188
3701
  }
3189
- if (plan4.generatedFiles && plan4.generatedFiles.length > 0) {
3702
+ if (plan6.generatedFiles && plan6.generatedFiles.length > 0) {
3190
3703
  lines.push("### generated files");
3191
- for (const gen of plan4.generatedFiles) {
3704
+ for (const gen of plan6.generatedFiles) {
3192
3705
  lines.push(`--- (new file) ${gen.file}`);
3193
3706
  for (const ln of gen.contents.split(/\r?\n/)) {
3194
3707
  lines.push(`+ ${ln}`);
@@ -3196,26 +3709,26 @@ function renderPatch(sections) {
3196
3709
  }
3197
3710
  lines.push("");
3198
3711
  }
3199
- if (plan4.entrypointEdits.length > 0) {
3712
+ if (plan6.entrypointEdits.length > 0) {
3200
3713
  lines.push("### entry-point injection");
3201
- for (const e of plan4.entrypointEdits) {
3714
+ for (const e of plan6.entrypointEdits) {
3202
3715
  lines.push(`--- ${e.file}`);
3203
3716
  lines.push(`+ ${e.after}`);
3204
3717
  lines.push(` ${e.before}`);
3205
3718
  }
3206
3719
  lines.push("");
3207
3720
  }
3208
- if (plan4.envEdits.length > 0) {
3721
+ if (plan6.envEdits.length > 0) {
3209
3722
  lines.push("### env (written to <package-dir>/.env.neat)");
3210
- for (const env of plan4.envEdits) {
3723
+ for (const env of plan6.envEdits) {
3211
3724
  lines.push(`- ${env.key}=${env.value}`);
3212
3725
  }
3213
3726
  lines.push("");
3214
3727
  }
3215
- if (plan4.nextConfigEdit) {
3728
+ if (plan6.nextConfigEdit) {
3216
3729
  lines.push("### next.config (framework flag)");
3217
- lines.push(`--- ${plan4.nextConfigEdit.file}`);
3218
- lines.push(`+ experimental: { instrumentationHook: true }, // ${plan4.nextConfigEdit.reason}`);
3730
+ lines.push(`--- ${plan6.nextConfigEdit.file}`);
3731
+ lines.push(`+ experimental: { instrumentationHook: true }, // ${plan6.nextConfigEdit.reason}`);
3219
3732
  lines.push("");
3220
3733
  }
3221
3734
  }
@@ -3223,10 +3736,10 @@ function renderPatch(sections) {
3223
3736
  }
3224
3737
 
3225
3738
  // src/orchestrator.ts
3226
- import { promises as fs7 } from "fs";
3739
+ import { promises as fs9 } from "fs";
3227
3740
  import http from "http";
3228
3741
  import net from "net";
3229
- import path8 from "path";
3742
+ import path10 from "path";
3230
3743
  import { fileURLToPath as fileURLToPath2 } from "url";
3231
3744
  import { spawn as spawn2 } from "child_process";
3232
3745
  import readline from "readline";
@@ -3236,7 +3749,7 @@ async function extractAndPersist(opts) {
3236
3749
  const graphKey = opts.projectExplicit ? opts.project : DEFAULT_PROJECT;
3237
3750
  resetGraph(graphKey);
3238
3751
  const graph = getGraph(graphKey);
3239
- const projectPaths = pathsForProject(graphKey, path8.join(opts.scanPath, "neat-out"));
3752
+ const projectPaths = pathsForProject(graphKey, path10.join(opts.scanPath, "neat-out"));
3240
3753
  const extraction = await extractFromDirectory(graph, opts.scanPath, {
3241
3754
  errorsPath: projectPaths.errorsPath
3242
3755
  });
@@ -3267,28 +3780,34 @@ async function applyInstallersOver(services, project, options = {}) {
3267
3780
  let cloudflareWorkers = 0;
3268
3781
  let electron = 0;
3269
3782
  const installPlans = /* @__PURE__ */ new Map();
3783
+ const dependencyInstructions = /* @__PURE__ */ new Map();
3270
3784
  for (const svc of services) {
3271
3785
  const installer = await pickInstaller(svc.dir);
3272
3786
  if (!installer) continue;
3273
- const plan4 = await installer.plan(svc.dir, { project });
3274
- if (isEmptyPlan(plan4) && !plan4.libOnly && plan4.runtimeKind === void 0) {
3787
+ const plan6 = await installer.plan(svc.dir, { project });
3788
+ if (isEmptyPlan(plan6) && !plan6.libOnly && plan6.runtimeKind === void 0) {
3275
3789
  already++;
3276
3790
  continue;
3277
3791
  }
3278
- const outcome = await installer.apply(plan4);
3792
+ const outcome = await installer.apply(plan6);
3279
3793
  if (outcome.outcome === "instrumented") {
3280
3794
  instrumented++;
3281
- if (plan4.dependencyEdits.length > 0) {
3282
- const cmd = await resolveManager(svc.dir);
3283
- const key = `${cmd.pm}:${cmd.cwd}`;
3284
- if (!installPlans.has(key)) installPlans.set(key, cmd);
3795
+ if (plan6.dependencyEdits.length > 0) {
3796
+ const manifest = path10.basename(plan6.dependencyEdits[0].file);
3797
+ if (manifest === "package.json") {
3798
+ const cmd = await resolveManager(svc.dir);
3799
+ const key = `${cmd.pm}:${cmd.cwd}`;
3800
+ if (!installPlans.has(key)) installPlans.set(key, cmd);
3801
+ } else if (outcome.followUpInstall) {
3802
+ dependencyInstructions.set(svc.dir, outcome.followUpInstall);
3803
+ }
3285
3804
  }
3286
3805
  } else if (outcome.outcome === "already-instrumented") already++;
3287
3806
  else if (outcome.outcome === "lib-only") {
3288
3807
  libOnly++;
3289
3808
  const appDeps = svc.pkg ? appFrameworkDependencies(svc.pkg) : [];
3290
3809
  if (appDeps.length > 0) {
3291
- const svcName = path8.basename(svc.dir);
3810
+ const svcName = path10.basename(svc.dir);
3292
3811
  const list = appDeps.join(", ");
3293
3812
  console.warn(
3294
3813
  `neat: runtime layer won't engage for ${svcName}: no entry point found.
@@ -3301,7 +3820,7 @@ async function applyInstallersOver(services, project, options = {}) {
3301
3820
  console.log(`skipping ${svc.dir}: browser bundle; browser-OTel support lands in a future release.`);
3302
3821
  } else if (outcome.outcome === "react-native") {
3303
3822
  reactNative++;
3304
- const svcName = path8.basename(svc.dir);
3823
+ const svcName = path10.basename(svc.dir);
3305
3824
  console.log(
3306
3825
  `neat: ${svc.dir} detected as React Native / Expo
3307
3826
  The installer doesn't cover this runtime deterministically.
@@ -3312,7 +3831,7 @@ async function applyInstallersOver(services, project, options = {}) {
3312
3831
  );
3313
3832
  } else if (outcome.outcome === "bun") {
3314
3833
  bun++;
3315
- const svcName = path8.basename(svc.dir);
3834
+ const svcName = path10.basename(svc.dir);
3316
3835
  console.log(
3317
3836
  `neat: ${svc.dir} detected as Bun
3318
3837
  The installer doesn't cover this runtime deterministically.
@@ -3323,7 +3842,7 @@ async function applyInstallersOver(services, project, options = {}) {
3323
3842
  );
3324
3843
  } else if (outcome.outcome === "deno") {
3325
3844
  deno++;
3326
- const svcName = path8.basename(svc.dir);
3845
+ const svcName = path10.basename(svc.dir);
3327
3846
  console.log(
3328
3847
  `neat: ${svc.dir} detected as Deno
3329
3848
  The installer doesn't cover this runtime deterministically.
@@ -3334,7 +3853,7 @@ async function applyInstallersOver(services, project, options = {}) {
3334
3853
  );
3335
3854
  } else if (outcome.outcome === "cloudflare-workers") {
3336
3855
  cloudflareWorkers++;
3337
- const svcName = path8.basename(svc.dir);
3856
+ const svcName = path10.basename(svc.dir);
3338
3857
  console.log(
3339
3858
  `neat: ${svc.dir} detected as Cloudflare Workers
3340
3859
  The installer doesn't cover this runtime deterministically.
@@ -3345,7 +3864,7 @@ async function applyInstallersOver(services, project, options = {}) {
3345
3864
  );
3346
3865
  } else if (outcome.outcome === "electron") {
3347
3866
  electron++;
3348
- const svcName = path8.basename(svc.dir);
3867
+ const svcName = path10.basename(svc.dir);
3349
3868
  console.log(
3350
3869
  `neat: ${svc.dir} detected as Electron
3351
3870
  The installer doesn't cover this runtime deterministically.
@@ -3358,7 +3877,7 @@ async function applyInstallersOver(services, project, options = {}) {
3358
3877
  if (svc.pkg && (outcome.outcome === "instrumented" || outcome.outcome === "already-instrumented")) {
3359
3878
  const gaps = uninstrumentedLibraries(svc.pkg);
3360
3879
  if (gaps.length > 0) {
3361
- const svcName = path8.basename(svc.dir);
3880
+ const svcName = path10.basename(svc.dir);
3362
3881
  const list = gaps.join(", ");
3363
3882
  const subject = gaps.length === 1 ? "this library" : "these libraries";
3364
3883
  const aux = gaps.length === 1 ? "isn't" : "aren't";
@@ -3386,6 +3905,11 @@ async function applyInstallersOver(services, project, options = {}) {
3386
3905
  }
3387
3906
  }
3388
3907
  }
3908
+ for (const [dir, command] of dependencyInstructions) {
3909
+ console.log(
3910
+ `neat: dependencies staged in ${dir}; run \`${command}\` to install them \u2014 NEAT does not run it for you.`
3911
+ );
3912
+ }
3389
3913
  return {
3390
3914
  instrumented,
3391
3915
  alreadyInstrumented: already,
@@ -3396,7 +3920,8 @@ async function applyInstallersOver(services, project, options = {}) {
3396
3920
  deno,
3397
3921
  cloudflareWorkers,
3398
3922
  electron,
3399
- packageManagerInstalls
3923
+ packageManagerInstalls,
3924
+ dependencyInstructions: [...dependencyInstructions].map(([dir, command]) => ({ dir, command }))
3400
3925
  };
3401
3926
  }
3402
3927
  async function promptYesNo(question) {
@@ -3564,24 +4089,24 @@ async function persistedPortsFor(scanPath) {
3564
4089
  return { rest: record.ports.rest, otlp: record.ports.otlp, web: record.ports.web };
3565
4090
  }
3566
4091
  async function acquireSpawnLock(scanPath) {
3567
- const lockPath = path8.join(scanPath, "neat-out", "daemon.spawn.lock");
3568
- await fs7.mkdir(path8.dirname(lockPath), { recursive: true });
4092
+ const lockPath = path10.join(scanPath, "neat-out", "daemon.spawn.lock");
4093
+ await fs9.mkdir(path10.dirname(lockPath), { recursive: true });
3569
4094
  const STALE_LOCK_MS = 6e4;
3570
4095
  try {
3571
- const fd = await fs7.open(lockPath, "wx");
4096
+ const fd = await fs9.open(lockPath, "wx");
3572
4097
  await fd.writeFile(`${process.pid}
3573
4098
  `, "utf8");
3574
4099
  await fd.close();
3575
4100
  return async () => {
3576
- await fs7.unlink(lockPath).catch(() => {
4101
+ await fs9.unlink(lockPath).catch(() => {
3577
4102
  });
3578
4103
  };
3579
4104
  } catch (err) {
3580
4105
  if (err.code !== "EEXIST") return null;
3581
4106
  try {
3582
- const stat = await fs7.stat(lockPath);
4107
+ const stat = await fs9.stat(lockPath);
3583
4108
  if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) {
3584
- await fs7.unlink(lockPath).catch(() => {
4109
+ await fs9.unlink(lockPath).catch(() => {
3585
4110
  });
3586
4111
  return acquireSpawnLock(scanPath);
3587
4112
  }
@@ -3610,13 +4135,13 @@ async function healthIsForProject(restPort, project) {
3610
4135
  return false;
3611
4136
  }
3612
4137
  function daemonLogPath(projectPath3) {
3613
- return path8.join(projectPath3, "neat-out", "daemon.log");
4138
+ return path10.join(projectPath3, "neat-out", "daemon.log");
3614
4139
  }
3615
4140
  function spawnDaemonDetached(spec) {
3616
- const here = path8.dirname(fileURLToPath2(import.meta.url));
4141
+ const here = path10.dirname(fileURLToPath2(import.meta.url));
3617
4142
  const candidates = [
3618
- path8.join(here, "neatd.cjs"),
3619
- path8.join(here, "neatd.js")
4143
+ path10.join(here, "neatd.cjs"),
4144
+ path10.join(here, "neatd.js")
3620
4145
  ];
3621
4146
  let entry2 = null;
3622
4147
  const fsSync = __require("fs");
@@ -3646,7 +4171,7 @@ function spawnDaemonDetached(spec) {
3646
4171
  let logFd = null;
3647
4172
  if (spec) {
3648
4173
  const logPath = daemonLogPath(spec.projectPath);
3649
- fsSync.mkdirSync(path8.dirname(logPath), { recursive: true });
4174
+ fsSync.mkdirSync(path10.dirname(logPath), { recursive: true });
3650
4175
  logFd = fsSync.openSync(logPath, "a");
3651
4176
  }
3652
4177
  const child = spawn2(process.execPath, [entry2, "start"], {
@@ -3685,7 +4210,7 @@ async function runOrchestrator(opts) {
3685
4210
  browser: "skipped"
3686
4211
  }
3687
4212
  };
3688
- const stat = await fs7.stat(opts.scanPath).catch(() => null);
4213
+ const stat = await fs9.stat(opts.scanPath).catch(() => null);
3689
4214
  if (!stat || !stat.isDirectory()) {
3690
4215
  console.error(`neat: ${opts.scanPath} is not a directory`);
3691
4216
  result.exitCode = 2;
@@ -3845,7 +4370,7 @@ async function runOrchestrator(opts) {
3845
4370
  result.steps.browser = openBrowser(dashboardUrl);
3846
4371
  }
3847
4372
  const daemonRunning = result.steps.daemon === "spawned" || result.steps.daemon === "already-running";
3848
- const daemonLog = daemonRunning ? path8.relative(opts.scanPath, daemonLogPath(opts.scanPath)) : null;
4373
+ const daemonLog = daemonRunning ? path10.relative(opts.scanPath, daemonLogPath(opts.scanPath)) : null;
3849
4374
  printSummary(result, graph, dashboardUrl, daemonLog);
3850
4375
  return result;
3851
4376
  }
@@ -4302,27 +4827,27 @@ async function runConnectorCommand(rawArgs, deps = {}) {
4302
4827
  }
4303
4828
 
4304
4829
  // src/hooks-cli.ts
4305
- import path9 from "path";
4830
+ import path11 from "path";
4306
4831
  import os from "os";
4307
- import { promises as fs8 } from "fs";
4832
+ import { promises as fs10 } from "fs";
4308
4833
  import { fileURLToPath as fileURLToPath3 } from "url";
4309
4834
  var HOOK_FILENAME = "neat-search-nudge.mjs";
4310
4835
  var GUIDE_FILENAME = "GRAPH_FIRST.md";
4311
4836
  var GUIDE_INSTALL_NAME = "neat-graph-first.md";
4312
4837
  var HOOK_MATCHER = "Grep|Glob|Bash";
4313
4838
  function moduleDir() {
4314
- return typeof __dirname !== "undefined" ? __dirname : path9.dirname(fileURLToPath3(import.meta.url));
4839
+ return typeof __dirname !== "undefined" ? __dirname : path11.dirname(fileURLToPath3(import.meta.url));
4315
4840
  }
4316
4841
  async function readSkillAsset(rel) {
4317
4842
  const here = moduleDir();
4318
4843
  const candidates = [
4319
- path9.resolve(here, "../../claude-skill", rel),
4320
- path9.resolve(here, "../../../claude-skill", rel),
4321
- path9.resolve(here, "../claude-skill", rel)
4844
+ path11.resolve(here, "../../claude-skill", rel),
4845
+ path11.resolve(here, "../../../claude-skill", rel),
4846
+ path11.resolve(here, "../claude-skill", rel)
4322
4847
  ];
4323
4848
  for (const candidate of candidates) {
4324
4849
  try {
4325
- return await fs8.readFile(candidate, "utf8");
4850
+ return await fs10.readFile(candidate, "utf8");
4326
4851
  } catch {
4327
4852
  }
4328
4853
  }
@@ -4332,17 +4857,17 @@ async function readSkillAsset(rel) {
4332
4857
  }
4333
4858
  function neatHome() {
4334
4859
  const override = process.env.NEAT_HOME;
4335
- if (override && override.length > 0) return path9.resolve(override);
4336
- return path9.join(os.homedir(), ".neat");
4860
+ if (override && override.length > 0) return path11.resolve(override);
4861
+ return path11.join(os.homedir(), ".neat");
4337
4862
  }
4338
4863
  function claudeSettingsPath() {
4339
4864
  const override = process.env.NEAT_CLAUDE_SETTINGS;
4340
- if (override && override.length > 0) return path9.resolve(override);
4865
+ if (override && override.length > 0) return path11.resolve(override);
4341
4866
  const home = process.env.HOME ?? process.env.USERPROFILE ?? os.homedir();
4342
- return path9.join(home, ".claude", "settings.json");
4867
+ return path11.join(home, ".claude", "settings.json");
4343
4868
  }
4344
4869
  function installedHookPath() {
4345
- return path9.join(neatHome(), "hooks", HOOK_FILENAME);
4870
+ return path11.join(neatHome(), "hooks", HOOK_FILENAME);
4346
4871
  }
4347
4872
  function isNeatSearchEntry(entry2) {
4348
4873
  return (entry2.hooks ?? []).some(
@@ -4375,14 +4900,14 @@ async function runHooks(opts) {
4375
4900
  const hookScript = await readSkillAsset(`hooks/${HOOK_FILENAME}`);
4376
4901
  const guide = await readSkillAsset(GUIDE_FILENAME);
4377
4902
  const scriptPath = installedHookPath();
4378
- await fs8.mkdir(path9.dirname(scriptPath), { recursive: true });
4379
- await fs8.writeFile(scriptPath, hookScript, { mode: 493 });
4380
- const guidePath = path9.join(neatHome(), GUIDE_INSTALL_NAME);
4381
- await fs8.writeFile(guidePath, guide, "utf8");
4903
+ await fs10.mkdir(path11.dirname(scriptPath), { recursive: true });
4904
+ await fs10.writeFile(scriptPath, hookScript, { mode: 493 });
4905
+ const guidePath = path11.join(neatHome(), GUIDE_INSTALL_NAME);
4906
+ await fs10.writeFile(guidePath, guide, "utf8");
4382
4907
  const settingsFile = claudeSettingsPath();
4383
4908
  let settings = {};
4384
4909
  try {
4385
- settings = JSON.parse(await fs8.readFile(settingsFile, "utf8"));
4910
+ settings = JSON.parse(await fs10.readFile(settingsFile, "utf8"));
4386
4911
  } catch (err) {
4387
4912
  if (err.code !== "ENOENT") {
4388
4913
  console.error(
@@ -4404,8 +4929,8 @@ async function runHooks(opts) {
4404
4929
  ...settings,
4405
4930
  hooks: { ...hooks, PreToolUse: preToolUse }
4406
4931
  };
4407
- await fs8.mkdir(path9.dirname(settingsFile), { recursive: true });
4408
- await fs8.writeFile(settingsFile, JSON.stringify(merged, null, 2) + "\n", "utf8");
4932
+ await fs10.mkdir(path11.dirname(settingsFile), { recursive: true });
4933
+ await fs10.writeFile(settingsFile, JSON.stringify(merged, null, 2) + "\n", "utf8");
4409
4934
  console.log(`neat hooks: installed the search-nudge hook`);
4410
4935
  console.log(` script: ${scriptPath}`);
4411
4936
  console.log(` settings: ${settingsFile} (PreToolUse \u2192 ${HOOK_MATCHER})`);
@@ -4475,9 +5000,9 @@ async function runHooksCommand(args) {
4475
5000
  }
4476
5001
 
4477
5002
  // src/codex-cli.ts
4478
- import path10 from "path";
5003
+ import path12 from "path";
4479
5004
  import os2 from "os";
4480
- import { promises as fs9 } from "fs";
5005
+ import { promises as fs11 } from "fs";
4481
5006
  import { isDeepStrictEqual } from "util";
4482
5007
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
4483
5008
  var CODEX_MCP_SERVER = {
@@ -4495,14 +5020,14 @@ var NEAT_GRAPH_FIRST_START = "<!-- neat:graph-first -->";
4495
5020
  var NEAT_GRAPH_FIRST_END = "<!-- /neat:graph-first -->";
4496
5021
  function codexConfigPath() {
4497
5022
  const override = process.env.NEAT_CODEX_CONFIG;
4498
- if (override && override.length > 0) return path10.resolve(override);
5023
+ if (override && override.length > 0) return path12.resolve(override);
4499
5024
  const home = process.env.HOME ?? process.env.USERPROFILE ?? os2.homedir();
4500
- return path10.join(home, ".codex", "config.toml");
5025
+ return path12.join(home, ".codex", "config.toml");
4501
5026
  }
4502
5027
  function agentsFilePath() {
4503
5028
  const override = process.env.NEAT_CODEX_AGENTS;
4504
- if (override && override.length > 0) return path10.resolve(override);
4505
- return path10.join(process.cwd(), "AGENTS.md");
5029
+ if (override && override.length > 0) return path12.resolve(override);
5030
+ return path12.join(process.cwd(), "AGENTS.md");
4506
5031
  }
4507
5032
  function isTableHeader(line) {
4508
5033
  return /^\s*\[\[?[^\]]+\]\]?\s*$/.test(line);
@@ -4636,7 +5161,7 @@ async function runCodex(opts) {
4636
5161
  const agentsPath = agentsFilePath();
4637
5162
  let configRaw = "";
4638
5163
  try {
4639
- configRaw = await fs9.readFile(configPath, "utf8");
5164
+ configRaw = await fs11.readFile(configPath, "utf8");
4640
5165
  } catch (err) {
4641
5166
  if (err.code !== "ENOENT") {
4642
5167
  console.error(`neat codex: failed to read ${configPath} \u2014 ${err.message}`);
@@ -4645,7 +5170,7 @@ async function runCodex(opts) {
4645
5170
  }
4646
5171
  let agentsRaw = "";
4647
5172
  try {
4648
- agentsRaw = await fs9.readFile(agentsPath, "utf8");
5173
+ agentsRaw = await fs11.readFile(agentsPath, "utf8");
4649
5174
  } catch (err) {
4650
5175
  if (err.code !== "ENOENT") {
4651
5176
  console.error(`neat codex: failed to read ${agentsPath} \u2014 ${err.message}`);
@@ -4685,15 +5210,15 @@ async function runCodex(opts) {
4685
5210
  return { exitCode: 0 };
4686
5211
  }
4687
5212
  if (config.changed) {
4688
- await fs9.mkdir(path10.dirname(configPath), { recursive: true });
4689
- await fs9.writeFile(configPath, config.text, "utf8");
5213
+ await fs11.mkdir(path12.dirname(configPath), { recursive: true });
5214
+ await fs11.writeFile(configPath, config.text, "utf8");
4690
5215
  console.log(`neat codex: wrote [mcp_servers.neat] to ${configPath}`);
4691
5216
  } else {
4692
5217
  console.log(`neat codex: ${configPath} already has NEAT's MCP server`);
4693
5218
  }
4694
5219
  if (agents.changed) {
4695
- await fs9.mkdir(path10.dirname(agentsPath), { recursive: true });
4696
- await fs9.writeFile(agentsPath, agents.text, "utf8");
5220
+ await fs11.mkdir(path12.dirname(agentsPath), { recursive: true });
5221
+ await fs11.writeFile(agentsPath, agents.text, "utf8");
4697
5222
  console.log(`neat codex: wrote the graph-first block to ${agentsPath}`);
4698
5223
  } else {
4699
5224
  console.log(`neat codex: ${agentsPath} already has the graph-first block`);
@@ -4749,9 +5274,9 @@ async function runCodexCommand(args) {
4749
5274
  }
4750
5275
 
4751
5276
  // src/editors-cli.ts
4752
- import path11 from "path";
5277
+ import path13 from "path";
4753
5278
  import os3 from "os";
4754
- import { promises as fs10 } from "fs";
5279
+ import { promises as fs12 } from "fs";
4755
5280
  import { isDeepStrictEqual as isDeepStrictEqual2 } from "util";
4756
5281
  import * as jsonc from "jsonc-parser";
4757
5282
  var NEAT_MCP_SERVER = {
@@ -4775,17 +5300,17 @@ function homeDir() {
4775
5300
  }
4776
5301
  function xdgConfigDir() {
4777
5302
  const xdg = process.env.XDG_CONFIG_HOME;
4778
- return xdg && xdg.length > 0 ? path11.resolve(xdg) : path11.join(homeDir(), ".config");
5303
+ return xdg && xdg.length > 0 ? path13.resolve(xdg) : path13.join(homeDir(), ".config");
4779
5304
  }
4780
5305
  function envOverride(name) {
4781
5306
  const v = process.env[name];
4782
- return v && v.length > 0 ? path11.resolve(v) : void 0;
5307
+ return v && v.length > 0 ? path13.resolve(v) : void 0;
4783
5308
  }
4784
5309
  var CURSOR_CLIENT = {
4785
5310
  id: "cursor",
4786
5311
  label: "Cursor",
4787
5312
  docsUrl: "https://docs.cursor.com/context/mcp",
4788
- mcpConfigPath: () => envOverride("NEAT_CURSOR_CONFIG") ?? path11.join(homeDir(), ".cursor", "mcp.json"),
5313
+ mcpConfigPath: () => envOverride("NEAT_CURSOR_CONFIG") ?? path13.join(homeDir(), ".cursor", "mcp.json"),
4789
5314
  mcpContainerKey: "mcpServers",
4790
5315
  format: "json",
4791
5316
  // Cursor still reads a single `.cursorrules` at the project root (the modern
@@ -4797,7 +5322,7 @@ var DEVIN_CLIENT = {
4797
5322
  id: "devin",
4798
5323
  label: "Devin Desktop (Cascade)",
4799
5324
  docsUrl: "https://docs.devin.ai/desktop/cascade/mcp",
4800
- mcpConfigPath: () => envOverride("NEAT_DEVIN_CONFIG") ?? path11.join(homeDir(), ".codeium", "windsurf", "mcp_config.json"),
5325
+ mcpConfigPath: () => envOverride("NEAT_DEVIN_CONFIG") ?? path13.join(homeDir(), ".codeium", "windsurf", "mcp_config.json"),
4801
5326
  mcpContainerKey: "mcpServers",
4802
5327
  format: "json",
4803
5328
  rulesFileName: ".windsurfrules"
@@ -4806,7 +5331,7 @@ var GEMINI_CLIENT = {
4806
5331
  id: "gemini",
4807
5332
  label: "Gemini CLI",
4808
5333
  docsUrl: "https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md",
4809
- mcpConfigPath: () => envOverride("NEAT_GEMINI_CONFIG") ?? path11.join(homeDir(), ".gemini", "settings.json"),
5334
+ mcpConfigPath: () => envOverride("NEAT_GEMINI_CONFIG") ?? path13.join(homeDir(), ".gemini", "settings.json"),
4810
5335
  mcpContainerKey: "mcpServers",
4811
5336
  format: "json",
4812
5337
  rulesFileName: "GEMINI.md"
@@ -4815,7 +5340,7 @@ var QWEN_CLIENT = {
4815
5340
  id: "qwen",
4816
5341
  label: "Qwen Code",
4817
5342
  docsUrl: "https://qwenlm.github.io/qwen-code-docs/en/users/features/mcp/",
4818
- mcpConfigPath: () => envOverride("NEAT_QWEN_CONFIG") ?? path11.join(homeDir(), ".qwen", "settings.json"),
5343
+ mcpConfigPath: () => envOverride("NEAT_QWEN_CONFIG") ?? path13.join(homeDir(), ".qwen", "settings.json"),
4819
5344
  mcpContainerKey: "mcpServers",
4820
5345
  format: "json",
4821
5346
  rulesFileName: "QWEN.md"
@@ -4824,7 +5349,7 @@ var AMAZONQ_CLIENT = {
4824
5349
  id: "amazonq",
4825
5350
  label: "Amazon Q Developer CLI",
4826
5351
  docsUrl: "https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/command-line-mcp-configuration.html",
4827
- mcpConfigPath: () => envOverride("NEAT_AMAZONQ_CONFIG") ?? path11.join(homeDir(), ".aws", "amazonq", "mcp.json"),
5352
+ mcpConfigPath: () => envOverride("NEAT_AMAZONQ_CONFIG") ?? path13.join(homeDir(), ".aws", "amazonq", "mcp.json"),
4828
5353
  mcpContainerKey: "mcpServers",
4829
5354
  format: "json"
4830
5355
  };
@@ -4832,7 +5357,7 @@ var ROOCODE_CLIENT = {
4832
5357
  id: "roocode",
4833
5358
  label: "Roo Code",
4834
5359
  docsUrl: "https://roocodeinc.github.io/Roo-Code/features/mcp/using-mcp-in-roo",
4835
- mcpConfigPath: () => envOverride("NEAT_ROOCODE_CONFIG") ?? path11.join(process.cwd(), ".roo", "mcp.json"),
5360
+ mcpConfigPath: () => envOverride("NEAT_ROOCODE_CONFIG") ?? path13.join(process.cwd(), ".roo", "mcp.json"),
4836
5361
  mcpContainerKey: "mcpServers",
4837
5362
  format: "json"
4838
5363
  };
@@ -4845,9 +5370,9 @@ var ZED_CLIENT = {
4845
5370
  if (override) return override;
4846
5371
  if (process.platform === "win32") {
4847
5372
  const appData = process.env.APPDATA;
4848
- if (appData && appData.length > 0) return path11.join(appData, "Zed", "settings.json");
5373
+ if (appData && appData.length > 0) return path13.join(appData, "Zed", "settings.json");
4849
5374
  }
4850
- return path11.join(homeDir(), ".config", "zed", "settings.json");
5375
+ return path13.join(homeDir(), ".config", "zed", "settings.json");
4851
5376
  },
4852
5377
  mcpContainerKey: "context_servers",
4853
5378
  format: "jsonc",
@@ -4857,7 +5382,7 @@ var OPENCODE_CLIENT = {
4857
5382
  id: "opencode",
4858
5383
  label: "OpenCode",
4859
5384
  docsUrl: "https://opencode.ai/docs/mcp-servers/",
4860
- mcpConfigPath: () => envOverride("NEAT_OPENCODE_CONFIG") ?? path11.join(xdgConfigDir(), "opencode", "opencode.json"),
5385
+ mcpConfigPath: () => envOverride("NEAT_OPENCODE_CONFIG") ?? path13.join(xdgConfigDir(), "opencode", "opencode.json"),
4861
5386
  mcpContainerKey: "mcp",
4862
5387
  format: "json",
4863
5388
  serverEntry: NEAT_OPENCODE_SERVER,
@@ -4867,7 +5392,7 @@ var CRUSH_CLIENT = {
4867
5392
  id: "crush",
4868
5393
  label: "Crush",
4869
5394
  docsUrl: "https://charmbracelet-crush.mintlify.app/configuration/mcp",
4870
- mcpConfigPath: () => envOverride("NEAT_CRUSH_CONFIG") ?? path11.join(xdgConfigDir(), "crush", "crush.json"),
5395
+ mcpConfigPath: () => envOverride("NEAT_CRUSH_CONFIG") ?? path13.join(xdgConfigDir(), "crush", "crush.json"),
4871
5396
  mcpContainerKey: "mcp",
4872
5397
  format: "json",
4873
5398
  serverEntry: NEAT_CRUSH_SERVER,
@@ -4930,7 +5455,7 @@ async function planMcp(client, mcpPath) {
4930
5455
  const serverEntry = client.serverEntry ?? NEAT_MCP_SERVER;
4931
5456
  let raw = "";
4932
5457
  try {
4933
- raw = await fs10.readFile(mcpPath, "utf8");
5458
+ raw = await fs12.readFile(mcpPath, "utf8");
4934
5459
  } catch (err) {
4935
5460
  const e = err;
4936
5461
  if (e.code === "ENOENT") {
@@ -4972,7 +5497,7 @@ async function runEditorInstall(client, opts) {
4972
5497
  const mcpPath = client.mcpConfigPath();
4973
5498
  const serverEntry = client.serverEntry ?? NEAT_MCP_SERVER;
4974
5499
  const hasRules = typeof client.rulesFileName === "string";
4975
- const rulesPath = hasRules ? path11.join(opts.projectDir, client.rulesFileName) : "";
5500
+ const rulesPath = hasRules ? path13.join(opts.projectDir, client.rulesFileName) : "";
4976
5501
  const mcp = await planMcp(client, mcpPath);
4977
5502
  if (mcp === null) return { exitCode: 1 };
4978
5503
  let existingRules = "";
@@ -4981,7 +5506,7 @@ async function runEditorInstall(client, opts) {
4981
5506
  let block = "";
4982
5507
  if (hasRules) {
4983
5508
  try {
4984
- existingRules = await fs10.readFile(rulesPath, "utf8");
5509
+ existingRules = await fs12.readFile(rulesPath, "utf8");
4985
5510
  } catch (err) {
4986
5511
  if (err.code !== "ENOENT") {
4987
5512
  console.error(`neat ${client.id}: failed to read ${rulesPath} \u2014 ${err.message}`);
@@ -5015,11 +5540,11 @@ async function runEditorInstall(client, opts) {
5015
5540
  );
5016
5541
  return { exitCode: 0 };
5017
5542
  }
5018
- await fs10.mkdir(path11.dirname(mcpPath), { recursive: true });
5019
- await fs10.writeFile(mcpPath, mcp.text, "utf8");
5543
+ await fs12.mkdir(path13.dirname(mcpPath), { recursive: true });
5544
+ await fs12.writeFile(mcpPath, mcp.text, "utf8");
5020
5545
  if (hasRules) {
5021
- await fs10.mkdir(path11.dirname(rulesPath), { recursive: true });
5022
- await fs10.writeFile(rulesPath, newRules, "utf8");
5546
+ await fs12.mkdir(path13.dirname(rulesPath), { recursive: true });
5547
+ await fs12.writeFile(rulesPath, newRules, "utf8");
5023
5548
  }
5024
5549
  console.log(`neat ${client.id}: wired NEAT into ${client.label}`);
5025
5550
  console.log(` MCP server: ${mcpPath} (${client.mcpContainerKey}.neat \u2192 npx -y @neat.is/mcp)`);
@@ -5055,11 +5580,11 @@ function usage3(client) {
5055
5580
  }
5056
5581
  async function runEditorCommand(clientId, args, projectDir = process.cwd()) {
5057
5582
  const client = CLIENTS[clientId];
5058
- let apply4 = false;
5583
+ let apply6 = false;
5059
5584
  for (const arg of args) {
5060
5585
  switch (arg) {
5061
5586
  case "--apply":
5062
- apply4 = true;
5587
+ apply6 = true;
5063
5588
  break;
5064
5589
  case "-h":
5065
5590
  case "--help":
@@ -5072,7 +5597,7 @@ async function runEditorCommand(clientId, args, projectDir = process.cwd()) {
5072
5597
  }
5073
5598
  }
5074
5599
  try {
5075
- const { exitCode } = await runEditorInstall(client, { apply: apply4, projectDir });
5600
+ const { exitCode } = await runEditorInstall(client, { apply: apply6, projectDir });
5076
5601
  return exitCode;
5077
5602
  } catch (err) {
5078
5603
  console.error(err.message);
@@ -5109,10 +5634,10 @@ function createHttpClient(baseUrl, bearerToken) {
5109
5634
  const root = baseUrl.replace(/\/$/, "");
5110
5635
  const authHeader = bearerToken && bearerToken.length > 0 ? { authorization: `Bearer ${bearerToken}` } : {};
5111
5636
  return {
5112
- async get(path14) {
5637
+ async get(path16) {
5113
5638
  let res;
5114
5639
  try {
5115
- res = await fetch(`${root}${path14}`, {
5640
+ res = await fetch(`${root}${path16}`, {
5116
5641
  headers: { ...authHeader }
5117
5642
  });
5118
5643
  } catch (err) {
@@ -5124,16 +5649,16 @@ function createHttpClient(baseUrl, bearerToken) {
5124
5649
  const body = await res.text().catch(() => "");
5125
5650
  throw new HttpError(
5126
5651
  res.status,
5127
- `${res.status} ${res.statusText} on GET ${path14}: ${body}`,
5652
+ `${res.status} ${res.statusText} on GET ${path16}: ${body}`,
5128
5653
  body
5129
5654
  );
5130
5655
  }
5131
5656
  return await res.json();
5132
5657
  },
5133
- async post(path14, body) {
5658
+ async post(path16, body) {
5134
5659
  let res;
5135
5660
  try {
5136
- res = await fetch(`${root}${path14}`, {
5661
+ res = await fetch(`${root}${path16}`, {
5137
5662
  method: "POST",
5138
5663
  headers: { "content-type": "application/json", ...authHeader },
5139
5664
  body: JSON.stringify(body)
@@ -5147,7 +5672,7 @@ function createHttpClient(baseUrl, bearerToken) {
5147
5672
  const text = await res.text().catch(() => "");
5148
5673
  throw new HttpError(
5149
5674
  res.status,
5150
- `${res.status} ${res.statusText} on POST ${path14}: ${text}`,
5675
+ `${res.status} ${res.statusText} on POST ${path16}: ${text}`,
5151
5676
  text
5152
5677
  );
5153
5678
  }
@@ -5161,12 +5686,12 @@ function projectPath(project, suffix) {
5161
5686
  }
5162
5687
  async function runRootCause(client, input) {
5163
5688
  const qs = input.errorId ? `?errorId=${encodeURIComponent(input.errorId)}` : "";
5164
- const path14 = projectPath(
5689
+ const path16 = projectPath(
5165
5690
  input.project,
5166
5691
  `/graph/root-cause/${encodeURIComponent(input.errorNode)}${qs}`
5167
5692
  );
5168
5693
  try {
5169
- const result = await client.get(path14);
5694
+ const result = await client.get(path16);
5170
5695
  const arrowPath = result.traversalPath.join(" \u2190 ");
5171
5696
  const provenances = result.edgeProvenances.length ? result.edgeProvenances.join(", ") : "(direct, no edges traversed)";
5172
5697
  const summary = `Root cause for ${input.errorNode} is ${result.rootCauseNode}. ` + result.rootCauseReason + (result.fixRecommendation ? ` Recommended fix: ${result.fixRecommendation}.` : "");
@@ -5192,12 +5717,12 @@ async function runRootCause(client, input) {
5192
5717
  }
5193
5718
  async function runBlastRadius(client, input) {
5194
5719
  const qs = input.depth !== void 0 ? `?depth=${input.depth}` : "";
5195
- const path14 = projectPath(
5720
+ const path16 = projectPath(
5196
5721
  input.project,
5197
5722
  `/graph/blast-radius/${encodeURIComponent(input.nodeId)}${qs}`
5198
5723
  );
5199
5724
  try {
5200
- const result = await client.get(path14);
5725
+ const result = await client.get(path16);
5201
5726
  if (result.totalAffected === 0) {
5202
5727
  return {
5203
5728
  summary: `${result.origin} has no dependents. Nothing else would break if it failed.`
@@ -5231,12 +5756,12 @@ function formatBlastEntry(n) {
5231
5756
  }
5232
5757
  async function runDependencies(client, input) {
5233
5758
  const depth = input.depth ?? 3;
5234
- const path14 = projectPath(
5759
+ const path16 = projectPath(
5235
5760
  input.project,
5236
5761
  `/graph/dependencies/${encodeURIComponent(input.nodeId)}?depth=${depth}`
5237
5762
  );
5238
5763
  try {
5239
- const result = await client.get(path14);
5764
+ const result = await client.get(path16);
5240
5765
  if (result.total === 0) {
5241
5766
  return {
5242
5767
  summary: depth === 1 ? `${input.nodeId} has no direct dependencies in the graph.` : `${input.nodeId} has no dependencies (BFS to depth ${depth}).`
@@ -5328,9 +5853,9 @@ function formatDuration(ms) {
5328
5853
  return `${Math.round(h / 24)}d`;
5329
5854
  }
5330
5855
  async function runIncidents(client, input) {
5331
- const path14 = input.nodeId ? projectPath(input.project, `/incidents/${encodeURIComponent(input.nodeId)}`) : projectPath(input.project, "/incidents");
5856
+ const path16 = input.nodeId ? projectPath(input.project, `/incidents/${encodeURIComponent(input.nodeId)}`) : projectPath(input.project, "/incidents");
5332
5857
  try {
5333
- const body = await client.get(path14);
5858
+ const body = await client.get(path16);
5334
5859
  const events = body.events;
5335
5860
  if (events.length === 0) {
5336
5861
  return {
@@ -5983,7 +6508,7 @@ function sleep(ms, signal) {
5983
6508
  }
5984
6509
 
5985
6510
  // src/cli-verbs.ts
5986
- import path12 from "path";
6511
+ import path14 from "path";
5987
6512
  async function resolveProjectEntry(opts) {
5988
6513
  const entries = await listProjects();
5989
6514
  if (opts.project) {
@@ -5993,7 +6518,7 @@ async function resolveProjectEntry(opts) {
5993
6518
  const cwd = opts.cwd ?? process.cwd();
5994
6519
  const resolvedCwd = await normalizeProjectPath(cwd);
5995
6520
  for (const entry2 of entries) {
5996
- if (resolvedCwd === entry2.path || resolvedCwd.startsWith(`${entry2.path}${path12.sep}`)) {
6521
+ if (resolvedCwd === entry2.path || resolvedCwd.startsWith(`${entry2.path}${path14.sep}`)) {
5997
6522
  return entry2;
5998
6523
  }
5999
6524
  }
@@ -6500,15 +7025,15 @@ async function buildPatchSections(services, project) {
6500
7025
  for (const svc of services) {
6501
7026
  const installer = await pickInstaller(svc.dir);
6502
7027
  if (!installer) continue;
6503
- const plan4 = await installer.plan(svc.dir, { project });
6504
- if (isEmptyPlan(plan4) && !plan4.libOnly && plan4.runtimeKind === void 0) continue;
6505
- sections.push({ installer: installer.name, plan: plan4 });
7028
+ const plan6 = await installer.plan(svc.dir, { project });
7029
+ if (isEmptyPlan(plan6) && !plan6.libOnly && plan6.runtimeKind === void 0) continue;
7030
+ sections.push({ installer: installer.name, plan: plan6 });
6506
7031
  }
6507
7032
  return sections;
6508
7033
  }
6509
7034
  async function runInit(opts) {
6510
7035
  const written = [];
6511
- const stat = await fs11.stat(opts.scanPath).catch(() => null);
7036
+ const stat = await fs13.stat(opts.scanPath).catch(() => null);
6512
7037
  if (!stat || !stat.isDirectory()) {
6513
7038
  console.error(`neat init: ${opts.scanPath} is not a directory`);
6514
7039
  return { exitCode: 2, writtenFiles: written };
@@ -6517,13 +7042,13 @@ async function runInit(opts) {
6517
7042
  printDiscoveryReport(opts, services);
6518
7043
  const sections = opts.noInstall ? [] : await buildPatchSections(services, opts.project);
6519
7044
  const patch = renderPatch(sections);
6520
- const patchPath = path13.join(opts.scanPath, "neat.patch");
7045
+ const patchPath = path15.join(opts.scanPath, "neat.patch");
6521
7046
  if (opts.dryRun) {
6522
- await fs11.writeFile(patchPath, patch, "utf8");
7047
+ await fs13.writeFile(patchPath, patch, "utf8");
6523
7048
  written.push(patchPath);
6524
7049
  console.log(`dry-run: patch written to ${patchPath}`);
6525
- const gitignorePath = path13.join(opts.scanPath, ".gitignore");
6526
- const gitignoreExists = await fs11.stat(gitignorePath).then(() => true).catch(() => false);
7050
+ const gitignorePath = path15.join(opts.scanPath, ".gitignore");
7051
+ const gitignoreExists = await fs13.stat(gitignorePath).then(() => true).catch(() => false);
6527
7052
  const verb = gitignoreExists ? "append" : "create";
6528
7053
  console.log(`dry-run: would ${verb} ${gitignorePath} (add neat-out/)`);
6529
7054
  console.log("rerun without --dry-run to register and snapshot.");
@@ -6534,9 +7059,9 @@ async function runInit(opts) {
6534
7059
  const graph = getGraph(graphKey);
6535
7060
  const projectPaths = pathsForProject(
6536
7061
  graphKey,
6537
- path13.join(opts.scanPath, "neat-out")
7062
+ path15.join(opts.scanPath, "neat-out")
6538
7063
  );
6539
- const errorsPath = path13.join(path13.dirname(opts.outPath), path13.basename(projectPaths.errorsPath));
7064
+ const errorsPath = path15.join(path15.dirname(opts.outPath), path15.basename(projectPaths.errorsPath));
6540
7065
  const result = await extractFromDirectory(graph, opts.scanPath, { errorsPath });
6541
7066
  await saveGraphToDisk(graph, opts.outPath);
6542
7067
  written.push(opts.outPath);
@@ -6615,7 +7140,7 @@ async function runInit(opts) {
6615
7140
  console.log("Run `npm install` (or your language equivalent) to refresh lockfiles.");
6616
7141
  }
6617
7142
  } else {
6618
- await fs11.writeFile(patchPath, patch, "utf8");
7143
+ await fs13.writeFile(patchPath, patch, "utf8");
6619
7144
  written.push(patchPath);
6620
7145
  }
6621
7146
  }
@@ -6655,9 +7180,9 @@ var CLAUDE_SKILL_CONFIG = {
6655
7180
  };
6656
7181
  function claudeConfigPath() {
6657
7182
  const override = process.env.NEAT_CLAUDE_CONFIG;
6658
- if (override && override.length > 0) return path13.resolve(override);
7183
+ if (override && override.length > 0) return path15.resolve(override);
6659
7184
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
6660
- return path13.join(home, ".claude.json");
7185
+ return path15.join(home, ".claude.json");
6661
7186
  }
6662
7187
  async function runSkill(opts) {
6663
7188
  const snippet = JSON.stringify(CLAUDE_SKILL_CONFIG, null, 2) + "\n";
@@ -6669,7 +7194,7 @@ async function runSkill(opts) {
6669
7194
  const target = claudeConfigPath();
6670
7195
  let existing = {};
6671
7196
  try {
6672
- existing = JSON.parse(await fs11.readFile(target, "utf8"));
7197
+ existing = JSON.parse(await fs13.readFile(target, "utf8"));
6673
7198
  } catch (err) {
6674
7199
  if (err.code !== "ENOENT") {
6675
7200
  console.error(`neat skill: failed to read ${target} \u2014 ${err.message}`);
@@ -6681,8 +7206,8 @@ async function runSkill(opts) {
6681
7206
  ...existing,
6682
7207
  mcpServers: { ...mcp, neat: CLAUDE_SKILL_CONFIG.mcpServers.neat }
6683
7208
  };
6684
- await fs11.mkdir(path13.dirname(target), { recursive: true });
6685
- await fs11.writeFile(target, JSON.stringify(merged, null, 2) + "\n", "utf8");
7209
+ await fs13.mkdir(path15.dirname(target), { recursive: true });
7210
+ await fs13.writeFile(target, JSON.stringify(merged, null, 2) + "\n", "utf8");
6686
7211
  console.log(`neat skill: wrote mcpServers.neat to ${target}`);
6687
7212
  console.log("restart Claude Code to pick up the new MCP server.");
6688
7213
  console.log("");
@@ -6755,7 +7280,7 @@ async function main() {
6755
7280
  }
6756
7281
  const cmd = argvParsed.positional[0];
6757
7282
  const parsed = { ...argvParsed, positional: argvParsed.positional.slice(1) };
6758
- const { positional, apply: apply4, dryRun, noInstall } = parsed;
7283
+ const { positional, apply: apply6, dryRun, noInstall } = parsed;
6759
7284
  const project = parsed.project ?? DEFAULT_PROJECT;
6760
7285
  if (cmd === "init") {
6761
7286
  const target = positional[0];
@@ -6764,22 +7289,22 @@ async function main() {
6764
7289
  usage4();
6765
7290
  process.exit(2);
6766
7291
  }
6767
- if (apply4 && dryRun) {
7292
+ if (apply6 && dryRun) {
6768
7293
  console.error("neat init: --apply and --dry-run are mutually exclusive");
6769
7294
  process.exit(2);
6770
7295
  }
6771
- const scanPath = path13.resolve(target);
7296
+ const scanPath = path15.resolve(target);
6772
7297
  const projectExplicit = parsed.project !== null;
6773
- const projectName = projectExplicit ? project : path13.basename(scanPath);
7298
+ const projectName = projectExplicit ? project : path15.basename(scanPath);
6774
7299
  const projectKey = projectExplicit ? project : DEFAULT_PROJECT;
6775
- const fallback = pathsForProject(projectKey, path13.join(scanPath, "neat-out")).snapshotPath;
6776
- const outPath = path13.resolve(process.env.NEAT_OUT_PATH ?? fallback);
7300
+ const fallback = pathsForProject(projectKey, path15.join(scanPath, "neat-out")).snapshotPath;
7301
+ const outPath = path15.resolve(process.env.NEAT_OUT_PATH ?? fallback);
6777
7302
  const result = await runInit({
6778
7303
  scanPath,
6779
7304
  outPath,
6780
7305
  project: projectName,
6781
7306
  projectExplicit,
6782
- apply: apply4,
7307
+ apply: apply6,
6783
7308
  dryRun,
6784
7309
  noInstall,
6785
7310
  verbose: parsed.verbose
@@ -6794,21 +7319,21 @@ async function main() {
6794
7319
  usage4();
6795
7320
  process.exit(2);
6796
7321
  }
6797
- const scanPath = path13.resolve(target);
6798
- const stat = await fs11.stat(scanPath).catch(() => null);
7322
+ const scanPath = path15.resolve(target);
7323
+ const stat = await fs13.stat(scanPath).catch(() => null);
6799
7324
  if (!stat || !stat.isDirectory()) {
6800
7325
  console.error(`neat watch: ${scanPath} is not a directory`);
6801
7326
  process.exit(2);
6802
7327
  }
6803
- const projectPaths = pathsForProject(project, path13.join(scanPath, "neat-out"));
6804
- const outPath = path13.resolve(process.env.NEAT_OUT_PATH ?? projectPaths.snapshotPath);
6805
- const errorsPath = path13.resolve(
6806
- process.env.NEAT_ERRORS_PATH ?? path13.join(path13.dirname(outPath), path13.basename(projectPaths.errorsPath))
7328
+ const projectPaths = pathsForProject(project, path15.join(scanPath, "neat-out"));
7329
+ const outPath = path15.resolve(process.env.NEAT_OUT_PATH ?? projectPaths.snapshotPath);
7330
+ const errorsPath = path15.resolve(
7331
+ process.env.NEAT_ERRORS_PATH ?? path15.join(path15.dirname(outPath), path15.basename(projectPaths.errorsPath))
6807
7332
  );
6808
- const staleEventsPath = path13.resolve(
6809
- process.env.NEAT_STALE_EVENTS_PATH ?? path13.join(path13.dirname(outPath), path13.basename(projectPaths.staleEventsPath))
7333
+ const staleEventsPath = path15.resolve(
7334
+ process.env.NEAT_STALE_EVENTS_PATH ?? path15.join(path15.dirname(outPath), path15.basename(projectPaths.staleEventsPath))
6810
7335
  );
6811
- const embeddingsCachePath = process.env.NEAT_EMBEDDINGS_CACHE_PATH ? path13.resolve(process.env.NEAT_EMBEDDINGS_CACHE_PATH) : void 0;
7336
+ const embeddingsCachePath = process.env.NEAT_EMBEDDINGS_CACHE_PATH ? path15.resolve(process.env.NEAT_EMBEDDINGS_CACHE_PATH) : void 0;
6812
7337
  const handle = await startWatch(getGraph(project), {
6813
7338
  scanPath,
6814
7339
  outPath,
@@ -6817,7 +7342,7 @@ async function main() {
6817
7342
  project,
6818
7343
  // Resolve NEAT_HOME so a `neat watch` picks up connectors added to
6819
7344
  // ~/.neat/connectors.json (#871). Same resolution the rest of the CLI uses.
6820
- neatHome: process.env.NEAT_HOME ? path13.resolve(process.env.NEAT_HOME) : path13.join(os4.homedir(), ".neat"),
7345
+ neatHome: process.env.NEAT_HOME ? path15.resolve(process.env.NEAT_HOME) : path15.join(os4.homedir(), ".neat"),
6821
7346
  ...embeddingsCachePath ? { embeddingsCachePath } : {},
6822
7347
  host: process.env.HOST ?? "0.0.0.0",
6823
7348
  port: Number(process.env.PORT ?? 8080),
@@ -6999,11 +7524,11 @@ async function main() {
6999
7524
  process.exit(1);
7000
7525
  }
7001
7526
  async function tryOrchestrator(cmd, parsed) {
7002
- const scanPath = path13.resolve(cmd);
7003
- const stat = await fs11.stat(scanPath).catch(() => null);
7527
+ const scanPath = path15.resolve(cmd);
7528
+ const stat = await fs13.stat(scanPath).catch(() => null);
7004
7529
  if (!stat || !stat.isDirectory()) return null;
7005
7530
  const projectExplicit = parsed.project !== null;
7006
- const projectName = projectExplicit ? parsed.project : path13.basename(scanPath);
7531
+ const projectName = projectExplicit ? parsed.project : path15.basename(scanPath);
7007
7532
  const result = await runOrchestrator({
7008
7533
  scanPath,
7009
7534
  project: projectName,