loadout-ai 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/MASTER_PLAN.md +1711 -0
- package/README.md +453 -0
- package/SECURITY.md +18 -0
- package/SIMPLE_PLAN.md +44 -0
- package/catalog/discovered.json +10469 -0
- package/catalog/packages.json +1150 -0
- package/dashboard/app.js +607 -0
- package/dashboard/index.html +249 -0
- package/dashboard/styles.css +384 -0
- package/dist/src/cli.js +2735 -0
- package/dist/src/core/active-policy.js +233 -0
- package/dist/src/core/active-set.js +382 -0
- package/dist/src/core/adapters.js +254 -0
- package/dist/src/core/adopt.js +92 -0
- package/dist/src/core/agent-health-score.js +475 -0
- package/dist/src/core/agent-inspection.js +152 -0
- package/dist/src/core/agent-versions.js +131 -0
- package/dist/src/core/api.js +90 -0
- package/dist/src/core/atomic-file.js +22 -0
- package/dist/src/core/audit.js +174 -0
- package/dist/src/core/benchmark-campaign.js +496 -0
- package/dist/src/core/benchmark-evidence.js +458 -0
- package/dist/src/core/benchmark-fixtures.js +1157 -0
- package/dist/src/core/benchmark-runner.js +301 -0
- package/dist/src/core/benchmark-trust.js +795 -0
- package/dist/src/core/canary.js +77 -0
- package/dist/src/core/candidate-intelligence.js +661 -0
- package/dist/src/core/catalog-coverage.js +85 -0
- package/dist/src/core/catalog-install.js +186 -0
- package/dist/src/core/catalog-release.js +286 -0
- package/dist/src/core/catalog.js +327 -0
- package/dist/src/core/codex-mcp.js +122 -0
- package/dist/src/core/community.js +86 -0
- package/dist/src/core/compatibility-intelligence.js +465 -0
- package/dist/src/core/completion.js +160 -0
- package/dist/src/core/components.js +138 -0
- package/dist/src/core/conversion.js +56 -0
- package/dist/src/core/credentials.js +377 -0
- package/dist/src/core/demo.js +136 -0
- package/dist/src/core/diff.js +83 -0
- package/dist/src/core/discovery-connector.js +165 -0
- package/dist/src/core/doctor.js +95 -0
- package/dist/src/core/ecosystem-import.js +1072 -0
- package/dist/src/core/evaluate.js +72 -0
- package/dist/src/core/file-lock.js +140 -0
- package/dist/src/core/freshness-alerts.js +230 -0
- package/dist/src/core/github-discovery.js +97 -0
- package/dist/src/core/github.js +148 -0
- package/dist/src/core/head-to-head.js +437 -0
- package/dist/src/core/health-score-evidence.js +169 -0
- package/dist/src/core/health.js +129 -0
- package/dist/src/core/improve.js +223 -0
- package/dist/src/core/install.js +233 -0
- package/dist/src/core/intelligence-feed-build.js +59 -0
- package/dist/src/core/intelligence-feed.js +353 -0
- package/dist/src/core/loadout-badge.js +55 -0
- package/dist/src/core/loadout-card.js +87 -0
- package/dist/src/core/manifest.js +307 -0
- package/dist/src/core/mcp-recipes.js +419 -0
- package/dist/src/core/mcp-registry-discovery.js +455 -0
- package/dist/src/core/mcp.js +287 -0
- package/dist/src/core/model-config.js +102 -0
- package/dist/src/core/observations.js +87 -0
- package/dist/src/core/outcomes.js +164 -0
- package/dist/src/core/package.js +225 -0
- package/dist/src/core/paths.js +214 -0
- package/dist/src/core/portable.js +130 -0
- package/dist/src/core/private-discovery.js +45 -0
- package/dist/src/core/profiles.js +255 -0
- package/dist/src/core/provenance.js +278 -0
- package/dist/src/core/ranking.js +104 -0
- package/dist/src/core/recommend.js +181 -0
- package/dist/src/core/registry-api.js +97 -0
- package/dist/src/core/registry.js +316 -0
- package/dist/src/core/release-claims.js +175 -0
- package/dist/src/core/remove.js +122 -0
- package/dist/src/core/review-queue.js +200 -0
- package/dist/src/core/runtime-tool-recipe.js +315 -0
- package/dist/src/core/runtime-tools.js +453 -0
- package/dist/src/core/safety.js +234 -0
- package/dist/src/core/sandbox.js +78 -0
- package/dist/src/core/scheduler.js +264 -0
- package/dist/src/core/share-report.js +85 -0
- package/dist/src/core/signing.js +115 -0
- package/dist/src/core/skill-compare.js +239 -0
- package/dist/src/core/skill-inventory.js +193 -0
- package/dist/src/core/skill-security.js +340 -0
- package/dist/src/core/skills-sh-discovery.js +336 -0
- package/dist/src/core/skills.js +208 -0
- package/dist/src/core/snapshot.js +210 -0
- package/dist/src/core/source.js +305 -0
- package/dist/src/core/state.js +300 -0
- package/dist/src/core/sync.js +348 -0
- package/dist/src/core/terminal.js +53 -0
- package/dist/src/core/transaction.js +211 -0
- package/dist/src/core/update-watch.js +39 -0
- package/dist/src/core/update.js +216 -0
- package/dist/src/core/upgrade.js +207 -0
- package/dist/src/dashboard.js +418 -0
- package/dist/src/shared/schemas.js +336 -0
- package/dist/src/shared/types.js +1 -0
- package/docs/ACTIVE_SET.md +53 -0
- package/docs/ACTIVE_SET_POLICY.md +45 -0
- package/docs/CANDIDATE_INTELLIGENCE.md +139 -0
- package/docs/CATALOG.md +78 -0
- package/docs/CATALOG_POLICY.md +41 -0
- package/docs/COMMUNITY_DISCOVERY.md +14 -0
- package/docs/COMPATIBILITY_POLICY.md +22 -0
- package/docs/CONVERSION_AND_SANDBOX.md +27 -0
- package/docs/CREDENTIAL_AND_UPDATE_POLICY.md +31 -0
- package/docs/DISCOVERED.md +280 -0
- package/docs/EVALUATION_PROTOCOL.md +40 -0
- package/docs/EVALUATION_PROTOCOL_V1.md +300 -0
- package/docs/FEATURE_TEST_MATRIX.md +802 -0
- package/docs/GITHUB_AUTHORIZATION.md +38 -0
- package/docs/HEAD_TO_HEAD_EVALUATION.md +79 -0
- package/docs/PROVENANCE_AND_COMPARISON.md +72 -0
- package/docs/PROVIDER_CONFIGURATION.md +45 -0
- package/docs/RELEASE_REVIEW.md +117 -0
- package/docs/SAFE_UPDATE_DEMO.md +25 -0
- package/docs/SCHEMA_DECISIONS.md +25 -0
- package/docs/TEAM_POLICY.md +18 -0
- package/docs/TESTING.md +190 -0
- package/package.json +82 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { discoverSkillDirectories } from "./skills.js";
|
|
4
|
+
import { discoverMcpManifests } from "./mcp.js";
|
|
5
|
+
import { discoverResources } from "./components.js";
|
|
6
|
+
const portableRelative = (root, path) => relative(root, path).split(/[\\/]/).join("/") || ".";
|
|
7
|
+
function frontmatter(path) {
|
|
8
|
+
return readFile(join(path, "SKILL.md"), "utf8").then((content) => {
|
|
9
|
+
const name = content.match(/^name:\s*(\S.*)$/m)?.[1];
|
|
10
|
+
const description = content.match(/^description:\s*(\S.*)$/m)?.[1];
|
|
11
|
+
return {
|
|
12
|
+
name: name ?? path.split(/[\\/]/).at(-1) ?? "unnamed",
|
|
13
|
+
...(description ? { description } : {}),
|
|
14
|
+
};
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
async function discoverPlugins(root) {
|
|
18
|
+
const plugins = [];
|
|
19
|
+
async function visit(directory, depth) {
|
|
20
|
+
if (depth > 5)
|
|
21
|
+
return;
|
|
22
|
+
let entries;
|
|
23
|
+
try {
|
|
24
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
for (const entry of entries) {
|
|
30
|
+
if (entry.name === ".git" || entry.name === "node_modules")
|
|
31
|
+
continue;
|
|
32
|
+
const path = join(directory, entry.name);
|
|
33
|
+
if (entry.isDirectory()) {
|
|
34
|
+
await visit(path, depth + 1);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
// Claude and Codex use the same plugin.json convention but different
|
|
38
|
+
// manifest directories. We parse declarations only; lifecycle code and
|
|
39
|
+
// hooks are deliberately never loaded or executed.
|
|
40
|
+
const pathParts = directory.split(/[\\/]/);
|
|
41
|
+
if (!entry.isFile() ||
|
|
42
|
+
entry.name !== "plugin.json" ||
|
|
43
|
+
(!pathParts.includes(".claude-plugin") &&
|
|
44
|
+
!pathParts.includes(".codex-plugin")))
|
|
45
|
+
continue;
|
|
46
|
+
try {
|
|
47
|
+
const value = JSON.parse(await readFile(path, "utf8"));
|
|
48
|
+
const name = typeof value.name === "string" && value.name
|
|
49
|
+
? value.name
|
|
50
|
+
: (relative(root, dirname(path)).split(/[\\/]/).at(-2) ??
|
|
51
|
+
"unnamed");
|
|
52
|
+
const components = new Set();
|
|
53
|
+
const warnings = [];
|
|
54
|
+
const names = (field) => {
|
|
55
|
+
const item = value[field];
|
|
56
|
+
if (typeof item === "string" && item)
|
|
57
|
+
return [item];
|
|
58
|
+
if (Array.isArray(item))
|
|
59
|
+
return item.filter((entry) => typeof entry === "string" && entry.length > 0);
|
|
60
|
+
if (item !== undefined)
|
|
61
|
+
warnings.push(`plugin field '${field}' has an unsupported shape`);
|
|
62
|
+
return [];
|
|
63
|
+
};
|
|
64
|
+
const commandPaths = names("commands");
|
|
65
|
+
if (commandPaths.length)
|
|
66
|
+
components.add("command");
|
|
67
|
+
const agentPaths = names("agents");
|
|
68
|
+
if (agentPaths.length)
|
|
69
|
+
components.add("agent");
|
|
70
|
+
const skillPaths = names("skills");
|
|
71
|
+
if (skillPaths.length)
|
|
72
|
+
components.add("skill");
|
|
73
|
+
const hookEvents = value.hooks &&
|
|
74
|
+
typeof value.hooks === "object" &&
|
|
75
|
+
!Array.isArray(value.hooks)
|
|
76
|
+
? Object.keys(value.hooks).sort()
|
|
77
|
+
: value.hooks === undefined
|
|
78
|
+
? []
|
|
79
|
+
: (warnings.push("plugin field 'hooks' has an unsupported shape"),
|
|
80
|
+
[]);
|
|
81
|
+
if (hookEvents.length)
|
|
82
|
+
warnings.push("hooks are inspected but never executed by Loadout");
|
|
83
|
+
const mcpServers = value.mcpServers &&
|
|
84
|
+
typeof value.mcpServers === "object" &&
|
|
85
|
+
!Array.isArray(value.mcpServers)
|
|
86
|
+
? Object.keys(value.mcpServers).sort()
|
|
87
|
+
: value.mcpServers === undefined
|
|
88
|
+
? []
|
|
89
|
+
: (warnings.push("plugin field 'mcpServers' has an unsupported shape"),
|
|
90
|
+
[]);
|
|
91
|
+
if (mcpServers.length)
|
|
92
|
+
components.add("mcp");
|
|
93
|
+
plugins.push({
|
|
94
|
+
type: "plugin",
|
|
95
|
+
name,
|
|
96
|
+
path: portableRelative(root, path),
|
|
97
|
+
...(typeof value.description === "string"
|
|
98
|
+
? { description: value.description }
|
|
99
|
+
: {}),
|
|
100
|
+
...(typeof value.version === "string"
|
|
101
|
+
? { version: value.version }
|
|
102
|
+
: {}),
|
|
103
|
+
...(typeof value.author === "string" ? { author: value.author } : {}),
|
|
104
|
+
components: [...components].sort(),
|
|
105
|
+
hookEvents,
|
|
106
|
+
mcpServers,
|
|
107
|
+
warnings,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
plugins.push({
|
|
112
|
+
type: "plugin",
|
|
113
|
+
name: "invalid",
|
|
114
|
+
path: portableRelative(root, path),
|
|
115
|
+
components: [],
|
|
116
|
+
hookEvents: [],
|
|
117
|
+
mcpServers: [],
|
|
118
|
+
warnings: [
|
|
119
|
+
`invalid plugin manifest: ${error instanceof Error ? error.message : String(error)}`,
|
|
120
|
+
],
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
await visit(root, 0);
|
|
126
|
+
return plugins;
|
|
127
|
+
}
|
|
128
|
+
/** Inspect a real package without running scripts or exposing environment values. */
|
|
129
|
+
export async function inspectPackage(root) {
|
|
130
|
+
const resolvedRoot = resolve(root);
|
|
131
|
+
const warnings = [];
|
|
132
|
+
let skillPaths = [];
|
|
133
|
+
try {
|
|
134
|
+
skillPaths = await discoverSkillDirectories(resolvedRoot);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
warnings.push(error instanceof Error ? error.message : String(error));
|
|
138
|
+
}
|
|
139
|
+
const skills = [];
|
|
140
|
+
for (const path of skillPaths) {
|
|
141
|
+
try {
|
|
142
|
+
const meta = await frontmatter(path);
|
|
143
|
+
skills.push({
|
|
144
|
+
type: "skill",
|
|
145
|
+
...meta,
|
|
146
|
+
path: portableRelative(resolvedRoot, path),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
warnings.push(`Could not read skill metadata at ${portableRelative(resolvedRoot, path)}: ${error instanceof Error ? error.message : String(error)}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const manifests = await discoverMcpManifests(resolvedRoot);
|
|
154
|
+
const resources = await discoverResources(resolvedRoot);
|
|
155
|
+
const plugins = await discoverPlugins(resolvedRoot);
|
|
156
|
+
plugins.sort((a, b) => a.path.localeCompare(b.path));
|
|
157
|
+
const mcpServers = manifests.flatMap((manifest) => manifest.servers.map((server) => ({
|
|
158
|
+
type: "mcp",
|
|
159
|
+
name: server.name,
|
|
160
|
+
transport: server.command
|
|
161
|
+
? "command"
|
|
162
|
+
: server.url
|
|
163
|
+
? "url"
|
|
164
|
+
: "unknown",
|
|
165
|
+
...(server.command ? { command: server.command } : {}),
|
|
166
|
+
...(server.url ? { url: server.url } : {}),
|
|
167
|
+
argumentCount: server.args.length,
|
|
168
|
+
environmentVariableCount: Object.keys(server.env).length,
|
|
169
|
+
path: portableRelative(resolvedRoot, manifest.path),
|
|
170
|
+
warnings: server.warnings,
|
|
171
|
+
})));
|
|
172
|
+
for (const manifest of manifests)
|
|
173
|
+
for (const warning of manifest.warnings)
|
|
174
|
+
warnings.push(`${portableRelative(resolvedRoot, manifest.path)}: ${warning}`);
|
|
175
|
+
return {
|
|
176
|
+
root: resolvedRoot,
|
|
177
|
+
skills,
|
|
178
|
+
resources,
|
|
179
|
+
plugins,
|
|
180
|
+
mcpServers,
|
|
181
|
+
counts: {
|
|
182
|
+
skills: skills.length,
|
|
183
|
+
rules: resources.filter((item) => item.type === "rule").length,
|
|
184
|
+
commands: resources.filter((item) => item.type === "command").length,
|
|
185
|
+
agents: resources.filter((item) => item.type === "agent").length,
|
|
186
|
+
plugins: plugins.length,
|
|
187
|
+
mcpServers: mcpServers.length,
|
|
188
|
+
manifests: manifests.length,
|
|
189
|
+
},
|
|
190
|
+
warnings,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
export function formatPackageInspection(result) {
|
|
194
|
+
const lines = [
|
|
195
|
+
`Package: ${result.root}`,
|
|
196
|
+
`Skills: ${result.counts.skills}`,
|
|
197
|
+
`Rules: ${result.counts.rules}`,
|
|
198
|
+
`Commands: ${result.counts.commands}`,
|
|
199
|
+
`Agents: ${result.counts.agents}`,
|
|
200
|
+
`Plugins: ${result.counts.plugins}`,
|
|
201
|
+
`MCP servers: ${result.counts.mcpServers}`,
|
|
202
|
+
`Manifests: ${result.counts.manifests}`,
|
|
203
|
+
];
|
|
204
|
+
for (const skill of result.skills)
|
|
205
|
+
lines.push(` skill: ${skill.name}${skill.description ? ` — ${skill.description}` : ""} (${skill.path})`);
|
|
206
|
+
for (const resource of result.resources)
|
|
207
|
+
lines.push(` ${resource.type}: ${resource.name} (${resource.path})`);
|
|
208
|
+
for (const plugin of result.plugins) {
|
|
209
|
+
const declared = plugin.components.length
|
|
210
|
+
? `; declares ${plugin.components.join(", ")}`
|
|
211
|
+
: "";
|
|
212
|
+
lines.push(` plugin: ${plugin.name}${plugin.version ? ` v${plugin.version}` : ""}${plugin.description ? ` — ${plugin.description}` : ""} (${plugin.path}${declared})`);
|
|
213
|
+
if (plugin.hookEvents.length)
|
|
214
|
+
lines.push(` hooks: ${plugin.hookEvents.join(", ")} (not executed)`);
|
|
215
|
+
if (plugin.mcpServers.length)
|
|
216
|
+
lines.push(` mcp servers: ${plugin.mcpServers.join(", ")}`);
|
|
217
|
+
for (const warning of plugin.warnings)
|
|
218
|
+
lines.push(` warning: ${warning}`);
|
|
219
|
+
}
|
|
220
|
+
for (const server of result.mcpServers)
|
|
221
|
+
lines.push(` mcp: ${server.name} (${server.transport}${server.command ? ` ${server.command}` : server.url ? ` ${server.url}` : ""}; ${server.environmentVariableCount} env var(s))`);
|
|
222
|
+
for (const warning of result.warnings)
|
|
223
|
+
lines.push(`warning: ${warning}`);
|
|
224
|
+
return lines.join("\n");
|
|
225
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { access, mkdir, readdir } from "node:fs/promises";
|
|
2
|
+
import { join, posix, win32 } from "node:path";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
export const AGENT_DEFINITIONS = [
|
|
7
|
+
{
|
|
8
|
+
id: "claude-code",
|
|
9
|
+
displayName: "Claude Code",
|
|
10
|
+
binary: "claude",
|
|
11
|
+
directory: [".claude", "skills"],
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
id: "codex",
|
|
15
|
+
displayName: "Codex",
|
|
16
|
+
binary: "codex",
|
|
17
|
+
directory: [".agents", "skills"],
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: "cursor",
|
|
21
|
+
displayName: "Cursor",
|
|
22
|
+
binary: "cursor",
|
|
23
|
+
directory: [".cursor", "skills"],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: "gemini-cli",
|
|
27
|
+
displayName: "Gemini CLI",
|
|
28
|
+
binary: "gemini",
|
|
29
|
+
directory: [".gemini", "skills"],
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: "opencode",
|
|
33
|
+
displayName: "OpenCode",
|
|
34
|
+
binary: "opencode",
|
|
35
|
+
directory: [".config", "opencode", "skills"],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "hermes",
|
|
39
|
+
displayName: "Hermes",
|
|
40
|
+
binary: "hermes",
|
|
41
|
+
directory: [".hermes", "skills"],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: "windsurf",
|
|
45
|
+
displayName: "Windsurf",
|
|
46
|
+
directory: [".codeium", "windsurf", "skills"],
|
|
47
|
+
detectionDirectories: [[".codeium", "windsurf"]],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: "cline",
|
|
51
|
+
displayName: "Cline",
|
|
52
|
+
binary: "cline",
|
|
53
|
+
directory: [".cline", "skills"],
|
|
54
|
+
detectionDirectories: [[".cline"]],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "github-copilot",
|
|
58
|
+
displayName: "GitHub Copilot",
|
|
59
|
+
binary: "copilot",
|
|
60
|
+
directory: [".copilot", "skills"],
|
|
61
|
+
detectionDirectories: [[".copilot"]],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "roo-code",
|
|
65
|
+
displayName: "Roo Code",
|
|
66
|
+
directory: [".roo", "skills"],
|
|
67
|
+
detectionDirectories: [[".roo"]],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: "kiro-cli",
|
|
71
|
+
displayName: "Kiro CLI",
|
|
72
|
+
directory: [".kiro", "skills"],
|
|
73
|
+
detectionDirectories: [[".kiro"]],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "junie",
|
|
77
|
+
displayName: "Junie",
|
|
78
|
+
directory: [".junie", "skills"],
|
|
79
|
+
detectionDirectories: [[".junie"]],
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
/** Parse one CLI agent selection consistently across every command. */
|
|
83
|
+
export function parseAgentSelection(input) {
|
|
84
|
+
if (input === undefined)
|
|
85
|
+
return undefined;
|
|
86
|
+
const values = input
|
|
87
|
+
.split(",")
|
|
88
|
+
.map((value) => value.trim())
|
|
89
|
+
.filter(Boolean);
|
|
90
|
+
if (!values.length)
|
|
91
|
+
throw new Error("--agents requires at least one supported agent id");
|
|
92
|
+
const known = new Set(AGENT_DEFINITIONS.map((definition) => definition.id));
|
|
93
|
+
const unknown = [
|
|
94
|
+
...new Set(values.filter((id) => !known.has(id))),
|
|
95
|
+
];
|
|
96
|
+
if (unknown.length)
|
|
97
|
+
throw new Error(`Unknown agent id(s): ${unknown.join(", ")}. Supported ids: ${[...known].join(", ")}`);
|
|
98
|
+
return [...new Set(values)];
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* WSL intentionally uses its Linux/POSIX home and paths. We never translate a
|
|
102
|
+
* Windows USERPROFILE into /mnt/c because that would install into a different
|
|
103
|
+
* agent profile than the Linux executable uses.
|
|
104
|
+
*/
|
|
105
|
+
export function runtimeBoundary(env = process.env, platform = process.platform) {
|
|
106
|
+
if (platform === "win32")
|
|
107
|
+
return "windows";
|
|
108
|
+
if (platform === "linux" &&
|
|
109
|
+
(Boolean(env.WSL_DISTRO_NAME) || Boolean(env.WSL_INTEROP)))
|
|
110
|
+
return "wsl";
|
|
111
|
+
return "posix";
|
|
112
|
+
}
|
|
113
|
+
export function userHome(env = process.env, platform = process.platform) {
|
|
114
|
+
if (env.LOADOUT_USER_HOME)
|
|
115
|
+
return env.LOADOUT_USER_HOME;
|
|
116
|
+
// USERPROFILE is the canonical home variable on native Windows. HOME is
|
|
117
|
+
// still accepted as a fallback for Git Bash/WSL and test environments.
|
|
118
|
+
if (platform === "win32")
|
|
119
|
+
return env.USERPROFILE ?? env.HOME ?? process.cwd();
|
|
120
|
+
return env.HOME ?? env.USERPROFILE ?? process.cwd();
|
|
121
|
+
}
|
|
122
|
+
export function loadoutHome(env = process.env, platform = process.platform) {
|
|
123
|
+
if (env.LOADOUT_HOME)
|
|
124
|
+
return env.LOADOUT_HOME;
|
|
125
|
+
if (platform === "win32") {
|
|
126
|
+
// Application state belongs in roaming app data on Windows rather than a
|
|
127
|
+
// dot-directory in the profile. APPDATA may be absent in stripped-down
|
|
128
|
+
// shells, so fall back to a conventional profile path.
|
|
129
|
+
const appData = env.APPDATA ?? win32.join(userHome(env, platform), "AppData", "Roaming");
|
|
130
|
+
return win32.join(appData, "loadout");
|
|
131
|
+
}
|
|
132
|
+
return join(userHome(env, platform), ".loadout");
|
|
133
|
+
}
|
|
134
|
+
/** The lookup order accepts npm's Windows .cmd shims as first-class executables. */
|
|
135
|
+
export function executableCandidates(binary, platform = process.platform) {
|
|
136
|
+
if (platform !== "win32")
|
|
137
|
+
return [binary];
|
|
138
|
+
return [binary, `${binary}.cmd`, `${binary}.exe`, `${binary}.bat`];
|
|
139
|
+
}
|
|
140
|
+
export function executableLookup(binary, platform = process.platform) {
|
|
141
|
+
return {
|
|
142
|
+
command: platform === "win32" ? "where" : "which",
|
|
143
|
+
candidates: executableCandidates(binary, platform),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
async function hasBinary(binary, platform = process.platform) {
|
|
147
|
+
const lookup = executableLookup(binary, platform);
|
|
148
|
+
try {
|
|
149
|
+
for (const candidate of lookup.candidates) {
|
|
150
|
+
try {
|
|
151
|
+
await execFileAsync(lookup.command, [candidate]);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// Continue so an npm-installed command such as codex.cmd is found
|
|
156
|
+
// even when the bare command is not resolved by a stripped-down PATH.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
export function agentSkillsDirectory(agent, home = userHome(), platform = process.platform) {
|
|
166
|
+
const definition = AGENT_DEFINITIONS.find((entry) => entry.id === agent);
|
|
167
|
+
if (!definition)
|
|
168
|
+
throw new Error(`Unknown agent '${agent}'`);
|
|
169
|
+
const path = platform === "win32" ? win32 : posix;
|
|
170
|
+
return path.join(home, ...definition.directory);
|
|
171
|
+
}
|
|
172
|
+
export async function detectAgents(options = {}) {
|
|
173
|
+
const env = options.env ?? process.env;
|
|
174
|
+
const platform = options.platform ?? process.platform;
|
|
175
|
+
const home = userHome(env, platform);
|
|
176
|
+
return Promise.all(AGENT_DEFINITIONS.map(async (definition) => {
|
|
177
|
+
const path = platform === "win32" ? win32 : posix;
|
|
178
|
+
const skillsDirectory = agentSkillsDirectory(definition.id, home, platform);
|
|
179
|
+
const detectionDirectories = definition.detectionDirectories?.map((parts) => path.join(home, ...parts)) ?? [dirnameForDetection(skillsDirectory)];
|
|
180
|
+
return {
|
|
181
|
+
id: definition.id,
|
|
182
|
+
displayName: definition.displayName,
|
|
183
|
+
...(definition.binary ? { binary: definition.binary } : {}),
|
|
184
|
+
installed: (definition.binary
|
|
185
|
+
? await hasBinary(definition.binary, platform)
|
|
186
|
+
: false) ||
|
|
187
|
+
(await Promise.all(detectionDirectories.map((directory) => directoryExists(directory)))).some(Boolean),
|
|
188
|
+
skillsDirectory,
|
|
189
|
+
};
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
function dirnameForDetection(skillsDirectory) {
|
|
193
|
+
return skillsDirectory.replace(/[\\/]skills$/, "");
|
|
194
|
+
}
|
|
195
|
+
export async function ensureDirectory(path) {
|
|
196
|
+
await mkdir(path, { recursive: true });
|
|
197
|
+
}
|
|
198
|
+
export async function directoryExists(path) {
|
|
199
|
+
try {
|
|
200
|
+
await access(path);
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
export async function listDirectory(path) {
|
|
208
|
+
try {
|
|
209
|
+
return await readdir(path);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { lstat, mkdir, readFile, rename, rm, writeFile, } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { parseLockfile, readLockfile } from "./audit.js";
|
|
4
|
+
import { parseManifest, readManifest } from "./manifest.js";
|
|
5
|
+
import { runMutationTransaction } from "./transaction.js";
|
|
6
|
+
import { detectSecretKinds } from "./safety.js";
|
|
7
|
+
export function parsePortableLoadout(value) {
|
|
8
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
9
|
+
throw new Error("Portable Loadout must be an object");
|
|
10
|
+
const item = value;
|
|
11
|
+
if (item.schemaVersion !== 1 ||
|
|
12
|
+
item.kind !== "loadout-portable" ||
|
|
13
|
+
typeof item.exportedAt !== "string")
|
|
14
|
+
throw new Error("Portable Loadout schema is invalid");
|
|
15
|
+
const manifest = parseManifest(item.manifest);
|
|
16
|
+
const lockfile = item.lockfile === undefined ? undefined : parseLockfile(item.lockfile);
|
|
17
|
+
if (lockfile && lockfile.manifestName !== manifest.name)
|
|
18
|
+
throw new Error("Portable lockfile does not belong to its manifest");
|
|
19
|
+
const serialized = JSON.stringify({ manifest, lockfile });
|
|
20
|
+
const secrets = detectSecretKinds(serialized);
|
|
21
|
+
if (secrets.length)
|
|
22
|
+
throw new Error(`Portable Loadout appears to contain secret material: ${secrets.join(", ")}`);
|
|
23
|
+
return {
|
|
24
|
+
schemaVersion: 1,
|
|
25
|
+
kind: "loadout-portable",
|
|
26
|
+
exportedAt: item.exportedAt,
|
|
27
|
+
manifest,
|
|
28
|
+
...(lockfile ? { lockfile } : {}),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export async function exportPortableLoadout(manifestPath, output, lockPath) {
|
|
32
|
+
const manifest = await readManifest(manifestPath);
|
|
33
|
+
const absoluteLocal = manifest.packages.find((pkg) => pkg.source.type === "local" && isAbsolute(pkg.source.path));
|
|
34
|
+
if (absoluteLocal)
|
|
35
|
+
throw new Error(`Package '${absoluteLocal.id}' uses an absolute local path and cannot be exported portably. Move it below the manifest and add it with a relative --path, or publish it to a Loadout registry first.`);
|
|
36
|
+
const lockfile = lockPath ? await readLockfile(lockPath) : undefined;
|
|
37
|
+
const bundle = parsePortableLoadout({
|
|
38
|
+
schemaVersion: 1,
|
|
39
|
+
kind: "loadout-portable",
|
|
40
|
+
exportedAt: new Date().toISOString(),
|
|
41
|
+
manifest,
|
|
42
|
+
lockfile,
|
|
43
|
+
});
|
|
44
|
+
const target = resolve(output);
|
|
45
|
+
await mkdir(dirname(target), { recursive: true });
|
|
46
|
+
await writeFile(target, `${JSON.stringify(bundle, null, 2)}\n`, {
|
|
47
|
+
flag: "wx",
|
|
48
|
+
mode: 0o600,
|
|
49
|
+
});
|
|
50
|
+
return bundle;
|
|
51
|
+
}
|
|
52
|
+
async function readPortableFile(path) {
|
|
53
|
+
const source = resolve(path);
|
|
54
|
+
const info = await lstat(source);
|
|
55
|
+
if (!info.isFile() || info.isSymbolicLink())
|
|
56
|
+
throw new Error("Portable Loadout source must be a real file");
|
|
57
|
+
if (info.size > 5_000_000)
|
|
58
|
+
throw new Error("Portable Loadout exceeds the 5 MB limit");
|
|
59
|
+
return parsePortableLoadout(JSON.parse(await readFile(source, "utf8")));
|
|
60
|
+
}
|
|
61
|
+
export async function planPortableImport(source, manifestPath = "loadout.json", lockPath = "loadout.lock") {
|
|
62
|
+
const bundle = await readPortableFile(source);
|
|
63
|
+
const warnings = bundle.manifest.packages
|
|
64
|
+
.filter((pkg) => pkg.source.type === "local")
|
|
65
|
+
.map((pkg) => `Local package '${pkg.id}' is resolved relative to the importing machine.`);
|
|
66
|
+
return {
|
|
67
|
+
plan: {
|
|
68
|
+
source: resolve(source),
|
|
69
|
+
manifestPath: resolve(manifestPath),
|
|
70
|
+
...(bundle.lockfile ? { lockPath: resolve(lockPath) } : {}),
|
|
71
|
+
packageCount: bundle.manifest.packages.length,
|
|
72
|
+
includesLockfile: Boolean(bundle.lockfile),
|
|
73
|
+
warnings,
|
|
74
|
+
},
|
|
75
|
+
bundle,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export async function applyPortableImport(source, manifestPath = "loadout.json", lockPath = "loadout.lock", options = {}) {
|
|
79
|
+
const applied = await runMutationTransaction(async () => {
|
|
80
|
+
const prepared = await planPortableImport(source, manifestPath, lockPath);
|
|
81
|
+
const targets = [
|
|
82
|
+
prepared.plan.manifestPath,
|
|
83
|
+
...(prepared.plan.lockPath ? [prepared.plan.lockPath] : []),
|
|
84
|
+
];
|
|
85
|
+
if (!options.overwrite) {
|
|
86
|
+
for (const target of targets) {
|
|
87
|
+
try {
|
|
88
|
+
await lstat(target);
|
|
89
|
+
throw new Error(`Refusing to overwrite existing file without --overwrite: ${target}`);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (!(error &&
|
|
93
|
+
typeof error === "object" &&
|
|
94
|
+
"code" in error &&
|
|
95
|
+
error.code === "ENOENT"))
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { targets, value: prepared };
|
|
101
|
+
}, async ({ plan, bundle }) => {
|
|
102
|
+
const writes = [
|
|
103
|
+
{ path: plan.manifestPath, value: bundle.manifest },
|
|
104
|
+
...(plan.lockPath && bundle.lockfile
|
|
105
|
+
? [{ path: plan.lockPath, value: bundle.lockfile }]
|
|
106
|
+
: []),
|
|
107
|
+
];
|
|
108
|
+
const temporary = [];
|
|
109
|
+
try {
|
|
110
|
+
for (const entry of writes) {
|
|
111
|
+
await mkdir(dirname(entry.path), { recursive: true });
|
|
112
|
+
const temp = `${entry.path}.loadout-import-${process.pid}-${Date.now()}`;
|
|
113
|
+
temporary.push(temp);
|
|
114
|
+
await writeFile(temp, `${JSON.stringify(entry.value, null, 2)}\n`, {
|
|
115
|
+
flag: "wx",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
for (let index = 0; index < writes.length; index += 1) {
|
|
119
|
+
if (options.overwrite)
|
|
120
|
+
await rm(writes[index].path, { force: true });
|
|
121
|
+
await rename(temporary[index], writes[index].path);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
await Promise.all(temporary.map((path) => rm(path, { force: true })));
|
|
126
|
+
}
|
|
127
|
+
return plan;
|
|
128
|
+
});
|
|
129
|
+
return { plan: applied.result, snapshotId: applied.snapshotId };
|
|
130
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in private discovery using a caller-provided GitHub token. OAuth/App
|
|
3
|
+
* brokering remains a deployment concern; this function proves the safe data
|
|
4
|
+
* boundary and never places the token in a URL, result, cache, or log.
|
|
5
|
+
*/
|
|
6
|
+
export async function discoverPrivateRepositories(options = {}) {
|
|
7
|
+
const token = options.token ?? process.env.GITHUB_TOKEN;
|
|
8
|
+
if (!token)
|
|
9
|
+
throw new Error("Private discovery requires an explicit GITHUB_TOKEN or token option; public discovery needs no credentials");
|
|
10
|
+
const response = await (options.fetcher ?? fetch)("https://api.github.com/user/repos?visibility=private&affiliation=owner,collaborator,organization_member&per_page=100", {
|
|
11
|
+
headers: {
|
|
12
|
+
accept: "application/vnd.github+json",
|
|
13
|
+
authorization: `Bearer ${token}`,
|
|
14
|
+
"user-agent": "loadout-discovery",
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
if (!response.ok)
|
|
18
|
+
throw new Error(`Private GitHub discovery failed (${response.status})`);
|
|
19
|
+
const value = await response.json();
|
|
20
|
+
if (!Array.isArray(value))
|
|
21
|
+
throw new Error("Private GitHub discovery response is invalid");
|
|
22
|
+
return value.flatMap((item) => {
|
|
23
|
+
if (!item || typeof item !== "object")
|
|
24
|
+
return [];
|
|
25
|
+
const record = item;
|
|
26
|
+
if (record.private !== true ||
|
|
27
|
+
typeof record.id !== "number" ||
|
|
28
|
+
typeof record.full_name !== "string")
|
|
29
|
+
return [];
|
|
30
|
+
return [
|
|
31
|
+
{
|
|
32
|
+
id: record.id,
|
|
33
|
+
repository: record.full_name,
|
|
34
|
+
description: typeof record.description === "string" ? record.description : "",
|
|
35
|
+
defaultBranch: typeof record.default_branch === "string"
|
|
36
|
+
? record.default_branch
|
|
37
|
+
: "main",
|
|
38
|
+
updatedAt: typeof record.updated_at === "string" ? record.updated_at : "",
|
|
39
|
+
topics: Array.isArray(record.topics)
|
|
40
|
+
? record.topics.filter((topic) => typeof topic === "string")
|
|
41
|
+
: [],
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
});
|
|
45
|
+
}
|