agent-trellis 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/dist/adapters/claude-code.d.ts +23 -0
- package/dist/adapters/claude-code.js +86 -0
- package/dist/adapters/codex.d.ts +27 -0
- package/dist/adapters/codex.js +119 -0
- package/dist/adapters/jsonMcp.d.ts +24 -0
- package/dist/adapters/jsonMcp.js +84 -0
- package/dist/adapters/kiro.d.ts +34 -0
- package/dist/adapters/kiro.js +175 -0
- package/dist/adapters/mcpPlan.d.ts +28 -0
- package/dist/adapters/mcpPlan.js +83 -0
- package/dist/adapters/pi.d.ts +23 -0
- package/dist/adapters/pi.js +108 -0
- package/dist/adapters/symlinkPlan.d.ts +33 -0
- package/dist/adapters/symlinkPlan.js +120 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +135 -0
- package/dist/commands/doctor.d.ts +88 -0
- package/dist/commands/doctor.js +269 -0
- package/dist/commands/init.d.ts +44 -0
- package/dist/commands/init.js +150 -0
- package/dist/commands/mcp.d.ts +28 -0
- package/dist/commands/mcp.js +70 -0
- package/dist/commands/migrate.d.ts +38 -0
- package/dist/commands/migrate.js +132 -0
- package/dist/commands/onboard.d.ts +50 -0
- package/dist/commands/onboard.js +155 -0
- package/dist/commands/secretsAudit.d.ts +35 -0
- package/dist/commands/secretsAudit.js +115 -0
- package/dist/commands/sync.d.ts +40 -0
- package/dist/commands/sync.js +91 -0
- package/dist/core/adapter.d.ts +133 -0
- package/dist/core/adapter.js +16 -0
- package/dist/core/canonical.d.ts +16 -0
- package/dist/core/canonical.js +148 -0
- package/dist/core/types.d.ts +201 -0
- package/dist/core/types.js +15 -0
- package/dist/lib/dirEquals.d.ts +7 -0
- package/dist/lib/dirEquals.js +39 -0
- package/dist/lib/envVarNames.d.ts +35 -0
- package/dist/lib/envVarNames.js +79 -0
- package/dist/lib/fsIdentity.d.ts +16 -0
- package/dist/lib/fsIdentity.js +53 -0
- package/dist/lib/mcpProbe.d.ts +14 -0
- package/dist/lib/mcpProbe.js +96 -0
- package/dist/lib/probeCommon.d.ts +24 -0
- package/dist/lib/probeCommon.js +108 -0
- package/dist/lib/secretEnv.d.ts +19 -0
- package/dist/lib/secretEnv.js +46 -0
- package/dist/lib/skillFile.d.ts +12 -0
- package/dist/lib/skillFile.js +26 -0
- package/dist/lib/syncArgs.d.ts +16 -0
- package/dist/lib/syncArgs.js +17 -0
- package/dist/lib/tomlSection.d.ts +57 -0
- package/dist/lib/tomlSection.js +162 -0
- package/dist/pi-bridge/bundle.js +32074 -0
- package/dist/pi-bridge/index.d.ts +48 -0
- package/dist/pi-bridge/index.js +188 -0
- package/dist/pi-bridge/schemaTranslate.d.ts +55 -0
- package/dist/pi-bridge/schemaTranslate.js +40 -0
- package/dist/probes/claude-code.d.ts +13 -0
- package/dist/probes/claude-code.js +48 -0
- package/dist/probes/codex.d.ts +24 -0
- package/dist/probes/codex.js +78 -0
- package/dist/probes/kiro.d.ts +12 -0
- package/dist/probes/kiro.js +48 -0
- package/dist/probes/pi.d.ts +14 -0
- package/dist/probes/pi.js +53 -0
- package/dist/sdk.d.ts +14 -0
- package/dist/sdk.js +13 -0
- package/docs/architecture.md +367 -0
- package/docs/getting-started.md +235 -0
- package/docs/implementation-plan.md +341 -0
- package/docs/research.md +175 -0
- package/docs/roadmap.md +484 -0
- package/package.json +59 -0
- package/schema/scope.example.yaml +33 -0
- package/schema/secrets.policy.example.yaml +43 -0
- package/schema/servers.example.yaml +87 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Entry point. Stays a thin dispatcher; real logic lives in
|
|
4
|
+
* src/commands/*.ts so it stays testable without going through argv. See
|
|
5
|
+
* docs/roadmap.md for what's implemented.
|
|
6
|
+
*/
|
|
7
|
+
import { runDoctor } from "./commands/doctor.js";
|
|
8
|
+
import { runInit } from "./commands/init.js";
|
|
9
|
+
import { runMigrate } from "./commands/migrate.js";
|
|
10
|
+
import { runOnboard } from "./commands/onboard.js";
|
|
11
|
+
import { runSync } from "./commands/sync.js";
|
|
12
|
+
import { runMcpSync } from "./commands/mcp.js";
|
|
13
|
+
import { runSecretsAudit } from "./commands/secretsAudit.js";
|
|
14
|
+
import { parseSyncArgs } from "./lib/syncArgs.js";
|
|
15
|
+
const KNOWN_COMMANDS = ["onboard", "init", "migrate", "doctor", "sync", "mcp", "secrets"];
|
|
16
|
+
function printUsage() {
|
|
17
|
+
console.log(`trellis - a single source of capability for every coding agent
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
trellis <command>
|
|
21
|
+
|
|
22
|
+
Commands:
|
|
23
|
+
onboard Guided flow: init -> detect agents -> pick a base agent ->
|
|
24
|
+
migrate -> sync, in one command
|
|
25
|
+
--agent <agent> non-interactive base-agent choice
|
|
26
|
+
(required with 2+ agents present and
|
|
27
|
+
no terminal to prompt in, e.g. --json)
|
|
28
|
+
--dry-run preview the whole flow, write nothing
|
|
29
|
+
--json machine-readable output, no report text
|
|
30
|
+
init Create ~/.trellis/ with a minimal valid skeleton if missing
|
|
31
|
+
(never overwrites an existing file — fills in only what's
|
|
32
|
+
missing) and prints which agents are present
|
|
33
|
+
--json machine-readable output, no report text
|
|
34
|
+
migrate --from <agent>
|
|
35
|
+
Import an existing agent's real skills/instructions into
|
|
36
|
+
canonical source (claude-code | codex | kiro | pi). Never
|
|
37
|
+
overwrites differing content — reports a conflict instead.
|
|
38
|
+
--dry-run preview the plan, write nothing
|
|
39
|
+
--json machine-readable output, no report text
|
|
40
|
+
doctor Scan Claude Code / Codex / Kiro / pi for drift
|
|
41
|
+
--json machine-readable output, no table text
|
|
42
|
+
--probe-mcp also handshake every configured MCP server
|
|
43
|
+
(off by default — spawns real processes,
|
|
44
|
+
some reaching real external services)
|
|
45
|
+
sync [skills|instructions]
|
|
46
|
+
Distribute skills/instructions to each agent (omit target for both)
|
|
47
|
+
--dry-run preview the plan, write nothing
|
|
48
|
+
--json machine-readable output, no report text
|
|
49
|
+
mcp sync Distribute MCP servers to each agent's native config
|
|
50
|
+
(create/repair only — no automatic removal, see docs/roadmap.md)
|
|
51
|
+
--json machine-readable output, no report text
|
|
52
|
+
secrets audit
|
|
53
|
+
Scan each present agent's real MCP config for leaked
|
|
54
|
+
credentials and unexpected env var names
|
|
55
|
+
--json machine-readable output, no report text
|
|
56
|
+
|
|
57
|
+
See docs/roadmap.md for what's built vs. planned.`);
|
|
58
|
+
}
|
|
59
|
+
async function main(argv) {
|
|
60
|
+
const [command, ...rest] = argv;
|
|
61
|
+
if (!command || command === "--help" || command === "-h") {
|
|
62
|
+
printUsage();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!KNOWN_COMMANDS.includes(command)) {
|
|
66
|
+
console.error(`Unknown command: ${command}\n`);
|
|
67
|
+
printUsage();
|
|
68
|
+
process.exitCode = 1;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (command === "onboard") {
|
|
72
|
+
const agentIndex = rest.indexOf("--agent");
|
|
73
|
+
const agent = agentIndex >= 0 ? rest[agentIndex + 1] : undefined;
|
|
74
|
+
const { exitCode } = await runOnboard({ agent, dryRun: rest.includes("--dry-run"), json: rest.includes("--json") });
|
|
75
|
+
process.exitCode = exitCode;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (command === "init") {
|
|
79
|
+
const { exitCode } = await runInit({ json: rest.includes("--json") });
|
|
80
|
+
process.exitCode = exitCode;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (command === "migrate") {
|
|
84
|
+
const fromIndex = rest.indexOf("--from");
|
|
85
|
+
const from = fromIndex >= 0 ? rest[fromIndex + 1] : undefined;
|
|
86
|
+
const { exitCode } = await runMigrate({ from, dryRun: rest.includes("--dry-run"), json: rest.includes("--json") });
|
|
87
|
+
process.exitCode = exitCode;
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (command === "doctor") {
|
|
91
|
+
const { exitCode } = await runDoctor({ json: rest.includes("--json"), probeMcp: rest.includes("--probe-mcp") });
|
|
92
|
+
process.exitCode = exitCode;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (command === "sync") {
|
|
96
|
+
const { target, unknownArg } = parseSyncArgs(rest);
|
|
97
|
+
if (unknownArg) {
|
|
98
|
+
console.error(`Unknown sync target: ${unknownArg}\n`);
|
|
99
|
+
printUsage();
|
|
100
|
+
process.exitCode = 1;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const { exitCode } = await runSync({ target, json: rest.includes("--json"), dryRun: rest.includes("--dry-run") });
|
|
104
|
+
process.exitCode = exitCode;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (command === "mcp") {
|
|
108
|
+
const [subcommand] = rest;
|
|
109
|
+
if (subcommand !== "sync") {
|
|
110
|
+
console.error(`Unknown mcp subcommand: ${subcommand ?? "(none)"}\nUsage: trellis mcp sync\n`);
|
|
111
|
+
process.exitCode = 1;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const { exitCode } = await runMcpSync({ json: rest.includes("--json") });
|
|
115
|
+
process.exitCode = exitCode;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (command === "secrets") {
|
|
119
|
+
const [subcommand] = rest;
|
|
120
|
+
if (subcommand !== "audit") {
|
|
121
|
+
console.error(`Unknown secrets subcommand: ${subcommand ?? "(none)"}\nUsage: trellis secrets audit\n`);
|
|
122
|
+
process.exitCode = 1;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const { exitCode } = await runSecretsAudit({ json: rest.includes("--json") });
|
|
126
|
+
process.exitCode = exitCode;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
console.error(`\`trellis ${command}\` is not implemented yet — this is a pre-alpha scaffold.\nSee docs/roadmap.md for status.`);
|
|
130
|
+
process.exitCode = 1;
|
|
131
|
+
}
|
|
132
|
+
main(process.argv.slice(2)).catch((err) => {
|
|
133
|
+
console.error(err instanceof Error ? err.stack : String(err));
|
|
134
|
+
process.exitCode = 1;
|
|
135
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trellis doctor` — read-only cross-agent scan. No `.trellis/` canonical
|
|
3
|
+
* source required (docs/roadmap.md): this compares the four agents' own
|
|
4
|
+
* current state against each other, the same manual process used
|
|
5
|
+
* throughout docs/research.md's investigation, now formalized.
|
|
6
|
+
*/
|
|
7
|
+
import type { AgentId, AgentSnapshot } from "../core/types.js";
|
|
8
|
+
/**
|
|
9
|
+
* Interim default until P1 wires this from `.trellis/mcp/servers.yaml`'s
|
|
10
|
+
* real `known_host_injected` field (P0 has no canonical source at all —
|
|
11
|
+
* docs/roadmap.md). Matches `schema/servers.example.yaml`'s list, which
|
|
12
|
+
* documents mirasim's actual known connectors on this project's own
|
|
13
|
+
* machine.
|
|
14
|
+
*/
|
|
15
|
+
export declare const DEFAULT_KNOWN_HOST_INJECTED: readonly string[];
|
|
16
|
+
export type FindingKind = "probe-failed" | "duplication" | "collision" | "drift" | "case-mismatch" | "parse-diagnostic" | "mcp-unreachable";
|
|
17
|
+
export interface Finding {
|
|
18
|
+
kind: FindingKind;
|
|
19
|
+
/** Omitted for a finding that names more than one agent (drift). */
|
|
20
|
+
agent?: AgentId;
|
|
21
|
+
message: string;
|
|
22
|
+
}
|
|
23
|
+
export interface RunDoctorOptions {
|
|
24
|
+
json?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Explicit override, mainly for tests. Most callers should leave this
|
|
27
|
+
* unset and let `runDoctor` resolve it itself: canonical source's real
|
|
28
|
+
* `known_host_injected` when `~/.trellis/` exists, `DEFAULT_KNOWN_HOST_INJECTED`
|
|
29
|
+
* otherwise (trellis-cli-init) — the same list `mcp sync`'s own
|
|
30
|
+
* collision refusal already reads, so the two commands agree on one
|
|
31
|
+
* machine instead of each trusting a different source.
|
|
32
|
+
*/
|
|
33
|
+
knownHostInjected?: readonly string[];
|
|
34
|
+
/**
|
|
35
|
+
* Off by default. Spawning every configured MCP server for a live
|
|
36
|
+
* handshake is not actually read-only in the sense that matters here:
|
|
37
|
+
* some servers reach real external services with real credentials
|
|
38
|
+
* (OAuth-backed connectors, chrome-devtools-mcp's `--autoConnect`,
|
|
39
|
+
* etc.), and probing all of them serially against a machine with a
|
|
40
|
+
* dozen-plus configured servers made a single run take minutes. Default
|
|
41
|
+
* `trellis doctor` sticks to structural checks (collision, duplication,
|
|
42
|
+
* drift, case) that need no process spawned at all; pass this to also
|
|
43
|
+
* attempt handshakes.
|
|
44
|
+
*/
|
|
45
|
+
probeMcp?: boolean;
|
|
46
|
+
/** Defaults to the real `~`; overridable for tests only — same seam
|
|
47
|
+
* every other Trellis entry point uses. */
|
|
48
|
+
homeDir?: string;
|
|
49
|
+
}
|
|
50
|
+
export interface DoctorReport {
|
|
51
|
+
snapshots: AgentSnapshot[];
|
|
52
|
+
findings: Finding[];
|
|
53
|
+
}
|
|
54
|
+
/** Exported so tests can assert on this resolution directly, without
|
|
55
|
+
* routing through real per-agent probes `runDoctor` also runs. */
|
|
56
|
+
export declare function resolveKnownHostInjected(opts: RunDoctorOptions): readonly string[];
|
|
57
|
+
export declare function runDoctor(opts?: RunDoctorOptions): Promise<{
|
|
58
|
+
exitCode: number;
|
|
59
|
+
}>;
|
|
60
|
+
/** Exported separately from `runDoctor` so tests can assert on findings
|
|
61
|
+
* without capturing stdout. */
|
|
62
|
+
export declare function collectDoctorReport(knownHostInjected?: readonly string[], probeMcp?: boolean): Promise<DoctorReport>;
|
|
63
|
+
/**
|
|
64
|
+
* Within one agent, across all of its skill roots: any realpath reached by
|
|
65
|
+
* more than one entry, where at least one of those entries is NOT a
|
|
66
|
+
* symlink, is a physical duplicate — design.md D3 / the Codex `openspec-*`
|
|
67
|
+
* incident this check exists to catch again if it recurs. Two symlinks
|
|
68
|
+
* sharing a realpath is the expected, already-deduplicated case: no finding.
|
|
69
|
+
*/
|
|
70
|
+
export declare function detectDuplication(snapshots: AgentSnapshot[]): Finding[];
|
|
71
|
+
/**
|
|
72
|
+
* A statically configured server name that also appears in
|
|
73
|
+
* `known_host_injected` — see docs/research.md "Codex — three hard
|
|
74
|
+
* constraints" #3 for why this is worth catching before it happens rather
|
|
75
|
+
* than after.
|
|
76
|
+
*/
|
|
77
|
+
export declare function detectCollisions(snapshots: AgentSnapshot[], knownHostInjected: readonly string[]): Finding[];
|
|
78
|
+
/**
|
|
79
|
+
* Same skill name, different realpath, across two or more DISTINCT
|
|
80
|
+
* agents. An agent reporting the same name from two of its own roots is a
|
|
81
|
+
* separate case (already covered by `detectDuplication` when the
|
|
82
|
+
* realpaths match, and not yet a specified finding class when they don't)
|
|
83
|
+
* — deliberately not conflated with cross-agent drift here.
|
|
84
|
+
*/
|
|
85
|
+
export declare function detectCrossAgentDrift(snapshots: AgentSnapshot[]): Finding[];
|
|
86
|
+
export declare function detectCaseMismatches(snapshots: AgentSnapshot[]): Finding[];
|
|
87
|
+
export declare function detectParseDiagnostics(snapshots: AgentSnapshot[]): Finding[];
|
|
88
|
+
export declare function detectMcpUnreachable(snapshots: AgentSnapshot[]): Finding[];
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trellis doctor` — read-only cross-agent scan. No `.trellis/` canonical
|
|
3
|
+
* source required (docs/roadmap.md): this compares the four agents' own
|
|
4
|
+
* current state against each other, the same manual process used
|
|
5
|
+
* throughout docs/research.md's investigation, now formalized.
|
|
6
|
+
*/
|
|
7
|
+
import * as claudeCodeProbe from "../probes/claude-code.js";
|
|
8
|
+
import * as codexProbe from "../probes/codex.js";
|
|
9
|
+
import * as kiroProbe from "../probes/kiro.js";
|
|
10
|
+
import * as piProbe from "../probes/pi.js";
|
|
11
|
+
import { loadCanonicalSource } from "../core/canonical.js";
|
|
12
|
+
import { ALL_AGENTS } from "../core/types.js";
|
|
13
|
+
/**
|
|
14
|
+
* Interim default until P1 wires this from `.trellis/mcp/servers.yaml`'s
|
|
15
|
+
* real `known_host_injected` field (P0 has no canonical source at all —
|
|
16
|
+
* docs/roadmap.md). Matches `schema/servers.example.yaml`'s list, which
|
|
17
|
+
* documents mirasim's actual known connectors on this project's own
|
|
18
|
+
* machine.
|
|
19
|
+
*/
|
|
20
|
+
export const DEFAULT_KNOWN_HOST_INJECTED = ["atlassian", "sentry", "memory", "mirasim"];
|
|
21
|
+
/**
|
|
22
|
+
* Every kind except "mcp-unreachable" fails the run per spec (a specific
|
|
23
|
+
* server not answering its handshake is real information, worth showing,
|
|
24
|
+
* but isn't itself drift/duplication/collision/an incomplete probe — see
|
|
25
|
+
* specs/capability-drift-detection/spec.md's "Non-zero exit" requirement).
|
|
26
|
+
*/
|
|
27
|
+
const EXIT_NONZERO_KINDS = new Set([
|
|
28
|
+
"probe-failed",
|
|
29
|
+
"duplication",
|
|
30
|
+
"collision",
|
|
31
|
+
"drift",
|
|
32
|
+
"case-mismatch",
|
|
33
|
+
"parse-diagnostic",
|
|
34
|
+
]);
|
|
35
|
+
/** Exported so tests can assert on this resolution directly, without
|
|
36
|
+
* routing through real per-agent probes `runDoctor` also runs. */
|
|
37
|
+
export function resolveKnownHostInjected(opts) {
|
|
38
|
+
if (opts.knownHostInjected)
|
|
39
|
+
return opts.knownHostInjected;
|
|
40
|
+
try {
|
|
41
|
+
const canonical = loadCanonicalSource(opts.homeDir);
|
|
42
|
+
return canonical.mcp.knownHostInjected;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// No canonical source — doctor must still work standalone (P0).
|
|
46
|
+
return DEFAULT_KNOWN_HOST_INJECTED;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export async function runDoctor(opts = {}) {
|
|
50
|
+
const report = await collectDoctorReport(resolveKnownHostInjected(opts), opts.probeMcp);
|
|
51
|
+
const exitCode = report.findings.some((f) => EXIT_NONZERO_KINDS.has(f.kind)) ? 1 : 0;
|
|
52
|
+
if (opts.json) {
|
|
53
|
+
console.log(JSON.stringify(report, null, 2));
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
printTable(report);
|
|
57
|
+
}
|
|
58
|
+
return { exitCode };
|
|
59
|
+
}
|
|
60
|
+
/** Exported separately from `runDoctor` so tests can assert on findings
|
|
61
|
+
* without capturing stdout. */
|
|
62
|
+
export async function collectDoctorReport(knownHostInjected = DEFAULT_KNOWN_HOST_INJECTED, probeMcp = false) {
|
|
63
|
+
const probeList = [
|
|
64
|
+
{ agent: "claude-code", run: () => claudeCodeProbe.probe(undefined, { probeMcp }) },
|
|
65
|
+
{ agent: "codex", run: () => codexProbe.probe(undefined, { probeMcp }) },
|
|
66
|
+
{ agent: "kiro", run: () => kiroProbe.probe(undefined, { probeMcp }) },
|
|
67
|
+
{ agent: "pi", run: piProbe.probe },
|
|
68
|
+
];
|
|
69
|
+
// One agent erroring must not blank the other three's results.
|
|
70
|
+
const settled = await Promise.allSettled(probeList.map((p) => p.run()));
|
|
71
|
+
const snapshots = [];
|
|
72
|
+
const findings = [];
|
|
73
|
+
settled.forEach((result, index) => {
|
|
74
|
+
if (result.status === "fulfilled") {
|
|
75
|
+
snapshots.push(result.value);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
findings.push({
|
|
79
|
+
kind: "probe-failed",
|
|
80
|
+
agent: probeList[index].agent,
|
|
81
|
+
message: `probe failed: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
findings.push(...detectDuplication(snapshots), ...detectCollisions(snapshots, knownHostInjected), ...detectCrossAgentDrift(snapshots), ...detectCaseMismatches(snapshots), ...detectParseDiagnostics(snapshots), ...detectMcpUnreachable(snapshots));
|
|
86
|
+
return { snapshots, findings };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Within one agent, across all of its skill roots: any realpath reached by
|
|
90
|
+
* more than one entry, where at least one of those entries is NOT a
|
|
91
|
+
* symlink, is a physical duplicate — design.md D3 / the Codex `openspec-*`
|
|
92
|
+
* incident this check exists to catch again if it recurs. Two symlinks
|
|
93
|
+
* sharing a realpath is the expected, already-deduplicated case: no finding.
|
|
94
|
+
*/
|
|
95
|
+
export function detectDuplication(snapshots) {
|
|
96
|
+
const findings = [];
|
|
97
|
+
for (const snap of snapshots) {
|
|
98
|
+
if (!snap.present)
|
|
99
|
+
continue;
|
|
100
|
+
const byRealDir = new Map();
|
|
101
|
+
for (const root of snap.skillRoots) {
|
|
102
|
+
for (const skill of root.skills) {
|
|
103
|
+
const bucket = byRealDir.get(skill.realDir) ?? [];
|
|
104
|
+
bucket.push(skill);
|
|
105
|
+
byRealDir.set(skill.realDir, bucket);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
for (const bucket of byRealDir.values()) {
|
|
109
|
+
if (bucket.length < 2)
|
|
110
|
+
continue;
|
|
111
|
+
const nonSymlinkEntries = bucket.filter((s) => !s.isSymlink);
|
|
112
|
+
for (const entry of nonSymlinkEntries) {
|
|
113
|
+
findings.push({
|
|
114
|
+
kind: "duplication",
|
|
115
|
+
agent: snap.agent,
|
|
116
|
+
message: `physical duplicate skill "${entry.name}" at ${entry.dir} — same content already reachable elsewhere; should be a symlink or removed`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return findings;
|
|
122
|
+
}
|
|
123
|
+
const CODEX_STDIO_URL_CRASH_NOTE = ' On Codex specifically, this crashes the entire process at startup ("url is not supported for stdio"), not just this one server — see docs/research.md.';
|
|
124
|
+
/**
|
|
125
|
+
* A statically configured server name that also appears in
|
|
126
|
+
* `known_host_injected` — see docs/research.md "Codex — three hard
|
|
127
|
+
* constraints" #3 for why this is worth catching before it happens rather
|
|
128
|
+
* than after.
|
|
129
|
+
*/
|
|
130
|
+
export function detectCollisions(snapshots, knownHostInjected) {
|
|
131
|
+
const findings = [];
|
|
132
|
+
const known = new Set(knownHostInjected);
|
|
133
|
+
for (const snap of snapshots) {
|
|
134
|
+
if (!snap.present)
|
|
135
|
+
continue;
|
|
136
|
+
for (const server of snap.mcpServers) {
|
|
137
|
+
if (known.has(server.name)) {
|
|
138
|
+
findings.push({
|
|
139
|
+
kind: "collision",
|
|
140
|
+
agent: snap.agent,
|
|
141
|
+
message: `MCP server "${server.name}" is statically configured but also appears in known_host_injected.${snap.agent === "codex" ? CODEX_STDIO_URL_CRASH_NOTE : ""}`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return findings;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Same skill name, different realpath, across two or more DISTINCT
|
|
150
|
+
* agents. An agent reporting the same name from two of its own roots is a
|
|
151
|
+
* separate case (already covered by `detectDuplication` when the
|
|
152
|
+
* realpaths match, and not yet a specified finding class when they don't)
|
|
153
|
+
* — deliberately not conflated with cross-agent drift here.
|
|
154
|
+
*/
|
|
155
|
+
export function detectCrossAgentDrift(snapshots) {
|
|
156
|
+
const findings = [];
|
|
157
|
+
const byName = new Map();
|
|
158
|
+
for (const snap of snapshots) {
|
|
159
|
+
if (!snap.present)
|
|
160
|
+
continue;
|
|
161
|
+
for (const root of snap.skillRoots) {
|
|
162
|
+
for (const skill of root.skills) {
|
|
163
|
+
let perAgent = byName.get(skill.name);
|
|
164
|
+
if (!perAgent) {
|
|
165
|
+
perAgent = new Map();
|
|
166
|
+
byName.set(skill.name, perAgent);
|
|
167
|
+
}
|
|
168
|
+
if (!perAgent.has(snap.agent)) {
|
|
169
|
+
perAgent.set(snap.agent, skill.realDir);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
for (const [name, perAgent] of byName) {
|
|
175
|
+
if (perAgent.size < 2)
|
|
176
|
+
continue;
|
|
177
|
+
if (new Set(perAgent.values()).size < 2)
|
|
178
|
+
continue;
|
|
179
|
+
const detail = [...perAgent.entries()].map(([agent, realDir]) => `${agent}=${realDir}`).join(", ");
|
|
180
|
+
findings.push({ kind: "drift", message: `skill "${name}" resolves to different sources across agents: ${detail}` });
|
|
181
|
+
}
|
|
182
|
+
return findings;
|
|
183
|
+
}
|
|
184
|
+
export function detectCaseMismatches(snapshots) {
|
|
185
|
+
const findings = [];
|
|
186
|
+
for (const snap of snapshots) {
|
|
187
|
+
if (!snap.present)
|
|
188
|
+
continue;
|
|
189
|
+
for (const root of snap.skillRoots) {
|
|
190
|
+
for (const skill of root.skills) {
|
|
191
|
+
if (!skill.caseCorrect) {
|
|
192
|
+
findings.push({
|
|
193
|
+
kind: "case-mismatch",
|
|
194
|
+
agent: snap.agent,
|
|
195
|
+
message: `skill "${skill.name}" at ${skill.dir} has a wrong-case entry file (expected exact "SKILL.md") — silently dropped from discovery on at least one target agent (Codex)`,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return findings;
|
|
202
|
+
}
|
|
203
|
+
export function detectParseDiagnostics(snapshots) {
|
|
204
|
+
const findings = [];
|
|
205
|
+
for (const snap of snapshots) {
|
|
206
|
+
for (const message of snap.diagnostics) {
|
|
207
|
+
findings.push({ kind: "parse-diagnostic", agent: snap.agent, message });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return findings;
|
|
211
|
+
}
|
|
212
|
+
export function detectMcpUnreachable(snapshots) {
|
|
213
|
+
const findings = [];
|
|
214
|
+
for (const snap of snapshots) {
|
|
215
|
+
if (!snap.present)
|
|
216
|
+
continue;
|
|
217
|
+
for (const server of snap.mcpServers) {
|
|
218
|
+
if (server.probe && !server.probe.ok) {
|
|
219
|
+
findings.push({
|
|
220
|
+
kind: "mcp-unreachable",
|
|
221
|
+
agent: snap.agent,
|
|
222
|
+
message: `MCP server "${server.name}" did not respond to handshake: ${server.probe.error ?? "unknown error"}`,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return findings;
|
|
228
|
+
}
|
|
229
|
+
function printTable(report) {
|
|
230
|
+
const byAgent = new Map();
|
|
231
|
+
const crossAgent = [];
|
|
232
|
+
for (const finding of report.findings) {
|
|
233
|
+
if (finding.agent) {
|
|
234
|
+
const list = byAgent.get(finding.agent) ?? [];
|
|
235
|
+
list.push(finding);
|
|
236
|
+
byAgent.set(finding.agent, list);
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
crossAgent.push(finding);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
for (const agent of ALL_AGENTS) {
|
|
243
|
+
const snap = report.snapshots.find((s) => s.agent === agent);
|
|
244
|
+
if (!snap) {
|
|
245
|
+
console.log(`❌ ${agent} — probe did not complete`);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (!snap.present) {
|
|
249
|
+
console.log(`— ${agent} (not installed)`);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const agentFindings = byAgent.get(agent) ?? [];
|
|
253
|
+
const label = `${agent}${snap.version ? ` (${snap.version})` : ""}`;
|
|
254
|
+
if (agentFindings.length === 0) {
|
|
255
|
+
console.log(`✅ ${label}`);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
console.log(`⚠️ ${label} — ${agentFindings.length} finding(s)`);
|
|
259
|
+
for (const finding of agentFindings) {
|
|
260
|
+
console.log(` - ${finding.message}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (crossAgent.length > 0) {
|
|
264
|
+
console.log("\nCross-agent:");
|
|
265
|
+
for (const finding of crossAgent) {
|
|
266
|
+
console.log(`⚠️ ${finding.message}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trellis init` — bootstraps a minimal, valid `~/.trellis/` if one
|
|
3
|
+
* doesn't already exist, per-file (never overwrites an existing file;
|
|
4
|
+
* fills in only what's missing — trellis-cli-init). `loadCanonicalSource`
|
|
5
|
+
* only actually requires the `.trellis/` directory itself to exist; this
|
|
6
|
+
* command exists because an empty directory gives a new user nothing to
|
|
7
|
+
* act on.
|
|
8
|
+
*/
|
|
9
|
+
import type { AgentId } from "../core/types.js";
|
|
10
|
+
export interface RunInitOptions {
|
|
11
|
+
json?: boolean;
|
|
12
|
+
/** Defaults to the real `~`; overridable for tests only — same seam
|
|
13
|
+
* every other Trellis entry point uses. */
|
|
14
|
+
homeDir?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface InitFileResult {
|
|
17
|
+
path: string;
|
|
18
|
+
action: "create" | "already-present";
|
|
19
|
+
}
|
|
20
|
+
export interface AgentPointer {
|
|
21
|
+
agent: AgentId;
|
|
22
|
+
present: boolean;
|
|
23
|
+
message: string;
|
|
24
|
+
}
|
|
25
|
+
export interface InitReport {
|
|
26
|
+
files: InitFileResult[];
|
|
27
|
+
agents: AgentPointer[];
|
|
28
|
+
}
|
|
29
|
+
/** Exported so `migrate` can byte-compare canonical `agents.md` against
|
|
30
|
+
* this exact string (design.md D3, trellis-cli-migrate) rather than
|
|
31
|
+
* retyping an equivalent one that could silently drift from this. */
|
|
32
|
+
export declare const AGENTS_MD_TEMPLATE = "# Shared instructions\n\nWrite what every agent should know here \u2014 communication preferences,\nproject conventions, anything you'd otherwise repeat per agent.\n\nAlready using Claude Code, Codex, Kiro, or pi? `trellis migrate --from\n<agent>` imports its real instructions and skills instead of starting\nfrom this placeholder.\n";
|
|
33
|
+
/**
|
|
34
|
+
* Trellis never spawns an installer itself (global package installs are
|
|
35
|
+
* exactly the kind of irreversible, system-wide action that needs the
|
|
36
|
+
* user's own explicit "yes", not a silent trellis-init side effect) —
|
|
37
|
+
* these are printed as a pointer only. Verified against each project's
|
|
38
|
+
* own current install docs, same discipline as everything else here.
|
|
39
|
+
*/
|
|
40
|
+
export declare const INSTALL_HINTS: Record<AgentId, string>;
|
|
41
|
+
export declare function collectInitReport(homeDir?: string): Promise<InitReport>;
|
|
42
|
+
export declare function runInit(opts?: RunInitOptions): Promise<{
|
|
43
|
+
exitCode: number;
|
|
44
|
+
}>;
|