aienvmp 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/CHANGELOG.md +7 -0
- package/CONTRIBUTING.md +18 -0
- package/LICENSE +134 -0
- package/README.md +298 -0
- package/ROADMAP.md +44 -0
- package/SECURITY.md +22 -0
- package/action.yml +42 -0
- package/bin/aienvmp.js +7 -0
- package/examples/github-action.yml +17 -0
- package/package.json +51 -0
- package/src/cli.js +81 -0
- package/src/commands/compile.js +41 -0
- package/src/commands/context.js +33 -0
- package/src/commands/dash.js +35 -0
- package/src/commands/diff.js +23 -0
- package/src/commands/doctor.js +34 -0
- package/src/commands/init.js +19 -0
- package/src/commands/intent.js +26 -0
- package/src/commands/record.js +26 -0
- package/src/commands/resolve.js +35 -0
- package/src/commands/scan.js +28 -0
- package/src/diff.js +23 -0
- package/src/doctor.js +38 -0
- package/src/fsutil.js +51 -0
- package/src/manifest.js +130 -0
- package/src/paths.js +33 -0
- package/src/policy.js +74 -0
- package/src/render.js +259 -0
- package/src/shell.js +33 -0
- package/src/timeline.js +46 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { initWorkspace } from "./commands/init.js";
|
|
2
|
+
import { scanWorkspace } from "./commands/scan.js";
|
|
3
|
+
import { compileWorkspace } from "./commands/compile.js";
|
|
4
|
+
import { diffWorkspace } from "./commands/diff.js";
|
|
5
|
+
import { doctorWorkspace } from "./commands/doctor.js";
|
|
6
|
+
import { dashWorkspace } from "./commands/dash.js";
|
|
7
|
+
import { contextWorkspace } from "./commands/context.js";
|
|
8
|
+
import { recordWorkspace } from "./commands/record.js";
|
|
9
|
+
import { intentWorkspace } from "./commands/intent.js";
|
|
10
|
+
import { resolveWorkspace } from "./commands/resolve.js";
|
|
11
|
+
|
|
12
|
+
const commands = new Map([
|
|
13
|
+
["init", initWorkspace],
|
|
14
|
+
["scan", scanWorkspace],
|
|
15
|
+
["compile", compileWorkspace],
|
|
16
|
+
["diff", diffWorkspace],
|
|
17
|
+
["doctor", doctorWorkspace],
|
|
18
|
+
["dash", dashWorkspace],
|
|
19
|
+
["context", contextWorkspace],
|
|
20
|
+
["record", recordWorkspace],
|
|
21
|
+
["intent", intentWorkspace],
|
|
22
|
+
["resolve", resolveWorkspace]
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const version = "0.1.0";
|
|
26
|
+
|
|
27
|
+
export async function main(argv) {
|
|
28
|
+
const [command, ...rest] = argv;
|
|
29
|
+
if (command === "-v" || command === "--version" || command === "version") {
|
|
30
|
+
console.log(version);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (!command || command === "-h" || command === "--help") {
|
|
34
|
+
printUsage();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const run = commands.get(command);
|
|
38
|
+
if (!run) {
|
|
39
|
+
printUsage();
|
|
40
|
+
throw new Error(`unknown command "${command}"`);
|
|
41
|
+
}
|
|
42
|
+
await run(parseArgs(rest));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function parseArgs(argv) {
|
|
46
|
+
const out = { _: [] };
|
|
47
|
+
for (let i = 0; i < argv.length; i++) {
|
|
48
|
+
const arg = argv[i];
|
|
49
|
+
if (!arg.startsWith("--")) {
|
|
50
|
+
out._.push(arg);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const [rawKey, inline] = arg.slice(2).split("=", 2);
|
|
54
|
+
const key = rawKey.replaceAll("-", "_");
|
|
55
|
+
if (inline !== undefined) {
|
|
56
|
+
out[key] = inline;
|
|
57
|
+
} else if (argv[i + 1] && !argv[i + 1].startsWith("--")) {
|
|
58
|
+
out[key] = argv[++i];
|
|
59
|
+
} else {
|
|
60
|
+
out[key] = true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function printUsage() {
|
|
67
|
+
console.log(`aienvmp - AI-first env map + lightweight runtime SBOM
|
|
68
|
+
|
|
69
|
+
Usage:
|
|
70
|
+
aienvmp init [--dir .]
|
|
71
|
+
aienvmp scan [--dir .]
|
|
72
|
+
aienvmp context [--dir .] [--json]
|
|
73
|
+
aienvmp intent [--dir .] --actor agent:codex --action "install pnpm"
|
|
74
|
+
aienvmp resolve [--dir .] --actor human:you --id <intent-id> [--status resolved|cancelled]
|
|
75
|
+
aienvmp record [--dir .] --actor agent:codex --summary "updated .nvmrc" [--target node] [--before 20] [--after 24]
|
|
76
|
+
aienvmp compile [--dir .] [--agents all|codex,claude,gemini]
|
|
77
|
+
aienvmp diff [--dir .]
|
|
78
|
+
aienvmp doctor [--dir .] [--json] [--ci]
|
|
79
|
+
aienvmp dash [--dir .] [--open]
|
|
80
|
+
`);
|
|
81
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { diagnose } from "../doctor.js";
|
|
4
|
+
import { readJson, replaceMarkerBlock } from "../fsutil.js";
|
|
5
|
+
import { openIntents, readJsonl, readTimeline } from "../timeline.js";
|
|
6
|
+
import { aiEnvPath, intentsPath, manifestPath, timelinePath, workspaceDir } from "../paths.js";
|
|
7
|
+
import { markerBegin, markerEnd, renderAgentBlock, renderAIEnv } from "../render.js";
|
|
8
|
+
import { loadPolicy, policyWarnings } from "../policy.js";
|
|
9
|
+
|
|
10
|
+
const agentFiles = {
|
|
11
|
+
codex: "AGENTS.md",
|
|
12
|
+
claude: "CLAUDE.md",
|
|
13
|
+
gemini: "GEMINI.md"
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export async function compileWorkspace(args) {
|
|
17
|
+
const dir = workspaceDir(args);
|
|
18
|
+
const manifest = await readJson(manifestPath(dir));
|
|
19
|
+
if (!manifest) throw new Error("missing manifest; run `aienvmp scan` first");
|
|
20
|
+
const timeline = await readTimeline(timelinePath(dir));
|
|
21
|
+
const intents = openIntents(await readJsonl(intentsPath(dir)));
|
|
22
|
+
const policy = await loadPolicy(dir);
|
|
23
|
+
const warnings = [...diagnose(manifest), ...policyWarnings(manifest, policy)];
|
|
24
|
+
const rendered = renderAIEnv(manifest, timeline, warnings, intents, policy);
|
|
25
|
+
await fs.writeFile(aiEnvPath(dir), rendered, "utf8");
|
|
26
|
+
const agents = parseAgents(args.agents);
|
|
27
|
+
const block = renderAgentBlock(manifest);
|
|
28
|
+
for (const agent of agents) {
|
|
29
|
+
const rel = agentFiles[agent];
|
|
30
|
+
if (!rel) continue;
|
|
31
|
+
await replaceMarkerBlock(path.join(dir, rel), markerBegin, markerEnd, block);
|
|
32
|
+
}
|
|
33
|
+
console.log(`compiled ${aiEnvPath(dir)}`);
|
|
34
|
+
if (agents.length) console.log(`injected: ${agents.join(", ")}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseAgents(value) {
|
|
38
|
+
if (!value) return [];
|
|
39
|
+
if (value === true || value === "all") return Object.keys(agentFiles);
|
|
40
|
+
return String(value).split(",").map((v) => v.trim()).filter(Boolean);
|
|
41
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { diagnose } from "../doctor.js";
|
|
2
|
+
import { readJson } from "../fsutil.js";
|
|
3
|
+
import { openIntents, readJsonl, readTimeline } from "../timeline.js";
|
|
4
|
+
import { intentsPath, manifestPath, timelinePath, workspaceDir } from "../paths.js";
|
|
5
|
+
import { renderContext } from "../render.js";
|
|
6
|
+
import { loadPolicy, policyWarnings } from "../policy.js";
|
|
7
|
+
|
|
8
|
+
export async function contextWorkspace(args) {
|
|
9
|
+
const dir = workspaceDir(args);
|
|
10
|
+
const manifest = await readJson(manifestPath(dir));
|
|
11
|
+
if (!manifest) throw new Error("missing manifest; run `aienvmp scan` first");
|
|
12
|
+
const timeline = await readTimeline(timelinePath(dir));
|
|
13
|
+
const intents = openIntents(await readJsonl(intentsPath(dir)));
|
|
14
|
+
const policy = await loadPolicy(dir);
|
|
15
|
+
const warnings = [...diagnose(manifest), ...policyWarnings(manifest, policy)];
|
|
16
|
+
if (args.json) {
|
|
17
|
+
console.log(JSON.stringify({
|
|
18
|
+
status: warnings.length ? "review-required" : "clear",
|
|
19
|
+
workspace: manifest.workspace,
|
|
20
|
+
runtimes: manifest.runtimes,
|
|
21
|
+
packageManagers: manifest.packageManagers,
|
|
22
|
+
containers: manifest.containers,
|
|
23
|
+
projectHints: manifest.projectHints,
|
|
24
|
+
warnings,
|
|
25
|
+
policy,
|
|
26
|
+
intents: intents.slice(-5),
|
|
27
|
+
recentLedger: timeline.slice(-5),
|
|
28
|
+
protocol: manifest.agentProtocol
|
|
29
|
+
}, null, 2));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
console.log(renderContext(manifest, timeline, warnings, intents, policy));
|
|
33
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { diagnose } from "../doctor.js";
|
|
5
|
+
import { readJson } from "../fsutil.js";
|
|
6
|
+
import { openIntents, readJsonl, readTimeline } from "../timeline.js";
|
|
7
|
+
import { dashboardPath, intentsPath, manifestPath, timelinePath, workspaceDir } from "../paths.js";
|
|
8
|
+
import { renderDashboard } from "../render.js";
|
|
9
|
+
import { loadPolicy, policyWarnings } from "../policy.js";
|
|
10
|
+
|
|
11
|
+
export async function dashWorkspace(args) {
|
|
12
|
+
const dir = workspaceDir(args);
|
|
13
|
+
const manifest = await readJson(manifestPath(dir));
|
|
14
|
+
if (!manifest) throw new Error("missing manifest; run `aienvmp scan` first");
|
|
15
|
+
const timeline = await readTimeline(timelinePath(dir));
|
|
16
|
+
const intents = openIntents(await readJsonl(intentsPath(dir)));
|
|
17
|
+
const policy = await loadPolicy(dir);
|
|
18
|
+
const warnings = [...diagnose(manifest), ...policyWarnings(manifest, policy)];
|
|
19
|
+
const html = renderDashboard(manifest, timeline, warnings, intents, policy);
|
|
20
|
+
const out = dashboardPath(dir);
|
|
21
|
+
await fs.mkdir(path.dirname(out), { recursive: true });
|
|
22
|
+
await fs.writeFile(out, html, "utf8");
|
|
23
|
+
console.log(`dashboard: ${out}`);
|
|
24
|
+
if (args.open) openFile(out);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function openFile(file) {
|
|
28
|
+
if (process.platform === "win32") {
|
|
29
|
+
execFile("cmd", ["/c", "start", "", file], { windowsHide: true });
|
|
30
|
+
} else if (process.platform === "darwin") {
|
|
31
|
+
execFile("open", [file]);
|
|
32
|
+
} else {
|
|
33
|
+
execFile("xdg-open", [file]);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { diffManifests } from "../diff.js";
|
|
2
|
+
import { readJson } from "../fsutil.js";
|
|
3
|
+
import { manifestPath, previousManifestPath, workspaceDir } from "../paths.js";
|
|
4
|
+
|
|
5
|
+
export async function diffWorkspace(args) {
|
|
6
|
+
const dir = workspaceDir(args);
|
|
7
|
+
const previous = await readJson(previousManifestPath(dir));
|
|
8
|
+
const current = await readJson(manifestPath(dir));
|
|
9
|
+
if (!current) throw new Error("missing manifest; run `aienvmp scan` first");
|
|
10
|
+
const changes = diffManifests(previous, current);
|
|
11
|
+
if (!changes.length) {
|
|
12
|
+
console.log("no environment changes detected");
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
for (const change of changes) {
|
|
16
|
+
console.log(format(change));
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function format(change) {
|
|
21
|
+
if (change.type === "changed") return `${change.scope} ${change.key}: ${change.before} -> ${change.after}`;
|
|
22
|
+
return `${change.scope} ${change.key}: ${change.type} ${change.after ?? change.before}`;
|
|
23
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { diagnose } from "../doctor.js";
|
|
2
|
+
import { readJson } from "../fsutil.js";
|
|
3
|
+
import { manifestPath, workspaceDir } from "../paths.js";
|
|
4
|
+
import { loadPolicy, policyWarnings } from "../policy.js";
|
|
5
|
+
|
|
6
|
+
export async function doctorWorkspace(args) {
|
|
7
|
+
const dir = workspaceDir(args);
|
|
8
|
+
const manifest = await readJson(manifestPath(dir));
|
|
9
|
+
if (!manifest) throw new Error("missing manifest; run `aienvmp scan` first");
|
|
10
|
+
const policy = await loadPolicy(dir);
|
|
11
|
+
const warnings = [...diagnose(manifest), ...policyWarnings(manifest, policy)];
|
|
12
|
+
if (args.json) {
|
|
13
|
+
console.log(JSON.stringify({
|
|
14
|
+
status: warnings.length ? "warning" : "ok",
|
|
15
|
+
policy,
|
|
16
|
+
warnings
|
|
17
|
+
}, null, 2));
|
|
18
|
+
if (args.ci && warnings.length) {
|
|
19
|
+
process.exitCode = 1;
|
|
20
|
+
}
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (!warnings.length) {
|
|
24
|
+
console.log("doctor: no blocking environment warnings detected");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
for (const warning of warnings) {
|
|
28
|
+
console.log(`[${warning.code}] ${warning.message}`);
|
|
29
|
+
}
|
|
30
|
+
console.log("doctor: warnings are non-blocking by default; pass --ci to fail automation.");
|
|
31
|
+
if (args.ci) {
|
|
32
|
+
process.exitCode = 1;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { stateDir, workspaceDir } from "../paths.js";
|
|
3
|
+
|
|
4
|
+
export async function initWorkspace(args) {
|
|
5
|
+
const dir = workspaceDir(args);
|
|
6
|
+
await fs.mkdir(stateDir(dir), { recursive: true });
|
|
7
|
+
await fs.writeFile(`${stateDir(dir)}/policy.yml`, [
|
|
8
|
+
"# aienvmp policy",
|
|
9
|
+
"# Keep this small and explicit so AI agents can avoid version drift.",
|
|
10
|
+
"# Default behavior is non-blocking: warn, ask, and record instead of locking.",
|
|
11
|
+
"# node: 24",
|
|
12
|
+
"# python: 3.11",
|
|
13
|
+
"# packageManager: npm",
|
|
14
|
+
"globalInstalls: ask-first",
|
|
15
|
+
"runtimeChanges: ask-first",
|
|
16
|
+
""
|
|
17
|
+
].join("\n"), { flag: "wx" }).catch(() => {});
|
|
18
|
+
console.log(`initialized ${stateDir(dir)}`);
|
|
19
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { appendJsonLine } from "../fsutil.js";
|
|
2
|
+
import { intentsPath, workspaceDir } from "../paths.js";
|
|
3
|
+
import { newIntentID } from "../timeline.js";
|
|
4
|
+
|
|
5
|
+
export async function intentWorkspace(args) {
|
|
6
|
+
const dir = workspaceDir(args);
|
|
7
|
+
const actor = required(args.actor, "actor");
|
|
8
|
+
const action = required(args.action, "action");
|
|
9
|
+
const entry = {
|
|
10
|
+
at: new Date().toISOString(),
|
|
11
|
+
type: "intent",
|
|
12
|
+
actor,
|
|
13
|
+
action,
|
|
14
|
+
target: args.target || "",
|
|
15
|
+
reason: args.reason || "",
|
|
16
|
+
status: "open"
|
|
17
|
+
};
|
|
18
|
+
entry.id = newIntentID();
|
|
19
|
+
await appendJsonLine(intentsPath(dir), entry);
|
|
20
|
+
console.log(`intent recorded: ${entry.id}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function required(value, name) {
|
|
24
|
+
if (!value) throw new Error(`intent: --${name} is required`);
|
|
25
|
+
return String(value);
|
|
26
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { appendJsonLine } from "../fsutil.js";
|
|
2
|
+
import { timelinePath, workspaceDir } from "../paths.js";
|
|
3
|
+
|
|
4
|
+
export async function recordWorkspace(args) {
|
|
5
|
+
const dir = workspaceDir(args);
|
|
6
|
+
const actor = required(args.actor, "actor");
|
|
7
|
+
const summary = required(args.summary || args.change, "summary");
|
|
8
|
+
const entry = {
|
|
9
|
+
at: new Date().toISOString(),
|
|
10
|
+
actor,
|
|
11
|
+
type: args.type || "agent-record",
|
|
12
|
+
summary,
|
|
13
|
+
target: args.target || "",
|
|
14
|
+
before: args.before || "",
|
|
15
|
+
after: args.after || "",
|
|
16
|
+
evidence: args.evidence || "",
|
|
17
|
+
requiresReview: args.review === true || args.review === "true"
|
|
18
|
+
};
|
|
19
|
+
await appendJsonLine(timelinePath(dir), entry);
|
|
20
|
+
console.log(`recorded ${entry.type} by ${actor}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function required(value, name) {
|
|
24
|
+
if (!value) throw new Error(`record: --${name} is required`);
|
|
25
|
+
return String(value);
|
|
26
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { appendJsonLine } from "../fsutil.js";
|
|
2
|
+
import { readJsonl, openIntents } from "../timeline.js";
|
|
3
|
+
import { intentsPath, workspaceDir } from "../paths.js";
|
|
4
|
+
|
|
5
|
+
export async function resolveWorkspace(args) {
|
|
6
|
+
const dir = workspaceDir(args);
|
|
7
|
+
const actor = required(args.actor, "actor");
|
|
8
|
+
const requested = required(args.id || args.ref, "id");
|
|
9
|
+
const ref = await resolveIntentRef(dir, requested);
|
|
10
|
+
const entry = {
|
|
11
|
+
at: new Date().toISOString(),
|
|
12
|
+
type: "intent-resolved",
|
|
13
|
+
actor,
|
|
14
|
+
ref,
|
|
15
|
+
status: args.status || "resolved",
|
|
16
|
+
reason: args.reason || ""
|
|
17
|
+
};
|
|
18
|
+
await appendJsonLine(intentsPath(dir), entry);
|
|
19
|
+
console.log(`intent ${entry.status}: ${ref}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function required(value, name) {
|
|
23
|
+
if (!value) throw new Error(`resolve: --${name} is required`);
|
|
24
|
+
return String(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function resolveIntentRef(dir, requested) {
|
|
28
|
+
const open = openIntents(await readJsonl(intentsPath(dir)));
|
|
29
|
+
const matches = open.filter((intent) => intent.id === requested || intent.id.startsWith(requested));
|
|
30
|
+
if (matches.length === 1) return matches[0].id;
|
|
31
|
+
if (matches.length > 1) {
|
|
32
|
+
throw new Error(`resolve: "${requested}" matches multiple open intents`);
|
|
33
|
+
}
|
|
34
|
+
return requested;
|
|
35
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { buildManifest } from "../manifest.js";
|
|
3
|
+
import { diffManifests } from "../diff.js";
|
|
4
|
+
import { appendJsonLine, exists, readJson, writeJson } from "../fsutil.js";
|
|
5
|
+
import { manifestPath, previousManifestPath, timelinePath, workspaceDir } from "../paths.js";
|
|
6
|
+
|
|
7
|
+
export async function scanWorkspace(args) {
|
|
8
|
+
const dir = workspaceDir(args);
|
|
9
|
+
const currentPath = manifestPath(dir);
|
|
10
|
+
const previous = await readJson(currentPath, null);
|
|
11
|
+
if (previous && await exists(currentPath)) {
|
|
12
|
+
await fs.copyFile(currentPath, previousManifestPath(dir));
|
|
13
|
+
}
|
|
14
|
+
const manifest = await buildManifest(dir);
|
|
15
|
+
await writeJson(currentPath, manifest);
|
|
16
|
+
const changes = diffManifests(previous, manifest);
|
|
17
|
+
for (const change of changes) {
|
|
18
|
+
await appendJsonLine(timelinePath(dir), {
|
|
19
|
+
at: manifest.generatedAt,
|
|
20
|
+
actor: "system:scan",
|
|
21
|
+
type: "detected-change",
|
|
22
|
+
change
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
console.log(`scanned ${dir}`);
|
|
26
|
+
console.log(`manifest: ${currentPath}`);
|
|
27
|
+
console.log(`changes: ${changes.length}`);
|
|
28
|
+
}
|
package/src/diff.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function diffManifests(previous, current) {
|
|
2
|
+
if (!previous) return [];
|
|
3
|
+
const changes = [];
|
|
4
|
+
compareFlat("runtime", previous.runtimes || {}, current.runtimes || {}, changes);
|
|
5
|
+
compareFlat("package-manager", previous.packageManagers || {}, current.packageManagers || {}, changes);
|
|
6
|
+
compareFlat("container", previous.containers || {}, current.containers || {}, changes);
|
|
7
|
+
compareFlat("project-hint", previous.projectHints || {}, current.projectHints || {}, changes);
|
|
8
|
+
compareFlat("agent-file", previous.agentFiles || {}, current.agentFiles || {}, changes);
|
|
9
|
+
return changes;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function compareFlat(scope, before, after, changes) {
|
|
13
|
+
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
14
|
+
for (const key of [...keys].sort()) {
|
|
15
|
+
if (before[key] === undefined && after[key] !== undefined) {
|
|
16
|
+
changes.push({ scope, key, type: "added", after: after[key] });
|
|
17
|
+
} else if (before[key] !== undefined && after[key] === undefined) {
|
|
18
|
+
changes.push({ scope, key, type: "removed", before: before[key] });
|
|
19
|
+
} else if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) {
|
|
20
|
+
changes.push({ scope, key, type: "changed", before: before[key], after: after[key] });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/doctor.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export function diagnose(manifest) {
|
|
2
|
+
const warnings = [];
|
|
3
|
+
const hints = manifest.projectHints || {};
|
|
4
|
+
const runtimes = manifest.runtimes || {};
|
|
5
|
+
if (hints.nvmrc && runtimes.node && !String(runtimes.node).startsWith(String(hints.nvmrc).replace(/^v/, ""))) {
|
|
6
|
+
warnings.push({
|
|
7
|
+
code: "node-version-mismatch",
|
|
8
|
+
message: `.nvmrc requests ${hints.nvmrc}, but detected node is ${runtimes.node}.`
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
const py = runtimes.python || runtimes.python3;
|
|
12
|
+
if (hints.pythonVersion && py && !String(py).startsWith(String(hints.pythonVersion))) {
|
|
13
|
+
warnings.push({
|
|
14
|
+
code: "python-version-mismatch",
|
|
15
|
+
message: `.python-version requests ${hints.pythonVersion}, but detected python is ${py}.`
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
const locks = ["packageLock", "pnpmLock", "yarnLock"].filter((key) => hints[key]);
|
|
19
|
+
if (locks.length > 1) {
|
|
20
|
+
warnings.push({
|
|
21
|
+
code: "mixed-node-lockfiles",
|
|
22
|
+
message: `Multiple Node lockfiles detected: ${locks.join(", ")}. Prefer one package manager.`
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
if ((hints.pyproject || hints.requirements) && !py) {
|
|
26
|
+
warnings.push({
|
|
27
|
+
code: "python-missing",
|
|
28
|
+
message: "Python project hints detected, but no Python runtime was found."
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
if (hints.dockerfile && !manifest.containers?.docker) {
|
|
32
|
+
warnings.push({
|
|
33
|
+
code: "docker-missing",
|
|
34
|
+
message: "Dockerfile detected, but Docker CLI was not found."
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return warnings;
|
|
38
|
+
}
|
package/src/fsutil.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export async function exists(file) {
|
|
5
|
+
try {
|
|
6
|
+
await fs.access(file);
|
|
7
|
+
return true;
|
|
8
|
+
} catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function readJson(file, fallback = null) {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(await fs.readFile(file, "utf8"));
|
|
16
|
+
} catch {
|
|
17
|
+
return fallback;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function writeJson(file, data) {
|
|
22
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
23
|
+
await fs.writeFile(file, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function appendJsonLine(file, data) {
|
|
27
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
28
|
+
await fs.appendFile(file, `${JSON.stringify(data)}\n`, "utf8");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function replaceMarkerBlock(file, begin, end, block) {
|
|
32
|
+
let current = "";
|
|
33
|
+
try {
|
|
34
|
+
current = await fs.readFile(file, "utf8");
|
|
35
|
+
} catch {
|
|
36
|
+
current = "";
|
|
37
|
+
}
|
|
38
|
+
const start = current.indexOf(begin);
|
|
39
|
+
const finish = current.indexOf(end);
|
|
40
|
+
const rendered = `${begin}\n${block.trimEnd()}\n${end}`;
|
|
41
|
+
let next;
|
|
42
|
+
if (start >= 0 && finish > start) {
|
|
43
|
+
next = current.slice(0, start) + rendered + current.slice(finish + end.length);
|
|
44
|
+
} else if (start < 0 && finish < 0) {
|
|
45
|
+
const sep = current.trim() ? "\n\n" : "";
|
|
46
|
+
next = `${current.trimEnd()}${sep}${rendered}\n`;
|
|
47
|
+
} else {
|
|
48
|
+
throw new Error(`${path.basename(file)} has a broken aienvmp marker block`);
|
|
49
|
+
}
|
|
50
|
+
await fs.writeFile(file, next, "utf8");
|
|
51
|
+
}
|
package/src/manifest.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { commandOutput, commandVersion } from "./shell.js";
|
|
5
|
+
import { exists } from "./fsutil.js";
|
|
6
|
+
|
|
7
|
+
export async function buildManifest(dir) {
|
|
8
|
+
const now = new Date().toISOString();
|
|
9
|
+
const manifest = {
|
|
10
|
+
schemaVersion: 1,
|
|
11
|
+
generatedAt: now,
|
|
12
|
+
workspace: {
|
|
13
|
+
path: dir,
|
|
14
|
+
name: path.basename(dir)
|
|
15
|
+
},
|
|
16
|
+
os: await scanOS(),
|
|
17
|
+
runtimes: await scanRuntimes(),
|
|
18
|
+
packageManagers: await scanPackageManagers(),
|
|
19
|
+
containers: await scanContainers(),
|
|
20
|
+
projectHints: await scanProjectHints(dir),
|
|
21
|
+
agentFiles: await scanAgentFiles(dir),
|
|
22
|
+
agentProtocol: {
|
|
23
|
+
sourceOfTruth: "AIENV.md",
|
|
24
|
+
preflightCommand: "aienvmp context",
|
|
25
|
+
intentCommand: "aienvmp intent --actor <agent:id> --action <planned-change>",
|
|
26
|
+
recordCommand: "aienvmp record --actor <agent:id> --summary <what-changed>",
|
|
27
|
+
afterEnvironmentChange: ["aienvmp scan", "aienvmp compile"],
|
|
28
|
+
globalRuntimeChangeRequiresUserApproval: true,
|
|
29
|
+
globalInstallPolicy: "ask-first",
|
|
30
|
+
projectLocalChanges: "allowed-when-task-requires"
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
return manifest;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function scanOS() {
|
|
37
|
+
return {
|
|
38
|
+
platform: os.platform(),
|
|
39
|
+
release: os.release(),
|
|
40
|
+
arch: os.arch(),
|
|
41
|
+
hostname: os.hostname(),
|
|
42
|
+
shell: process.env.SHELL || process.env.ComSpec || ""
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function scanRuntimes() {
|
|
47
|
+
return compact({
|
|
48
|
+
node: await commandVersion("node"),
|
|
49
|
+
python: await commandVersion("python"),
|
|
50
|
+
python3: await commandVersion("python3"),
|
|
51
|
+
go: await commandVersion("go", ["version"]),
|
|
52
|
+
java: await commandVersion("java", ["-version"]),
|
|
53
|
+
rustc: await commandVersion("rustc", ["--version"])
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function scanPackageManagers() {
|
|
58
|
+
return compact({
|
|
59
|
+
npm: await commandVersion(process.platform === "win32" ? "npm.cmd" : "npm"),
|
|
60
|
+
pnpm: await commandVersion(process.platform === "win32" ? "pnpm.cmd" : "pnpm"),
|
|
61
|
+
yarn: await commandVersion(process.platform === "win32" ? "yarn.cmd" : "yarn"),
|
|
62
|
+
uv: await commandVersion("uv"),
|
|
63
|
+
pip: await commandVersion("pip"),
|
|
64
|
+
pipx: await commandVersion("pipx"),
|
|
65
|
+
mise: await commandVersion("mise"),
|
|
66
|
+
asdf: await commandVersion("asdf"),
|
|
67
|
+
pyenv: await commandVersion("pyenv"),
|
|
68
|
+
nvm: await commandVersion("nvm"),
|
|
69
|
+
fnm: await commandVersion("fnm"),
|
|
70
|
+
volta: await commandVersion("volta")
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function scanContainers() {
|
|
75
|
+
return compact({
|
|
76
|
+
docker: await commandVersion("docker", ["--version"]),
|
|
77
|
+
compose: await dockerComposeVersion()
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function dockerComposeVersion() {
|
|
82
|
+
const v2 = await commandOutput("docker", ["compose", "version"]);
|
|
83
|
+
if (v2) return v2.replace(/^Docker Compose version\s+/i, "");
|
|
84
|
+
return await commandVersion(process.platform === "win32" ? "docker-compose.exe" : "docker-compose");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function scanProjectHints(dir) {
|
|
88
|
+
const hints = {};
|
|
89
|
+
for (const [key, file] of [
|
|
90
|
+
["nvmrc", ".nvmrc"],
|
|
91
|
+
["pythonVersion", ".python-version"],
|
|
92
|
+
["mise", "mise.toml"],
|
|
93
|
+
["toolVersions", ".tool-versions"],
|
|
94
|
+
["packageJson", "package.json"],
|
|
95
|
+
["pyproject", "pyproject.toml"],
|
|
96
|
+
["requirements", "requirements.txt"],
|
|
97
|
+
["dockerfile", "Dockerfile"],
|
|
98
|
+
["packageLock", "package-lock.json"],
|
|
99
|
+
["pnpmLock", "pnpm-lock.yaml"],
|
|
100
|
+
["yarnLock", "yarn.lock"]
|
|
101
|
+
]) {
|
|
102
|
+
const full = path.join(dir, file);
|
|
103
|
+
if (!(await exists(full))) continue;
|
|
104
|
+
if (["nvmrc", "pythonVersion"].includes(key)) {
|
|
105
|
+
hints[key] = (await fs.readFile(full, "utf8")).trim();
|
|
106
|
+
} else {
|
|
107
|
+
hints[key] = true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return hints;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function scanAgentFiles(dir) {
|
|
114
|
+
const files = {
|
|
115
|
+
agents: "AGENTS.md",
|
|
116
|
+
claude: "CLAUDE.md",
|
|
117
|
+
gemini: "GEMINI.md",
|
|
118
|
+
cursor: path.join(".cursor", "rules", "environment.md"),
|
|
119
|
+
copilot: path.join(".github", "copilot-instructions.md")
|
|
120
|
+
};
|
|
121
|
+
const out = {};
|
|
122
|
+
for (const [name, rel] of Object.entries(files)) {
|
|
123
|
+
out[name] = await exists(path.join(dir, rel));
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function compact(obj) {
|
|
129
|
+
return Object.fromEntries(Object.entries(obj).filter(([, value]) => value));
|
|
130
|
+
}
|
package/src/paths.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
export function workspaceDir(args) {
|
|
4
|
+
return path.resolve(String(args.dir || "."));
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function stateDir(dir) {
|
|
8
|
+
return path.join(dir, ".aienvmp");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function manifestPath(dir) {
|
|
12
|
+
return path.join(stateDir(dir), "manifest.json");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function previousManifestPath(dir) {
|
|
16
|
+
return path.join(stateDir(dir), "manifest.previous.json");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function timelinePath(dir) {
|
|
20
|
+
return path.join(stateDir(dir), "timeline.jsonl");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function intentsPath(dir) {
|
|
24
|
+
return path.join(stateDir(dir), "intents.jsonl");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function dashboardPath(dir) {
|
|
28
|
+
return path.join(stateDir(dir), "dashboard.html");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function aiEnvPath(dir) {
|
|
32
|
+
return path.join(dir, "AIENV.md");
|
|
33
|
+
}
|