Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 175 additions & 8 deletions packages/cli/src/core/manifest.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import type { ScopePaths, SyncManifest } from "../types.js";
import { readJsonIfExists, writeJsonAtomic } from "./fs.js";
import path from "node:path";
import type { EntityType, ScopePaths, SyncManifest } from "../types.js";
import { readJsonIfExists, toPosixPath, writeJsonAtomic } from "./fs.js";

const EMPTY_MANIFEST: SyncManifest = {
version: 1,
generatedFiles: [],
};

const ENTITY_TYPES: EntityType[] = ["agent", "command", "mcp", "skill"];

export function readManifest(paths: ScopePaths): SyncManifest {
const manifest = readJsonIfExists<SyncManifest>(paths.manifestPath);
if (
Expand All @@ -16,17 +19,181 @@ export function readManifest(paths: ScopePaths): SyncManifest {
return { ...EMPTY_MANIFEST };
}

const generatedByEntity =
manifest.generatedByEntity && typeof manifest.generatedByEntity === "object"
? manifest.generatedByEntity
: undefined;
const generatedByEntity = normalizeGeneratedByEntityForRuntime(
paths,
manifest.generatedByEntity,
);
const codex = normalizeCodexMetadata(manifest.codex);

return {
...manifest,
version: 1,
generatedFiles: normalizePathListForRuntime(paths, manifest.generatedFiles),
generatedByEntity,
codex,
};
}

export function writeManifest(paths: ScopePaths, manifest: SyncManifest): void {
writeJsonAtomic(paths.manifestPath, manifest);
const generatedByEntity = normalizeGeneratedByEntityForDisk(
paths,
manifest.generatedByEntity,
);
const codex = normalizeCodexMetadata(manifest.codex);

writeJsonAtomic(paths.manifestPath, {
version: 1,
generatedFiles: normalizePathListForDisk(paths, manifest.generatedFiles),
generatedByEntity,
codex,
});
}

function normalizeGeneratedByEntityForRuntime(
paths: ScopePaths,
generatedByEntity: SyncManifest["generatedByEntity"],
): SyncManifest["generatedByEntity"] {
if (!generatedByEntity || typeof generatedByEntity !== "object") {
return undefined;
}

const normalized: Partial<Record<EntityType, string[]>> = {};
for (const entity of ENTITY_TYPES) {
const values = normalizePathListForRuntime(
paths,
generatedByEntity[entity],
);
if (values.length === 0) continue;
normalized[entity] = values;
}

return Object.keys(normalized).length > 0 ? normalized : {};
}

function normalizeGeneratedByEntityForDisk(
paths: ScopePaths,
generatedByEntity: SyncManifest["generatedByEntity"],
): SyncManifest["generatedByEntity"] {
if (!generatedByEntity || typeof generatedByEntity !== "object") {
return undefined;
}

const normalized: Partial<Record<EntityType, string[]>> = {};
for (const entity of ENTITY_TYPES) {
const values = normalizePathListForDisk(paths, generatedByEntity[entity]);
if (values.length === 0) continue;
normalized[entity] = values;
}

return Object.keys(normalized).length > 0 ? normalized : {};
}

function normalizePathListForRuntime(
paths: ScopePaths,
value: unknown,
): string[] {
if (!Array.isArray(value)) return [];

const normalized = value
.filter((item): item is string => typeof item === "string")
.map((item) => resolveManifestPathForRuntime(paths, item))
.filter((item) => item.length > 0);

return [...new Set(normalized)].sort();
}

function normalizePathListForDisk(paths: ScopePaths, value: unknown): string[] {
if (!Array.isArray(value)) return [];

const normalized = value
.filter((item): item is string => typeof item === "string")
.map((item) => resolveManifestPathForDisk(paths, item))
.filter((item) => item.length > 0);

return [...new Set(normalized)].sort();
}

function resolveManifestPathForRuntime(
paths: ScopePaths,
filePath: string,
): string {
const normalized = filePath.trim();
if (!normalized) return "";

if (normalized === "~") {
return paths.homeDir;
}

if (normalized.startsWith("~/")) {
return path.resolve(paths.homeDir, normalized.slice(2));
}

if (path.isAbsolute(normalized)) {
return path.normalize(normalized);
}

return path.resolve(paths.workspaceRoot, normalized);
}

function resolveManifestPathForDisk(
paths: ScopePaths,
filePath: string,
): string {
const normalized = filePath.trim();
if (!normalized) return "";

if (!path.isAbsolute(normalized)) {
return toPosixPath(normalized);
}

const absolutePath = path.normalize(normalized);

if (paths.scope === "global") {
if (isSubpath(paths.homeDir, absolutePath)) {
const relativePath = path.relative(paths.homeDir, absolutePath);
return relativePath ? `~/${toPosixPath(relativePath)}` : "~";
}
return toPosixPath(absolutePath);
}

if (isSubpath(paths.workspaceRoot, absolutePath)) {
const relativePath = path.relative(paths.workspaceRoot, absolutePath);
return toPosixPath(relativePath || ".");
}

if (isSubpath(paths.homeDir, absolutePath)) {
const relativePath = path.relative(paths.homeDir, absolutePath);
return relativePath ? `~/${toPosixPath(relativePath)}` : "~";
}

return toPosixPath(absolutePath);
}

function isSubpath(rootPath: string, candidatePath: string): boolean {
const relative = path.relative(rootPath, candidatePath);
return (
relative === "" ||
(!relative.startsWith("..") && !path.isAbsolute(relative))
);
}

function normalizeCodexMetadata(
codex: SyncManifest["codex"],
): SyncManifest["codex"] {
if (!codex || typeof codex !== "object") return undefined;

const roles = Array.isArray(codex.roles)
? [...new Set(codex.roles.filter((item): item is string => !!item))].sort()
: undefined;
const mcpServers = Array.isArray(codex.mcpServers)
? [
...new Set(codex.mcpServers.filter((item): item is string => !!item)),
].sort()
: undefined;

if (!roles && !mcpServers) return undefined;

return {
roles,
mcpServers,
};
}
167 changes: 167 additions & 0 deletions packages/cli/tests/unit/manifest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { readManifest, writeManifest } from "../../src/core/manifest.js";
import { buildScopePaths } from "../../src/core/scope.js";

const tempDirs: string[] = [];

afterEach(() => {
for (const dir of tempDirs.splice(0, tempDirs.length)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});

describe("manifest helpers", () => {
it("stores local generated paths as workspace-relative and ~/ paths on disk", () => {
const workspaceRoot = fs.mkdtempSync(
path.join(os.tmpdir(), "agentloom-workspace-"),
);
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-home-"));
tempDirs.push(workspaceRoot, homeDir);

const paths = buildScopePaths(workspaceRoot, "local", homeDir);
fs.mkdirSync(path.dirname(paths.manifestPath), { recursive: true });

const workspaceOutput = path.join(
workspaceRoot,
".cursor",
"commands",
"review.md",
);
const homeOutput = path.join(homeDir, ".codex", "prompts", "review.md");

writeManifest(paths, {
version: 1,
generatedFiles: [workspaceOutput, homeOutput],
generatedByEntity: {
command: [workspaceOutput],
mcp: [homeOutput],
},
codex: {
roles: ["reviewer"],
mcpServers: ["browser"],
},
});

const onDisk = JSON.parse(fs.readFileSync(paths.manifestPath, "utf8")) as {
generatedFiles?: string[];
generatedByEntity?: {
command?: string[];
mcp?: string[];
};
};

expect(onDisk.generatedFiles).toEqual([
".cursor/commands/review.md",
"~/.codex/prompts/review.md",
]);
expect(onDisk.generatedByEntity?.command).toEqual([
".cursor/commands/review.md",
]);
expect(onDisk.generatedByEntity?.mcp).toEqual([
"~/.codex/prompts/review.md",
]);
});

it("resolves relative and ~/ manifest entries to absolute runtime paths", () => {
const workspaceRoot = fs.mkdtempSync(
path.join(os.tmpdir(), "agentloom-workspace-"),
);
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-home-"));
tempDirs.push(workspaceRoot, homeDir);

const paths = buildScopePaths(workspaceRoot, "local", homeDir);
fs.mkdirSync(path.dirname(paths.manifestPath), { recursive: true });
fs.writeFileSync(
paths.manifestPath,
`${JSON.stringify(
{
version: 1,
generatedFiles: [
".cursor/commands/review.md",
"~/.codex/prompts/review.md",
],
generatedByEntity: {
command: [".cursor/commands/review.md"],
mcp: ["~/.codex/prompts/review.md"],
},
},
null,
2,
)}\n`,
"utf8",
);

const manifest = readManifest(paths);

expect(manifest.generatedFiles).toEqual([
path.join(homeDir, ".codex", "prompts", "review.md"),
path.join(workspaceRoot, ".cursor", "commands", "review.md"),
]);
expect(manifest.generatedByEntity?.command).toEqual([
path.join(workspaceRoot, ".cursor", "commands", "review.md"),
]);
expect(manifest.generatedByEntity?.mcp).toEqual([
path.join(homeDir, ".codex", "prompts", "review.md"),
]);
});

it("keeps legacy absolute entries readable at runtime", () => {
const workspaceRoot = fs.mkdtempSync(
path.join(os.tmpdir(), "agentloom-workspace-"),
);
tempDirs.push(workspaceRoot);

const paths = buildScopePaths(workspaceRoot, "local");
fs.mkdirSync(path.dirname(paths.manifestPath), { recursive: true });
const absoluteOutput = path.join(
workspaceRoot,
".cursor",
"agents",
"a.md",
);
fs.writeFileSync(
paths.manifestPath,
`${JSON.stringify(
{
version: 1,
generatedFiles: [absoluteOutput],
},
null,
2,
)}\n`,
"utf8",
);

const manifest = readManifest(paths);
expect(manifest.generatedFiles).toEqual([absoluteOutput]);
});

it("encodes global home outputs with ~/ so reads are stable across cwd changes", () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-home-"));
const secondCwd = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-cwd-"));
tempDirs.push(homeDir, secondCwd);

const writePaths = buildScopePaths(homeDir, "global", homeDir);
fs.mkdirSync(path.dirname(writePaths.manifestPath), { recursive: true });
const globalOutput = path.join(homeDir, ".codex", "prompts", "review.md");

writeManifest(writePaths, {
version: 1,
generatedFiles: [globalOutput],
});

const onDisk = JSON.parse(
fs.readFileSync(writePaths.manifestPath, "utf8"),
) as {
generatedFiles?: string[];
};
expect(onDisk.generatedFiles).toEqual(["~/.codex/prompts/review.md"]);

const readPaths = buildScopePaths(secondCwd, "global", homeDir);
const manifest = readManifest(readPaths);
expect(manifest.generatedFiles).toEqual([globalOutput]);
});
});