@cleocode/caamp 2026.4.6 → 2026.4.9
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/README.md +9 -8
- package/dist/{chunk-43GULI6J.js → chunk-JC77OAHA.js} +1766 -400
- package/dist/chunk-JC77OAHA.js.map +1 -0
- package/dist/cli.js +1420 -71
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +1860 -293
- package/dist/index.js +29 -1
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/dist/chunk-43GULI6J.js.map +0 -1
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
getCanonicalSkillsDir,
|
|
14
14
|
getLockFilePath,
|
|
15
15
|
getPlatformLocations,
|
|
16
|
+
resolveProviderConfigPath,
|
|
16
17
|
resolveProviderProjectPath,
|
|
17
18
|
resolveProviderSkillsDirs
|
|
18
19
|
} from "./chunk-364OHA2T.js";
|
|
@@ -220,8 +221,8 @@ async function linkToAgent(canonicalPath, provider, skillName, isGlobal, project
|
|
|
220
221
|
await mkdir(targetSkillsDir, { recursive: true });
|
|
221
222
|
const linkPath = join2(targetSkillsDir, skillName);
|
|
222
223
|
if (existsSync2(linkPath)) {
|
|
223
|
-
const
|
|
224
|
-
if (
|
|
224
|
+
const stat4 = lstatSync(linkPath);
|
|
225
|
+
if (stat4.isSymbolicLink()) {
|
|
225
226
|
await rm(linkPath);
|
|
226
227
|
} else {
|
|
227
228
|
await rm(linkPath, { recursive: true });
|
|
@@ -352,8 +353,8 @@ async function snapshotSkillState(providerTargets, operation, projectDir, backup
|
|
|
352
353
|
pathSnapshots.push({ linkPath, state: "missing" });
|
|
353
354
|
continue;
|
|
354
355
|
}
|
|
355
|
-
const
|
|
356
|
-
if (
|
|
356
|
+
const stat4 = lstatSync2(linkPath);
|
|
357
|
+
if (stat4.isSymbolicLink()) {
|
|
357
358
|
pathSnapshots.push({
|
|
358
359
|
linkPath,
|
|
359
360
|
state: "symlink",
|
|
@@ -363,7 +364,7 @@ async function snapshotSkillState(providerTargets, operation, projectDir, backup
|
|
|
363
364
|
}
|
|
364
365
|
const backupPath = join3(backupRoot, "links", provider.id, `${skillName}-${basename(linkPath)}`);
|
|
365
366
|
await mkdir2(dirname(backupPath), { recursive: true });
|
|
366
|
-
if (
|
|
367
|
+
if (stat4.isDirectory()) {
|
|
367
368
|
await cp2(linkPath, backupPath, { recursive: true });
|
|
368
369
|
pathSnapshots.push({ linkPath, state: "directory", backupPath });
|
|
369
370
|
continue;
|
|
@@ -510,15 +511,83 @@ async function updateInstructionsSingleOperation(providers, content, scope = "pr
|
|
|
510
511
|
return summary;
|
|
511
512
|
}
|
|
512
513
|
|
|
514
|
+
// src/core/config/caamp-config.ts
|
|
515
|
+
var DEFAULT_EXCLUSIVITY_MODE = "auto";
|
|
516
|
+
var EXCLUSIVITY_MODE_ENV_VAR = "CAAMP_EXCLUSIVITY_MODE";
|
|
517
|
+
var programmaticOverride = null;
|
|
518
|
+
var exclusivityWarningState = {
|
|
519
|
+
piAbsentAutoWarned: false,
|
|
520
|
+
explicitNonPiAutoWarned: false
|
|
521
|
+
};
|
|
522
|
+
function hasPiAbsentAutoWarned() {
|
|
523
|
+
return exclusivityWarningState.piAbsentAutoWarned;
|
|
524
|
+
}
|
|
525
|
+
function hasExplicitNonPiAutoWarned() {
|
|
526
|
+
return exclusivityWarningState.explicitNonPiAutoWarned;
|
|
527
|
+
}
|
|
528
|
+
function markPiAbsentAutoWarned() {
|
|
529
|
+
exclusivityWarningState.piAbsentAutoWarned = true;
|
|
530
|
+
}
|
|
531
|
+
function markExplicitNonPiAutoWarned() {
|
|
532
|
+
exclusivityWarningState.explicitNonPiAutoWarned = true;
|
|
533
|
+
}
|
|
534
|
+
var PiRequiredError = class extends Error {
|
|
535
|
+
/** LAFS-stable error code identifying this failure mode. */
|
|
536
|
+
code = "E_NOT_FOUND_RESOURCE";
|
|
537
|
+
/**
|
|
538
|
+
* Construct a new {@link PiRequiredError}.
|
|
539
|
+
*
|
|
540
|
+
* @param message - Human-readable failure description; defaults to a
|
|
541
|
+
* stable string suitable for direct CLI display.
|
|
542
|
+
*/
|
|
543
|
+
constructor(message = 'caamp.exclusivityMode is set to "force-pi" but Pi is not installed. Install Pi (https://github.com/mariozechner/pi-coding-agent) or change the mode with CAAMP_EXCLUSIVITY_MODE=auto.') {
|
|
544
|
+
super(message);
|
|
545
|
+
this.name = "PiRequiredError";
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
function isExclusivityMode(value) {
|
|
549
|
+
return value === "auto" || value === "force-pi" || value === "legacy";
|
|
550
|
+
}
|
|
551
|
+
function getExclusivityMode() {
|
|
552
|
+
if (programmaticOverride !== null) {
|
|
553
|
+
return programmaticOverride;
|
|
554
|
+
}
|
|
555
|
+
const envValue = process.env[EXCLUSIVITY_MODE_ENV_VAR];
|
|
556
|
+
if (envValue !== void 0 && isExclusivityMode(envValue)) {
|
|
557
|
+
return envValue;
|
|
558
|
+
}
|
|
559
|
+
return DEFAULT_EXCLUSIVITY_MODE;
|
|
560
|
+
}
|
|
561
|
+
function setExclusivityMode(mode) {
|
|
562
|
+
programmaticOverride = mode;
|
|
563
|
+
}
|
|
564
|
+
function resetExclusivityModeOverride() {
|
|
565
|
+
programmaticOverride = null;
|
|
566
|
+
}
|
|
567
|
+
|
|
513
568
|
// src/core/harness/pi.ts
|
|
514
569
|
import { spawn } from "child_process";
|
|
515
570
|
import { existsSync as existsSync4 } from "fs";
|
|
516
|
-
import {
|
|
571
|
+
import {
|
|
572
|
+
appendFile,
|
|
573
|
+
cp as cp3,
|
|
574
|
+
mkdir as mkdir3,
|
|
575
|
+
open,
|
|
576
|
+
readdir,
|
|
577
|
+
readFile,
|
|
578
|
+
rename,
|
|
579
|
+
rm as rm3,
|
|
580
|
+
stat,
|
|
581
|
+
writeFile
|
|
582
|
+
} from "fs/promises";
|
|
583
|
+
import { homedir as homedir2 } from "os";
|
|
584
|
+
import { basename as basename2, dirname as dirname2, extname, join as join5 } from "path";
|
|
585
|
+
import { parseDocument, validateDocument } from "@cleocode/cant";
|
|
586
|
+
|
|
587
|
+
// src/core/harness/scope.ts
|
|
517
588
|
import { homedir } from "os";
|
|
518
|
-
import {
|
|
519
|
-
var
|
|
520
|
-
var MARKER_END = "<!-- CAAMP:END -->";
|
|
521
|
-
var MARKER_PATTERN = /<!-- CAAMP:START -->[\s\S]*?<!-- CAAMP:END -->/;
|
|
589
|
+
import { join as join4 } from "path";
|
|
590
|
+
var TIER_PRECEDENCE = ["project", "user", "global"];
|
|
522
591
|
function getPiAgentDir() {
|
|
523
592
|
const env = process.env["PI_CODING_AGENT_DIR"];
|
|
524
593
|
if (env !== void 0 && env.length > 0) {
|
|
@@ -528,6 +597,79 @@ function getPiAgentDir() {
|
|
|
528
597
|
}
|
|
529
598
|
return join4(homedir(), ".pi", "agent");
|
|
530
599
|
}
|
|
600
|
+
function getCleoHomeDir() {
|
|
601
|
+
const env = process.env["CLEO_HOME"];
|
|
602
|
+
if (env !== void 0 && env.trim().length > 0) {
|
|
603
|
+
return env.trim();
|
|
604
|
+
}
|
|
605
|
+
if (process.platform === "win32") {
|
|
606
|
+
const localAppData = process.env["LOCALAPPDATA"];
|
|
607
|
+
if (localAppData !== void 0 && localAppData.length > 0) {
|
|
608
|
+
return join4(localAppData, "cleo", "Data");
|
|
609
|
+
}
|
|
610
|
+
return join4(homedir(), "AppData", "Local", "cleo", "Data");
|
|
611
|
+
}
|
|
612
|
+
if (process.platform === "darwin") {
|
|
613
|
+
return join4(homedir(), "Library", "Application Support", "cleo");
|
|
614
|
+
}
|
|
615
|
+
const xdgData = process.env["XDG_DATA_HOME"];
|
|
616
|
+
if (xdgData !== void 0 && xdgData.length > 0) {
|
|
617
|
+
return join4(xdgData, "cleo");
|
|
618
|
+
}
|
|
619
|
+
return join4(homedir(), ".local", "share", "cleo");
|
|
620
|
+
}
|
|
621
|
+
function assetDirName(kind) {
|
|
622
|
+
switch (kind) {
|
|
623
|
+
case "extensions":
|
|
624
|
+
return { native: "extensions", hubSuffix: "pi-extensions" };
|
|
625
|
+
case "prompts":
|
|
626
|
+
return { native: "prompts", hubSuffix: "pi-prompts" };
|
|
627
|
+
case "themes":
|
|
628
|
+
return { native: "themes", hubSuffix: "pi-themes" };
|
|
629
|
+
case "sessions":
|
|
630
|
+
return { native: "sessions", hubSuffix: "pi-sessions" };
|
|
631
|
+
case "cant":
|
|
632
|
+
return { native: "cant", hubSuffix: "pi-cant" };
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
function resolveTierDir(opts) {
|
|
636
|
+
const { tier, kind } = opts;
|
|
637
|
+
const names = assetDirName(kind);
|
|
638
|
+
if (tier === "project") {
|
|
639
|
+
if (opts.projectDir === void 0 || opts.projectDir.length === 0) {
|
|
640
|
+
throw new Error("resolveTierDir: 'project' tier requires a projectDir argument");
|
|
641
|
+
}
|
|
642
|
+
return join4(opts.projectDir, ".pi", names.native);
|
|
643
|
+
}
|
|
644
|
+
if (tier === "user") {
|
|
645
|
+
return join4(getPiAgentDir(), names.native);
|
|
646
|
+
}
|
|
647
|
+
return join4(getCleoHomeDir(), names.hubSuffix);
|
|
648
|
+
}
|
|
649
|
+
function resolveAllTiers(kind, projectDir) {
|
|
650
|
+
const out = [];
|
|
651
|
+
for (const tier of TIER_PRECEDENCE) {
|
|
652
|
+
if (tier === "project" && (projectDir === void 0 || projectDir.length === 0)) {
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
out.push({ tier, dir: resolveTierDir({ tier, kind, projectDir }) });
|
|
656
|
+
}
|
|
657
|
+
return out;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// src/core/harness/pi.ts
|
|
661
|
+
var MARKER_START = "<!-- CAAMP:START -->";
|
|
662
|
+
var MARKER_END = "<!-- CAAMP:END -->";
|
|
663
|
+
var MARKER_PATTERN = /<!-- CAAMP:START -->[\s\S]*?<!-- CAAMP:END -->/;
|
|
664
|
+
function getPiAgentDir2() {
|
|
665
|
+
const env = process.env["PI_CODING_AGENT_DIR"];
|
|
666
|
+
if (env !== void 0 && env.length > 0) {
|
|
667
|
+
if (env === "~") return homedir2();
|
|
668
|
+
if (env.startsWith("~/")) return join5(homedir2(), env.slice(2));
|
|
669
|
+
return env;
|
|
670
|
+
}
|
|
671
|
+
return join5(homedir2(), ".pi", "agent");
|
|
672
|
+
}
|
|
531
673
|
function isPlainObject(v) {
|
|
532
674
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
533
675
|
}
|
|
@@ -550,6 +692,38 @@ async function atomicWriteJson(filePath, data) {
|
|
|
550
692
|
`, "utf8");
|
|
551
693
|
await rename(tmp, filePath);
|
|
552
694
|
}
|
|
695
|
+
var DEFAULT_TERMINATE_GRACE_MS = 5e3;
|
|
696
|
+
var STDERR_RING_BUFFER_SIZE = 100;
|
|
697
|
+
var activeSubagents = /* @__PURE__ */ new Set();
|
|
698
|
+
var orphanSweeperRegistered = false;
|
|
699
|
+
function ensureOrphanSweeperRegistered() {
|
|
700
|
+
if (orphanSweeperRegistered) return;
|
|
701
|
+
orphanSweeperRegistered = true;
|
|
702
|
+
const sweeper = () => {
|
|
703
|
+
for (const entry of activeSubagents) {
|
|
704
|
+
try {
|
|
705
|
+
entry.terminate();
|
|
706
|
+
} catch {
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
};
|
|
710
|
+
process.on("exit", sweeper);
|
|
711
|
+
}
|
|
712
|
+
function generateShortId() {
|
|
713
|
+
const ts = Date.now().toString(36);
|
|
714
|
+
const rand = Math.random().toString(36).slice(2, 8);
|
|
715
|
+
return `${ts}${rand}`;
|
|
716
|
+
}
|
|
717
|
+
function readTerminateGraceFromSettings(settings, fallback) {
|
|
718
|
+
if (!isPlainObject(settings)) return fallback;
|
|
719
|
+
const piBlock = settings["pi"];
|
|
720
|
+
if (!isPlainObject(piBlock)) return fallback;
|
|
721
|
+
const subBlock = piBlock["subagent"];
|
|
722
|
+
if (!isPlainObject(subBlock)) return fallback;
|
|
723
|
+
const value = subBlock["terminateGraceMs"];
|
|
724
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return fallback;
|
|
725
|
+
return value;
|
|
726
|
+
}
|
|
553
727
|
var PiHarness = class {
|
|
554
728
|
/**
|
|
555
729
|
* Construct a harness bound to a resolved Pi provider.
|
|
@@ -566,19 +740,13 @@ var PiHarness = class {
|
|
|
566
740
|
* Resolve the skills directory for a given scope.
|
|
567
741
|
*/
|
|
568
742
|
skillsDir(scope) {
|
|
569
|
-
return scope.kind === "global" ?
|
|
570
|
-
}
|
|
571
|
-
/**
|
|
572
|
-
* Resolve the extensions directory for a given scope.
|
|
573
|
-
*/
|
|
574
|
-
extensionsDir(scope) {
|
|
575
|
-
return scope.kind === "global" ? join4(getPiAgentDir(), "extensions") : join4(scope.projectDir, ".pi", "extensions");
|
|
743
|
+
return scope.kind === "global" ? join5(getPiAgentDir2(), "skills") : join5(scope.projectDir, ".pi", "skills");
|
|
576
744
|
}
|
|
577
745
|
/**
|
|
578
746
|
* Resolve the settings.json path for a given scope.
|
|
579
747
|
*/
|
|
580
748
|
settingsPath(scope) {
|
|
581
|
-
return scope.kind === "global" ?
|
|
749
|
+
return scope.kind === "global" ? join5(getPiAgentDir2(), "settings.json") : join5(scope.projectDir, ".pi", "settings.json");
|
|
582
750
|
}
|
|
583
751
|
/**
|
|
584
752
|
* Resolve the AGENTS.md instruction file path for a given scope.
|
|
@@ -589,19 +757,19 @@ var PiHarness = class {
|
|
|
589
757
|
* auto-discovering `AGENTS.md` from the working directory upwards.
|
|
590
758
|
*/
|
|
591
759
|
agentsMdPath(scope) {
|
|
592
|
-
return scope.kind === "global" ?
|
|
760
|
+
return scope.kind === "global" ? join5(getPiAgentDir2(), "AGENTS.md") : join5(scope.projectDir, "AGENTS.md");
|
|
593
761
|
}
|
|
594
762
|
// ── Skills ──────────────────────────────────────────────────────────
|
|
595
763
|
/** {@inheritDoc Harness.installSkill} */
|
|
596
764
|
async installSkill(sourcePath, skillName, scope) {
|
|
597
|
-
const targetDir =
|
|
765
|
+
const targetDir = join5(this.skillsDir(scope), skillName);
|
|
598
766
|
await rm3(targetDir, { recursive: true, force: true });
|
|
599
767
|
await mkdir3(dirname2(targetDir), { recursive: true });
|
|
600
768
|
await cp3(sourcePath, targetDir, { recursive: true });
|
|
601
769
|
}
|
|
602
770
|
/** {@inheritDoc Harness.removeSkill} */
|
|
603
771
|
async removeSkill(skillName, scope) {
|
|
604
|
-
const targetDir =
|
|
772
|
+
const targetDir = join5(this.skillsDir(scope), skillName);
|
|
605
773
|
await rm3(targetDir, { recursive: true, force: true });
|
|
606
774
|
}
|
|
607
775
|
/** {@inheritDoc Harness.listSkills} */
|
|
@@ -646,86 +814,79 @@ ${MARKER_END}`;
|
|
|
646
814
|
await writeFile(filePath, stripped.length === 0 ? "" : `${stripped}
|
|
647
815
|
`, "utf8");
|
|
648
816
|
}
|
|
649
|
-
// ──
|
|
817
|
+
// ── Subagent spawn (ADR-035 §D6) ────────────────────────────────────
|
|
650
818
|
/**
|
|
651
|
-
*
|
|
819
|
+
* Spawn a subagent through Pi's configured `spawnCommand` and return a
|
|
820
|
+
* live handle bound to the canonical streaming, attribution, and
|
|
821
|
+
* cleanup contract.
|
|
652
822
|
*
|
|
653
823
|
* @remarks
|
|
654
|
-
*
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
*
|
|
658
|
-
*
|
|
659
|
-
*
|
|
660
|
-
* in
|
|
661
|
-
*/
|
|
662
|
-
async installMcpAsExtension(server, scope) {
|
|
663
|
-
const dir = this.extensionsDir(scope);
|
|
664
|
-
await mkdir3(dir, { recursive: true });
|
|
665
|
-
const filePath = join4(dir, `mcp-${server.name}.ts`);
|
|
666
|
-
const launchConfig = JSON.stringify(
|
|
667
|
-
{
|
|
668
|
-
command: server.command,
|
|
669
|
-
args: server.args ?? [],
|
|
670
|
-
url: server.url,
|
|
671
|
-
env: server.env ?? {},
|
|
672
|
-
headers: server.headers ?? {}
|
|
673
|
-
},
|
|
674
|
-
null,
|
|
675
|
-
2
|
|
676
|
-
);
|
|
677
|
-
const src = `// AUTO-GENERATED by @cleocode/caamp \u2014 do not edit.
|
|
678
|
-
// MCP-as-Pi-extension bridge scaffold for "${server.name}".
|
|
679
|
-
// TODO: implement the MCP JSON-RPC bridge. Current behavior is a stub
|
|
680
|
-
// that logs every tool invocation. The scaffold exists so that CAAMP
|
|
681
|
-
// can manage the extension lifecycle (install/remove/list) without
|
|
682
|
-
// blocking on a full MCP runtime bridge.
|
|
683
|
-
|
|
684
|
-
const CONFIG = ${launchConfig};
|
|
685
|
-
|
|
686
|
-
export default (pi: unknown) => {
|
|
687
|
-
const api = pi as {
|
|
688
|
-
registerTool: (def: {
|
|
689
|
-
name: string;
|
|
690
|
-
label: string;
|
|
691
|
-
description: string;
|
|
692
|
-
parameters: unknown;
|
|
693
|
-
execute: (...args: unknown[]) => Promise<{ type: 'text'; text: string; isError?: boolean }>;
|
|
694
|
-
}) => void;
|
|
695
|
-
};
|
|
696
|
-
|
|
697
|
-
api.registerTool({
|
|
698
|
-
name: ${JSON.stringify(`mcp_${server.name}`)},
|
|
699
|
-
label: ${JSON.stringify(`MCP: ${server.name}`)},
|
|
700
|
-
description: ${JSON.stringify(
|
|
701
|
-
`MCP server "${server.name}" \u2014 bridge scaffold, not yet implemented.`
|
|
702
|
-
)},
|
|
703
|
-
parameters: { type: 'object', properties: {} },
|
|
704
|
-
execute: async () => ({
|
|
705
|
-
type: 'text',
|
|
706
|
-
text: \`MCP bridge for "${server.name}" is a scaffold. Config: \${JSON.stringify(CONFIG)}\`,
|
|
707
|
-
isError: true,
|
|
708
|
-
}),
|
|
709
|
-
});
|
|
710
|
-
};
|
|
711
|
-
`;
|
|
712
|
-
await writeFile(filePath, src, "utf8");
|
|
713
|
-
}
|
|
714
|
-
// ── Subagent spawn ──────────────────────────────────────────────────
|
|
715
|
-
/**
|
|
716
|
-
* {@inheritDoc Harness.spawnSubagent}
|
|
824
|
+
* Per ADR-035 §D6 this is the **only** sanctioned subagent spawn path
|
|
825
|
+
* in CLEO. All historical direct `child_process.spawn` callers in
|
|
826
|
+
* subagent contexts (including the `cant-bridge.ts` Pi extension and
|
|
827
|
+
* the legacy CLEO orchestrator paths) MUST migrate to this method so
|
|
828
|
+
* the contract below holds uniformly. A custom biome rule banning
|
|
829
|
+
* raw `spawn()` from subagent code is planned for v3 cleanup but is
|
|
830
|
+
* intentionally NOT enforced in v2 to keep the migration incremental.
|
|
717
831
|
*
|
|
718
|
-
*
|
|
719
|
-
*
|
|
720
|
-
*
|
|
721
|
-
*
|
|
722
|
-
*
|
|
723
|
-
*
|
|
832
|
+
* **Streaming semantics** — Pi's `--mode json` produces line-delimited
|
|
833
|
+
* JSON on stdout. The harness:
|
|
834
|
+
*
|
|
835
|
+
* - Line-buffers stdout, parses each line as JSON, and forwards a
|
|
836
|
+
* `{ kind: 'message', subagentId, lineNumber, payload }`
|
|
837
|
+
* {@link SubagentStreamEvent} via {@link SubagentSpawnOptions.onStream}.
|
|
838
|
+
* Non-parseable lines increment a warning counter (recorded in the
|
|
839
|
+
* child session as `{ type: 'raw' }`) but never crash the loop.
|
|
840
|
+
* - Line-buffers stderr separately, forwards each line as
|
|
841
|
+
* `{ kind: 'stderr', subagentId, payload: { line } }`, and stores
|
|
842
|
+
* it in a 100-line ring buffer accessible via
|
|
843
|
+
* {@link SubagentHandle.recentStderr}. Stderr is **never** injected
|
|
844
|
+
* into the parent LLM context per ADR-035 §D6.
|
|
845
|
+
* - Emits a final `{ kind: 'exit', subagentId, payload: SubagentExitResult }`
|
|
846
|
+
* when the child terminates.
|
|
847
|
+
*
|
|
848
|
+
* **Session attribution** — Every spawn produces a child session JSONL
|
|
849
|
+
* file at
|
|
850
|
+
* `~/.pi/agent/sessions/subagents/subagent-{parentSessionId}-{taskId}.jsonl`.
|
|
851
|
+
* The header line records the subagentId, taskId, and parent linkage.
|
|
852
|
+
* When {@link SubagentTask.parentSessionPath} is supplied, a
|
|
853
|
+
* {@link SubagentLinkEntry} is appended to the parent session file as
|
|
854
|
+
* a JSONL line so listing the parent surfaces its children.
|
|
855
|
+
*
|
|
856
|
+
* **Exit propagation** — {@link SubagentHandle.exitPromise} resolves
|
|
857
|
+
* with `{ code, signal, childSessionPath, durationMs }` exactly once
|
|
858
|
+
* when the child exits. The promise NEVER rejects: failure is
|
|
859
|
+
* encoded by a non-zero `code`, a non-null `signal`, or partial
|
|
860
|
+
* output preserved in the child session file.
|
|
861
|
+
*
|
|
862
|
+
* **Cleanup** — {@link SubagentHandle.terminate} sends SIGTERM, waits
|
|
863
|
+
* the configured grace window, then sends SIGKILL if the child is
|
|
864
|
+
* still alive. The grace window is sourced from
|
|
865
|
+
* {@link SubagentSpawnOptions.terminateGraceMs} when supplied,
|
|
866
|
+
* otherwise from `settings.json:pi.subagent.terminateGraceMs`,
|
|
867
|
+
* otherwise from {@link DEFAULT_TERMINATE_GRACE_MS}. A
|
|
868
|
+
* `subagent_exit` entry with reason `terminated` is appended to the
|
|
869
|
+
* child session file when cleanup runs.
|
|
724
870
|
*
|
|
725
|
-
*
|
|
726
|
-
*
|
|
871
|
+
* **Concurrency** — Use the static helpers
|
|
872
|
+
* {@link PiHarness.raceSubagents} and
|
|
873
|
+
* {@link PiHarness.settleAllSubagents} to compose `parallel: race`
|
|
874
|
+
* and `parallel: settle` constructs from CANT workflows over multiple
|
|
875
|
+
* handles.
|
|
876
|
+
*
|
|
877
|
+
* **Orphan handling** — On the first spawn the harness registers a
|
|
878
|
+
* process-wide `'exit'` handler that terminates every still-active
|
|
879
|
+
* subagent so a parent crash never strands children.
|
|
880
|
+
*
|
|
881
|
+
* Throws immediately when the provider entry is missing a
|
|
882
|
+
* `spawnCommand` so callers see configuration errors early rather
|
|
883
|
+
* than at child-exit time.
|
|
884
|
+
*
|
|
885
|
+
* @param task - Subagent task specification.
|
|
886
|
+
* @param opts - Per-call streaming and cleanup overrides.
|
|
887
|
+
* @returns A live subagent handle.
|
|
727
888
|
*/
|
|
728
|
-
async spawnSubagent(task) {
|
|
889
|
+
async spawnSubagent(task, opts = {}) {
|
|
729
890
|
const cmd = this.provider.capabilities.spawn.spawnCommand;
|
|
730
891
|
if (cmd === null || cmd.length === 0) {
|
|
731
892
|
throw new Error(
|
|
@@ -736,44 +897,312 @@ export default (pi: unknown) => {
|
|
|
736
897
|
if (typeof program !== "string" || program.length === 0) {
|
|
737
898
|
throw new Error("PiHarness.spawnSubagent: invalid spawnCommand (missing program)");
|
|
738
899
|
}
|
|
900
|
+
const taskId = task.taskId ?? generateShortId();
|
|
901
|
+
const parentSessionId = task.parentSessionId ?? "orphan";
|
|
902
|
+
const subagentId = `sub-${taskId}-${generateShortId().slice(0, 6)}`;
|
|
903
|
+
const childSessionPath = join5(
|
|
904
|
+
getPiAgentDir2(),
|
|
905
|
+
"sessions",
|
|
906
|
+
"subagents",
|
|
907
|
+
`subagent-${parentSessionId}-${taskId}.jsonl`
|
|
908
|
+
);
|
|
909
|
+
await mkdir3(dirname2(childSessionPath), { recursive: true });
|
|
910
|
+
let grace = opts.terminateGraceMs;
|
|
911
|
+
if (grace === void 0) {
|
|
912
|
+
try {
|
|
913
|
+
const settings = await this.readSettings({ kind: "global" });
|
|
914
|
+
grace = readTerminateGraceFromSettings(settings, DEFAULT_TERMINATE_GRACE_MS);
|
|
915
|
+
} catch {
|
|
916
|
+
grace = DEFAULT_TERMINATE_GRACE_MS;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
if (!Number.isFinite(grace) || grace < 0) {
|
|
920
|
+
grace = DEFAULT_TERMINATE_GRACE_MS;
|
|
921
|
+
}
|
|
922
|
+
const startedAt = /* @__PURE__ */ new Date();
|
|
923
|
+
const startedAtIso = startedAt.toISOString();
|
|
924
|
+
const sessionHeader = {
|
|
925
|
+
type: "session",
|
|
926
|
+
version: 3,
|
|
927
|
+
id: subagentId,
|
|
928
|
+
timestamp: startedAtIso,
|
|
929
|
+
cwd: opts.cwd ?? task.cwd ?? process.cwd(),
|
|
930
|
+
parentSession: task.parentSessionId ?? null,
|
|
931
|
+
taskId,
|
|
932
|
+
childSessionPath
|
|
933
|
+
};
|
|
934
|
+
await writeFile(childSessionPath, `${JSON.stringify(sessionHeader)}
|
|
935
|
+
`, "utf8");
|
|
739
936
|
const baseArgs = cmd.slice(1);
|
|
740
937
|
const args = [...baseArgs, task.prompt];
|
|
741
938
|
const child = spawn(program, args, {
|
|
742
|
-
cwd: task.cwd,
|
|
743
|
-
env: { ...process.env, ...task.env },
|
|
939
|
+
cwd: opts.cwd ?? task.cwd,
|
|
940
|
+
env: { ...process.env, ...task.env, ...opts.env },
|
|
744
941
|
stdio: ["ignore", "pipe", "pipe"]
|
|
745
942
|
});
|
|
746
|
-
let
|
|
747
|
-
let
|
|
943
|
+
let stdoutAccum = "";
|
|
944
|
+
let stderrAccum = "";
|
|
945
|
+
let stdoutBuffer = "";
|
|
946
|
+
let stderrBuffer = "";
|
|
947
|
+
let stdoutLineNumber = 0;
|
|
948
|
+
let nonJsonLineCount = 0;
|
|
949
|
+
const stderrRing = [];
|
|
950
|
+
const safeOnStream = (event) => {
|
|
951
|
+
if (opts.onStream === void 0) return;
|
|
952
|
+
try {
|
|
953
|
+
opts.onStream(event);
|
|
954
|
+
} catch (err) {
|
|
955
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
956
|
+
stderrRing.push(`[onStream] ${message}`);
|
|
957
|
+
if (stderrRing.length > STDERR_RING_BUFFER_SIZE) stderrRing.shift();
|
|
958
|
+
}
|
|
959
|
+
};
|
|
960
|
+
const writeChildSession = (entry) => {
|
|
961
|
+
void appendFile(childSessionPath, `${JSON.stringify(entry)}
|
|
962
|
+
`, "utf8").catch(() => {
|
|
963
|
+
const synthetic = `[childSession] failed to append entry`;
|
|
964
|
+
stderrRing.push(synthetic);
|
|
965
|
+
if (stderrRing.length > STDERR_RING_BUFFER_SIZE) stderrRing.shift();
|
|
966
|
+
});
|
|
967
|
+
};
|
|
968
|
+
const flushStdoutBuffer = (final) => {
|
|
969
|
+
let nlIdx = stdoutBuffer.indexOf("\n");
|
|
970
|
+
while (nlIdx !== -1) {
|
|
971
|
+
const line = stdoutBuffer.slice(0, nlIdx);
|
|
972
|
+
stdoutBuffer = stdoutBuffer.slice(nlIdx + 1);
|
|
973
|
+
this.handleStdoutLine(line, {
|
|
974
|
+
subagentId,
|
|
975
|
+
increment: () => ++stdoutLineNumber,
|
|
976
|
+
incrementNonJson: () => ++nonJsonLineCount,
|
|
977
|
+
writeChildSession,
|
|
978
|
+
safeOnStream
|
|
979
|
+
});
|
|
980
|
+
nlIdx = stdoutBuffer.indexOf("\n");
|
|
981
|
+
}
|
|
982
|
+
if (final && stdoutBuffer.length > 0) {
|
|
983
|
+
const remainder = stdoutBuffer;
|
|
984
|
+
stdoutBuffer = "";
|
|
985
|
+
this.handleStdoutLine(remainder, {
|
|
986
|
+
subagentId,
|
|
987
|
+
increment: () => ++stdoutLineNumber,
|
|
988
|
+
incrementNonJson: () => ++nonJsonLineCount,
|
|
989
|
+
writeChildSession,
|
|
990
|
+
safeOnStream
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
};
|
|
994
|
+
const flushStderrBuffer = (final) => {
|
|
995
|
+
let nlIdx = stderrBuffer.indexOf("\n");
|
|
996
|
+
while (nlIdx !== -1) {
|
|
997
|
+
const line = stderrBuffer.slice(0, nlIdx);
|
|
998
|
+
stderrBuffer = stderrBuffer.slice(nlIdx + 1);
|
|
999
|
+
stderrRing.push(line);
|
|
1000
|
+
if (stderrRing.length > STDERR_RING_BUFFER_SIZE) stderrRing.shift();
|
|
1001
|
+
writeChildSession({ type: "subagent_stderr", line });
|
|
1002
|
+
safeOnStream({ kind: "stderr", subagentId, payload: { line } });
|
|
1003
|
+
nlIdx = stderrBuffer.indexOf("\n");
|
|
1004
|
+
}
|
|
1005
|
+
if (final && stderrBuffer.length > 0) {
|
|
1006
|
+
const line = stderrBuffer;
|
|
1007
|
+
stderrBuffer = "";
|
|
1008
|
+
stderrRing.push(line);
|
|
1009
|
+
if (stderrRing.length > STDERR_RING_BUFFER_SIZE) stderrRing.shift();
|
|
1010
|
+
writeChildSession({ type: "subagent_stderr", line });
|
|
1011
|
+
safeOnStream({ kind: "stderr", subagentId, payload: { line } });
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
748
1014
|
child.stdout?.on("data", (chunk) => {
|
|
749
|
-
|
|
1015
|
+
const text = chunk.toString("utf8");
|
|
1016
|
+
stdoutAccum += text;
|
|
1017
|
+
stdoutBuffer += text;
|
|
1018
|
+
flushStdoutBuffer(false);
|
|
750
1019
|
});
|
|
751
1020
|
child.stderr?.on("data", (chunk) => {
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
let parsed;
|
|
757
|
-
try {
|
|
758
|
-
parsed = JSON.parse(stdout);
|
|
759
|
-
} catch {
|
|
760
|
-
}
|
|
761
|
-
resolve({ exitCode, stdout, stderr, parsed });
|
|
762
|
-
});
|
|
1021
|
+
const text = chunk.toString("utf8");
|
|
1022
|
+
stderrAccum += text;
|
|
1023
|
+
stderrBuffer += text;
|
|
1024
|
+
flushStderrBuffer(false);
|
|
763
1025
|
});
|
|
1026
|
+
let terminating = false;
|
|
1027
|
+
let terminationReason = "natural";
|
|
1028
|
+
let terminatePromise = null;
|
|
1029
|
+
const terminateImpl = () => {
|
|
1030
|
+
if (terminatePromise !== null) return terminatePromise;
|
|
1031
|
+
terminating = true;
|
|
1032
|
+
terminationReason = "terminated";
|
|
1033
|
+
terminatePromise = terminateSubagent(child, grace ?? DEFAULT_TERMINATE_GRACE_MS);
|
|
1034
|
+
return terminatePromise;
|
|
1035
|
+
};
|
|
1036
|
+
const terminateSync = () => {
|
|
1037
|
+
if (terminating) return;
|
|
1038
|
+
terminating = true;
|
|
1039
|
+
terminationReason = "terminated";
|
|
1040
|
+
try {
|
|
1041
|
+
child.kill("SIGTERM");
|
|
1042
|
+
} catch {
|
|
1043
|
+
}
|
|
1044
|
+
};
|
|
1045
|
+
const activeRecord = {
|
|
1046
|
+
child,
|
|
1047
|
+
subagentId,
|
|
1048
|
+
terminate: terminateSync
|
|
1049
|
+
};
|
|
1050
|
+
activeSubagents.add(activeRecord);
|
|
1051
|
+
ensureOrphanSweeperRegistered();
|
|
764
1052
|
if (task.signal !== void 0) {
|
|
765
|
-
|
|
766
|
-
|
|
1053
|
+
const onAbort = () => {
|
|
1054
|
+
void terminateImpl();
|
|
1055
|
+
};
|
|
1056
|
+
if (task.signal.aborted) {
|
|
1057
|
+
onAbort();
|
|
1058
|
+
} else {
|
|
1059
|
+
task.signal.addEventListener("abort", onAbort, { once: true });
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
const exitPromise = new Promise((resolve) => {
|
|
1063
|
+
child.on("close", (exitCode, signal) => {
|
|
1064
|
+
flushStdoutBuffer(true);
|
|
1065
|
+
flushStderrBuffer(true);
|
|
1066
|
+
const durationMs = Date.now() - startedAt.getTime();
|
|
1067
|
+
writeChildSession({
|
|
1068
|
+
type: "subagent_exit",
|
|
1069
|
+
code: exitCode,
|
|
1070
|
+
signal,
|
|
1071
|
+
reason: terminationReason,
|
|
1072
|
+
durationMs,
|
|
1073
|
+
nonJsonLineCount
|
|
1074
|
+
});
|
|
1075
|
+
activeSubagents.delete(activeRecord);
|
|
1076
|
+
const result2 = {
|
|
1077
|
+
code: exitCode,
|
|
1078
|
+
signal,
|
|
1079
|
+
childSessionPath,
|
|
1080
|
+
durationMs
|
|
1081
|
+
};
|
|
1082
|
+
safeOnStream({ kind: "exit", subagentId, payload: result2 });
|
|
1083
|
+
resolve(result2);
|
|
1084
|
+
});
|
|
1085
|
+
child.on("error", () => {
|
|
767
1086
|
});
|
|
1087
|
+
});
|
|
1088
|
+
const result = exitPromise.then(({ code }) => {
|
|
1089
|
+
let parsed;
|
|
1090
|
+
try {
|
|
1091
|
+
parsed = JSON.parse(stdoutAccum);
|
|
1092
|
+
} catch {
|
|
1093
|
+
}
|
|
1094
|
+
return { exitCode: code, stdout: stdoutAccum, stderr: stderrAccum, parsed };
|
|
1095
|
+
});
|
|
1096
|
+
const linkEntry = {
|
|
1097
|
+
type: "subagent_link",
|
|
1098
|
+
subagentId,
|
|
1099
|
+
taskId,
|
|
1100
|
+
childSessionPath,
|
|
1101
|
+
startedAt: startedAtIso
|
|
1102
|
+
};
|
|
1103
|
+
if (task.parentSessionPath !== void 0 && task.parentSessionPath.length > 0) {
|
|
1104
|
+
try {
|
|
1105
|
+
await writeSubagentLink(task.parentSessionPath, linkEntry);
|
|
1106
|
+
safeOnStream({ kind: "link", subagentId, payload: linkEntry });
|
|
1107
|
+
} catch {
|
|
1108
|
+
stderrRing.push(`[link] failed to write subagent_link to parent`);
|
|
1109
|
+
if (stderrRing.length > STDERR_RING_BUFFER_SIZE) stderrRing.shift();
|
|
1110
|
+
}
|
|
768
1111
|
}
|
|
769
1112
|
return {
|
|
1113
|
+
subagentId,
|
|
1114
|
+
taskId,
|
|
1115
|
+
childSessionPath,
|
|
770
1116
|
pid: child.pid ?? null,
|
|
1117
|
+
startedAt,
|
|
1118
|
+
exitPromise,
|
|
771
1119
|
result,
|
|
1120
|
+
terminate: terminateImpl,
|
|
772
1121
|
abort: () => {
|
|
773
|
-
|
|
774
|
-
}
|
|
1122
|
+
void terminateImpl();
|
|
1123
|
+
},
|
|
1124
|
+
recentStderr: () => stderrRing.slice()
|
|
775
1125
|
};
|
|
776
1126
|
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Race a set of subagent handles, returning the first one that exits.
|
|
1129
|
+
*
|
|
1130
|
+
* @remarks
|
|
1131
|
+
* Maps CANT's `parallel: race` construct (per ADR-035 §D6) onto the
|
|
1132
|
+
* canonical {@link spawnSubagent} contract. The losing handles are
|
|
1133
|
+
* gracefully terminated via {@link SubagentHandle.terminate} once the
|
|
1134
|
+
* first settles so no straggler children outlive the race.
|
|
1135
|
+
*
|
|
1136
|
+
* @param handles - Subagent handles to race.
|
|
1137
|
+
* @returns The {@link SubagentExitResult} of the first child to exit.
|
|
1138
|
+
* @throws When `handles` is empty (caller bug — a race over zero
|
|
1139
|
+
* children has no winner).
|
|
1140
|
+
*/
|
|
1141
|
+
static async raceSubagents(handles) {
|
|
1142
|
+
if (handles.length === 0) {
|
|
1143
|
+
throw new Error("PiHarness.raceSubagents: cannot race an empty handle list");
|
|
1144
|
+
}
|
|
1145
|
+
const tagged = handles.map(
|
|
1146
|
+
(handle, index) => handle.exitPromise.then((value) => ({ index, value }))
|
|
1147
|
+
);
|
|
1148
|
+
const winner = await Promise.race(tagged);
|
|
1149
|
+
const losers = [];
|
|
1150
|
+
for (let i = 0; i < handles.length; i += 1) {
|
|
1151
|
+
if (i === winner.index) continue;
|
|
1152
|
+
const loser = handles[i];
|
|
1153
|
+
if (loser === void 0) continue;
|
|
1154
|
+
losers.push(loser.terminate().catch(() => void 0));
|
|
1155
|
+
}
|
|
1156
|
+
await Promise.all(losers);
|
|
1157
|
+
return winner.value;
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* Settle a set of subagent handles, returning a parallel array of
|
|
1161
|
+
* results.
|
|
1162
|
+
*
|
|
1163
|
+
* @remarks
|
|
1164
|
+
* Maps CANT's `parallel: settle` construct (per ADR-035 §D6) onto the
|
|
1165
|
+
* canonical {@link spawnSubagent} contract. Because
|
|
1166
|
+
* {@link SubagentHandle.exitPromise} never rejects, every entry in
|
|
1167
|
+
* the returned array is `{ status: 'fulfilled', value: ... }` under
|
|
1168
|
+
* normal operation; the `PromiseSettledResult` shape is preserved
|
|
1169
|
+
* for forward compatibility with future failure modes.
|
|
1170
|
+
*
|
|
1171
|
+
* @param handles - Subagent handles to settle.
|
|
1172
|
+
* @returns Parallel array of settled exit results, one per input.
|
|
1173
|
+
*/
|
|
1174
|
+
static async settleAllSubagents(handles) {
|
|
1175
|
+
return Promise.allSettled(handles.map((h) => h.exitPromise));
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Per-line stdout dispatcher used by the streaming buffer flusher.
|
|
1179
|
+
*
|
|
1180
|
+
* @remarks
|
|
1181
|
+
* Extracted as a private method so the line-handling logic stays
|
|
1182
|
+
* close to {@link spawnSubagent} but does not bloat the parent
|
|
1183
|
+
* function. Skips empty lines (a leading newline produces a zero-
|
|
1184
|
+
* length entry that has no semantic meaning).
|
|
1185
|
+
*/
|
|
1186
|
+
handleStdoutLine(rawLine, ctx) {
|
|
1187
|
+
const trimmed = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
1188
|
+
if (trimmed.length === 0) return;
|
|
1189
|
+
const lineNumber = ctx.increment();
|
|
1190
|
+
let parsed;
|
|
1191
|
+
try {
|
|
1192
|
+
parsed = JSON.parse(trimmed);
|
|
1193
|
+
} catch {
|
|
1194
|
+
ctx.incrementNonJson();
|
|
1195
|
+
ctx.writeChildSession({ type: "raw", lineNumber, line: trimmed });
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
ctx.writeChildSession({ type: "custom_message", lineNumber, payload: parsed });
|
|
1199
|
+
ctx.safeOnStream({
|
|
1200
|
+
kind: "message",
|
|
1201
|
+
subagentId: ctx.subagentId,
|
|
1202
|
+
lineNumber,
|
|
1203
|
+
payload: parsed
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
777
1206
|
// ── Settings ────────────────────────────────────────────────────────
|
|
778
1207
|
/** {@inheritDoc Harness.readSettings} */
|
|
779
1208
|
async readSettings(scope) {
|
|
@@ -798,7 +1227,722 @@ export default (pi: unknown) => {
|
|
|
798
1227
|
async configureModels(modelPatterns, scope) {
|
|
799
1228
|
await this.writeSettings({ enabledModels: modelPatterns }, scope);
|
|
800
1229
|
}
|
|
801
|
-
|
|
1230
|
+
// ── Wave-1 three-tier helpers ───────────────────────────────────────
|
|
1231
|
+
/**
|
|
1232
|
+
* Resolve the `models.json` path for a given legacy two-tier scope.
|
|
1233
|
+
*
|
|
1234
|
+
* @remarks
|
|
1235
|
+
* Lives next to `settings.json`. Global scope uses the Pi state root,
|
|
1236
|
+
* project scope uses the project's `.pi/` directory, matching the
|
|
1237
|
+
* dual-file authority model documented in ADR-035 §D3.
|
|
1238
|
+
*/
|
|
1239
|
+
modelsConfigPath(scope) {
|
|
1240
|
+
return scope.kind === "global" ? join5(getPiAgentDir2(), "models.json") : join5(scope.projectDir, ".pi", "models.json");
|
|
1241
|
+
}
|
|
1242
|
+
/**
|
|
1243
|
+
* Resolve the sessions directory — always user-tier because Pi owns
|
|
1244
|
+
* session storage and the three-tier model folds session listings to
|
|
1245
|
+
* the single authoritative location per ADR-035 §D2.
|
|
1246
|
+
*/
|
|
1247
|
+
sessionsDir() {
|
|
1248
|
+
return join5(getPiAgentDir2(), "sessions");
|
|
1249
|
+
}
|
|
1250
|
+
// ── Extensions (Wave-1, T263) ───────────────────────────────────────
|
|
1251
|
+
/** {@inheritDoc Harness.installExtension} */
|
|
1252
|
+
async installExtension(sourcePath, name, tier, projectDir, opts) {
|
|
1253
|
+
if (!existsSync4(sourcePath)) {
|
|
1254
|
+
throw new Error(`installExtension: source file does not exist: ${sourcePath}`);
|
|
1255
|
+
}
|
|
1256
|
+
const stats = await stat(sourcePath);
|
|
1257
|
+
if (!stats.isFile()) {
|
|
1258
|
+
throw new Error(`installExtension: source path is not a regular file: ${sourcePath}`);
|
|
1259
|
+
}
|
|
1260
|
+
const ext = extname(sourcePath);
|
|
1261
|
+
if (ext !== ".ts" && ext !== ".tsx" && ext !== ".mts") {
|
|
1262
|
+
throw new Error(
|
|
1263
|
+
`installExtension: expected a TypeScript source file (.ts/.tsx/.mts), got: ${ext || "(no extension)"}`
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1266
|
+
const contents = await readFile(sourcePath, "utf8");
|
|
1267
|
+
if (!/\bexport\s+default\b/.test(contents)) {
|
|
1268
|
+
throw new Error(
|
|
1269
|
+
`installExtension: source file is missing an 'export default' \u2014 Pi extensions must export a default function`
|
|
1270
|
+
);
|
|
1271
|
+
}
|
|
1272
|
+
const dir = resolveTierDir({ tier, kind: "extensions", projectDir });
|
|
1273
|
+
const targetPath = join5(dir, `${name}.ts`);
|
|
1274
|
+
if (existsSync4(targetPath) && opts?.force !== true) {
|
|
1275
|
+
throw new Error(
|
|
1276
|
+
`installExtension: target already exists at ${targetPath} (pass --force to overwrite)`
|
|
1277
|
+
);
|
|
1278
|
+
}
|
|
1279
|
+
await mkdir3(dir, { recursive: true });
|
|
1280
|
+
await writeFile(targetPath, contents, "utf8");
|
|
1281
|
+
return { targetPath, tier };
|
|
1282
|
+
}
|
|
1283
|
+
/** {@inheritDoc Harness.removeExtension} */
|
|
1284
|
+
async removeExtension(name, tier, projectDir) {
|
|
1285
|
+
const dir = resolveTierDir({ tier, kind: "extensions", projectDir });
|
|
1286
|
+
const targetPath = join5(dir, `${name}.ts`);
|
|
1287
|
+
if (!existsSync4(targetPath)) return false;
|
|
1288
|
+
await rm3(targetPath, { force: true });
|
|
1289
|
+
return true;
|
|
1290
|
+
}
|
|
1291
|
+
/** {@inheritDoc Harness.listExtensions} */
|
|
1292
|
+
async listExtensions(projectDir) {
|
|
1293
|
+
const tiers = resolveAllTiers("extensions", projectDir);
|
|
1294
|
+
const out = [];
|
|
1295
|
+
const seenNames = /* @__PURE__ */ new Set();
|
|
1296
|
+
for (const { tier, dir } of tiers) {
|
|
1297
|
+
if (!existsSync4(dir)) continue;
|
|
1298
|
+
let entries;
|
|
1299
|
+
try {
|
|
1300
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
1301
|
+
} catch {
|
|
1302
|
+
continue;
|
|
1303
|
+
}
|
|
1304
|
+
for (const entry of entries) {
|
|
1305
|
+
if (!entry.isFile()) continue;
|
|
1306
|
+
const fileName = entry.name;
|
|
1307
|
+
if (!fileName.endsWith(".ts")) continue;
|
|
1308
|
+
const name = fileName.slice(0, -".ts".length);
|
|
1309
|
+
const shadowed = seenNames.has(name);
|
|
1310
|
+
out.push({
|
|
1311
|
+
name,
|
|
1312
|
+
tier,
|
|
1313
|
+
path: join5(dir, fileName),
|
|
1314
|
+
shadowed
|
|
1315
|
+
});
|
|
1316
|
+
seenNames.add(name);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
return out;
|
|
1320
|
+
}
|
|
1321
|
+
// ── Sessions (Wave-1, T264) ─────────────────────────────────────────
|
|
1322
|
+
/** {@inheritDoc Harness.listSessions} */
|
|
1323
|
+
async listSessions(opts) {
|
|
1324
|
+
const rootDir = this.sessionsDir();
|
|
1325
|
+
if (!existsSync4(rootDir)) return [];
|
|
1326
|
+
const files = [];
|
|
1327
|
+
let rootEntries;
|
|
1328
|
+
try {
|
|
1329
|
+
rootEntries = await readdir(rootDir, { withFileTypes: true });
|
|
1330
|
+
} catch {
|
|
1331
|
+
return [];
|
|
1332
|
+
}
|
|
1333
|
+
for (const entry of rootEntries) {
|
|
1334
|
+
if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
1335
|
+
files.push(join5(rootDir, entry.name));
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
if (opts?.includeSubagents !== false) {
|
|
1339
|
+
const subDir = join5(rootDir, "subagents");
|
|
1340
|
+
if (existsSync4(subDir)) {
|
|
1341
|
+
try {
|
|
1342
|
+
const subEntries = await readdir(subDir, { withFileTypes: true });
|
|
1343
|
+
for (const entry of subEntries) {
|
|
1344
|
+
if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
1345
|
+
files.push(join5(subDir, entry.name));
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
} catch {
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
const summaries = [];
|
|
1353
|
+
for (const filePath of files) {
|
|
1354
|
+
const summary = await readSessionHeader(filePath);
|
|
1355
|
+
if (summary !== null) {
|
|
1356
|
+
summaries.push(summary);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
summaries.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
1360
|
+
return summaries;
|
|
1361
|
+
}
|
|
1362
|
+
/** {@inheritDoc Harness.showSession} */
|
|
1363
|
+
async showSession(id) {
|
|
1364
|
+
const summaries = await this.listSessions({ includeSubagents: true });
|
|
1365
|
+
const match = summaries.find((s) => s.id === id);
|
|
1366
|
+
if (match === void 0) {
|
|
1367
|
+
throw new Error(`showSession: no session found with id ${id}`);
|
|
1368
|
+
}
|
|
1369
|
+
const raw = await readFile(match.filePath, "utf8");
|
|
1370
|
+
const allLines = raw.split("\n");
|
|
1371
|
+
while (allLines.length > 0 && allLines[allLines.length - 1] === "") {
|
|
1372
|
+
allLines.pop();
|
|
1373
|
+
}
|
|
1374
|
+
const entries = allLines.slice(1);
|
|
1375
|
+
return { summary: match, entries };
|
|
1376
|
+
}
|
|
1377
|
+
// ── Models (Wave-1, T265) ───────────────────────────────────────────
|
|
1378
|
+
/** {@inheritDoc Harness.readModelsConfig} */
|
|
1379
|
+
async readModelsConfig(scope) {
|
|
1380
|
+
const filePath = this.modelsConfigPath(scope);
|
|
1381
|
+
if (!existsSync4(filePath)) return { providers: {} };
|
|
1382
|
+
let raw;
|
|
1383
|
+
try {
|
|
1384
|
+
raw = await readFile(filePath, "utf8");
|
|
1385
|
+
} catch {
|
|
1386
|
+
return { providers: {} };
|
|
1387
|
+
}
|
|
1388
|
+
try {
|
|
1389
|
+
const parsed = JSON.parse(raw);
|
|
1390
|
+
if (!isPlainObject(parsed)) return { providers: {} };
|
|
1391
|
+
const providersField = parsed["providers"];
|
|
1392
|
+
if (!isPlainObject(providersField)) return { providers: {} };
|
|
1393
|
+
const providers = {};
|
|
1394
|
+
for (const [id, block] of Object.entries(providersField)) {
|
|
1395
|
+
if (isPlainObject(block)) {
|
|
1396
|
+
providers[id] = block;
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
return { providers };
|
|
1400
|
+
} catch {
|
|
1401
|
+
return { providers: {} };
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
/** {@inheritDoc Harness.writeModelsConfig} */
|
|
1405
|
+
async writeModelsConfig(config, scope) {
|
|
1406
|
+
const filePath = this.modelsConfigPath(scope);
|
|
1407
|
+
await atomicWriteJson(filePath, config);
|
|
1408
|
+
}
|
|
1409
|
+
/** {@inheritDoc Harness.listModels} */
|
|
1410
|
+
async listModels(scope) {
|
|
1411
|
+
const models = await this.readModelsConfig(scope);
|
|
1412
|
+
const settings = await this.readSettings(scope);
|
|
1413
|
+
const settingsObj = isPlainObject(settings) ? settings : {};
|
|
1414
|
+
const enabledRaw = settingsObj["enabledModels"];
|
|
1415
|
+
const enabled = Array.isArray(enabledRaw) ? enabledRaw.filter((v) => typeof v === "string") : [];
|
|
1416
|
+
const defaultModel = typeof settingsObj["defaultModel"] === "string" ? settingsObj["defaultModel"] : null;
|
|
1417
|
+
const defaultProvider = typeof settingsObj["defaultProvider"] === "string" ? settingsObj["defaultProvider"] : null;
|
|
1418
|
+
const out = [];
|
|
1419
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1420
|
+
for (const [providerId, providerBlock] of Object.entries(models.providers)) {
|
|
1421
|
+
const modelDefs = providerBlock.models ?? [];
|
|
1422
|
+
for (const def of modelDefs) {
|
|
1423
|
+
const key = `${providerId}:${def.id}`;
|
|
1424
|
+
seen.add(key);
|
|
1425
|
+
const isEnabled = enabled.includes(key) || enabled.includes(`${providerId}/*`);
|
|
1426
|
+
const isDefault = defaultProvider === providerId && defaultModel === def.id;
|
|
1427
|
+
out.push({
|
|
1428
|
+
provider: providerId,
|
|
1429
|
+
id: def.id,
|
|
1430
|
+
name: def.name ?? null,
|
|
1431
|
+
enabled: isEnabled,
|
|
1432
|
+
isDefault,
|
|
1433
|
+
custom: true
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
for (const selection of enabled) {
|
|
1438
|
+
if (!selection.includes(":") && !selection.includes("/")) continue;
|
|
1439
|
+
const match = selection.match(/^([^:/]+)[:/]([^:/].*)$/);
|
|
1440
|
+
if (match === null) continue;
|
|
1441
|
+
const provider = match[1];
|
|
1442
|
+
const id = match[2];
|
|
1443
|
+
if (provider === void 0 || id === void 0) continue;
|
|
1444
|
+
if (id.endsWith("*")) continue;
|
|
1445
|
+
const key = `${provider}:${id}`;
|
|
1446
|
+
if (seen.has(key)) continue;
|
|
1447
|
+
seen.add(key);
|
|
1448
|
+
const isDefault = defaultProvider === provider && defaultModel === id;
|
|
1449
|
+
out.push({
|
|
1450
|
+
provider,
|
|
1451
|
+
id,
|
|
1452
|
+
name: null,
|
|
1453
|
+
enabled: true,
|
|
1454
|
+
isDefault,
|
|
1455
|
+
custom: false
|
|
1456
|
+
});
|
|
1457
|
+
}
|
|
1458
|
+
if (defaultProvider !== null && defaultModel !== null && !seen.has(`${defaultProvider}:${defaultModel}`)) {
|
|
1459
|
+
out.push({
|
|
1460
|
+
provider: defaultProvider,
|
|
1461
|
+
id: defaultModel,
|
|
1462
|
+
name: null,
|
|
1463
|
+
enabled: false,
|
|
1464
|
+
isDefault: true,
|
|
1465
|
+
custom: false
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
return out;
|
|
1469
|
+
}
|
|
1470
|
+
// ── Prompts (Wave-1, T266) ──────────────────────────────────────────
|
|
1471
|
+
/** {@inheritDoc Harness.installPrompt} */
|
|
1472
|
+
async installPrompt(sourceDir, name, tier, projectDir, opts) {
|
|
1473
|
+
if (!existsSync4(sourceDir)) {
|
|
1474
|
+
throw new Error(`installPrompt: source directory does not exist: ${sourceDir}`);
|
|
1475
|
+
}
|
|
1476
|
+
const stats = await stat(sourceDir);
|
|
1477
|
+
if (!stats.isDirectory()) {
|
|
1478
|
+
throw new Error(`installPrompt: source path is not a directory: ${sourceDir}`);
|
|
1479
|
+
}
|
|
1480
|
+
if (!existsSync4(join5(sourceDir, "prompt.md"))) {
|
|
1481
|
+
throw new Error(`installPrompt: source directory is missing a prompt.md file: ${sourceDir}`);
|
|
1482
|
+
}
|
|
1483
|
+
const baseDir = resolveTierDir({ tier, kind: "prompts", projectDir });
|
|
1484
|
+
const targetPath = join5(baseDir, name);
|
|
1485
|
+
if (existsSync4(targetPath)) {
|
|
1486
|
+
if (opts?.force !== true) {
|
|
1487
|
+
throw new Error(
|
|
1488
|
+
`installPrompt: target already exists at ${targetPath} (pass --force to overwrite)`
|
|
1489
|
+
);
|
|
1490
|
+
}
|
|
1491
|
+
await rm3(targetPath, { recursive: true, force: true });
|
|
1492
|
+
}
|
|
1493
|
+
await mkdir3(baseDir, { recursive: true });
|
|
1494
|
+
await cp3(sourceDir, targetPath, { recursive: true });
|
|
1495
|
+
return { targetPath, tier };
|
|
1496
|
+
}
|
|
1497
|
+
/** {@inheritDoc Harness.listPrompts} */
|
|
1498
|
+
async listPrompts(projectDir) {
|
|
1499
|
+
const tiers = resolveAllTiers("prompts", projectDir);
|
|
1500
|
+
const out = [];
|
|
1501
|
+
const seenNames = /* @__PURE__ */ new Set();
|
|
1502
|
+
for (const { tier, dir } of tiers) {
|
|
1503
|
+
if (!existsSync4(dir)) continue;
|
|
1504
|
+
let entries;
|
|
1505
|
+
try {
|
|
1506
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
1507
|
+
} catch {
|
|
1508
|
+
continue;
|
|
1509
|
+
}
|
|
1510
|
+
for (const entry of entries) {
|
|
1511
|
+
if (!entry.isDirectory()) continue;
|
|
1512
|
+
const name = entry.name;
|
|
1513
|
+
const shadowed = seenNames.has(name);
|
|
1514
|
+
out.push({
|
|
1515
|
+
name,
|
|
1516
|
+
tier,
|
|
1517
|
+
path: join5(dir, name),
|
|
1518
|
+
shadowed
|
|
1519
|
+
});
|
|
1520
|
+
seenNames.add(name);
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
return out;
|
|
1524
|
+
}
|
|
1525
|
+
/** {@inheritDoc Harness.removePrompt} */
|
|
1526
|
+
async removePrompt(name, tier, projectDir) {
|
|
1527
|
+
const dir = resolveTierDir({ tier, kind: "prompts", projectDir });
|
|
1528
|
+
const targetPath = join5(dir, name);
|
|
1529
|
+
if (!existsSync4(targetPath)) return false;
|
|
1530
|
+
await rm3(targetPath, { recursive: true, force: true });
|
|
1531
|
+
return true;
|
|
1532
|
+
}
|
|
1533
|
+
// ── Themes (Wave-1, T267) ───────────────────────────────────────────
|
|
1534
|
+
/** {@inheritDoc Harness.installTheme} */
|
|
1535
|
+
async installTheme(sourceFile, name, tier, projectDir, opts) {
|
|
1536
|
+
if (!existsSync4(sourceFile)) {
|
|
1537
|
+
throw new Error(`installTheme: source file does not exist: ${sourceFile}`);
|
|
1538
|
+
}
|
|
1539
|
+
const stats = await stat(sourceFile);
|
|
1540
|
+
if (!stats.isFile()) {
|
|
1541
|
+
throw new Error(`installTheme: source path is not a regular file: ${sourceFile}`);
|
|
1542
|
+
}
|
|
1543
|
+
const ext = extname(sourceFile);
|
|
1544
|
+
if (ext !== ".ts" && ext !== ".tsx" && ext !== ".mts" && ext !== ".json") {
|
|
1545
|
+
throw new Error(
|
|
1546
|
+
`installTheme: expected a theme file (.ts/.tsx/.mts/.json), got: ${ext || "(no extension)"}`
|
|
1547
|
+
);
|
|
1548
|
+
}
|
|
1549
|
+
const dir = resolveTierDir({ tier, kind: "themes", projectDir });
|
|
1550
|
+
const targetPath = join5(dir, `${name}${ext}`);
|
|
1551
|
+
if (existsSync4(targetPath) && opts?.force !== true) {
|
|
1552
|
+
throw new Error(
|
|
1553
|
+
`installTheme: target already exists at ${targetPath} (pass --force to overwrite)`
|
|
1554
|
+
);
|
|
1555
|
+
}
|
|
1556
|
+
const otherExts = [".ts", ".tsx", ".mts", ".json"].filter((e) => e !== ext);
|
|
1557
|
+
for (const otherExt of otherExts) {
|
|
1558
|
+
const otherPath = join5(dir, `${name}${otherExt}`);
|
|
1559
|
+
if (existsSync4(otherPath) && opts?.force !== true) {
|
|
1560
|
+
throw new Error(
|
|
1561
|
+
`installTheme: conflicting theme exists at ${otherPath} (pass --force to overwrite both)`
|
|
1562
|
+
);
|
|
1563
|
+
}
|
|
1564
|
+
if (existsSync4(otherPath) && opts?.force === true) {
|
|
1565
|
+
await rm3(otherPath, { force: true });
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
await mkdir3(dir, { recursive: true });
|
|
1569
|
+
const contents = await readFile(sourceFile);
|
|
1570
|
+
await writeFile(targetPath, contents);
|
|
1571
|
+
return { targetPath, tier };
|
|
1572
|
+
}
|
|
1573
|
+
/** {@inheritDoc Harness.listThemes} */
|
|
1574
|
+
async listThemes(projectDir) {
|
|
1575
|
+
const tiers = resolveAllTiers("themes", projectDir);
|
|
1576
|
+
const out = [];
|
|
1577
|
+
const seenNames = /* @__PURE__ */ new Set();
|
|
1578
|
+
const validExts = /* @__PURE__ */ new Set([".ts", ".tsx", ".mts", ".json"]);
|
|
1579
|
+
for (const { tier, dir } of tiers) {
|
|
1580
|
+
if (!existsSync4(dir)) continue;
|
|
1581
|
+
let entries;
|
|
1582
|
+
try {
|
|
1583
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
1584
|
+
} catch {
|
|
1585
|
+
continue;
|
|
1586
|
+
}
|
|
1587
|
+
for (const entry of entries) {
|
|
1588
|
+
if (!entry.isFile()) continue;
|
|
1589
|
+
const fileExt = extname(entry.name);
|
|
1590
|
+
if (!validExts.has(fileExt)) continue;
|
|
1591
|
+
const name = entry.name.slice(0, -fileExt.length);
|
|
1592
|
+
const shadowed = seenNames.has(name);
|
|
1593
|
+
out.push({
|
|
1594
|
+
name,
|
|
1595
|
+
tier,
|
|
1596
|
+
path: join5(dir, entry.name),
|
|
1597
|
+
fileExt,
|
|
1598
|
+
shadowed
|
|
1599
|
+
});
|
|
1600
|
+
seenNames.add(name);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
return out;
|
|
1604
|
+
}
|
|
1605
|
+
/** {@inheritDoc Harness.removeTheme} */
|
|
1606
|
+
async removeTheme(name, tier, projectDir) {
|
|
1607
|
+
const dir = resolveTierDir({ tier, kind: "themes", projectDir });
|
|
1608
|
+
let removed = false;
|
|
1609
|
+
for (const ext of [".ts", ".tsx", ".mts", ".json"]) {
|
|
1610
|
+
const targetPath = join5(dir, `${name}${ext}`);
|
|
1611
|
+
if (existsSync4(targetPath)) {
|
|
1612
|
+
await rm3(targetPath, { force: true });
|
|
1613
|
+
removed = true;
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
return removed;
|
|
1617
|
+
}
|
|
1618
|
+
// ── CANT profiles (Wave-1, T276) ────────────────────────────────────
|
|
1619
|
+
/**
|
|
1620
|
+
* {@inheritDoc Harness.installCantProfile}
|
|
1621
|
+
*
|
|
1622
|
+
* @remarks
|
|
1623
|
+
* Validates the source via {@link validateCantProfile} before copying so
|
|
1624
|
+
* we never persist a `.cant` file the runtime bridge cannot load. The
|
|
1625
|
+
* target layout is `<tier-root>/cant/<name>.cant`, resolved through
|
|
1626
|
+
* {@link resolveTierDir} so the project/user/global hierarchy stays
|
|
1627
|
+
* consistent with the other Wave-1 verbs.
|
|
1628
|
+
*/
|
|
1629
|
+
async installCantProfile(sourcePath, name, tier, projectDir, opts) {
|
|
1630
|
+
if (!existsSync4(sourcePath)) {
|
|
1631
|
+
throw new Error(`installCantProfile: source file does not exist: ${sourcePath}`);
|
|
1632
|
+
}
|
|
1633
|
+
const stats = await stat(sourcePath);
|
|
1634
|
+
if (!stats.isFile()) {
|
|
1635
|
+
throw new Error(`installCantProfile: source path is not a regular file: ${sourcePath}`);
|
|
1636
|
+
}
|
|
1637
|
+
const ext = extname(sourcePath);
|
|
1638
|
+
if (ext !== ".cant") {
|
|
1639
|
+
throw new Error(
|
|
1640
|
+
`installCantProfile: expected a CANT source file (.cant), got: ${ext || "(no extension)"}`
|
|
1641
|
+
);
|
|
1642
|
+
}
|
|
1643
|
+
const validation = await this.validateCantProfile(sourcePath);
|
|
1644
|
+
if (!validation.valid) {
|
|
1645
|
+
const firstError = validation.errors.find((e) => e.severity === "error") ?? validation.errors[0];
|
|
1646
|
+
const detail = firstError !== void 0 ? ` (${firstError.ruleId} at ${firstError.line}:${firstError.col}: ${firstError.message})` : "";
|
|
1647
|
+
throw new Error(`installCantProfile: source file failed cant-core validation${detail}`);
|
|
1648
|
+
}
|
|
1649
|
+
const dir = resolveTierDir({ tier, kind: "cant", projectDir });
|
|
1650
|
+
const targetPath = join5(dir, `${name}.cant`);
|
|
1651
|
+
if (existsSync4(targetPath) && opts?.force !== true) {
|
|
1652
|
+
throw new Error(
|
|
1653
|
+
`installCantProfile: target already exists at ${targetPath} (pass --force to overwrite)`
|
|
1654
|
+
);
|
|
1655
|
+
}
|
|
1656
|
+
const contents = await readFile(sourcePath);
|
|
1657
|
+
await mkdir3(dir, { recursive: true });
|
|
1658
|
+
await writeFile(targetPath, contents);
|
|
1659
|
+
return { targetPath, tier, counts: validation.counts };
|
|
1660
|
+
}
|
|
1661
|
+
/** {@inheritDoc Harness.removeCantProfile} */
|
|
1662
|
+
async removeCantProfile(name, tier, projectDir) {
|
|
1663
|
+
const dir = resolveTierDir({ tier, kind: "cant", projectDir });
|
|
1664
|
+
const targetPath = join5(dir, `${name}.cant`);
|
|
1665
|
+
if (!existsSync4(targetPath)) return false;
|
|
1666
|
+
await rm3(targetPath, { force: true });
|
|
1667
|
+
return true;
|
|
1668
|
+
}
|
|
1669
|
+
/**
|
|
1670
|
+
* {@inheritDoc Harness.listCantProfiles}
|
|
1671
|
+
*
|
|
1672
|
+
* @remarks
|
|
1673
|
+
* Walks every tier in {@link TIER_PRECEDENCE} order, parsing each
|
|
1674
|
+
* discovered `.cant` file via cant-core to extract a
|
|
1675
|
+
* {@link CantProfileCounts} bag. Higher-precedence tiers shadow
|
|
1676
|
+
* lower-precedence entries with the same name; shadowed entries
|
|
1677
|
+
* still appear in the result but carry the
|
|
1678
|
+
* `shadowedByHigherTier` flag so callers can render the precedence
|
|
1679
|
+
* story without losing visibility of the duplicate.
|
|
1680
|
+
*/
|
|
1681
|
+
async listCantProfiles(projectDir) {
|
|
1682
|
+
const tiers = resolveAllTiers("cant", projectDir);
|
|
1683
|
+
const out = [];
|
|
1684
|
+
const seenNames = /* @__PURE__ */ new Set();
|
|
1685
|
+
for (const { tier, dir } of tiers) {
|
|
1686
|
+
if (!existsSync4(dir)) continue;
|
|
1687
|
+
let entries;
|
|
1688
|
+
try {
|
|
1689
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
1690
|
+
} catch {
|
|
1691
|
+
continue;
|
|
1692
|
+
}
|
|
1693
|
+
for (const entry of entries) {
|
|
1694
|
+
if (!entry.isFile()) continue;
|
|
1695
|
+
const fileName = entry.name;
|
|
1696
|
+
if (!fileName.endsWith(".cant")) continue;
|
|
1697
|
+
const name = fileName.slice(0, -".cant".length);
|
|
1698
|
+
const sourcePath = join5(dir, fileName);
|
|
1699
|
+
const counts = await extractCantCounts(sourcePath);
|
|
1700
|
+
const shadowed = seenNames.has(name);
|
|
1701
|
+
const profile = {
|
|
1702
|
+
name,
|
|
1703
|
+
tier,
|
|
1704
|
+
sourcePath,
|
|
1705
|
+
counts
|
|
1706
|
+
};
|
|
1707
|
+
if (shadowed) {
|
|
1708
|
+
profile.shadowedByHigherTier = true;
|
|
1709
|
+
}
|
|
1710
|
+
out.push(profile);
|
|
1711
|
+
seenNames.add(name);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
return out;
|
|
1715
|
+
}
|
|
1716
|
+
/**
|
|
1717
|
+
* {@inheritDoc Harness.validateCantProfile}
|
|
1718
|
+
*
|
|
1719
|
+
* @remarks
|
|
1720
|
+
* Pure validator. Reads the file, runs `parseDocument` to derive
|
|
1721
|
+
* counts (when parsing succeeds) and `validateDocument` to collect
|
|
1722
|
+
* the 42-rule diagnostic feed. The two calls are kept independent so
|
|
1723
|
+
* we can still report counts for files that pass parsing but fail a
|
|
1724
|
+
* lint rule.
|
|
1725
|
+
*/
|
|
1726
|
+
async validateCantProfile(sourcePath) {
|
|
1727
|
+
if (!existsSync4(sourcePath)) {
|
|
1728
|
+
throw new Error(`validateCantProfile: source file does not exist: ${sourcePath}`);
|
|
1729
|
+
}
|
|
1730
|
+
const stats = await stat(sourcePath);
|
|
1731
|
+
if (!stats.isFile()) {
|
|
1732
|
+
throw new Error(`validateCantProfile: source path is not a regular file: ${sourcePath}`);
|
|
1733
|
+
}
|
|
1734
|
+
const counts = await extractCantCounts(sourcePath);
|
|
1735
|
+
const validation = await validateDocument(sourcePath);
|
|
1736
|
+
const errors = validation.diagnostics.map((d) => ({
|
|
1737
|
+
ruleId: d.ruleId,
|
|
1738
|
+
message: d.message,
|
|
1739
|
+
line: d.line,
|
|
1740
|
+
col: d.col,
|
|
1741
|
+
severity: normaliseSeverity(d.severity)
|
|
1742
|
+
}));
|
|
1743
|
+
return {
|
|
1744
|
+
valid: validation.valid,
|
|
1745
|
+
errors,
|
|
1746
|
+
counts
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1749
|
+
};
|
|
1750
|
+
async function terminateSubagent(child, graceMs) {
|
|
1751
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
1752
|
+
return;
|
|
1753
|
+
}
|
|
1754
|
+
try {
|
|
1755
|
+
child.kill("SIGTERM");
|
|
1756
|
+
} catch {
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
const pollInterval = Math.min(25, Math.max(1, graceMs));
|
|
1760
|
+
const deadline = Date.now() + graceMs;
|
|
1761
|
+
await new Promise((resolve) => {
|
|
1762
|
+
const timer = setInterval(() => {
|
|
1763
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
1764
|
+
clearInterval(timer);
|
|
1765
|
+
resolve();
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
if (Date.now() >= deadline) {
|
|
1769
|
+
clearInterval(timer);
|
|
1770
|
+
try {
|
|
1771
|
+
child.kill("SIGKILL");
|
|
1772
|
+
} catch {
|
|
1773
|
+
}
|
|
1774
|
+
resolve();
|
|
1775
|
+
}
|
|
1776
|
+
}, pollInterval);
|
|
1777
|
+
child.once("close", () => {
|
|
1778
|
+
clearInterval(timer);
|
|
1779
|
+
resolve();
|
|
1780
|
+
});
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
async function writeSubagentLink(parentSessionPath, entry) {
|
|
1784
|
+
await mkdir3(dirname2(parentSessionPath), { recursive: true });
|
|
1785
|
+
const wrapped = {
|
|
1786
|
+
type: "custom",
|
|
1787
|
+
subtype: entry.type,
|
|
1788
|
+
subagentId: entry.subagentId,
|
|
1789
|
+
taskId: entry.taskId,
|
|
1790
|
+
childSessionPath: entry.childSessionPath,
|
|
1791
|
+
startedAt: entry.startedAt
|
|
1792
|
+
};
|
|
1793
|
+
const line = `${JSON.stringify(wrapped)}
|
|
1794
|
+
`;
|
|
1795
|
+
await appendFile(parentSessionPath, line, "utf8");
|
|
1796
|
+
}
|
|
1797
|
+
async function readSessionHeader(filePath) {
|
|
1798
|
+
let handle = null;
|
|
1799
|
+
try {
|
|
1800
|
+
handle = await open(filePath, "r");
|
|
1801
|
+
const stats = await handle.stat();
|
|
1802
|
+
const capacity = Math.min(stats.size, 64 * 1024);
|
|
1803
|
+
if (capacity === 0) return null;
|
|
1804
|
+
const buffer = Buffer.alloc(capacity);
|
|
1805
|
+
const { bytesRead } = await handle.read(buffer, 0, capacity, 0);
|
|
1806
|
+
const text = buffer.subarray(0, bytesRead).toString("utf8");
|
|
1807
|
+
const newlineIdx = text.indexOf("\n");
|
|
1808
|
+
const firstLine = newlineIdx === -1 ? text : text.slice(0, newlineIdx);
|
|
1809
|
+
if (firstLine.trim().length === 0) return null;
|
|
1810
|
+
let parsed;
|
|
1811
|
+
try {
|
|
1812
|
+
parsed = JSON.parse(firstLine);
|
|
1813
|
+
} catch {
|
|
1814
|
+
return null;
|
|
1815
|
+
}
|
|
1816
|
+
if (!isPlainObject(parsed)) return null;
|
|
1817
|
+
const id = typeof parsed["id"] === "string" ? parsed["id"] : null;
|
|
1818
|
+
if (id === null) {
|
|
1819
|
+
const stem = basename2(filePath, ".jsonl");
|
|
1820
|
+
return {
|
|
1821
|
+
id: stem,
|
|
1822
|
+
version: typeof parsed["version"] === "number" ? parsed["version"] : 0,
|
|
1823
|
+
timestamp: typeof parsed["timestamp"] === "string" ? parsed["timestamp"] : null,
|
|
1824
|
+
cwd: typeof parsed["cwd"] === "string" ? parsed["cwd"] : null,
|
|
1825
|
+
parentSession: typeof parsed["parentSession"] === "string" ? parsed["parentSession"] : null,
|
|
1826
|
+
filePath,
|
|
1827
|
+
mtimeMs: stats.mtimeMs
|
|
1828
|
+
};
|
|
1829
|
+
}
|
|
1830
|
+
return {
|
|
1831
|
+
id,
|
|
1832
|
+
version: typeof parsed["version"] === "number" ? parsed["version"] : 0,
|
|
1833
|
+
timestamp: typeof parsed["timestamp"] === "string" ? parsed["timestamp"] : null,
|
|
1834
|
+
cwd: typeof parsed["cwd"] === "string" ? parsed["cwd"] : null,
|
|
1835
|
+
parentSession: typeof parsed["parentSession"] === "string" ? parsed["parentSession"] : null,
|
|
1836
|
+
filePath,
|
|
1837
|
+
mtimeMs: stats.mtimeMs
|
|
1838
|
+
};
|
|
1839
|
+
} catch {
|
|
1840
|
+
return null;
|
|
1841
|
+
} finally {
|
|
1842
|
+
if (handle !== null) {
|
|
1843
|
+
await handle.close().catch(() => {
|
|
1844
|
+
});
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
var EMPTY_CANT_COUNTS = {
|
|
1849
|
+
agentCount: 0,
|
|
1850
|
+
workflowCount: 0,
|
|
1851
|
+
pipelineCount: 0,
|
|
1852
|
+
hookCount: 0,
|
|
1853
|
+
skillCount: 0
|
|
1854
|
+
};
|
|
1855
|
+
function isRecord(v) {
|
|
1856
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1857
|
+
}
|
|
1858
|
+
function unwrapSpanned(value) {
|
|
1859
|
+
if (typeof value === "string") return value;
|
|
1860
|
+
if (isRecord(value) && typeof value["value"] === "string") {
|
|
1861
|
+
return value["value"];
|
|
1862
|
+
}
|
|
1863
|
+
return null;
|
|
1864
|
+
}
|
|
1865
|
+
function collectSkillNames(value, out) {
|
|
1866
|
+
if (!isRecord(value)) return;
|
|
1867
|
+
const arr = value["Array"];
|
|
1868
|
+
if (!Array.isArray(arr)) return;
|
|
1869
|
+
for (const item of arr) {
|
|
1870
|
+
if (!isRecord(item)) continue;
|
|
1871
|
+
const stringWrapper = item["String"];
|
|
1872
|
+
if (isRecord(stringWrapper) && typeof stringWrapper["raw"] === "string") {
|
|
1873
|
+
out.add(stringWrapper["raw"]);
|
|
1874
|
+
continue;
|
|
1875
|
+
}
|
|
1876
|
+
const identWrapper = item["Identifier"];
|
|
1877
|
+
if (typeof identWrapper === "string") {
|
|
1878
|
+
out.add(identWrapper);
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
async function extractCantCounts(sourcePath) {
|
|
1883
|
+
let parsed;
|
|
1884
|
+
try {
|
|
1885
|
+
parsed = await parseDocument(sourcePath);
|
|
1886
|
+
} catch {
|
|
1887
|
+
return { ...EMPTY_CANT_COUNTS };
|
|
1888
|
+
}
|
|
1889
|
+
if (!parsed.success || !isRecord(parsed.document)) {
|
|
1890
|
+
return { ...EMPTY_CANT_COUNTS };
|
|
1891
|
+
}
|
|
1892
|
+
const sections = parsed.document["sections"];
|
|
1893
|
+
if (!Array.isArray(sections)) {
|
|
1894
|
+
return { ...EMPTY_CANT_COUNTS };
|
|
1895
|
+
}
|
|
1896
|
+
let agentCount = 0;
|
|
1897
|
+
let workflowCount = 0;
|
|
1898
|
+
let pipelineCount = 0;
|
|
1899
|
+
let hookCount = 0;
|
|
1900
|
+
const skillNames = /* @__PURE__ */ new Set();
|
|
1901
|
+
for (const section of sections) {
|
|
1902
|
+
if (!isRecord(section)) continue;
|
|
1903
|
+
if (isRecord(section["Agent"])) {
|
|
1904
|
+
agentCount += 1;
|
|
1905
|
+
const agent = section["Agent"];
|
|
1906
|
+
const hooks = agent["hooks"];
|
|
1907
|
+
if (Array.isArray(hooks)) {
|
|
1908
|
+
hookCount += hooks.length;
|
|
1909
|
+
}
|
|
1910
|
+
const properties = agent["properties"];
|
|
1911
|
+
if (Array.isArray(properties)) {
|
|
1912
|
+
for (const prop of properties) {
|
|
1913
|
+
if (!isRecord(prop)) continue;
|
|
1914
|
+
const key = unwrapSpanned(prop["key"]);
|
|
1915
|
+
if (key === "skills") {
|
|
1916
|
+
collectSkillNames(prop["value"], skillNames);
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
continue;
|
|
1921
|
+
}
|
|
1922
|
+
if (isRecord(section["Workflow"])) {
|
|
1923
|
+
workflowCount += 1;
|
|
1924
|
+
continue;
|
|
1925
|
+
}
|
|
1926
|
+
if (isRecord(section["Pipeline"])) {
|
|
1927
|
+
pipelineCount += 1;
|
|
1928
|
+
continue;
|
|
1929
|
+
}
|
|
1930
|
+
if (isRecord(section["Hook"])) {
|
|
1931
|
+
hookCount += 1;
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
return {
|
|
1935
|
+
agentCount,
|
|
1936
|
+
workflowCount,
|
|
1937
|
+
pipelineCount,
|
|
1938
|
+
hookCount,
|
|
1939
|
+
skillCount: skillNames.size
|
|
1940
|
+
};
|
|
1941
|
+
}
|
|
1942
|
+
function normaliseSeverity(raw) {
|
|
1943
|
+
if (raw === "warning" || raw === "info" || raw === "hint") return raw;
|
|
1944
|
+
return "error";
|
|
1945
|
+
}
|
|
802
1946
|
|
|
803
1947
|
// src/core/harness/index.ts
|
|
804
1948
|
function getHarnessFor(provider) {
|
|
@@ -818,28 +1962,67 @@ function getAllHarnesses() {
|
|
|
818
1962
|
}
|
|
819
1963
|
return result;
|
|
820
1964
|
}
|
|
821
|
-
function resolveDefaultTargetProviders() {
|
|
1965
|
+
function resolveDefaultTargetProviders(options = {}) {
|
|
1966
|
+
const mode = getExclusivityMode();
|
|
822
1967
|
let primary = null;
|
|
823
1968
|
try {
|
|
824
1969
|
primary = getPrimaryHarness();
|
|
825
1970
|
} catch {
|
|
826
1971
|
primary = null;
|
|
827
1972
|
}
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
1973
|
+
let installed;
|
|
1974
|
+
try {
|
|
1975
|
+
installed = getInstalledProviders();
|
|
1976
|
+
} catch {
|
|
1977
|
+
installed = [];
|
|
1978
|
+
}
|
|
1979
|
+
const primaryId = primary?.provider.id ?? null;
|
|
1980
|
+
const primaryInstalled = primaryId !== null && installed.some((provider) => provider.id === primaryId);
|
|
1981
|
+
const explicit = options.explicit;
|
|
1982
|
+
const explicitContainsPrimary = explicit !== void 0 && primaryId !== null ? explicit.some((provider) => provider.id === primaryId) : false;
|
|
1983
|
+
const legacyFallback = () => {
|
|
1984
|
+
if (primary !== null && primaryInstalled) {
|
|
833
1985
|
return [primary.provider];
|
|
834
1986
|
}
|
|
1987
|
+
const highTier = installed.filter(
|
|
1988
|
+
(provider) => provider.priority === "primary" || provider.priority === "high"
|
|
1989
|
+
);
|
|
1990
|
+
if (highTier.length > 0) {
|
|
1991
|
+
return highTier;
|
|
1992
|
+
}
|
|
1993
|
+
return installed;
|
|
1994
|
+
};
|
|
1995
|
+
if (mode === "force-pi") {
|
|
1996
|
+
if (primary === null || !primaryInstalled) {
|
|
1997
|
+
throw new PiRequiredError();
|
|
1998
|
+
}
|
|
1999
|
+
return [primary.provider];
|
|
835
2000
|
}
|
|
836
|
-
|
|
837
|
-
(
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
return
|
|
2001
|
+
if (mode === "legacy") {
|
|
2002
|
+
if (explicit !== void 0) {
|
|
2003
|
+
return explicit;
|
|
2004
|
+
}
|
|
2005
|
+
return legacyFallback();
|
|
2006
|
+
}
|
|
2007
|
+
if (explicit !== void 0 && explicit.length > 0 && !explicitContainsPrimary && primaryInstalled && !hasExplicitNonPiAutoWarned()) {
|
|
2008
|
+
console.warn(
|
|
2009
|
+
"Warning: Targeting a non-Pi provider explicitly is deprecated when Pi is installed. Future versions will route all runtime commands through Pi. To suppress this warning, set caamp.exclusivityMode to 'legacy'."
|
|
2010
|
+
);
|
|
2011
|
+
markExplicitNonPiAutoWarned();
|
|
841
2012
|
}
|
|
842
|
-
|
|
2013
|
+
if (explicit !== void 0) {
|
|
2014
|
+
return explicit;
|
|
2015
|
+
}
|
|
2016
|
+
if (primary !== null && primaryInstalled) {
|
|
2017
|
+
return [primary.provider];
|
|
2018
|
+
}
|
|
2019
|
+
if (!hasPiAbsentAutoWarned()) {
|
|
2020
|
+
console.warn(
|
|
2021
|
+
"Warning: Pi is not installed. CAAMP is falling back to direct provider dispatch. Install Pi (https://github.com/mariozechner/pi-coding-agent) to enable orchestration, or set caamp.exclusivityMode to 'legacy' to suppress this warning."
|
|
2022
|
+
);
|
|
2023
|
+
markPiAbsentAutoWarned();
|
|
2024
|
+
}
|
|
2025
|
+
return legacyFallback();
|
|
843
2026
|
}
|
|
844
2027
|
async function dispatchInstallSkillAcrossProviders(sourcePath, skillName, providers, isGlobal, projectDir) {
|
|
845
2028
|
const harnessTargets = [];
|
|
@@ -1105,63 +2288,395 @@ async function removeYamlConfig(filePath, configKey, serverName) {
|
|
|
1105
2288
|
if (typeof next !== "object" || next === null) return false;
|
|
1106
2289
|
current = next;
|
|
1107
2290
|
}
|
|
1108
|
-
if (!(serverName in current)) return false;
|
|
1109
|
-
delete current[serverName];
|
|
1110
|
-
const content = yaml.dump(existing, {
|
|
1111
|
-
indent: 2,
|
|
1112
|
-
lineWidth: -1,
|
|
1113
|
-
noRefs: true,
|
|
1114
|
-
sortKeys: false
|
|
1115
|
-
});
|
|
1116
|
-
await writeFile4(filePath, content, "utf-8");
|
|
1117
|
-
return true;
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
// src/core/formats/index.ts
|
|
1121
|
-
async function readConfig(filePath, format) {
|
|
1122
|
-
debug(`reading config: ${filePath} (format: ${format})`);
|
|
1123
|
-
switch (format) {
|
|
1124
|
-
case "json":
|
|
1125
|
-
case "jsonc":
|
|
1126
|
-
return readJsonConfig(filePath);
|
|
1127
|
-
case "yaml":
|
|
1128
|
-
return readYamlConfig(filePath);
|
|
1129
|
-
case "toml":
|
|
1130
|
-
return readTomlConfig(filePath);
|
|
1131
|
-
default:
|
|
1132
|
-
throw new Error(`Unsupported config format: ${format}`);
|
|
2291
|
+
if (!(serverName in current)) return false;
|
|
2292
|
+
delete current[serverName];
|
|
2293
|
+
const content = yaml.dump(existing, {
|
|
2294
|
+
indent: 2,
|
|
2295
|
+
lineWidth: -1,
|
|
2296
|
+
noRefs: true,
|
|
2297
|
+
sortKeys: false
|
|
2298
|
+
});
|
|
2299
|
+
await writeFile4(filePath, content, "utf-8");
|
|
2300
|
+
return true;
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
// src/core/formats/index.ts
|
|
2304
|
+
async function readConfig(filePath, format) {
|
|
2305
|
+
debug(`reading config: ${filePath} (format: ${format})`);
|
|
2306
|
+
switch (format) {
|
|
2307
|
+
case "json":
|
|
2308
|
+
case "jsonc":
|
|
2309
|
+
return readJsonConfig(filePath);
|
|
2310
|
+
case "yaml":
|
|
2311
|
+
return readYamlConfig(filePath);
|
|
2312
|
+
case "toml":
|
|
2313
|
+
return readTomlConfig(filePath);
|
|
2314
|
+
default:
|
|
2315
|
+
throw new Error(`Unsupported config format: ${format}`);
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
async function writeConfig(filePath, format, key, serverName, serverConfig) {
|
|
2319
|
+
debug(`writing config: ${filePath} (format: ${format}, key: ${key}, server: ${serverName})`);
|
|
2320
|
+
switch (format) {
|
|
2321
|
+
case "json":
|
|
2322
|
+
case "jsonc":
|
|
2323
|
+
return writeJsonConfig(filePath, key, serverName, serverConfig);
|
|
2324
|
+
case "yaml":
|
|
2325
|
+
return writeYamlConfig(filePath, key, serverName, serverConfig);
|
|
2326
|
+
case "toml":
|
|
2327
|
+
return writeTomlConfig(filePath, key, serverName, serverConfig);
|
|
2328
|
+
default:
|
|
2329
|
+
throw new Error(`Unsupported config format: ${format}`);
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
async function removeConfig(filePath, format, key, serverName) {
|
|
2333
|
+
switch (format) {
|
|
2334
|
+
case "json":
|
|
2335
|
+
case "jsonc":
|
|
2336
|
+
return removeJsonConfig(filePath, key, serverName);
|
|
2337
|
+
case "yaml":
|
|
2338
|
+
return removeYamlConfig(filePath, key, serverName);
|
|
2339
|
+
case "toml":
|
|
2340
|
+
return removeTomlConfig(filePath, key, serverName);
|
|
2341
|
+
default:
|
|
2342
|
+
throw new Error(`Unsupported config format: ${format}`);
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
// src/core/mcp/reader.ts
|
|
2347
|
+
import { existsSync as existsSync8 } from "fs";
|
|
2348
|
+
import { stat as stat2 } from "fs/promises";
|
|
2349
|
+
function resolveMcpConfigPath(provider, scope, projectDir) {
|
|
2350
|
+
if (provider.capabilities.mcp === null) return null;
|
|
2351
|
+
return resolveProviderConfigPath(provider, scope, projectDir);
|
|
2352
|
+
}
|
|
2353
|
+
async function listMcpServers(provider, scope, projectDir) {
|
|
2354
|
+
const mcp = provider.capabilities.mcp;
|
|
2355
|
+
if (mcp === null) return [];
|
|
2356
|
+
const configPath = resolveMcpConfigPath(provider, scope, projectDir);
|
|
2357
|
+
if (configPath === null) return [];
|
|
2358
|
+
if (!existsSync8(configPath)) {
|
|
2359
|
+
debug(`mcp.list: ${provider.id} (${scope}) \u2014 config file missing at ${configPath}`);
|
|
2360
|
+
return [];
|
|
2361
|
+
}
|
|
2362
|
+
let parsed;
|
|
2363
|
+
try {
|
|
2364
|
+
parsed = await readConfig(configPath, mcp.configFormat);
|
|
2365
|
+
} catch (err) {
|
|
2366
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2367
|
+
debug(`mcp.list: ${provider.id} parse failed at ${configPath}: ${message}`);
|
|
2368
|
+
return [];
|
|
2369
|
+
}
|
|
2370
|
+
const servers = getNestedValue(parsed, mcp.configKey);
|
|
2371
|
+
if (servers === void 0 || servers === null || typeof servers !== "object") return [];
|
|
2372
|
+
const out = [];
|
|
2373
|
+
for (const [name, raw] of Object.entries(servers)) {
|
|
2374
|
+
out.push({
|
|
2375
|
+
name,
|
|
2376
|
+
providerId: provider.id,
|
|
2377
|
+
providerName: provider.toolName,
|
|
2378
|
+
scope,
|
|
2379
|
+
configPath,
|
|
2380
|
+
config: raw ?? {}
|
|
2381
|
+
});
|
|
2382
|
+
}
|
|
2383
|
+
return out;
|
|
2384
|
+
}
|
|
2385
|
+
async function listAllMcpServers(scope, projectDir) {
|
|
2386
|
+
const out = /* @__PURE__ */ new Map();
|
|
2387
|
+
for (const provider of getAllProviders()) {
|
|
2388
|
+
if (provider.capabilities.mcp === null) continue;
|
|
2389
|
+
const entries = await listMcpServers(provider, scope, projectDir);
|
|
2390
|
+
out.set(provider.id, entries);
|
|
2391
|
+
}
|
|
2392
|
+
return out;
|
|
2393
|
+
}
|
|
2394
|
+
async function detectMcpInstallations(scope, projectDir) {
|
|
2395
|
+
const out = [];
|
|
2396
|
+
for (const provider of getAllProviders()) {
|
|
2397
|
+
const mcp = provider.capabilities.mcp;
|
|
2398
|
+
if (mcp === null) continue;
|
|
2399
|
+
const configPath = resolveMcpConfigPath(provider, scope, projectDir);
|
|
2400
|
+
if (configPath === null) continue;
|
|
2401
|
+
const exists = existsSync8(configPath);
|
|
2402
|
+
let serverCount = null;
|
|
2403
|
+
let lastModified = null;
|
|
2404
|
+
if (exists) {
|
|
2405
|
+
try {
|
|
2406
|
+
const stats = await stat2(configPath);
|
|
2407
|
+
lastModified = stats.mtime.toISOString();
|
|
2408
|
+
} catch {
|
|
2409
|
+
lastModified = null;
|
|
2410
|
+
}
|
|
2411
|
+
const entries = await listMcpServers(provider, scope, projectDir);
|
|
2412
|
+
serverCount = entries.length;
|
|
2413
|
+
}
|
|
2414
|
+
out.push({
|
|
2415
|
+
providerId: provider.id,
|
|
2416
|
+
providerName: provider.toolName,
|
|
2417
|
+
scope,
|
|
2418
|
+
configPath,
|
|
2419
|
+
exists,
|
|
2420
|
+
serverCount,
|
|
2421
|
+
lastModified
|
|
2422
|
+
});
|
|
2423
|
+
}
|
|
2424
|
+
return out;
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
// src/core/mcp/installer.ts
|
|
2428
|
+
async function installMcpServer(provider, serverName, config, opts) {
|
|
2429
|
+
const mcp = provider.capabilities.mcp;
|
|
2430
|
+
if (mcp === null) {
|
|
2431
|
+
throw new Error(`Provider ${provider.id} does not declare an MCP capability.`);
|
|
2432
|
+
}
|
|
2433
|
+
const configPath = resolveMcpConfigPath(provider, opts.scope, opts.projectDir);
|
|
2434
|
+
if (configPath === null) {
|
|
2435
|
+
throw new Error(
|
|
2436
|
+
`Provider ${provider.id} has no ${opts.scope}-scoped MCP config path available.`
|
|
2437
|
+
);
|
|
2438
|
+
}
|
|
2439
|
+
debug(
|
|
2440
|
+
`mcp.install: ${provider.id} ${serverName} \u2192 ${configPath} (format=${mcp.configFormat}, key=${mcp.configKey})`
|
|
2441
|
+
);
|
|
2442
|
+
const existing = await listMcpServers(provider, opts.scope, opts.projectDir);
|
|
2443
|
+
const conflicted = existing.some((e) => e.name === serverName);
|
|
2444
|
+
if (conflicted && opts.force !== true) {
|
|
2445
|
+
return {
|
|
2446
|
+
installed: false,
|
|
2447
|
+
conflicted: true,
|
|
2448
|
+
sourcePath: configPath,
|
|
2449
|
+
providerId: provider.id,
|
|
2450
|
+
serverName
|
|
2451
|
+
};
|
|
2452
|
+
}
|
|
2453
|
+
await writeConfig(configPath, mcp.configFormat, mcp.configKey, serverName, config);
|
|
2454
|
+
return {
|
|
2455
|
+
installed: true,
|
|
2456
|
+
conflicted,
|
|
2457
|
+
sourcePath: configPath,
|
|
2458
|
+
providerId: provider.id,
|
|
2459
|
+
serverName
|
|
2460
|
+
};
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
// src/core/mcp/remover.ts
|
|
2464
|
+
import { existsSync as existsSync9 } from "fs";
|
|
2465
|
+
async function removeMcpServer(provider, serverName, opts) {
|
|
2466
|
+
const mcp = provider.capabilities.mcp;
|
|
2467
|
+
if (mcp === null) {
|
|
2468
|
+
return {
|
|
2469
|
+
providerId: provider.id,
|
|
2470
|
+
serverName,
|
|
2471
|
+
sourcePath: null,
|
|
2472
|
+
removed: false,
|
|
2473
|
+
reason: "no-mcp-capability"
|
|
2474
|
+
};
|
|
2475
|
+
}
|
|
2476
|
+
const configPath = resolveMcpConfigPath(provider, opts.scope, opts.projectDir);
|
|
2477
|
+
if (configPath === null) {
|
|
2478
|
+
return {
|
|
2479
|
+
providerId: provider.id,
|
|
2480
|
+
serverName,
|
|
2481
|
+
sourcePath: null,
|
|
2482
|
+
removed: false,
|
|
2483
|
+
reason: "no-config-path"
|
|
2484
|
+
};
|
|
2485
|
+
}
|
|
2486
|
+
if (!existsSync9(configPath)) {
|
|
2487
|
+
return {
|
|
2488
|
+
providerId: provider.id,
|
|
2489
|
+
serverName,
|
|
2490
|
+
sourcePath: configPath,
|
|
2491
|
+
removed: false,
|
|
2492
|
+
reason: "file-missing"
|
|
2493
|
+
};
|
|
2494
|
+
}
|
|
2495
|
+
debug(`mcp.remove: ${provider.id} ${serverName} \u2192 ${configPath}`);
|
|
2496
|
+
const removed = await removeConfig(configPath, mcp.configFormat, mcp.configKey, serverName);
|
|
2497
|
+
return {
|
|
2498
|
+
providerId: provider.id,
|
|
2499
|
+
serverName,
|
|
2500
|
+
sourcePath: configPath,
|
|
2501
|
+
removed,
|
|
2502
|
+
reason: removed ? null : "entry-missing"
|
|
2503
|
+
};
|
|
2504
|
+
}
|
|
2505
|
+
async function removeMcpServerFromAll(serverName, opts) {
|
|
2506
|
+
const out = [];
|
|
2507
|
+
for (const provider of getAllProviders()) {
|
|
2508
|
+
if (provider.capabilities.mcp === null) continue;
|
|
2509
|
+
out.push(await removeMcpServer(provider, serverName, opts));
|
|
2510
|
+
}
|
|
2511
|
+
return out;
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
// src/core/sources/parser.ts
|
|
2515
|
+
var GITHUB_SHORTHAND = /^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)(?:\/(.+))?$/;
|
|
2516
|
+
var GITHUB_URL = /^https?:\/\/(?:www\.)?github\.com\/([^/]+)\/([^/]+)(?:\/(?:tree|blob)\/([^/]+)(?:\/(.+))?)?/;
|
|
2517
|
+
var GITLAB_URL = /^https?:\/\/(?:www\.)?gitlab\.com\/([^/]+)\/([^/]+)(?:\/-\/(?:tree|blob)\/([^/]+)(?:\/(.+))?)?/;
|
|
2518
|
+
var HTTP_URL = /^https?:\/\//;
|
|
2519
|
+
var NPM_SCOPED = /^@[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/;
|
|
2520
|
+
var NPM_PACKAGE = /^[a-zA-Z0-9_.-]+$/;
|
|
2521
|
+
var LIBRARY_SKILL = /^(@[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+|[a-zA-Z0-9_.-]+):([a-zA-Z0-9_.-]+)$/;
|
|
2522
|
+
function inferName(source, type) {
|
|
2523
|
+
if (type === "library") {
|
|
2524
|
+
const match = source.match(LIBRARY_SKILL);
|
|
2525
|
+
return match?.[2] ?? source;
|
|
2526
|
+
}
|
|
2527
|
+
if (type === "remote") {
|
|
2528
|
+
try {
|
|
2529
|
+
const url = new URL(source);
|
|
2530
|
+
const parts = url.hostname.split(".");
|
|
2531
|
+
if (parts.length >= 2) {
|
|
2532
|
+
const fallback = parts[0] ?? source;
|
|
2533
|
+
const secondLevel = parts[parts.length - 2] ?? fallback;
|
|
2534
|
+
const brand = parts.length === 3 ? secondLevel : fallback;
|
|
2535
|
+
if (brand !== "www" && brand !== "api" && brand !== "mcp") {
|
|
2536
|
+
return brand;
|
|
2537
|
+
}
|
|
2538
|
+
return secondLevel;
|
|
2539
|
+
}
|
|
2540
|
+
return parts[0] ?? source;
|
|
2541
|
+
} catch {
|
|
2542
|
+
return source;
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
if (type === "package") {
|
|
2546
|
+
let name = source.replace(/^@[^/]+\//, "");
|
|
2547
|
+
name = name.replace(/^mcp-server-/, "");
|
|
2548
|
+
name = name.replace(/^server-/, "");
|
|
2549
|
+
name = name.replace(/-mcp$/, "");
|
|
2550
|
+
name = name.replace(/-server$/, "");
|
|
2551
|
+
return name;
|
|
2552
|
+
}
|
|
2553
|
+
if (type === "github" || type === "gitlab") {
|
|
2554
|
+
const match = source.match(/\/([^/]+?)(?:\.git)?$/);
|
|
2555
|
+
return match?.[1] ?? source;
|
|
2556
|
+
}
|
|
2557
|
+
if (type === "local") {
|
|
2558
|
+
const normalized = source.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
2559
|
+
const lastSegment = normalized.split("/").pop();
|
|
2560
|
+
return lastSegment ?? source;
|
|
2561
|
+
}
|
|
2562
|
+
if (type === "command") {
|
|
2563
|
+
const parts = source.split(/\s+/);
|
|
2564
|
+
const command = parts.find(
|
|
2565
|
+
(p) => !p.startsWith("-") && p !== "npx" && p !== "node" && p !== "python" && p !== "python3"
|
|
2566
|
+
);
|
|
2567
|
+
return command ?? parts[0] ?? source;
|
|
2568
|
+
}
|
|
2569
|
+
return source;
|
|
2570
|
+
}
|
|
2571
|
+
function parseSource(input) {
|
|
2572
|
+
const ghUrlMatch = input.match(GITHUB_URL);
|
|
2573
|
+
if (ghUrlMatch) {
|
|
2574
|
+
const owner = ghUrlMatch[1];
|
|
2575
|
+
const repo = ghUrlMatch[2];
|
|
2576
|
+
const path = ghUrlMatch[4];
|
|
2577
|
+
if (!owner || !repo) {
|
|
2578
|
+
return { type: "command", value: input, inferredName: inferName(input, "command") };
|
|
2579
|
+
}
|
|
2580
|
+
const inferredName = path ? path.split("/").pop() ?? repo : repo;
|
|
2581
|
+
return {
|
|
2582
|
+
type: "github",
|
|
2583
|
+
value: input,
|
|
2584
|
+
inferredName,
|
|
2585
|
+
owner,
|
|
2586
|
+
repo,
|
|
2587
|
+
ref: ghUrlMatch[3],
|
|
2588
|
+
path
|
|
2589
|
+
};
|
|
2590
|
+
}
|
|
2591
|
+
const glUrlMatch = input.match(GITLAB_URL);
|
|
2592
|
+
if (glUrlMatch) {
|
|
2593
|
+
const owner = glUrlMatch[1];
|
|
2594
|
+
const repo = glUrlMatch[2];
|
|
2595
|
+
const path = glUrlMatch[4];
|
|
2596
|
+
if (!owner || !repo) {
|
|
2597
|
+
return { type: "command", value: input, inferredName: inferName(input, "command") };
|
|
2598
|
+
}
|
|
2599
|
+
const inferredName = path ? path.split("/").pop() ?? repo : repo;
|
|
2600
|
+
return {
|
|
2601
|
+
type: "gitlab",
|
|
2602
|
+
value: input,
|
|
2603
|
+
inferredName,
|
|
2604
|
+
owner,
|
|
2605
|
+
repo,
|
|
2606
|
+
ref: glUrlMatch[3],
|
|
2607
|
+
path
|
|
2608
|
+
};
|
|
2609
|
+
}
|
|
2610
|
+
if (HTTP_URL.test(input)) {
|
|
2611
|
+
return {
|
|
2612
|
+
type: "remote",
|
|
2613
|
+
value: input,
|
|
2614
|
+
inferredName: inferName(input, "remote")
|
|
2615
|
+
};
|
|
2616
|
+
}
|
|
2617
|
+
if (input.startsWith("/") || input.startsWith("./") || input.startsWith("../") || input.startsWith("~")) {
|
|
2618
|
+
return {
|
|
2619
|
+
type: "local",
|
|
2620
|
+
value: input,
|
|
2621
|
+
inferredName: inferName(input, "local")
|
|
2622
|
+
};
|
|
2623
|
+
}
|
|
2624
|
+
const ghShorthand = input.match(GITHUB_SHORTHAND);
|
|
2625
|
+
if (ghShorthand && !NPM_SCOPED.test(input)) {
|
|
2626
|
+
const owner = ghShorthand[1];
|
|
2627
|
+
const repo = ghShorthand[2];
|
|
2628
|
+
const path = ghShorthand[3];
|
|
2629
|
+
if (!owner || !repo) {
|
|
2630
|
+
return { type: "command", value: input, inferredName: inferName(input, "command") };
|
|
2631
|
+
}
|
|
2632
|
+
const inferredName = path ? path.split("/").pop() ?? repo : repo;
|
|
2633
|
+
return {
|
|
2634
|
+
type: "github",
|
|
2635
|
+
value: `https://github.com/${owner}/${repo}`,
|
|
2636
|
+
inferredName,
|
|
2637
|
+
owner,
|
|
2638
|
+
repo,
|
|
2639
|
+
path
|
|
2640
|
+
};
|
|
2641
|
+
}
|
|
2642
|
+
const libraryMatch = input.match(LIBRARY_SKILL);
|
|
2643
|
+
if (libraryMatch) {
|
|
2644
|
+
return {
|
|
2645
|
+
type: "library",
|
|
2646
|
+
value: input,
|
|
2647
|
+
inferredName: inferName(input, "library"),
|
|
2648
|
+
owner: libraryMatch[1],
|
|
2649
|
+
// This will be the package name, e.g. @cleocode/skills
|
|
2650
|
+
repo: libraryMatch[2]
|
|
2651
|
+
// This will be the skill name, e.g. ct-research-agent
|
|
2652
|
+
};
|
|
2653
|
+
}
|
|
2654
|
+
if (NPM_SCOPED.test(input)) {
|
|
2655
|
+
return {
|
|
2656
|
+
type: "package",
|
|
2657
|
+
value: input,
|
|
2658
|
+
inferredName: inferName(input, "package")
|
|
2659
|
+
};
|
|
1133
2660
|
}
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
return writeJsonConfig(filePath, key, serverName, serverConfig);
|
|
1141
|
-
case "yaml":
|
|
1142
|
-
return writeYamlConfig(filePath, key, serverName, serverConfig);
|
|
1143
|
-
case "toml":
|
|
1144
|
-
return writeTomlConfig(filePath, key, serverName, serverConfig);
|
|
1145
|
-
default:
|
|
1146
|
-
throw new Error(`Unsupported config format: ${format}`);
|
|
2661
|
+
if (NPM_PACKAGE.test(input) && !input.includes(" ")) {
|
|
2662
|
+
return {
|
|
2663
|
+
type: "package",
|
|
2664
|
+
value: input,
|
|
2665
|
+
inferredName: inferName(input, "package")
|
|
2666
|
+
};
|
|
1147
2667
|
}
|
|
2668
|
+
return {
|
|
2669
|
+
type: "command",
|
|
2670
|
+
value: input,
|
|
2671
|
+
inferredName: inferName(input, "command")
|
|
2672
|
+
};
|
|
1148
2673
|
}
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
case "json":
|
|
1152
|
-
case "jsonc":
|
|
1153
|
-
return removeJsonConfig(filePath, key, serverName);
|
|
1154
|
-
case "yaml":
|
|
1155
|
-
return removeYamlConfig(filePath, key, serverName);
|
|
1156
|
-
case "toml":
|
|
1157
|
-
return removeTomlConfig(filePath, key, serverName);
|
|
1158
|
-
default:
|
|
1159
|
-
throw new Error(`Unsupported config format: ${format}`);
|
|
1160
|
-
}
|
|
2674
|
+
function isMarketplaceScoped(input) {
|
|
2675
|
+
return /^@[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(input);
|
|
1161
2676
|
}
|
|
1162
2677
|
|
|
1163
2678
|
// src/core/skills/audit/scanner.ts
|
|
1164
|
-
import { existsSync as
|
|
2679
|
+
import { existsSync as existsSync10 } from "fs";
|
|
1165
2680
|
import { readFile as readFile5 } from "fs/promises";
|
|
1166
2681
|
|
|
1167
2682
|
// src/core/skills/audit/rules.ts
|
|
@@ -1534,7 +3049,7 @@ var SEVERITY_WEIGHTS = {
|
|
|
1534
3049
|
info: 0
|
|
1535
3050
|
};
|
|
1536
3051
|
async function scanFile(filePath, rules) {
|
|
1537
|
-
if (!
|
|
3052
|
+
if (!existsSync10(filePath)) {
|
|
1538
3053
|
return { file: filePath, findings: [], score: 100, passed: true };
|
|
1539
3054
|
}
|
|
1540
3055
|
const content = await readFile5(filePath, "utf-8");
|
|
@@ -1568,14 +3083,14 @@ async function scanFile(filePath, rules) {
|
|
|
1568
3083
|
}
|
|
1569
3084
|
async function scanDirectory(dirPath) {
|
|
1570
3085
|
const { readdir: readdir3 } = await import("fs/promises");
|
|
1571
|
-
const { join:
|
|
1572
|
-
if (!
|
|
3086
|
+
const { join: join9 } = await import("path");
|
|
3087
|
+
if (!existsSync10(dirPath)) return [];
|
|
1573
3088
|
const entries = await readdir3(dirPath, { withFileTypes: true });
|
|
1574
3089
|
const results = [];
|
|
1575
3090
|
for (const entry of entries) {
|
|
1576
3091
|
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
|
1577
|
-
const skillFile =
|
|
1578
|
-
if (
|
|
3092
|
+
const skillFile = join9(dirPath, entry.name, "SKILL.md");
|
|
3093
|
+
if (existsSync10(skillFile)) {
|
|
1579
3094
|
results.push(await scanFile(skillFile));
|
|
1580
3095
|
}
|
|
1581
3096
|
}
|
|
@@ -1626,178 +3141,14 @@ function toSarif(results) {
|
|
|
1626
3141
|
};
|
|
1627
3142
|
}
|
|
1628
3143
|
|
|
1629
|
-
// src/core/sources/parser.ts
|
|
1630
|
-
var GITHUB_SHORTHAND = /^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)(?:\/(.+))?$/;
|
|
1631
|
-
var GITHUB_URL = /^https?:\/\/(?:www\.)?github\.com\/([^/]+)\/([^/]+)(?:\/(?:tree|blob)\/([^/]+)(?:\/(.+))?)?/;
|
|
1632
|
-
var GITLAB_URL = /^https?:\/\/(?:www\.)?gitlab\.com\/([^/]+)\/([^/]+)(?:\/-\/(?:tree|blob)\/([^/]+)(?:\/(.+))?)?/;
|
|
1633
|
-
var HTTP_URL = /^https?:\/\//;
|
|
1634
|
-
var NPM_SCOPED = /^@[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/;
|
|
1635
|
-
var NPM_PACKAGE = /^[a-zA-Z0-9_.-]+$/;
|
|
1636
|
-
var LIBRARY_SKILL = /^(@[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+|[a-zA-Z0-9_.-]+):([a-zA-Z0-9_.-]+)$/;
|
|
1637
|
-
function inferName(source, type) {
|
|
1638
|
-
if (type === "library") {
|
|
1639
|
-
const match = source.match(LIBRARY_SKILL);
|
|
1640
|
-
return match?.[2] ?? source;
|
|
1641
|
-
}
|
|
1642
|
-
if (type === "remote") {
|
|
1643
|
-
try {
|
|
1644
|
-
const url = new URL(source);
|
|
1645
|
-
const parts = url.hostname.split(".");
|
|
1646
|
-
if (parts.length >= 2) {
|
|
1647
|
-
const fallback = parts[0] ?? source;
|
|
1648
|
-
const secondLevel = parts[parts.length - 2] ?? fallback;
|
|
1649
|
-
const brand = parts.length === 3 ? secondLevel : fallback;
|
|
1650
|
-
if (brand !== "www" && brand !== "api" && brand !== "mcp") {
|
|
1651
|
-
return brand;
|
|
1652
|
-
}
|
|
1653
|
-
return secondLevel;
|
|
1654
|
-
}
|
|
1655
|
-
return parts[0] ?? source;
|
|
1656
|
-
} catch {
|
|
1657
|
-
return source;
|
|
1658
|
-
}
|
|
1659
|
-
}
|
|
1660
|
-
if (type === "package") {
|
|
1661
|
-
let name = source.replace(/^@[^/]+\//, "");
|
|
1662
|
-
name = name.replace(/^mcp-server-/, "");
|
|
1663
|
-
name = name.replace(/^server-/, "");
|
|
1664
|
-
name = name.replace(/-mcp$/, "");
|
|
1665
|
-
name = name.replace(/-server$/, "");
|
|
1666
|
-
return name;
|
|
1667
|
-
}
|
|
1668
|
-
if (type === "github" || type === "gitlab") {
|
|
1669
|
-
const match = source.match(/\/([^/]+?)(?:\.git)?$/);
|
|
1670
|
-
return match?.[1] ?? source;
|
|
1671
|
-
}
|
|
1672
|
-
if (type === "local") {
|
|
1673
|
-
const normalized = source.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
1674
|
-
const lastSegment = normalized.split("/").pop();
|
|
1675
|
-
return lastSegment ?? source;
|
|
1676
|
-
}
|
|
1677
|
-
if (type === "command") {
|
|
1678
|
-
const parts = source.split(/\s+/);
|
|
1679
|
-
const command = parts.find(
|
|
1680
|
-
(p) => !p.startsWith("-") && p !== "npx" && p !== "node" && p !== "python" && p !== "python3"
|
|
1681
|
-
);
|
|
1682
|
-
return command ?? parts[0] ?? source;
|
|
1683
|
-
}
|
|
1684
|
-
return source;
|
|
1685
|
-
}
|
|
1686
|
-
function parseSource(input) {
|
|
1687
|
-
const ghUrlMatch = input.match(GITHUB_URL);
|
|
1688
|
-
if (ghUrlMatch) {
|
|
1689
|
-
const owner = ghUrlMatch[1];
|
|
1690
|
-
const repo = ghUrlMatch[2];
|
|
1691
|
-
const path = ghUrlMatch[4];
|
|
1692
|
-
if (!owner || !repo) {
|
|
1693
|
-
return { type: "command", value: input, inferredName: inferName(input, "command") };
|
|
1694
|
-
}
|
|
1695
|
-
const inferredName = path ? path.split("/").pop() ?? repo : repo;
|
|
1696
|
-
return {
|
|
1697
|
-
type: "github",
|
|
1698
|
-
value: input,
|
|
1699
|
-
inferredName,
|
|
1700
|
-
owner,
|
|
1701
|
-
repo,
|
|
1702
|
-
ref: ghUrlMatch[3],
|
|
1703
|
-
path
|
|
1704
|
-
};
|
|
1705
|
-
}
|
|
1706
|
-
const glUrlMatch = input.match(GITLAB_URL);
|
|
1707
|
-
if (glUrlMatch) {
|
|
1708
|
-
const owner = glUrlMatch[1];
|
|
1709
|
-
const repo = glUrlMatch[2];
|
|
1710
|
-
const path = glUrlMatch[4];
|
|
1711
|
-
if (!owner || !repo) {
|
|
1712
|
-
return { type: "command", value: input, inferredName: inferName(input, "command") };
|
|
1713
|
-
}
|
|
1714
|
-
const inferredName = path ? path.split("/").pop() ?? repo : repo;
|
|
1715
|
-
return {
|
|
1716
|
-
type: "gitlab",
|
|
1717
|
-
value: input,
|
|
1718
|
-
inferredName,
|
|
1719
|
-
owner,
|
|
1720
|
-
repo,
|
|
1721
|
-
ref: glUrlMatch[3],
|
|
1722
|
-
path
|
|
1723
|
-
};
|
|
1724
|
-
}
|
|
1725
|
-
if (HTTP_URL.test(input)) {
|
|
1726
|
-
return {
|
|
1727
|
-
type: "remote",
|
|
1728
|
-
value: input,
|
|
1729
|
-
inferredName: inferName(input, "remote")
|
|
1730
|
-
};
|
|
1731
|
-
}
|
|
1732
|
-
if (input.startsWith("/") || input.startsWith("./") || input.startsWith("../") || input.startsWith("~")) {
|
|
1733
|
-
return {
|
|
1734
|
-
type: "local",
|
|
1735
|
-
value: input,
|
|
1736
|
-
inferredName: inferName(input, "local")
|
|
1737
|
-
};
|
|
1738
|
-
}
|
|
1739
|
-
const ghShorthand = input.match(GITHUB_SHORTHAND);
|
|
1740
|
-
if (ghShorthand && !NPM_SCOPED.test(input)) {
|
|
1741
|
-
const owner = ghShorthand[1];
|
|
1742
|
-
const repo = ghShorthand[2];
|
|
1743
|
-
const path = ghShorthand[3];
|
|
1744
|
-
if (!owner || !repo) {
|
|
1745
|
-
return { type: "command", value: input, inferredName: inferName(input, "command") };
|
|
1746
|
-
}
|
|
1747
|
-
const inferredName = path ? path.split("/").pop() ?? repo : repo;
|
|
1748
|
-
return {
|
|
1749
|
-
type: "github",
|
|
1750
|
-
value: `https://github.com/${owner}/${repo}`,
|
|
1751
|
-
inferredName,
|
|
1752
|
-
owner,
|
|
1753
|
-
repo,
|
|
1754
|
-
path
|
|
1755
|
-
};
|
|
1756
|
-
}
|
|
1757
|
-
const libraryMatch = input.match(LIBRARY_SKILL);
|
|
1758
|
-
if (libraryMatch) {
|
|
1759
|
-
return {
|
|
1760
|
-
type: "library",
|
|
1761
|
-
value: input,
|
|
1762
|
-
inferredName: inferName(input, "library"),
|
|
1763
|
-
owner: libraryMatch[1],
|
|
1764
|
-
// This will be the package name, e.g. @cleocode/skills
|
|
1765
|
-
repo: libraryMatch[2]
|
|
1766
|
-
// This will be the skill name, e.g. ct-research-agent
|
|
1767
|
-
};
|
|
1768
|
-
}
|
|
1769
|
-
if (NPM_SCOPED.test(input)) {
|
|
1770
|
-
return {
|
|
1771
|
-
type: "package",
|
|
1772
|
-
value: input,
|
|
1773
|
-
inferredName: inferName(input, "package")
|
|
1774
|
-
};
|
|
1775
|
-
}
|
|
1776
|
-
if (NPM_PACKAGE.test(input) && !input.includes(" ")) {
|
|
1777
|
-
return {
|
|
1778
|
-
type: "package",
|
|
1779
|
-
value: input,
|
|
1780
|
-
inferredName: inferName(input, "package")
|
|
1781
|
-
};
|
|
1782
|
-
}
|
|
1783
|
-
return {
|
|
1784
|
-
type: "command",
|
|
1785
|
-
value: input,
|
|
1786
|
-
inferredName: inferName(input, "command")
|
|
1787
|
-
};
|
|
1788
|
-
}
|
|
1789
|
-
function isMarketplaceScoped(input) {
|
|
1790
|
-
return /^@[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(input);
|
|
1791
|
-
}
|
|
1792
|
-
|
|
1793
3144
|
// src/core/skills/lock.ts
|
|
1794
3145
|
import { execFile } from "child_process";
|
|
1795
3146
|
import { promisify } from "util";
|
|
1796
3147
|
import { simpleGit } from "simple-git";
|
|
1797
3148
|
|
|
1798
3149
|
// src/core/lock-utils.ts
|
|
1799
|
-
import { existsSync as
|
|
1800
|
-
import { mkdir as mkdir4, open, readFile as readFile6, rename as rename2, rm as rm4, stat, writeFile as writeFile5 } from "fs/promises";
|
|
3150
|
+
import { existsSync as existsSync11 } from "fs";
|
|
3151
|
+
import { mkdir as mkdir4, open as open2, readFile as readFile6, rename as rename2, rm as rm4, stat as stat3, writeFile as writeFile5 } from "fs/promises";
|
|
1801
3152
|
var LOCK_GUARD_PATH = `${LOCK_FILE_PATH}.lock`;
|
|
1802
3153
|
var STALE_LOCK_MS = 5e3;
|
|
1803
3154
|
function sleep(ms) {
|
|
@@ -1805,7 +3156,7 @@ function sleep(ms) {
|
|
|
1805
3156
|
}
|
|
1806
3157
|
async function removeStaleLock() {
|
|
1807
3158
|
try {
|
|
1808
|
-
const info = await
|
|
3159
|
+
const info = await stat3(LOCK_GUARD_PATH);
|
|
1809
3160
|
if (Date.now() - info.mtimeMs > STALE_LOCK_MS) {
|
|
1810
3161
|
await rm4(LOCK_GUARD_PATH, { force: true });
|
|
1811
3162
|
return true;
|
|
@@ -1818,7 +3169,7 @@ async function acquireLockGuard(retries = 40, delayMs = 25) {
|
|
|
1818
3169
|
await mkdir4(AGENTS_HOME, { recursive: true });
|
|
1819
3170
|
for (let attempt = 0; attempt < retries; attempt += 1) {
|
|
1820
3171
|
try {
|
|
1821
|
-
const handle = await
|
|
3172
|
+
const handle = await open2(LOCK_GUARD_PATH, "wx");
|
|
1822
3173
|
await handle.close();
|
|
1823
3174
|
return;
|
|
1824
3175
|
} catch (error) {
|
|
@@ -1844,7 +3195,7 @@ async function writeLockFileUnsafe(lock) {
|
|
|
1844
3195
|
}
|
|
1845
3196
|
async function readLockFile() {
|
|
1846
3197
|
try {
|
|
1847
|
-
if (!
|
|
3198
|
+
if (!existsSync11(LOCK_FILE_PATH)) {
|
|
1848
3199
|
return { version: 1, skills: {}, mcpServers: {} };
|
|
1849
3200
|
}
|
|
1850
3201
|
const content = await readFile6(LOCK_FILE_PATH, "utf-8");
|
|
@@ -2575,9 +3926,9 @@ async function recommendSkills2(query, criteria, options = {}) {
|
|
|
2575
3926
|
}
|
|
2576
3927
|
|
|
2577
3928
|
// src/core/skills/library-loader.ts
|
|
2578
|
-
import { existsSync as
|
|
3929
|
+
import { existsSync as existsSync12, readdirSync, readFileSync } from "fs";
|
|
2579
3930
|
import { createRequire } from "module";
|
|
2580
|
-
import { basename as
|
|
3931
|
+
import { basename as basename3, dirname as dirname3, join as join6 } from "path";
|
|
2581
3932
|
var require2 = createRequire(import.meta.url);
|
|
2582
3933
|
function loadLibraryFromModule(root) {
|
|
2583
3934
|
let mod;
|
|
@@ -2623,16 +3974,16 @@ function loadLibraryFromModule(root) {
|
|
|
2623
3974
|
return mod;
|
|
2624
3975
|
}
|
|
2625
3976
|
function buildLibraryFromFiles(root) {
|
|
2626
|
-
const catalogPath =
|
|
2627
|
-
if (!
|
|
3977
|
+
const catalogPath = join6(root, "skills.json");
|
|
3978
|
+
if (!existsSync12(catalogPath)) {
|
|
2628
3979
|
throw new Error(`No skills.json found at ${root}`);
|
|
2629
3980
|
}
|
|
2630
3981
|
const catalogData = JSON.parse(readFileSync(catalogPath, "utf-8"));
|
|
2631
3982
|
const entries = catalogData.skills ?? [];
|
|
2632
3983
|
const version = catalogData.version ?? "0.0.0";
|
|
2633
|
-
const manifestPath =
|
|
3984
|
+
const manifestPath = join6(root, "skills", "manifest.json");
|
|
2634
3985
|
let manifest;
|
|
2635
|
-
if (
|
|
3986
|
+
if (existsSync12(manifestPath)) {
|
|
2636
3987
|
manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
2637
3988
|
} else {
|
|
2638
3989
|
manifest = {
|
|
@@ -2642,14 +3993,14 @@ function buildLibraryFromFiles(root) {
|
|
|
2642
3993
|
skills: []
|
|
2643
3994
|
};
|
|
2644
3995
|
}
|
|
2645
|
-
const profilesDir =
|
|
3996
|
+
const profilesDir = join6(root, "profiles");
|
|
2646
3997
|
const profiles = /* @__PURE__ */ new Map();
|
|
2647
|
-
if (
|
|
3998
|
+
if (existsSync12(profilesDir)) {
|
|
2648
3999
|
for (const file of readdirSync(profilesDir)) {
|
|
2649
4000
|
if (!file.endsWith(".json")) continue;
|
|
2650
4001
|
try {
|
|
2651
4002
|
const profile = JSON.parse(
|
|
2652
|
-
readFileSync(
|
|
4003
|
+
readFileSync(join6(profilesDir, file), "utf-8")
|
|
2653
4004
|
);
|
|
2654
4005
|
profiles.set(profile.name, profile);
|
|
2655
4006
|
} catch {
|
|
@@ -2663,9 +4014,9 @@ function buildLibraryFromFiles(root) {
|
|
|
2663
4014
|
function getSkillDir2(name) {
|
|
2664
4015
|
const entry = skillMap.get(name);
|
|
2665
4016
|
if (entry) {
|
|
2666
|
-
return dirname3(
|
|
4017
|
+
return dirname3(join6(root, entry.path));
|
|
2667
4018
|
}
|
|
2668
|
-
return
|
|
4019
|
+
return join6(root, "skills", name);
|
|
2669
4020
|
}
|
|
2670
4021
|
function resolveDeps(names, visited = /* @__PURE__ */ new Set()) {
|
|
2671
4022
|
const result = [];
|
|
@@ -2693,8 +4044,8 @@ function buildLibraryFromFiles(root) {
|
|
|
2693
4044
|
return resolveDeps([...new Set(skills)]);
|
|
2694
4045
|
}
|
|
2695
4046
|
function discoverFiles(dir, ext) {
|
|
2696
|
-
if (!
|
|
2697
|
-
return readdirSync(dir).filter((f) => f.endsWith(ext)).map((f) =>
|
|
4047
|
+
if (!existsSync12(dir)) return [];
|
|
4048
|
+
return readdirSync(dir).filter((f) => f.endsWith(ext)).map((f) => basename3(f, ext));
|
|
2698
4049
|
}
|
|
2699
4050
|
const library = {
|
|
2700
4051
|
version,
|
|
@@ -2710,14 +4061,14 @@ function buildLibraryFromFiles(root) {
|
|
|
2710
4061
|
getSkillPath(name) {
|
|
2711
4062
|
const entry = skillMap.get(name);
|
|
2712
4063
|
if (entry) {
|
|
2713
|
-
return
|
|
4064
|
+
return join6(root, entry.path);
|
|
2714
4065
|
}
|
|
2715
|
-
return
|
|
4066
|
+
return join6(root, "skills", name, "SKILL.md");
|
|
2716
4067
|
},
|
|
2717
4068
|
getSkillDir: getSkillDir2,
|
|
2718
4069
|
readSkillContent(name) {
|
|
2719
4070
|
const skillPath = library.getSkillPath(name);
|
|
2720
|
-
if (!
|
|
4071
|
+
if (!existsSync12(skillPath)) {
|
|
2721
4072
|
throw new Error(`Skill content not found: ${skillPath}`);
|
|
2722
4073
|
}
|
|
2723
4074
|
return readFileSync(skillPath, "utf-8");
|
|
@@ -2744,11 +4095,11 @@ function buildLibraryFromFiles(root) {
|
|
|
2744
4095
|
return resolveProfileByName(name);
|
|
2745
4096
|
},
|
|
2746
4097
|
listSharedResources() {
|
|
2747
|
-
return discoverFiles(
|
|
4098
|
+
return discoverFiles(join6(root, "skills", "_shared"), ".md");
|
|
2748
4099
|
},
|
|
2749
4100
|
getSharedResourcePath(name) {
|
|
2750
|
-
const resourcePath =
|
|
2751
|
-
return
|
|
4101
|
+
const resourcePath = join6(root, "skills", "_shared", `${name}.md`);
|
|
4102
|
+
return existsSync12(resourcePath) ? resourcePath : void 0;
|
|
2752
4103
|
},
|
|
2753
4104
|
readSharedResource(name) {
|
|
2754
4105
|
const resourcePath = library.getSharedResourcePath(name);
|
|
@@ -2756,15 +4107,15 @@ function buildLibraryFromFiles(root) {
|
|
|
2756
4107
|
return readFileSync(resourcePath, "utf-8");
|
|
2757
4108
|
},
|
|
2758
4109
|
listProtocols() {
|
|
2759
|
-
const rootProtocols = discoverFiles(
|
|
4110
|
+
const rootProtocols = discoverFiles(join6(root, "protocols"), ".md");
|
|
2760
4111
|
if (rootProtocols.length > 0) return rootProtocols;
|
|
2761
|
-
return discoverFiles(
|
|
4112
|
+
return discoverFiles(join6(root, "skills", "protocols"), ".md");
|
|
2762
4113
|
},
|
|
2763
4114
|
getProtocolPath(name) {
|
|
2764
|
-
const rootPath =
|
|
2765
|
-
if (
|
|
2766
|
-
const skillsPath =
|
|
2767
|
-
return
|
|
4115
|
+
const rootPath = join6(root, "protocols", `${name}.md`);
|
|
4116
|
+
if (existsSync12(rootPath)) return rootPath;
|
|
4117
|
+
const skillsPath = join6(root, "skills", "protocols", `${name}.md`);
|
|
4118
|
+
return existsSync12(skillsPath) ? skillsPath : void 0;
|
|
2768
4119
|
},
|
|
2769
4120
|
readProtocol(name) {
|
|
2770
4121
|
const protocolPath = library.getProtocolPath(name);
|
|
@@ -2789,8 +4140,8 @@ function buildLibraryFromFiles(root) {
|
|
|
2789
4140
|
if (!entry.version) {
|
|
2790
4141
|
issues.push({ level: "warn", field: "version", message: "Missing version" });
|
|
2791
4142
|
}
|
|
2792
|
-
const skillPath =
|
|
2793
|
-
if (!
|
|
4143
|
+
const skillPath = join6(root, entry.path);
|
|
4144
|
+
if (!existsSync12(skillPath)) {
|
|
2794
4145
|
issues.push({
|
|
2795
4146
|
level: "error",
|
|
2796
4147
|
field: "path",
|
|
@@ -2849,15 +4200,15 @@ __export(catalog_exports, {
|
|
|
2849
4200
|
validateAll: () => validateAll,
|
|
2850
4201
|
validateSkillFrontmatter: () => validateSkillFrontmatter
|
|
2851
4202
|
});
|
|
2852
|
-
import { existsSync as
|
|
2853
|
-
import { join as
|
|
4203
|
+
import { existsSync as existsSync13 } from "fs";
|
|
4204
|
+
import { join as join7 } from "path";
|
|
2854
4205
|
var _library = null;
|
|
2855
4206
|
function registerSkillLibrary(library) {
|
|
2856
4207
|
_library = library;
|
|
2857
4208
|
}
|
|
2858
4209
|
function registerSkillLibraryFromPath(root) {
|
|
2859
|
-
const indexPath =
|
|
2860
|
-
if (
|
|
4210
|
+
const indexPath = join7(root, "index.js");
|
|
4211
|
+
if (existsSync13(indexPath)) {
|
|
2861
4212
|
_library = loadLibraryFromModule(root);
|
|
2862
4213
|
return;
|
|
2863
4214
|
}
|
|
@@ -2868,13 +4219,13 @@ function clearRegisteredLibrary() {
|
|
|
2868
4219
|
}
|
|
2869
4220
|
function discoverLibrary() {
|
|
2870
4221
|
const envPath = process.env["CAAMP_SKILL_LIBRARY"];
|
|
2871
|
-
if (envPath &&
|
|
4222
|
+
if (envPath && existsSync13(envPath)) {
|
|
2872
4223
|
try {
|
|
2873
|
-
const indexPath =
|
|
2874
|
-
if (
|
|
4224
|
+
const indexPath = join7(envPath, "index.js");
|
|
4225
|
+
if (existsSync13(indexPath)) {
|
|
2875
4226
|
return loadLibraryFromModule(envPath);
|
|
2876
4227
|
}
|
|
2877
|
-
if (
|
|
4228
|
+
if (existsSync13(join7(envPath, "skills.json"))) {
|
|
2878
4229
|
return buildLibraryFromFiles(envPath);
|
|
2879
4230
|
}
|
|
2880
4231
|
} catch {
|
|
@@ -2981,9 +4332,9 @@ function getLibraryRoot() {
|
|
|
2981
4332
|
}
|
|
2982
4333
|
|
|
2983
4334
|
// src/core/skills/discovery.ts
|
|
2984
|
-
import { existsSync as
|
|
4335
|
+
import { existsSync as existsSync14 } from "fs";
|
|
2985
4336
|
import { readdir as readdir2, readFile as readFile7 } from "fs/promises";
|
|
2986
|
-
import { join as
|
|
4337
|
+
import { join as join8 } from "path";
|
|
2987
4338
|
import matter from "gray-matter";
|
|
2988
4339
|
async function parseSkillFile(filePath) {
|
|
2989
4340
|
try {
|
|
@@ -3007,8 +4358,8 @@ async function parseSkillFile(filePath) {
|
|
|
3007
4358
|
}
|
|
3008
4359
|
}
|
|
3009
4360
|
async function discoverSkill(skillDir) {
|
|
3010
|
-
const skillFile =
|
|
3011
|
-
if (!
|
|
4361
|
+
const skillFile = join8(skillDir, "SKILL.md");
|
|
4362
|
+
if (!existsSync14(skillFile)) return null;
|
|
3012
4363
|
const metadata = await parseSkillFile(skillFile);
|
|
3013
4364
|
if (!metadata) return null;
|
|
3014
4365
|
return {
|
|
@@ -3019,12 +4370,12 @@ async function discoverSkill(skillDir) {
|
|
|
3019
4370
|
};
|
|
3020
4371
|
}
|
|
3021
4372
|
async function discoverSkills(rootDir) {
|
|
3022
|
-
if (!
|
|
4373
|
+
if (!existsSync14(rootDir)) return [];
|
|
3023
4374
|
const entries = await readdir2(rootDir, { withFileTypes: true });
|
|
3024
4375
|
const skills = [];
|
|
3025
4376
|
for (const entry of entries) {
|
|
3026
4377
|
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
3027
|
-
const skillDir =
|
|
4378
|
+
const skillDir = join8(rootDir, entry.name);
|
|
3028
4379
|
const skill = await discoverSkill(skillDir);
|
|
3029
4380
|
if (skill) {
|
|
3030
4381
|
skills.push(skill);
|
|
@@ -3048,7 +4399,7 @@ async function discoverSkillsMulti(dirs) {
|
|
|
3048
4399
|
}
|
|
3049
4400
|
|
|
3050
4401
|
// src/core/skills/validator.ts
|
|
3051
|
-
import { existsSync as
|
|
4402
|
+
import { existsSync as existsSync15 } from "fs";
|
|
3052
4403
|
import { readFile as readFile8 } from "fs/promises";
|
|
3053
4404
|
import matter2 from "gray-matter";
|
|
3054
4405
|
var RESERVED_NAMES = [
|
|
@@ -3070,7 +4421,7 @@ var WARN_BODY_LINES = 500;
|
|
|
3070
4421
|
var WARN_DESCRIPTION_LENGTH = 50;
|
|
3071
4422
|
async function validateSkill(filePath) {
|
|
3072
4423
|
const issues = [];
|
|
3073
|
-
if (!
|
|
4424
|
+
if (!existsSync15(filePath)) {
|
|
3074
4425
|
return {
|
|
3075
4426
|
valid: false,
|
|
3076
4427
|
issues: [{ level: "error", field: "file", message: "File does not exist" }],
|
|
@@ -3205,6 +4556,13 @@ export {
|
|
|
3205
4556
|
selectProvidersByMinimumPriority,
|
|
3206
4557
|
installBatchWithRollback,
|
|
3207
4558
|
updateInstructionsSingleOperation,
|
|
4559
|
+
DEFAULT_EXCLUSIVITY_MODE,
|
|
4560
|
+
EXCLUSIVITY_MODE_ENV_VAR,
|
|
4561
|
+
PiRequiredError,
|
|
4562
|
+
isExclusivityMode,
|
|
4563
|
+
getExclusivityMode,
|
|
4564
|
+
setExclusivityMode,
|
|
4565
|
+
resetExclusivityModeOverride,
|
|
3208
4566
|
PiHarness,
|
|
3209
4567
|
getHarnessFor,
|
|
3210
4568
|
getPrimaryHarness,
|
|
@@ -3219,17 +4577,25 @@ export {
|
|
|
3219
4577
|
writeConfig,
|
|
3220
4578
|
removeConfig,
|
|
3221
4579
|
readLockFile,
|
|
4580
|
+
resolveMcpConfigPath,
|
|
4581
|
+
listMcpServers,
|
|
4582
|
+
listAllMcpServers,
|
|
4583
|
+
detectMcpInstallations,
|
|
4584
|
+
installMcpServer,
|
|
4585
|
+
removeMcpServer,
|
|
4586
|
+
removeMcpServerFromAll,
|
|
4587
|
+
fetchWithTimeout,
|
|
4588
|
+
formatNetworkError,
|
|
4589
|
+
parseSource,
|
|
4590
|
+
isMarketplaceScoped,
|
|
3222
4591
|
scanFile,
|
|
3223
4592
|
scanDirectory,
|
|
3224
4593
|
toSarif,
|
|
3225
|
-
parseSource,
|
|
3226
|
-
isMarketplaceScoped,
|
|
3227
4594
|
recordSkillInstall,
|
|
3228
4595
|
removeSkillFromLock,
|
|
3229
4596
|
getTrackedSkills,
|
|
3230
4597
|
checkSkillUpdate,
|
|
3231
4598
|
checkAllSkillUpdates,
|
|
3232
|
-
formatNetworkError,
|
|
3233
4599
|
MarketplaceClient,
|
|
3234
4600
|
RECOMMENDATION_ERROR_CODES,
|
|
3235
4601
|
tokenizeCriteriaValue,
|
|
@@ -3258,4 +4624,4 @@ export {
|
|
|
3258
4624
|
discoverSkillsMulti,
|
|
3259
4625
|
validateSkill
|
|
3260
4626
|
};
|
|
3261
|
-
//# sourceMappingURL=chunk-
|
|
4627
|
+
//# sourceMappingURL=chunk-JC77OAHA.js.map
|