amxx-builder 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/AGENTS.md +13 -3
  2. package/README.md +276 -20
  3. package/action.yml +1 -1
  4. package/defaults/amxbuild.defaults.yml +7 -1
  5. package/mcp/handlers.js +342 -152
  6. package/mcp/registry.js +264 -25
  7. package/package.json +1 -1
  8. package/skills/amxb-migration/SKILL.md +126 -27
  9. package/skills/amxx-pawn-style/SKILL.md +422 -0
  10. package/src/agent-assets.js +206 -0
  11. package/src/asset-fetcher.js +33 -48
  12. package/src/build-plan.js +22 -5
  13. package/src/build-service.js +54 -11
  14. package/src/cli.js +38 -4
  15. package/src/collector.js +9 -1
  16. package/src/commands/build.js +5 -4
  17. package/src/commands/dry-run.js +28 -1
  18. package/src/commands/init.js +125 -11
  19. package/src/commands/opencode-skills.js +47 -0
  20. package/src/commands/serve.js +309 -111
  21. package/src/commands/skills-dir.js +21 -0
  22. package/src/commands/watch.js +66 -23
  23. package/src/compile-utils.js +119 -4
  24. package/src/compiler-fetcher.js +196 -41
  25. package/src/compiler.js +68 -29
  26. package/src/dep-graph.js +27 -0
  27. package/src/deployer.js +39 -15
  28. package/src/deps-resolver.js +151 -11
  29. package/src/deps-tree.js +20 -9
  30. package/src/download.js +132 -0
  31. package/src/fs-utils.js +35 -1
  32. package/src/fungun-fetcher.js +28 -8
  33. package/src/github-api.js +32 -0
  34. package/src/include-tree.js +30 -61
  35. package/src/ini-builder.js +1 -1
  36. package/src/jsonrpc-transport.js +53 -5
  37. package/src/local-sources.js +363 -0
  38. package/src/manifest-path.js +18 -1
  39. package/src/manifest.js +398 -40
  40. package/src/opencode-skills.js +262 -0
  41. package/src/release-fetcher.js +26 -33
  42. package/src/repo-fetcher.js +388 -37
  43. package/src/retry.js +3 -1
  44. package/src/schema.js +1 -1
  45. package/templates/init-deploy.env +9 -0
  46. package/templates/init-gitignore +22 -0
  47. package/src/dep-docs.js +0 -115
@@ -16,8 +16,11 @@
16
16
  * Method table:
17
17
  * manifest.validate → validate.manifestFile
18
18
  * manifest.resolve → env.loadEnv + manifest.resolveManifest
19
+ * manifest.dep → agent-assets readDepManifest + parseDocEntries/parseSkillEntries
20
+ * docs.list / docs.get → agent-assets collectDepAssets / collectLocalAssets
21
+ * skills.list / skills.get → agent-assets collectDepAssets / collectLocalAssets
19
22
  * include.resolve → include-tree parseIncludeDirective + searchIncludeFile
20
- * include.list → include-tree fetchDepIncludeDir + collectIncFiles
23
+ * include.list → deps-resolver collectDepIncludeDirs + include-tree collectIncFiles
21
24
  * amxmodx.includes.list → compiler-fetcher fetchCompiler + glob
22
25
  * amxmodx.include.get → compiler-fetcher fetchCompiler + glob + read
23
26
  * deps.tree → deps-tree buildDepTree + assembleRootDeps
@@ -51,11 +54,13 @@ const { on, off, EVENTS } = require('../events');
51
54
 
52
55
  const { loadEnv } = require('../env');
53
56
  const { resolveManifestPath } = require('../manifest-path');
54
- const { resolveManifest, parseManifest, resolveGithubToken, parseDepString } = require('../manifest');
57
+ const { resolveManifest, parseManifest, resolveGithubToken, parseDepString, parseDepObject, parseRefTtl, parseDocEntries, parseSkillEntries } = require('../manifest');
58
+ const { resolveLocalEntry } = require('../local-sources');
59
+ const { readDepManifest, collectDepAssets, collectLocalAssets } = require('../agent-assets');
55
60
  const { validateManifestFile } = require('../validate');
