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
|
@@ -0,0 +1,150 @@
|
|
|
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 { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { parse as parseYaml } from "yaml";
|
|
14
|
+
import * as claudeCodeProbe from "../probes/claude-code.js";
|
|
15
|
+
import * as codexProbe from "../probes/codex.js";
|
|
16
|
+
import * as kiroProbe from "../probes/kiro.js";
|
|
17
|
+
import * as piProbe from "../probes/pi.js";
|
|
18
|
+
import { ALL_AGENTS } from "../core/types.js";
|
|
19
|
+
/** Exported so `migrate` can byte-compare canonical `agents.md` against
|
|
20
|
+
* this exact string (design.md D3, trellis-cli-migrate) rather than
|
|
21
|
+
* retyping an equivalent one that could silently drift from this. */
|
|
22
|
+
export const AGENTS_MD_TEMPLATE = `# Shared instructions
|
|
23
|
+
|
|
24
|
+
Write what every agent should know here — communication preferences,
|
|
25
|
+
project conventions, anything you'd otherwise repeat per agent.
|
|
26
|
+
|
|
27
|
+
Already using Claude Code, Codex, Kiro, or pi? \`trellis migrate --from
|
|
28
|
+
<agent>\` imports its real instructions and skills instead of starting
|
|
29
|
+
from this placeholder.
|
|
30
|
+
`;
|
|
31
|
+
/**
|
|
32
|
+
* `known_host_injected` starts empty deliberately (design.md D3, D1):
|
|
33
|
+
* seeding it with any specific runtime's connector names (e.g. this
|
|
34
|
+
* project's own mirasim) would be actively wrong on a machine without
|
|
35
|
+
* that runtime. \`trellis doctor\`'s collision check reads this file's
|
|
36
|
+
* real value once it exists, so leaving it empty is not just a stub —
|
|
37
|
+
* it accurately means "nothing known to be host-injected here yet."
|
|
38
|
+
*/
|
|
39
|
+
function serversYamlTemplate() {
|
|
40
|
+
return `# See schema/servers.example.yaml for the full documented shape
|
|
41
|
+
# (transports, env references, agent scoping, hub mode).
|
|
42
|
+
servers: {}
|
|
43
|
+
|
|
44
|
+
# Names a host environment (e.g. mirasim) injects into an agent process
|
|
45
|
+
# at runtime. A same-name static definition here would collide with it —
|
|
46
|
+
# on Codex that crashes the whole process at startup, not just that one
|
|
47
|
+
# server. Leave empty until you know what your own host injects; run
|
|
48
|
+
# \`trellis doctor\` to find out. See docs/research.md "Codex — three
|
|
49
|
+
# hard constraints".
|
|
50
|
+
known_host_injected: []
|
|
51
|
+
`;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Reads schema/secrets.policy.example.yaml's own \`reject_patterns\`
|
|
55
|
+
* rather than retyping them — those patterns are reviewed, shipped
|
|
56
|
+
* content; duplicating them here would let the two drift (design.md D1).
|
|
57
|
+
*/
|
|
58
|
+
function secretsPolicyYamlTemplate() {
|
|
59
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
60
|
+
const examplePath = join(here, "..", "..", "schema", "secrets.policy.example.yaml");
|
|
61
|
+
const parsed = parseYaml(readFileSync(examplePath, "utf-8"));
|
|
62
|
+
const patterns = parsed.reject_patterns ?? [];
|
|
63
|
+
const patternLines = patterns.map((p) => ` - '${p}'`).join("\n");
|
|
64
|
+
return `# See schema/secrets.policy.example.yaml for the full documented shape
|
|
65
|
+
# (env_file, what it does and doesn't protect).
|
|
66
|
+
allowed_vars: []
|
|
67
|
+
|
|
68
|
+
# Seeded from schema/secrets.policy.example.yaml's own reviewed list —
|
|
69
|
+
# generic credential shapes, not specific to any one project.
|
|
70
|
+
reject_patterns:
|
|
71
|
+
${patternLines}
|
|
72
|
+
`;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Trellis never spawns an installer itself (global package installs are
|
|
76
|
+
* exactly the kind of irreversible, system-wide action that needs the
|
|
77
|
+
* user's own explicit "yes", not a silent trellis-init side effect) —
|
|
78
|
+
* these are printed as a pointer only. Verified against each project's
|
|
79
|
+
* own current install docs, same discipline as everything else here.
|
|
80
|
+
*/
|
|
81
|
+
export const INSTALL_HINTS = {
|
|
82
|
+
"claude-code": "npm install -g @anthropic-ai/claude-code",
|
|
83
|
+
codex: "npm install -g @openai/codex",
|
|
84
|
+
kiro: "https://kiro.dev/downloads/",
|
|
85
|
+
pi: "npm install -g @earendil-works/pi-coding-agent",
|
|
86
|
+
};
|
|
87
|
+
function ensureFile(path, template) {
|
|
88
|
+
if (existsSync(path)) {
|
|
89
|
+
return { path, action: "already-present" };
|
|
90
|
+
}
|
|
91
|
+
writeFileSync(path, template);
|
|
92
|
+
return { path, action: "create" };
|
|
93
|
+
}
|
|
94
|
+
export async function collectInitReport(homeDir = homedir()) {
|
|
95
|
+
const root = join(homeDir, ".trellis");
|
|
96
|
+
mkdirSync(root, { recursive: true });
|
|
97
|
+
mkdirSync(join(root, "mcp"), { recursive: true });
|
|
98
|
+
mkdirSync(join(root, "skills"), { recursive: true });
|
|
99
|
+
mkdirSync(join(root, "agents"), { recursive: true });
|
|
100
|
+
mkdirSync(join(root, "memories"), { recursive: true });
|
|
101
|
+
const files = [
|
|
102
|
+
ensureFile(join(root, "agents.md"), AGENTS_MD_TEMPLATE),
|
|
103
|
+
ensureFile(join(root, "mcp", "servers.yaml"), serversYamlTemplate()),
|
|
104
|
+
ensureFile(join(root, "secrets.policy.yaml"), secretsPolicyYamlTemplate()),
|
|
105
|
+
];
|
|
106
|
+
const probes = [
|
|
107
|
+
{ agent: "claude-code", run: () => claudeCodeProbe.probe(homeDir) },
|
|
108
|
+
{ agent: "codex", run: () => codexProbe.probe(homeDir) },
|
|
109
|
+
{ agent: "kiro", run: () => kiroProbe.probe(homeDir) },
|
|
110
|
+
{ agent: "pi", run: () => piProbe.probe(homeDir) },
|
|
111
|
+
];
|
|
112
|
+
const settled = await Promise.allSettled(probes.map((p) => p.run()));
|
|
113
|
+
const agents = settled.map((result, index) => {
|
|
114
|
+
const agent = ALL_AGENTS[index];
|
|
115
|
+
const present = result.status === "fulfilled" && result.value.present;
|
|
116
|
+
const message = present
|
|
117
|
+
? `${agent} is present — run \`trellis migrate --from ${agent}\` to import its skills and instructions`
|
|
118
|
+
: `${agent} — not detected on this machine. Install: ${INSTALL_HINTS[agent]}`;
|
|
119
|
+
return { agent, present, message };
|
|
120
|
+
});
|
|
121
|
+
return { files, agents };
|
|
122
|
+
}
|
|
123
|
+
export async function runInit(opts = {}) {
|
|
124
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
125
|
+
const report = await collectInitReport(homeDir);
|
|
126
|
+
if (opts.json) {
|
|
127
|
+
console.log(JSON.stringify(report, null, 2));
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
printReport(report);
|
|
131
|
+
}
|
|
132
|
+
return { exitCode: 0 };
|
|
133
|
+
}
|
|
134
|
+
function printReport(report) {
|
|
135
|
+
const created = report.files.filter((f) => f.action === "create");
|
|
136
|
+
if (created.length === 0) {
|
|
137
|
+
console.log("~/.trellis already fully initialized — nothing to create.");
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
console.log(`Created ${created.length} file(s):`);
|
|
141
|
+
for (const f of created)
|
|
142
|
+
console.log(` - ${f.path}`);
|
|
143
|
+
}
|
|
144
|
+
const alreadyPresent = report.files.filter((f) => f.action === "already-present");
|
|
145
|
+
for (const f of alreadyPresent)
|
|
146
|
+
console.log(` - ${f.path} (already present, left untouched)`);
|
|
147
|
+
console.log("");
|
|
148
|
+
for (const a of report.agents)
|
|
149
|
+
console.log(a.message);
|
|
150
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trellis mcp sync` — loads canonical once, runs every present agent's
|
|
3
|
+
* adapter, applies only the "mcp" slice of its plan, and reports what
|
|
4
|
+
* happened. Same create/repair/refuse shape as `src/commands/sync.ts`,
|
|
5
|
+
* kept as a separate command because MCP has no ownership marker for
|
|
6
|
+
* automatic removal yet (trellis-mcp-sync-p2 design.md D7) — folding it
|
|
7
|
+
* into bare `trellis sync` would blur that distinction.
|
|
8
|
+
*/
|
|
9
|
+
import type { AdapterPlanItem } from "../core/adapter.js";
|
|
10
|
+
import type { AgentId } from "../core/types.js";
|
|
11
|
+
export interface RunMcpSyncOptions {
|
|
12
|
+
json?: boolean;
|
|
13
|
+
/** Same test/sandbox-only seam as `RunSyncOptions.homeDir` — never a CLI
|
|
14
|
+
* flag. See docs/architecture.md's testing philosophy. */
|
|
15
|
+
homeDir?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface AgentMcpSyncReport {
|
|
18
|
+
agent: AgentId;
|
|
19
|
+
present: boolean;
|
|
20
|
+
items: AdapterPlanItem[];
|
|
21
|
+
}
|
|
22
|
+
export interface McpSyncReport {
|
|
23
|
+
reports: AgentMcpSyncReport[];
|
|
24
|
+
}
|
|
25
|
+
export declare function collectMcpSyncReport(opts?: RunMcpSyncOptions): Promise<McpSyncReport>;
|
|
26
|
+
export declare function runMcpSync(opts?: RunMcpSyncOptions): Promise<{
|
|
27
|
+
exitCode: number;
|
|
28
|
+
}>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trellis mcp sync` — loads canonical once, runs every present agent's
|
|
3
|
+
* adapter, applies only the "mcp" slice of its plan, and reports what
|
|
4
|
+
* happened. Same create/repair/refuse shape as `src/commands/sync.ts`,
|
|
5
|
+
* kept as a separate command because MCP has no ownership marker for
|
|
6
|
+
* automatic removal yet (trellis-mcp-sync-p2 design.md D7) — folding it
|
|
7
|
+
* into bare `trellis sync` would blur that distinction.
|
|
8
|
+
*/
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { loadCanonicalSource } from "../core/canonical.js";
|
|
11
|
+
import { ClaudeCodeAdapter } from "../adapters/claude-code.js";
|
|
12
|
+
import { CodexAdapter } from "../adapters/codex.js";
|
|
13
|
+
import { KiroAdapter } from "../adapters/kiro.js";
|
|
14
|
+
import { PiAdapter } from "../adapters/pi.js";
|
|
15
|
+
function buildAdapters(homeDir) {
|
|
16
|
+
return [new ClaudeCodeAdapter(homeDir), new CodexAdapter(homeDir), new KiroAdapter(homeDir), new PiAdapter(homeDir)];
|
|
17
|
+
}
|
|
18
|
+
export async function collectMcpSyncReport(opts = {}) {
|
|
19
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
20
|
+
const canonical = loadCanonicalSource(homeDir);
|
|
21
|
+
const reports = [];
|
|
22
|
+
for (const adapter of buildAdapters(homeDir)) {
|
|
23
|
+
const probeResult = await adapter.probe();
|
|
24
|
+
if (!probeResult.present) {
|
|
25
|
+
reports.push({ agent: adapter.id, present: false, items: [] });
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const items = (await adapter.plan(canonical)).filter((item) => item.kind === "mcp");
|
|
29
|
+
await adapter.apply(items);
|
|
30
|
+
reports.push({ agent: adapter.id, present: true, items });
|
|
31
|
+
}
|
|
32
|
+
return { reports };
|
|
33
|
+
}
|
|
34
|
+
export async function runMcpSync(opts = {}) {
|
|
35
|
+
let report;
|
|
36
|
+
try {
|
|
37
|
+
report = await collectMcpSyncReport(opts);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
41
|
+
return { exitCode: 1 };
|
|
42
|
+
}
|
|
43
|
+
if (opts.json) {
|
|
44
|
+
console.log(JSON.stringify(report, null, 2));
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
printReport(report);
|
|
48
|
+
}
|
|
49
|
+
const hasConflict = report.reports.some((r) => r.items.some((i) => i.action === "conflict"));
|
|
50
|
+
return { exitCode: hasConflict ? 1 : 0 };
|
|
51
|
+
}
|
|
52
|
+
function printReport(report) {
|
|
53
|
+
for (const { agent, present, items } of report.reports) {
|
|
54
|
+
if (!present) {
|
|
55
|
+
console.log(`— ${agent} (not installed)`);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const created = items.filter((i) => i.action === "create");
|
|
59
|
+
const conflicts = items.filter((i) => i.action === "conflict");
|
|
60
|
+
if (created.length === 0 && conflicts.length === 0) {
|
|
61
|
+
console.log(`✅ ${agent} — already in sync`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const icon = conflicts.length > 0 ? "⚠️ " : "✅";
|
|
65
|
+
console.log(`${icon} ${agent} — ${created.length} created/updated, ${conflicts.length} conflict(s)`);
|
|
66
|
+
for (const item of [...created, ...conflicts]) {
|
|
67
|
+
console.log(` - [${item.action}] ${item.description}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trellis migrate --from <agent>` — imports an existing agent's real
|
|
3
|
+
* skills and instructions into canonical source (trellis-cli-migrate).
|
|
4
|
+
* Pure `collectMigratePlan` / effectful `applyMigratePlan`, same
|
|
5
|
+
* plan-then-apply split every adapter already uses.
|
|
6
|
+
*/
|
|
7
|
+
import type { AgentId } from "../core/types.js";
|
|
8
|
+
export type MigrateAction = "create" | "skip-symlink" | "skip-case-broken" | "already-migrated" | "conflict";
|
|
9
|
+
export interface MigratePlanItem {
|
|
10
|
+
kind: "skill" | "instructions";
|
|
11
|
+
name: string;
|
|
12
|
+
action: MigrateAction;
|
|
13
|
+
detail: string;
|
|
14
|
+
/** Only set when action === "create"; consumed by applyMigratePlan. */
|
|
15
|
+
sourceDir?: string;
|
|
16
|
+
sourceContent?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface MigratePlan {
|
|
19
|
+
agent: AgentId;
|
|
20
|
+
present: boolean;
|
|
21
|
+
items: MigratePlanItem[];
|
|
22
|
+
}
|
|
23
|
+
export declare function collectMigratePlan(agent: AgentId, homeDir?: string): Promise<MigratePlan>;
|
|
24
|
+
export declare function applyMigratePlan(plan: MigratePlan, homeDir?: string): void;
|
|
25
|
+
export interface RunMigrateOptions {
|
|
26
|
+
from?: string;
|
|
27
|
+
dryRun?: boolean;
|
|
28
|
+
json?: boolean;
|
|
29
|
+
/** Defaults to the real `~`; overridable for tests only. */
|
|
30
|
+
homeDir?: string;
|
|
31
|
+
}
|
|
32
|
+
export declare function runMigrate(opts?: RunMigrateOptions): Promise<{
|
|
33
|
+
exitCode: number;
|
|
34
|
+
}>;
|
|
35
|
+
/** Exported so `onboard` prints a migrate plan identically to running
|
|
36
|
+
* `migrate` standalone, instead of a second, easily-drifting copy of
|
|
37
|
+
* this formatting (including the empty-plan "nothing to migrate" case). */
|
|
38
|
+
export declare function printPlan(plan: MigratePlan, dryRun: boolean): void;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trellis migrate --from <agent>` — imports an existing agent's real
|
|
3
|
+
* skills and instructions into canonical source (trellis-cli-migrate).
|
|
4
|
+
* Pure `collectMigratePlan` / effectful `applyMigratePlan`, same
|
|
5
|
+
* plan-then-apply split every adapter already uses.
|
|
6
|
+
*/
|
|
7
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import * as claudeCodeProbe from "../probes/claude-code.js";
|
|
11
|
+
import * as codexProbe from "../probes/codex.js";
|
|
12
|
+
import * as kiroProbe from "../probes/kiro.js";
|
|
13
|
+
import * as piProbe from "../probes/pi.js";
|
|
14
|
+
import { AGENTS_MD_TEMPLATE } from "./init.js";
|
|
15
|
+
import { dirContentsEqual } from "../lib/dirEquals.js";
|
|
16
|
+
import { ALL_AGENTS } from "../core/types.js";
|
|
17
|
+
const PROBES = {
|
|
18
|
+
"claude-code": (homeDir) => claudeCodeProbe.probe(homeDir),
|
|
19
|
+
codex: (homeDir) => codexProbe.probe(homeDir),
|
|
20
|
+
kiro: (homeDir) => kiroProbe.probe(homeDir),
|
|
21
|
+
pi: (homeDir) => piProbe.probe(homeDir),
|
|
22
|
+
};
|
|
23
|
+
function planSkill(name, sourceDir, isSymlink, caseCorrect, canonicalDir) {
|
|
24
|
+
if (isSymlink) {
|
|
25
|
+
return { kind: "skill", name, action: "skip-symlink", detail: "shared in from elsewhere, not this agent's own content" };
|
|
26
|
+
}
|
|
27
|
+
if (!caseCorrect) {
|
|
28
|
+
return { kind: "skill", name, action: "skip-case-broken", detail: "already undiscoverable on at least one other agent — fix on the source before migrating" };
|
|
29
|
+
}
|
|
30
|
+
if (!existsSync(canonicalDir)) {
|
|
31
|
+
return { kind: "skill", name, action: "create", detail: `will copy from ${sourceDir}`, sourceDir };
|
|
32
|
+
}
|
|
33
|
+
if (dirContentsEqual(sourceDir, canonicalDir)) {
|
|
34
|
+
return { kind: "skill", name, action: "already-migrated", detail: "canonical content is byte-identical" };
|
|
35
|
+
}
|
|
36
|
+
return { kind: "skill", name, action: "conflict", detail: `canonical skills/${name}/ already exists with different content — resolve by hand` };
|
|
37
|
+
}
|
|
38
|
+
function planInstructions(snapshot, canonicalAgentsMd) {
|
|
39
|
+
if (!snapshot.instructionsFile)
|
|
40
|
+
return undefined;
|
|
41
|
+
if (snapshot.instructionsFile.isSymlink) {
|
|
42
|
+
return { kind: "instructions", name: "agents.md", action: "skip-symlink", detail: "source agent's instructions file is itself a symlink, nothing real to read" };
|
|
43
|
+
}
|
|
44
|
+
let sourceContent;
|
|
45
|
+
try {
|
|
46
|
+
sourceContent = readFileSync(snapshot.instructionsFile.path, "utf-8");
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
return { kind: "instructions", name: "agents.md", action: "conflict", detail: `could not read source instructions: ${err instanceof Error ? err.message : String(err)}` };
|
|
50
|
+
}
|
|
51
|
+
if (!existsSync(canonicalAgentsMd)) {
|
|
52
|
+
return { kind: "instructions", name: "agents.md", action: "create", detail: "canonical agents.md does not exist yet", sourceContent };
|
|
53
|
+
}
|
|
54
|
+
const current = readFileSync(canonicalAgentsMd, "utf-8");
|
|
55
|
+
if (current === AGENTS_MD_TEMPLATE) {
|
|
56
|
+
return { kind: "instructions", name: "agents.md", action: "create", detail: "canonical agents.md is still trellis init's placeholder", sourceContent };
|
|
57
|
+
}
|
|
58
|
+
if (current === sourceContent) {
|
|
59
|
+
return { kind: "instructions", name: "agents.md", action: "already-migrated", detail: "canonical content is byte-identical" };
|
|
60
|
+
}
|
|
61
|
+
return { kind: "instructions", name: "agents.md", action: "conflict", detail: "canonical agents.md already has different real content — resolve by hand" };
|
|
62
|
+
}
|
|
63
|
+
export async function collectMigratePlan(agent, homeDir = homedir()) {
|
|
64
|
+
const snapshot = await PROBES[agent](homeDir);
|
|
65
|
+
if (!snapshot.present) {
|
|
66
|
+
return { agent, present: false, items: [] };
|
|
67
|
+
}
|
|
68
|
+
const canonicalRoot = join(homeDir, ".trellis");
|
|
69
|
+
const items = [];
|
|
70
|
+
for (const root of snapshot.skillRoots) {
|
|
71
|
+
for (const skill of root.skills) {
|
|
72
|
+
items.push(planSkill(skill.name, skill.dir, skill.isSymlink, skill.caseCorrect, join(canonicalRoot, "skills", skill.name)));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const instructionsItem = planInstructions(snapshot, join(canonicalRoot, "agents.md"));
|
|
76
|
+
if (instructionsItem)
|
|
77
|
+
items.push(instructionsItem);
|
|
78
|
+
return { agent, present: true, items };
|
|
79
|
+
}
|
|
80
|
+
export function applyMigratePlan(plan, homeDir = homedir()) {
|
|
81
|
+
const canonicalRoot = join(homeDir, ".trellis");
|
|
82
|
+
for (const item of plan.items) {
|
|
83
|
+
if (item.action !== "create")
|
|
84
|
+
continue;
|
|
85
|
+
if (item.kind === "skill" && item.sourceDir) {
|
|
86
|
+
const dest = join(canonicalRoot, "skills", item.name);
|
|
87
|
+
mkdirSync(dest, { recursive: true });
|
|
88
|
+
cpSync(item.sourceDir, dest, { recursive: true });
|
|
89
|
+
}
|
|
90
|
+
else if (item.kind === "instructions" && item.sourceContent !== undefined) {
|
|
91
|
+
mkdirSync(canonicalRoot, { recursive: true });
|
|
92
|
+
writeFileSync(join(canonicalRoot, "agents.md"), item.sourceContent);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export async function runMigrate(opts = {}) {
|
|
97
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
98
|
+
if (!opts.from || !ALL_AGENTS.includes(opts.from)) {
|
|
99
|
+
console.error(`--from must be one of: ${ALL_AGENTS.join(", ")} (got ${opts.from ?? "(none)"})`);
|
|
100
|
+
return { exitCode: 1 };
|
|
101
|
+
}
|
|
102
|
+
const agent = opts.from;
|
|
103
|
+
const plan = await collectMigratePlan(agent, homeDir);
|
|
104
|
+
if (!plan.present) {
|
|
105
|
+
console.error(`${agent} is not present on this machine — nothing to migrate.`);
|
|
106
|
+
return { exitCode: 1 };
|
|
107
|
+
}
|
|
108
|
+
if (!opts.dryRun) {
|
|
109
|
+
applyMigratePlan(plan, homeDir);
|
|
110
|
+
}
|
|
111
|
+
if (opts.json) {
|
|
112
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
printPlan(plan, opts.dryRun ?? false);
|
|
116
|
+
}
|
|
117
|
+
const hasConflict = plan.items.some((i) => i.action === "conflict");
|
|
118
|
+
return { exitCode: hasConflict ? 1 : 0 };
|
|
119
|
+
}
|
|
120
|
+
/** Exported so `onboard` prints a migrate plan identically to running
|
|
121
|
+
* `migrate` standalone, instead of a second, easily-drifting copy of
|
|
122
|
+
* this formatting (including the empty-plan "nothing to migrate" case). */
|
|
123
|
+
export function printPlan(plan, dryRun) {
|
|
124
|
+
console.log(`${dryRun ? "[dry run] " : ""}migrate --from ${plan.agent}`);
|
|
125
|
+
if (plan.items.length === 0) {
|
|
126
|
+
console.log(" nothing to migrate");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
for (const item of plan.items) {
|
|
130
|
+
console.log(` [${item.action}] ${item.kind === "skill" ? `skill "${item.name}"` : "instructions"} — ${item.detail}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trellis onboard` — chains `init` → agent detection → base-agent
|
|
3
|
+
* resolution → `migrate` → `sync` into one guided flow
|
|
4
|
+
* (trellis-cli-onboard). Orchestrates existing commands' own
|
|
5
|
+
* plan/apply logic; no new skill-copy, symlink, or conflict-detection
|
|
6
|
+
* judgment is made here.
|
|
7
|
+
*/
|
|
8
|
+
import type { AgentId } from "../core/types.js";
|
|
9
|
+
import type { MigratePlan } from "./migrate.js";
|
|
10
|
+
import type { SyncReport } from "./sync.js";
|
|
11
|
+
export interface OnboardAgentSummary {
|
|
12
|
+
agent: AgentId;
|
|
13
|
+
present: boolean;
|
|
14
|
+
skillCount: number;
|
|
15
|
+
skillNames: string[];
|
|
16
|
+
/** instructionsFile exists and isn't itself a symlink — real content
|
|
17
|
+
* of that agent's own to potentially migrate, not a judgment about
|
|
18
|
+
* its quality or length. */
|
|
19
|
+
hasRealInstructions: boolean;
|
|
20
|
+
}
|
|
21
|
+
export declare function collectOnboardSummary(homeDir?: string): Promise<OnboardAgentSummary[]>;
|
|
22
|
+
export interface RunOnboardOptions {
|
|
23
|
+
agent?: string;
|
|
24
|
+
dryRun?: boolean;
|
|
25
|
+
json?: boolean;
|
|
26
|
+
/** Defaults to the real `~`; overridable for tests only. */
|
|
27
|
+
homeDir?: string;
|
|
28
|
+
/** Test-only: overrides the real `process.stdin.isTTY` check so the
|
|
29
|
+
* interactive-vs-refuse branch is exercisable without a real terminal. */
|
|
30
|
+
isTTY?: boolean;
|
|
31
|
+
/** Test-only: replaces the real readline prompt with a scripted
|
|
32
|
+
* answer, so the prompt path is exercisable without a real terminal. */
|
|
33
|
+
promptForAgent?: (present: OnboardAgentSummary[]) => Promise<string>;
|
|
34
|
+
}
|
|
35
|
+
export interface OnboardResult {
|
|
36
|
+
summary: OnboardAgentSummary[];
|
|
37
|
+
base?: AgentId;
|
|
38
|
+
baseReason?: "auto-selected" | "flag" | "prompt";
|
|
39
|
+
migratePlan?: MigratePlan;
|
|
40
|
+
syncReport?: SyncReport;
|
|
41
|
+
refusal?: string;
|
|
42
|
+
/** Only set when no agent is present — the same values `--json` and
|
|
43
|
+
* text output both surface, so a machine caller doesn't have to
|
|
44
|
+
* hardcode them a second time. */
|
|
45
|
+
installHints?: Record<AgentId, string>;
|
|
46
|
+
}
|
|
47
|
+
export declare function collectOnboardPlan(opts?: RunOnboardOptions): Promise<OnboardResult>;
|
|
48
|
+
export declare function runOnboard(opts?: RunOnboardOptions): Promise<{
|
|
49
|
+
exitCode: number;
|
|
50
|
+
}>;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trellis onboard` — chains `init` → agent detection → base-agent
|
|
3
|
+
* resolution → `migrate` → `sync` into one guided flow
|
|
4
|
+
* (trellis-cli-onboard). Orchestrates existing commands' own
|
|
5
|
+
* plan/apply logic; no new skill-copy, symlink, or conflict-detection
|
|
6
|
+
* judgment is made here.
|
|
7
|
+
*/
|
|
8
|
+
import { createInterface } from "node:readline/promises";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import * as claudeCodeProbe from "../probes/claude-code.js";
|
|
11
|
+
import * as codexProbe from "../probes/codex.js";
|
|
12
|
+
import * as kiroProbe from "../probes/kiro.js";
|
|
13
|
+
import * as piProbe from "../probes/pi.js";
|
|
14
|
+
import { ALL_AGENTS } from "../core/types.js";
|
|
15
|
+
import { INSTALL_HINTS, collectInitReport } from "./init.js";
|
|
16
|
+
import { applyMigratePlan, collectMigratePlan, printPlan as printMigratePlan } from "./migrate.js";
|
|
17
|
+
import { collectSyncReport, printReport as printSyncReport } from "./sync.js";
|
|
18
|
+
const PROBES = {
|
|
19
|
+
"claude-code": (homeDir) => claudeCodeProbe.probe(homeDir),
|
|
20
|
+
codex: (homeDir) => codexProbe.probe(homeDir),
|
|
21
|
+
kiro: (homeDir) => kiroProbe.probe(homeDir),
|
|
22
|
+
pi: (homeDir) => piProbe.probe(homeDir),
|
|
23
|
+
};
|
|
24
|
+
export async function collectOnboardSummary(homeDir = homedir()) {
|
|
25
|
+
return Promise.all(ALL_AGENTS.map(async (agent) => {
|
|
26
|
+
const snapshot = await PROBES[agent](homeDir);
|
|
27
|
+
if (!snapshot.present) {
|
|
28
|
+
return { agent, present: false, skillCount: 0, skillNames: [], hasRealInstructions: false };
|
|
29
|
+
}
|
|
30
|
+
const skillNames = snapshot.skillRoots.flatMap((root) => root.skills.map((s) => s.name));
|
|
31
|
+
const hasRealInstructions = snapshot.instructionsFile !== undefined && !snapshot.instructionsFile.isSymlink;
|
|
32
|
+
return { agent, present: true, skillCount: skillNames.length, skillNames, hasRealInstructions };
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
async function promptForAgentReal(present) {
|
|
36
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
37
|
+
try {
|
|
38
|
+
console.log("Multiple agents detected:");
|
|
39
|
+
for (const s of present) {
|
|
40
|
+
const skills = s.skillCount > 0 ? ` (${s.skillNames.join(", ")})` : "";
|
|
41
|
+
console.log(` ${s.agent} — ${s.skillCount} skill(s)${skills}, instructions: ${s.hasRealInstructions ? "yes" : "no"}`);
|
|
42
|
+
}
|
|
43
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
44
|
+
const answer = (await rl.question("Choose a base agent to migrate from: ")).trim();
|
|
45
|
+
if (present.some((s) => s.agent === answer))
|
|
46
|
+
return answer;
|
|
47
|
+
console.log(`Not one of the present agents: ${present.map((s) => s.agent).join(", ")}`);
|
|
48
|
+
}
|
|
49
|
+
throw new Error("no valid agent chosen after 2 attempts");
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
rl.close();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export async function collectOnboardPlan(opts = {}) {
|
|
56
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
57
|
+
await collectInitReport(homeDir);
|
|
58
|
+
const summary = await collectOnboardSummary(homeDir);
|
|
59
|
+
const present = summary.filter((s) => s.present);
|
|
60
|
+
if (present.length === 0) {
|
|
61
|
+
return { summary, installHints: { ...INSTALL_HINTS } };
|
|
62
|
+
}
|
|
63
|
+
let base;
|
|
64
|
+
let baseReason;
|
|
65
|
+
if (opts.agent) {
|
|
66
|
+
const match = present.find((s) => s.agent === opts.agent);
|
|
67
|
+
if (!match) {
|
|
68
|
+
return {
|
|
69
|
+
summary,
|
|
70
|
+
refusal: `"${opts.agent}" is not one of the present agents (${present.map((s) => s.agent).join(", ")})`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
base = match.agent;
|
|
74
|
+
baseReason = "flag";
|
|
75
|
+
}
|
|
76
|
+
else if (present.length === 1) {
|
|
77
|
+
base = present[0].agent;
|
|
78
|
+
baseReason = "auto-selected";
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
const canPrompt = !opts.json && (opts.isTTY ?? process.stdin.isTTY === true);
|
|
82
|
+
if (!canPrompt) {
|
|
83
|
+
return {
|
|
84
|
+
summary,
|
|
85
|
+
refusal: `multiple agents detected (${present.map((s) => s.agent).join(", ")}) and no terminal to prompt in — pass --agent <id>`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const prompt = opts.promptForAgent ?? promptForAgentReal;
|
|
89
|
+
try {
|
|
90
|
+
base = (await prompt(present));
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
return { summary, refusal: err instanceof Error ? err.message : String(err) };
|
|
94
|
+
}
|
|
95
|
+
baseReason = "prompt";
|
|
96
|
+
}
|
|
97
|
+
const migratePlan = await collectMigratePlan(base, homeDir);
|
|
98
|
+
if (!opts.dryRun) {
|
|
99
|
+
applyMigratePlan(migratePlan, homeDir);
|
|
100
|
+
}
|
|
101
|
+
const syncReport = await collectSyncReport({ homeDir, dryRun: opts.dryRun });
|
|
102
|
+
return { summary, base, baseReason, migratePlan, syncReport };
|
|
103
|
+
}
|
|
104
|
+
export async function runOnboard(opts = {}) {
|
|
105
|
+
const result = await collectOnboardPlan(opts);
|
|
106
|
+
if (opts.json) {
|
|
107
|
+
console.log(JSON.stringify(result, null, 2));
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
printResult(result, opts.dryRun ?? false);
|
|
111
|
+
}
|
|
112
|
+
if (result.refusal)
|
|
113
|
+
return { exitCode: 1 };
|
|
114
|
+
const hasConflict = (result.migratePlan?.items.some((i) => i.action === "conflict") ?? false) ||
|
|
115
|
+
(result.syncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false);
|
|
116
|
+
return { exitCode: hasConflict ? 1 : 0 };
|
|
117
|
+
}
|
|
118
|
+
function printResult(result, dryRun) {
|
|
119
|
+
if (dryRun)
|
|
120
|
+
console.log("[dry run]");
|
|
121
|
+
const present = result.summary.filter((s) => s.present);
|
|
122
|
+
if (present.length === 0) {
|
|
123
|
+
console.log("No agent detected on this machine yet. Install one, then re-run `trellis onboard`:");
|
|
124
|
+
for (const agent of ALL_AGENTS) {
|
|
125
|
+
console.log(` ${agent}: ${INSTALL_HINTS[agent]}`);
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (result.refusal) {
|
|
130
|
+
console.error(result.refusal);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (result.baseReason === "auto-selected") {
|
|
134
|
+
console.log(`Only ${result.base} detected — using it as the migration base.`);
|
|
135
|
+
}
|
|
136
|
+
else if (result.baseReason === "flag") {
|
|
137
|
+
console.log(`Using ${result.base} as the migration base (--agent).`);
|
|
138
|
+
}
|
|
139
|
+
else if (result.baseReason === "prompt") {
|
|
140
|
+
console.log(`Using ${result.base} as the migration base.`);
|
|
141
|
+
}
|
|
142
|
+
// Reuse `migrate`/`sync`'s own printing verbatim (including the
|
|
143
|
+
// "nothing to migrate" / "already in sync" cases) rather than a second,
|
|
144
|
+
// easily-drifting copy of this formatting. `dryRun: false` here since
|
|
145
|
+
// this function's own leading "[dry run]" line already said so once.
|
|
146
|
+
if (result.migratePlan) {
|
|
147
|
+
console.log("");
|
|
148
|
+
printMigratePlan(result.migratePlan, false);
|
|
149
|
+
}
|
|
150
|
+
if (result.syncReport) {
|
|
151
|
+
console.log("\nsync");
|
|
152
|
+
printSyncReport(result.syncReport, false);
|
|
153
|
+
}
|
|
154
|
+
console.log("\nNext: `trellis mcp sync` to distribute MCP servers, `trellis secrets audit` to check for leaked credentials.");
|
|
155
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trellis secrets audit` — reads every present, MCP-capable agent's real
|
|
3
|
+
* config file (never the canonical source) and checks it against
|
|
4
|
+
* `CanonicalSource.secretsPolicy`: a literal-value scan against
|
|
5
|
+
* `rejectPatterns`, and a declared-env-var-name check against
|
|
6
|
+
* `allowedVars`. Also checks, agent-agnostically, whether every env var
|
|
7
|
+
* name declared across canonical `mcp.servers[*].env` actually resolves
|
|
8
|
+
* to a value via the same `resolveSecretEnv` the pi bridge uses
|
|
9
|
+
* (trellis-secrets-env-management) — authoritative for pi, a best-effort
|
|
10
|
+
* proxy for the other three (design.md D3 in that change). Read-only —
|
|
11
|
+
* never writes anything. Fails non-zero on any finding
|
|
12
|
+
* (trellis-secrets-audit-p3).
|
|
13
|
+
*/
|
|
14
|
+
import type { AgentId } from "../core/types.js";
|
|
15
|
+
export interface RunSecretsAuditOptions {
|
|
16
|
+
json?: boolean;
|
|
17
|
+
/** Same test/sandbox-only seam as every other command — never a CLI
|
|
18
|
+
* flag. See docs/architecture.md's testing philosophy. */
|
|
19
|
+
homeDir?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface SecretsFinding {
|
|
22
|
+
/** "environment" for `missing-env-value` — that check isn't scoped to
|
|
23
|
+
* any single agent's config file (see module doc comment). */
|
|
24
|
+
agent: AgentId | "environment";
|
|
25
|
+
file: string;
|
|
26
|
+
kind: "literal-secret" | "unexpected-var-name" | "missing-env-value";
|
|
27
|
+
detail: string;
|
|
28
|
+
}
|
|
29
|
+
export interface SecretsAuditReport {
|
|
30
|
+
findings: SecretsFinding[];
|
|
31
|
+
}
|
|
32
|
+
export declare function collectSecretsAuditReport(opts?: RunSecretsAuditOptions): Promise<SecretsAuditReport>;
|
|
33
|
+
export declare function runSecretsAudit(opts?: RunSecretsAuditOptions): Promise<{
|
|
34
|
+
exitCode: number;
|
|
35
|
+
}>;
|