56
- const { fetchDepIncludeDir, collectIncFiles, parseIncludeDirective, searchIncludeFile } = require('../include-tree');
57
- const { depLabel } = require('../deps-resolver');
58
- const { fetchCompiler, resolveAmxmodxVersion, getCompilerInfo } = require('../compiler-fetcher');
61
+ const { collectIncFiles, parseIncludeDirective, searchIncludeFile } = require('../include-tree');
62
+ const { depLabel, collectDepIncludeDirs } = require('../deps-resolver');
63
+ const { fetchCompiler, resolveAmxmodxVersion, getCompilerInfo, getHostPlatform, resolveStdlibVersion } = require('../compiler-fetcher');
59
64
  const { buildDepTree, assembleRootDeps } = require('../deps-tree');
60
65
  const { listReleases, listTags } = require('../release-lister');
61
66
  const {
@@ -83,20 +88,74 @@ function readFileSafe(absPath) {
83
88
  }
84
89
  }
85
90
 
86
- // Resolve the AMX Mod X version for a request: explicit `version` arg wins,
87
- // then the project manifest's amxmodx.version, then latest. Priority logic
88
- // lives in core (compiler-fetcher.resolveAmxmodxVersion).
91
+ // JSON-RPC params spell no-fetch either `noFetch` (serve convention) or
92
+ // `no_fetch` (snake_case convention) — normalize once here.
93
+ function noFetchParam(params) {
94
+ return params?.noFetch === true || params?.no_fetch === true;
95
+ }
96
+
97
+ // Resolve a concrete compiler version string for handlers that go on to call
98
+ // fetchCompiler() (dep-graph.get, compile.single). Explicit `version` arg wins,
99
+ // then the project manifest's amxmodx.version, then latest — priority lives in
100
+ // core (compiler-fetcher.resolveAmxmodxVersion). An invalid explicit version is
101
+ // a JSON-RPC param error (-32602); everything else (incl. LATEST_NOT_CACHED)
102
+ // propagates — these handlers fetch a real compiler, so a graceful empty state
103
+ // is meaningless here.
89
104
  async function resolveVersionFromParams(params) {
90
- if (params?.version) return resolveAmxmodxVersion(null, { version: params.version });
105
+ try {
106
+ if (params?.version) {
107
+ // await is required: a returned non-awaited promise bypasses this
108
+ // try/catch, so INVALID_AMXMODX_VERSION would never map to -32602.
109
+ return await resolveAmxmodxVersion(null, { version: params.version, noFetch: noFetchParam(params) });
110
+ }
91
111
 
92
- const manifestPath = params?.manifest
93
- ? path.resolve(params.manifest)
94
- : resolveManifestPath().path;
112
+ const manifestPath = params?.manifest
113
+ ? path.resolve(params.manifest)
114
+ : resolveManifestPath().path;
115
+ let manifest = null;
116
+ if (fs.existsSync(manifestPath)) {
117
+ try { manifest = parseManifest(manifestPath); } catch { manifest = null; }
118
+ }
119
+ return await resolveAmxmodxVersion(manifest, { noFetch: noFetchParam(params) });
120
+ } catch (err) {
121
+ if (err && err.code === 'INVALID_AMXMODX_VERSION') {
122
+ err.code = -32602;
123
+ throw err;
124
+ }
125
+ throw err;
126
+ }
127
+ }
128
+
129
+ // Informational stdlib lookup for manifest-less / offline clients. Never
130
+ // downloads when noFetch; if latest cannot be resolved offline, degrades to
131
+ // the newest cached compiler instead of failing (degraded: true). Nothing
132
+ // cached at all → graceful empty state (version: null), not an error.
133
+ // Version-priority + offline degradation live in core
134
+ // (compiler-fetcher.resolveStdlibVersion); this wrapper only does arg/manifest
135
+ // discovery, the INVALID→-32602 remap and getCompilerInfo state shaping.
136
+ async function resolveStdlibState(params) {
137
+ const noFetch = noFetchParam(params);
95
138
  let manifest = null;
96
- if (fs.existsSync(manifestPath)) {
97
- try { manifest = parseManifest(manifestPath); } catch { manifest = null; }
139
+ if (!params?.version) {
140
+ const manifestPath = manifestPathFor(params);
141
+ if (fs.existsSync(manifestPath)) {
142
+ try { manifest = parseManifest(manifestPath); } catch { manifest = null; }
143
+ }
98
144
  }
99
- return resolveAmxmodxVersion(manifest, { noFetch: params?.noFetch === true });
145
+ const { version, degraded, error } = await resolveStdlibVersion({
146
+ version: params?.version,
147
+ manifest,
148
+ noFetch,
149
+ });
150
+ if (error) {
151
+ if (error.code === 'INVALID_AMXMODX_VERSION') error.code = -32602;
152
+ throw error;
153
+ }
154
+ if (version === null) {
155
+ return { version: null, degraded: false, platform: getHostPlatform(), compilerPath: null, includeDir: null, cached: false };
156
+ }
157
+ const info = await getCompilerInfo(version, { noFetch });
158
+ return { version: info.version, degraded, platform: info.platform, compilerPath: info.compilerPath, includeDir: info.includeDir, cached: info.cached };
100
159
  }
101
160
 
102
161
  function manifestPathFor(params) {
@@ -121,6 +180,43 @@ function resolveGithubTokenFor(params, repo) {
121
180
  return params?.token || process.env.GITHUB_TOKEN || null;
122
181
  }
123
182
 
183
+ // Build a parsed dep object from params: either a full `dep` string/object or
184
+ // explicit { repo, ref?, source?, include_path?, asset? } fields.
185
+ function depFromParams(params) {
186
+ let dep;
187
+ if (params?.dep) {
188
+ if (typeof params.dep === 'string') dep = parseDepString(params.dep);
189
+ else if (params.dep.source === 'local') dep = parseDepObject(params.dep);
190
+ else dep = { ...params.dep };
191
+ } else {
192
+ if (!params?.repo) throw new Error('Provide either "dep" or "repo"');
193
+ const source = params.source || 'git';
194
+ const ref = params.ref || (source === 'release' ? 'latest' : null);
195
+ dep = { repo: params.repo, ref, source, include_path: params.include_path || null, asset: params.asset ?? null };
196
+ }
197
+ if (params?.source) dep.source = params.source;
198
+ if (params?.include_path) dep.include_path = params.include_path;
199
+ if (params?.asset != null) dep.asset = params.asset;
200
+ return resolveLocalEntry(
201
+ dep,
202
+ params?.manifest ? path.dirname(path.resolve(params.manifest)) : process.cwd()
203
+ );
204
+ }
205
+
206
+ // Agent docs/skills: dep mode when dep/repo is present, otherwise the local
207
+ // project's own manifest.
208
+ async function collectAgentAssets(params) {
209
+ const noFetch = noFetchParam(params);
210
+ if (params?.dep || params?.repo) {
211
+ const dep = depFromParams(params);
212
+ const token = resolveGithubTokenFor(params, dep.repo);
213
+ return collectDepAssets(dep, { token, noFetch });
214
+ }
215
+ const manifestPath = manifestPathFor(params);
216
+ loadEnvQuiet(manifestPath);
217
+ return collectLocalAssets(parseManifest(manifestPath));
218
+ }
219
+
124
220
  /**
125
221
  * Shape a GitHub API error into the JSON-RPC error contract: -32603 with
126
222
  * error.data = { status, repo, message }. Returns null for non-GitHub errors
@@ -198,6 +294,89 @@ function createServeServer() {
198
294
  return resolveManifest(manifestPath, { set: params?.set, define: params?.define });
199
295
  });
200
296
 
297
+ // ─── Dependency manifest + agent assets ──────────────────────────────────
298
+
299
+ server.onRequest('manifest.dep', async (params) => {
300
+ let dep;
301
+ try {
302
+ dep = depFromParams(params);
303
+ } catch (err) {
304
+ err.code = -32602;
305
+ throw err;
306
+ }
307
+ const token = resolveGithubTokenFor(params, dep.repo);
308
+ const m = await readDepManifest(dep, { token, noFetch: noFetchParam(params) });
309
+
310
+ let docs = [];
311
+ let skills = [];
312
+ try { docs = parseDocEntries(m.raw?.docs || []); } catch { docs = []; }
313
+ try { skills = parseSkillEntries(m.raw?.skills || []); } catch { skills = []; }
314
+
315
+ return {
316
+ label: m.label,
317
+ manifestPath: m.manifestPath,
318
+ manifestName: m.raw?.name || null,
319
+ raw: m.raw,
320
+ docs: docs.map((d) => ({ name: d.name, description: d.description, file: d.file })),
321
+ skills: skills.map((s) => ({ name: s.name, description: s.description, file: s.file, dir: s.dir })),
322
+ };
323
+ });
324
+
325
+ server.onRequest('docs.list', async (params) => {
326
+ const assets = await collectAgentAssets(params);
327
+ return {
328
+ label: assets.label || null,
329
+ docs: assets.docs.map((d) => ({ name: d.name, description: d.description, file: d.file })),
330
+ missing: assets.missing,
331
+ };
332
+ });
333
+
334
+ server.onRequest('docs.get', async (params) => {
335
+ const assets = await collectAgentAssets(params);
336
+ let docs = assets.docs;
337
+ if (params?.name || params?.file) {
338
+ docs = docs.filter(
339
+ (d) => (params.name && d.name === params.name) || (params.file && d.file === params.file)
340
+ );
341
+ }
342
+ return {
343
+ label: assets.label || null,
344
+ docs: docs.map((d) => ({ name: d.name, description: d.description, file: d.file, content: d.content })),
345
+ missing: assets.missing,
346
+ };
347
+ });
348
+
349
+ server.onRequest('skills.list', async (params) => {
350
+ const assets = await collectAgentAssets(params);
351
+ return {
352
+ label: assets.label || null,
353
+ skills: assets.skills.map((s) => ({
354
+ name: s.name,
355
+ description: s.description,
356
+ kind: s.kind,
357
+ file: s.file,
358
+ dir: s.dir,
359
+ files: s.kind === 'dir' ? s.files.map((f) => f.rel) : undefined,
360
+ })),
361
+ missing: assets.missing,
362
+ };
363
+ });
364
+
365
+ server.onRequest('skills.get', async (params) => {
366
+ const assets = await collectAgentAssets(params);
367
+ let skills = assets.skills;
368
+ if (params?.name) skills = skills.filter((s) => s.name === params.name);
369
+ return {
370
+ label: assets.label || null,
371
+ skills: skills.map((s) => (
372
+ s.kind === 'dir'
373
+ ? { name: s.name, description: s.description, kind: 'dir', dir: s.dir, files: s.files.map((f) => ({ rel: f.rel, content: f.content })) }
374
+ : { name: s.name, description: s.description, kind: 'file', file: s.file, content: s.content }
375
+ )),
376
+ missing: assets.missing,
377
+ };
378
+ });
379
+
201
380
  // ─── Include resolution ──────────────────────────────────────────────────
202
381
 
203
382
  server.onRequest('include.resolve', async (params) => {
@@ -230,25 +409,23 @@ function createServeServer() {
230
409
  loadEnvQuiet(manifestPath);
231
410
  try {
232
411
  manifest = parseManifest(manifestPath);
233
- for (const dep of manifest.globalDeps) {
234
- try {
235
- const depDir = await fetchDepIncludeDir(
236
- dep, resolveGithubToken(manifest, dep.repo),
237
- params?.noFetch === true, manifest.github.ssh
238
- );
239
- searchPaths.push({ path: depDir, label: depLabel(dep) });
240
- } catch (err) {
241
- errors.push(`${depLabel(dep)}: ${err.message}`);
242
- }
243
- }
244
412
  } catch (err) {
245
413
  errors.push(`manifest ${manifestPath}: ${err.message}`);
246
414
  }
247
415
  }
416
+ if (manifest) {
417
+ const { dirs, errors: depErrors } = await collectDepIncludeDirs(manifest, {
418
+ noFetch: noFetchParam(params),
419
+ ssh: manifest.github.ssh,
420
+ });
421
+ manifest.globalDeps.forEach((dep, i) => {
422
+ if (dirs[i]) searchPaths.push({ path: dirs[i], label: depLabel(dep) });
423
+ else if (depErrors[i]) errors.push(`${depLabel(dep)}: ${depErrors[i]}`);
424
+ });
425
+ }
248
426
 
249
- const version = await resolveVersionFromParams(params);
250
- const { includeDir } = await fetchCompiler(version);
251
- if (includeDir) searchPaths.push({ path: includeDir, label: `AMXX stdlib ${version}` });
427
+ const stdlib = await resolveStdlibState(params);
428
+ if (stdlib.includeDir) searchPaths.push({ path: stdlib.includeDir, label: `AMXX stdlib ${stdlib.version}` });
252
429
 
253
430
  const result = searchIncludeFile(searchPaths, filename);
254
431
  if (!result) {
@@ -279,21 +456,27 @@ function createServeServer() {
279
456
  loadEnvQuiet(manifestPath);
280
457
  const manifest = parseManifest(manifestPath);
281
458
 
459
+ const { dirs, errors } = await collectDepIncludeDirs(manifest, {
460
+ noFetch: noFetchParam(params),
461
+ ssh: manifest.github.ssh,
462
+ });
463
+
282
464
  const deps = [];
283
- for (const dep of manifest.globalDeps) {
465
+ for (let i = 0; i < manifest.globalDeps.length; i++) {
466
+ const dep = manifest.globalDeps[i];
284
467
  const base = dep.source === 'fungun'
285
468
  ? { source: 'fungun', id: dep.id, url: dep.url }
286
469
  : { source: dep.source || 'git', repo: dep.repo, ref: dep.ref };
470
+ if (errors[i]) {
471
+ deps.push({ ...base, error: errors[i], files: [], count: 0 });
472
+ continue;
473
+ }
287
474
  try {
288
- const includeDir = await fetchDepIncludeDir(
289
- dep, resolveGithubToken(manifest, dep.repo),
290
- params?.noFetch === true, manifest.github.ssh
291
- );
292
- const files = await collectIncFiles(includeDir);
475
+ const files = await collectIncFiles(dirs[i]);
293
476
  deps.push({
294
477
  ...base,
295
478
  include_path: dep.include_path || null,
296
- include_dir: includeDir,
479
+ include_dir: dirs[i],
297
480
  count: files.length,
298
481
  files: files.map((f) => ({ rel: f.rel, abs: f.abs })),
299
482
  });
@@ -307,39 +490,49 @@ function createServeServer() {
307
490
  // ─── AMXX standard includes ──────────────────────────────────────────────
308
491
 
309
492
  server.onRequest('amxmodx.includes.list', async (params) => {
310
- const version = await resolveVersionFromParams(params);
493
+ const state = await resolveStdlibState(params);
311
494
  const pattern = params?.pattern || '*.inc';
312
495
 
313
- const { includeDir } = await fetchCompiler(version);
314
- if (!includeDir) return { version, includeDir: null, pattern, count: 0, files: [] };
496
+ if (!state.includeDir) {
497
+ const result = { version: state.version, includeDir: null, pattern, count: 0, files: [] };
498
+ if (state.degraded) result.degraded = true;
499
+ return result;
500
+ }
315
501
 
316
- const files = await glob(pattern, { cwd: includeDir, dot: false });
502
+ const files = await glob(pattern, { cwd: state.includeDir, dot: false });
317
503
  files.sort();
318
- return { version, includeDir, pattern, count: files.length, files };
504
+ const result = { version: state.version, includeDir: state.includeDir, pattern, count: files.length, files };
505
+ if (state.degraded) result.degraded = true;
506
+ return result;
319
507
  });
320
508
 
321
509
  server.onRequest('amxmodx.include.get', async (params) => {
322
- const version = await resolveVersionFromParams(params);
510
+ const state = await resolveStdlibState(params);
323
511
  const pattern = params?.file || params?.pattern || '*.inc';
324
512
 
325
- const { includeDir } = await fetchCompiler(version);
326
- if (!includeDir) return { version, includeDir: null, count: 0, files: [] };
513
+ if (!state.includeDir) {
514
+ const result = { version: state.version, includeDir: null, count: 0, files: [] };
515
+ if (state.degraded) result.degraded = true;
516
+ return result;
517
+ }
327
518
 
328
- const files = await glob(pattern, { cwd: includeDir, dot: false });
519
+ const files = await glob(pattern, { cwd: state.includeDir, dot: false });
329
520
  files.sort();
330
- return {
331
- version,
332
- includeDir,
521
+ const result = {
522
+ version: state.version,
523
+ includeDir: state.includeDir,
333
524
  count: files.length,
334
- files: files.map((rel) => ({ rel, content: readFileSafe(path.join(includeDir, rel)) })),
525
+ files: files.map((rel) => ({ rel, content: readFileSafe(path.join(state.includeDir, rel)) })),
335
526
  };
527
+ if (state.degraded) result.degraded = true;
528
+ return result;
336
529
  });
337
530
 
338
531
  // ─── Deps tree ───────────────────────────────────────────────────────────
339
532
 
340
533
  server.onRequest('deps.tree', async (params) => {
341
534
  const depth = params?.depth || 0;
342
- const noFetch = params?.noFetch === true;
535
+ const noFetch = noFetchParam(params);
343
536
 
344
537
  if (params?.deps) {
345
538
  const rootDeps = params.deps.map((entry) => {
@@ -353,6 +546,7 @@ function createServeServer() {
353
546
  source: entry.source || 'git',
354
547
  include_path: entry.include_path || null,
355
548
  asset: entry.asset != null ? entry.asset : null,
549
+ ...(entry.ref_ttl != null ? { ref_ttl: parseRefTtl(entry.ref_ttl, `deps entry ${entry.repo}`) } : {}),
356
550
  };
357
551
  });
358
552
  return buildDepTree(rootDeps, { token: params?.token, noFetch, depth, from: 'user' });
@@ -387,7 +581,7 @@ function createServeServer() {
387
581
  throw err;
388
582
  }
389
583
 
390
- const noFetch = params?.noFetch === true;
584
+ const noFetch = noFetchParam(params);
391
585
 
392
586
  const manifestPath = manifestPathFor(params);
393
587
  let manifest = null;
@@ -402,13 +596,8 @@ function createServeServer() {
402
596
  // Dep includes come BEFORE the stdlib — matching the real build's search order.
403
597
  const includeDirs = [];
404
598
  if (manifest) {
405
- for (const dep of manifest.globalDeps) {
406
- try {
407
- includeDirs.push(await fetchDepIncludeDir(
408
- dep, resolveGithubToken(manifest, dep.repo), noFetch, manifest.github.ssh
409
- ));
410
- } catch (err) { /* keep the rest of the dirs on partial failure */ }
411
- }
599
+ const { dirs } = await collectDepIncludeDirs(manifest, { noFetch, ssh: manifest.github.ssh });
600
+ for (const dir of dirs) if (dir) includeDirs.push(dir);
412
601
  }
413
602
  if (includeDir) includeDirs.push(includeDir);
414
603
  for (const d of (params?.include_dirs || [])) includeDirs.push(path.resolve(d));
@@ -515,8 +704,10 @@ function createServeServer() {
515
704
  });
516
705
 
517
706
  server.onRequest('compiler.info', async (params) => {
518
- const version = await resolveVersionFromParams(params);
519
- return getCompilerInfo(version, { noFetch: params?.noFetch === true });
707
+ const state = await resolveStdlibState(params);
708
+ const result = { version: state.version, platform: state.platform, compilerPath: state.compilerPath, includeDir: state.includeDir, cached: state.cached };
709
+ if (state.degraded) result.degraded = true;
710
+ return result;
520
711
  });
521
712
 
522
713
  server.onRequest('build.plan', (params) => {
@@ -541,37 +732,43 @@ function createServeServer() {
541
732
  const controller = new AbortController();
542
733
  activeBuild = controller;
543
734
 
544
- const manifestPath = manifestPathFor(params);
545
- loadEnvQuiet(manifestPath);
546
- const manifest = resolveManifest(manifestPath, { set: params?.set, define: params?.define });
547
-
548
- // Forward core lifecycle events as server→client notifications while the
549
- // build runs. COMPILED/PROGRESS are emitted by compiler.js/progress.js on
550
- // the bus; STAGE/DONE/ERROR by build-service.
551
- const listeners = [
552
- [EVENTS.STAGE, (p) => server.notify('build.stage', p)],
553
- [EVENTS.COMPILED, (p) => server.notify('build.compiled', p)],
554
- [EVENTS.PROGRESS, (p) => server.notify('build.progress', p)],
555
- [EVENTS.DONE, (p) => server.notify('build.done', p)],
556
- [EVENTS.ERROR, (p) => server.notify('build.error', p)],
557
- ];
558
- for (const [ev, fn] of listeners) on(ev, fn);
559
-
560
735
  try {
561
- const result = await runBuild(manifest, {
562
- buildDir: params?.buildDir,
563
- fetch: params?.fetch,
564
- archive: params?.archive,
565
- signal: controller.signal,
566
- });
567
- return result;
568
- } catch (err) {
569
- if (err.code === 'CANCELLED') {
570
- return { ok: false, cancelled: true, message: err.message };
736
+ const manifestPath = manifestPathFor(params);
737
+ loadEnvQuiet(manifestPath);
738
+ const manifest = resolveManifest(manifestPath, { set: params?.set, define: params?.define });
739
+
740
+ // Forward core lifecycle events as server→client notifications while the
741
+ // build runs. COMPILED/PROGRESS are emitted by compiler.js/progress.js on
742
+ // the bus; STAGE/DONE/ERROR by build-service.
743
+ const listeners = [
744
+ [EVENTS.STAGE, (p) => server.notify('build.stage', p)],
745
+ [EVENTS.COMPILED, (p) => server.notify('build.compiled', p)],
746
+ [EVENTS.PROGRESS, (p) => server.notify('build.progress', p)],
747
+ [EVENTS.DONE, (p) => server.notify('build.done', p)],
748
+ [EVENTS.ERROR, (p) => server.notify('build.error', p)],
749
+ ];
750
+ for (const [ev, fn] of listeners) on(ev, fn);
751
+
752
+ try {
753
+ const result = await runBuild(manifest, {
754
+ buildDir: params?.buildDir,
755
+ fetch: params?.fetch,
756
+ archive: params?.archive,
757
+ signal: controller.signal,
758
+ });
759
+ return result;
760
+ } catch (err) {
761
+ if (err.code === 'CANCELLED') {
762
+ return { ok: false, cancelled: true, message: err.message };
763
+ }
764
+ return { ok: false, message: err.message };
765
+ } finally {
766
+ for (const [ev, fn] of listeners) off(ev, fn);
571
767
  }
572
- return { ok: false, message: err.message };
573
768
  } finally {
574
- for (const [ev, fn] of listeners) off(ev, fn);
769
+ // Even a pre-build failure (manifest missing, bad --set) must release the
770
+ // single-build lock, or every later build.start dies with "Build already
771
+ // running" until the process is restarted.
575
772
  activeBuild = null;
576
773
  }
577
774
  });
@@ -676,7 +873,7 @@ function createServeServer() {
676
873
  throw err;
677
874
  }
678
875
 
679
- const noFetch = params?.noFetch === true;
876
+ const noFetch = noFetchParam(params);
680
877
 
681
878
  const manifestPath = manifestPathFor(params);
682
879
  let manifest = null;
@@ -692,28 +889,26 @@ function createServeServer() {
692
889
  const depDirs = [];
693
890
  const depErrors = [];
694
891
  if (manifest) {
695
- for (const dep of manifest.globalDeps) {
696
- try {
697
- depDirs.push(await fetchDepIncludeDir(
698
- dep, resolveGithubToken(manifest, dep.repo), noFetch, manifest.github.ssh
699
- ));
700
- } catch (err) {
701
- depErrors.push(`${depLabel(dep)}: ${err.message}`);
702
- }
703
- }
892
+ const { dirs, errors } = await collectDepIncludeDirs(manifest, { noFetch, ssh: manifest.github.ssh });
893
+ manifest.globalDeps.forEach((dep, i) => {
894
+ if (dirs[i]) depDirs.push(dirs[i]);
895
+ else if (errors[i]) depErrors.push(`${depLabel(dep)}: ${errors[i]}`);
896
+ });
704
897
  }
705
898
 
706
899
  const includeDirs = [...depDirs];
707
900
  if (includeDir) includeDirs.push(includeDir);
708
901
  for (const d of (params?.include_dirs || [])) includeDirs.push(path.resolve(d));
709
902
 
710
- const buildDir = path.join(os.tmpdir(), 'amxb-serve-compile');
903
+ // Unique per-call build dir + event tag: the transport dispatches requests
904
+ // concurrently, so two compile.single calls for same-named plugins must not
905
+ // share an output path nor mis-attribute COMPILED events (cf. MCP).
906
+ const runId = `${process.pid}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
907
+ const buildDir = path.join(os.tmpdir(), 'amxb-serve-compile', runId);
711
908
  const compileManifest = manifest || { amxmodx: { defines: [] } };
712
909
 
713
- // Capture the compiler output emitted as an event by compileSingle.
714
- const baseName = path.basename(smaPath);
715
910
  let compiled = null;
716
- const onCompiled = (p) => { if (p.baseName === baseName) compiled = p; };
911
+ const onCompiled = (p) => { if (p.tag === runId) compiled = p; };
717
912
  on(EVENTS.COMPILED, onCompiled);
718
913
  let amxxName;
719
914
  try {
@@ -723,17 +918,19 @@ function createServeServer() {
723
918
  compilerPath,
724
919
  includeDirs,
725
920
  buildDir,
726
- params?.scripting_root ? path.resolve(params.scripting_root) : undefined
921
+ params?.scripting_root ? path.resolve(params.scripting_root) : undefined,
922
+ runId
727
923
  );
728
924
  } finally {
729
925
  off(EVENTS.COMPILED, onCompiled);
730
926
  }
731
927
 
928
+ const outputPath = amxxName ? path.join(buildDir, 'amxmodx', 'plugins', amxxName) : null;
732
929
  return {
733
930
  ok: amxxName != null,
734
931
  amxxName,
735
932
  output: compiled ? compiled.output : undefined,
736
- output_path: amxxName ? path.join(buildDir, 'amxmodx', 'plugins', amxxName) : null,
933
+ output_path: outputPath,
737
934
  dep_errors: depErrors.length ? depErrors : undefined,
738
935
  };
739
936
  });
@@ -748,6 +945,7 @@ function createServeServer() {
748
945
  }
749
946
 
750
947
  const manifestPath = manifestPathFor(params);
948
+ loadEnvQuiet(manifestPath);
751
949
  const manifest = parseManifest(manifestPath);
752
950
 
753
951
  const notify = (kind, extra = {}) => server.notify('watch.changed', { kind, ...extra });
@@ -773,13 +971,13 @@ function createServeServer() {
773
971
  return server;
774
972
  }
775
973
 
776
- // Load project .env from the workspace root like the CLI does; keep stdout free
777
- // for JSON-RPC (logs → stderr, progress bars disabled).
778
- function prepareEnvironment() {
779
- dotenv.config({ path: path.join(process.cwd(), '.env'), quiet: true });
780
- logger.setStderr(true);
781
- progress.setEnabled(false);
782
- }
974
+ // Load project .env from the workspace root like the CLI does; keep stdout free
975
+ // for JSON-RPC (logs → stderr, progress bars disabled).
976
+ function prepareEnvironment() {
977
+ dotenv.config({ path: path.join(process.cwd(), '.env'), quiet: true });
978
+ logger.setStderr(true);
979
+ progress.setEnabled(false);
980
+ }
783
981
 
784
982
  /**
785
983
  * Start the serve server — listens on stdin/stdout forever.
@@ -0,0 +1,21 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ // Absolute path to the skills/ directory bundled with this amxb installation.
7
+ // Resolved relative to the running code, so it works for any install method
8
+ // (npm -g, install.sh/ps1, linked dev checkout) without consulting npm.
9
+ const SKILLS_DIR = path.join(__dirname, '..', '..', 'skills');
10
+
11
+ function runSkillsDir() {
12
+ if (!fs.existsSync(SKILLS_DIR)) {
13
+ process.stderr.write(`skills directory not found: ${SKILLS_DIR}\n`);
14
+ process.exitCode = 1;
15
+ return;
16
+ }
17
+ // Pure stdout — consumers (e.g. the opencode bridge plugin) parse exactly this line.
18
+ process.stdout.write(SKILLS_DIR + '\n');
19
+ }
20
+
21
+ module.exports = { runSkillsDir, SKILLS_DIR };