Skip to content
Open
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
12 changes: 5 additions & 7 deletions src/commands/clean.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import readline from "node:readline";
import { styleText } from "node:util";
import { removeFile, removeEmptyDirs, displayPath } from "../utils/fs-utils.ts";
import { log } from "../utils/log.ts";
import { getPlatform, PLATFORMS } from "../core/constants.ts";
import { resolvePlatforms, PLATFORMS } from "../core/constants.ts";
import type { Platform } from "../core/constants.ts";
import { getSections, readConfigOrExit } from "../core/resolver.ts";
import { loadHashDB, saveHashDB, normalizeKey } from "../core/hash-db.ts";
Expand Down Expand Up @@ -71,9 +71,9 @@ async function removeFilesForPlatform(platform: Platform, sections: ReturnType<t
}

/** Removes hash DB entries whose paths don't match any known platform, guarding against DB corruption. */
function sweepOrphanedEntries(hashDB: HashDB, sections: ReturnType<typeof getSections>): void {
function sweepOrphanedEntries(hashDB: HashDB, sections: ReturnType<typeof getSections>, extraPlatforms: Platform[] = []): void {
const allKnownPrefixes = new Set<string>();
for (const platform of PLATFORMS) {
for (const platform of [...PLATFORMS, ...extraPlatforms]) {
for (const section of sections) {
allKnownPrefixes.add(normalizeKey(path.join(platform.targetDir, section.name)) + "/");
}
Expand All @@ -92,9 +92,7 @@ export async function clean(options: CleanOptions = {}): Promise<void> {
const hashDB = await loadHashDB();
const sections = getSections(config);

const activePlatforms = config.platforms
.map((n) => getPlatform(n))
.filter((p): p is NonNullable<typeof p> => p !== undefined);
const activePlatforms = resolvePlatforms(config.platforms, config.platform, (msg) => log.warn(msg));

const activePlatformDirs = new Set(activePlatforms.map((p) => p.targetDir));
const inactivePlatforms = PLATFORMS.filter((p) => !activePlatformDirs.has(p.targetDir));
Expand Down Expand Up @@ -148,7 +146,7 @@ export async function clean(options: CleanOptions = {}): Promise<void> {
}
}

sweepOrphanedEntries(hashDB, sections);
sweepOrphanedEntries(hashDB, sections, activePlatforms);
await saveHashDB(hashDB);

console.log();
Expand Down
121 changes: 121 additions & 0 deletions src/commands/status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import fs from "node:fs/promises";
import path from "node:path";
import { styleText } from "node:util";
import { log } from "../utils/log.ts";
import { resolvePlatforms } from "../core/constants.ts";
import { resolveEntries, deduplicateEntries, expandEntries, getSections, readConfigOrExit } from "../core/resolver.ts";
import { loadHashDB, normalizeKey, md5 } from "../core/hash-db.ts";

type FileState = "synced" | "modified" | "missing" | "new";

/** Determines the most severe state for a file across all active platforms. */
async function resolveState(
fileName: string,
sectionName: string,
platforms: Awaited<ReturnType<typeof resolvePlatforms>>,
hashDB: Record<string, string>,
): Promise<FileState> {
let state: FileState = "new";

for (const platform of platforms) {
const destPath = path.join(platform.targetDir, sectionName, fileName);
const stored = hashDB[normalizeKey(destPath)];

if (!stored) continue;

let diskHash: string | null = null;
try {
diskHash = md5(await fs.readFile(destPath));
} catch {
// file is missing from disk
}

if (diskHash === null) {
state = "missing";
} else if (diskHash !== stored) {
return "modified"; // worst case — return immediately
} else if (state === "new") {
state = "synced";
}
}

return state;
}

const STATE_LABEL: Record<FileState, string> = {
synced: "synced ",
modified: "modified",
missing: "missing ",
new: "new ",
};

function renderFileState(fileName: string, state: FileState): void {
const name = fileName.replace(/\\/g, "/");
const label = STATE_LABEL[state];
const I = " ";

switch (state) {
case "synced":
console.log(`${I} ${styleText("green", "✓")} ${styleText("dim", label)} ${name}`);
break;
case "modified":
console.log(`${I} ${styleText("yellow", "⚠")} ${styleText("yellow", label)} ${name} ${styleText("dim", "(locally modified, use --force to overwrite)")}`);
break;
case "missing":
console.log(`${I} ${styleText("red", "✗")} ${styleText("dim", label)} ${name} ${styleText("dim", "(was synced but removed from destination)")}`);
break;
case "new":
console.log(`${I} ${styleText("cyan", "+")} ${styleText("dim", label)} ${name} ${styleText("dim", "(not yet synced)")}`);
break;
}
}

/** Shows the sync state of each configured file across all active platforms. */
export async function status(): Promise<void> {
log.header("status");

const config = await readConfigOrExit();
const activePlatforms = resolvePlatforms(config.platforms, config.platform, (msg) => log.warn(msg));

if (activePlatforms.length === 0) {
log.warn("No platforms configured. Add platforms in cortex.toml.");
log.outro("Nothing to show.");
return;
}

const hashDB = await loadHashDB();
const counts: Record<FileState, number> = { synced: 0, modified: 0, missing: 0, new: 0 };
let totalFiles = 0;

for (const section of getSections(config)) {
const raw = await resolveEntries(section.paths);
const entries = deduplicateEntries(await expandEntries(raw));

if (entries.length === 0) continue;

console.log(` ${styleText("dim", section.name)}`);

for (const { fileName } of entries) {
const state = await resolveState(fileName, section.name, activePlatforms, hashDB);
renderFileState(fileName, state);
counts[state]++;
totalFiles++;
}

console.log();
}

if (totalFiles === 0) {
log.outro("No files configured.");
return;
}

const parts: string[] = [];
if (counts.synced) parts.push(styleText("green", `${counts.synced} synced`));
if (counts.modified) parts.push(styleText("yellow", `${counts.modified} modified`));
if (counts.missing) parts.push(styleText("red", `${counts.missing} missing`));
if (counts.new) parts.push(styleText("cyan", `${counts.new} new`));

console.log(` ${parts.join(" · ")}`);
console.log();
}
101 changes: 46 additions & 55 deletions src/commands/sync.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import path from "node:path";
import fs from "node:fs/promises";
import { styleText } from "node:util";
import { copyFileAtomic, removeFile, removeEmptyDirs, listMdFiles, fileExists, displayPath } from "../utils/fs-utils.ts";
import { copyFileAtomic, removeFile, removeEmptyDirs, fileExists, displayPath } from "../utils/fs-utils.ts";
import { loadHashDB, saveHashDB, md5, isDirty, normalizeKey, type HashDB } from "../core/hash-db.ts";
import { log } from "../utils/log.ts";
import { getPlatform } from "../core/constants.ts";
import { resolvePlatforms } from "../core/constants.ts";
import type { Platform } from "../core/constants.ts";
import { resolveEntries, deduplicateEntries, getSections, readConfigOrExit } from "../core/resolver.ts";
import { resolveEntries, deduplicateEntries, expandEntries, getSections, readConfigOrExit } from "../core/resolver.ts";
import type { ResolvedEntry } from "../core/resolver.ts";
import { renderTree } from "../utils/tree.ts";
import { syncMCP } from "../core/mcp.ts";
Expand All @@ -23,25 +22,6 @@ interface SyncResult {
failed: number;
}

/** Expands directory entries into individual .md file entries. */
async function expandEntries(entries: ResolvedEntry[]): Promise<ResolvedEntry[]> {
const result: ResolvedEntry[] = [];
for (const entry of entries) {
const stat = await fs.stat(entry.source).catch(() => null);
if (stat?.isDirectory()) {
const files = await listMdFiles(entry.source);
const dirName = path.basename(entry.source);
for (const file of files) {
const rel = path.relative(entry.source, file);
result.push({ source: file, fileName: path.join(dirName, rel) });
}
} else {
result.push(entry);
}
}
return result;
}

/** Removes files from the target directory that are no longer in the resolved entries. */
async function removeStaleFiles(entries: ResolvedEntry[], targetDirPath: string, hashDB: HashDB): Promise<void> {
const expectedDests = new Set(entries.map(({ fileName }) => normalizeKey(path.join(targetDirPath, fileName))));
Expand Down Expand Up @@ -69,31 +49,47 @@ interface CopyResult {

/** Copies resolved entries to the target directory, skipping dirty or unmanaged files unless forced. */
async function copyEntries(entries: ResolvedEntry[], targetDirPath: string, hashDB: HashDB, force: boolean): Promise<CopyResult> {
const copied: string[] = [];
const skipped: TaggedEntry[] = [];
const failed: TaggedEntry[] = [];
type EntryOutcome =
| { kind: "copied"; rel: string; key: string; hash: string }
| { kind: "skipped"; rel: string; reason: string }
| { kind: "failed"; rel: string; reason: string };

for (const { source, fileName } of entries) {
const outcomes = await Promise.all(entries.map(async ({ source, fileName }): Promise<EntryOutcome> => {
const destPath = path.join(targetDirPath, fileName);
const relDest = fileName.replace(/\\/g, "/");
log.verbose(`copy: ${source} → ${displayPath(destPath)}`);
try {
const isManaged = normalizeKey(destPath) in hashDB;
const destExists = await fileExists(destPath);

if (!force && destExists && !isManaged) {
skipped.push({ path: relDest, reason: "unmanaged" });
continue;
log.verbose(` skip: unmanaged file at destination`);
return { kind: "skipped", rel: relDest, reason: "unmanaged" };
}
if (!force && await isDirty(hashDB, destPath)) {
skipped.push({ path: relDest, reason: "locally modified" });
continue;
log.verbose(` skip: locally modified (hash mismatch)`);
return { kind: "skipped", rel: relDest, reason: "locally modified" };
}
const data = await copyFileAtomic(source, destPath);
hashDB[normalizeKey(destPath)] = md5(data);
copied.push(relDest);
return { kind: "copied", rel: relDest, key: normalizeKey(destPath), hash: md5(data) };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
failed.push({ path: relDest, reason: msg });
return { kind: "failed", rel: relDest, reason: msg };
}
}));

const copied: string[] = [];
const skipped: TaggedEntry[] = [];
const failed: TaggedEntry[] = [];

for (const outcome of outcomes) {
if (outcome.kind === "copied") {
hashDB[outcome.key] = outcome.hash;
copied.push(outcome.rel);
} else if (outcome.kind === "skipped") {
skipped.push({ path: outcome.rel, reason: outcome.reason });
} else {
failed.push({ path: outcome.rel, reason: outcome.reason });
}
}

Expand Down Expand Up @@ -165,17 +161,15 @@ function renderSectionResult(sectionName: string, { copied, skipped, failed }: C

interface SyncSectionOptions {
section: { name: string; paths: string[] };
entries: ResolvedEntry[];
targetDirPath: string;
hashDB: HashDB;
dryRun: boolean;
force: boolean;
}

/** Resolves, deduplicates, and syncs a single section (skills or agents) to a platform directory. */
async function syncSection({ section, targetDirPath, hashDB, dryRun, force }: SyncSectionOptions): Promise<SyncResult> {
const raw = await resolveEntries(section.paths);
const entries = deduplicateEntries(await expandEntries(raw));

/** Syncs pre-resolved entries for a single section to a platform target directory. */
async function syncSection({ section, entries, targetDirPath, hashDB, dryRun, force }: SyncSectionOptions): Promise<SyncResult> {
if (!dryRun) await removeStaleFiles(entries, targetDirPath, hashDB);

if (entries.length === 0) {
Expand All @@ -194,19 +188,6 @@ async function syncSection({ section, targetDirPath, hashDB, dryRun, force }: Sy
return { copied: result.copied.length, skipped: result.skipped.length, failed: result.failed.length };
}

/** Maps platform names from config to Platform objects, warning on unknown names. */
function resolveActivePlatforms(platformNames: string[]): Platform[] {
const platforms: Platform[] = [];
for (const name of platformNames) {
const platform = getPlatform(name);
if (!platform) {
log.warn(`Unknown platform "${name}" — skipping.`);
continue;
}
platforms.push(platform);
}
return platforms;
}

/** Syncs MCP server configs into each platform's config file and prints results. */
async function renderMcpResults(mcp: Record<string, McpServer>, platforms: Platform[], dryRun: boolean): Promise<void> {
Expand Down Expand Up @@ -261,14 +242,24 @@ async function syncKnowledgeFiles(
force: boolean,
): Promise<SyncResult> {
const totals: SyncResult = { copied: 0, skipped: 0, failed: 0 };
const sections = getSections(config);

// Resolve and expand entries once — same source paths apply to every platform
const resolvedSections = await Promise.all(
sections.map(async (section) => {
const raw = await resolveEntries(section.paths);
const entries = deduplicateEntries(await expandEntries(raw));
return { section, entries };
}),
);

for (const platform of activePlatforms) {
console.log(`${styleText("cyan", "●")} ${platform.name} ${styleText("dim", `(${displayPath(platform.targetDir)}/)`)}`);
console.log();

for (const section of getSections(config)) {
for (const { section, entries } of resolvedSections) {
const targetDirPath = path.join(platform.targetDir, section.name);
const result = await syncSection({ section, targetDirPath, hashDB, dryRun, force });
const result = await syncSection({ section, entries, targetDirPath, hashDB, dryRun, force });
console.log();
totals.copied += result.copied;
totals.skipped += result.skipped;
Expand All @@ -289,7 +280,7 @@ export async function sync(options: SyncOptions = {}): Promise<void> {
if (dryRun) log.warn("dry-run — no changes will be made");

const config = await readConfigOrExit();
const activePlatforms = resolveActivePlatforms(config.platforms);
const activePlatforms = resolvePlatforms(config.platforms, config.platform, (msg) => log.warn(msg));

if (activePlatforms.length === 0) {
log.warn("No platforms configured. Add platforms in cortex.toml.");
Expand Down
33 changes: 33 additions & 0 deletions src/core/constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import path from "node:path";
import os from "node:os";
import type { CustomPlatformDef } from "./parser.ts";

export const CORTEX_DIR = path.join(os.homedir(), ".cortex");
export const AI_DIR = path.join(CORTEX_DIR, "ai");
Expand All @@ -21,3 +22,35 @@ export const PLATFORMS: Platform[] = [
export function getPlatform(name: string): Platform | undefined {
return PLATFORMS.find((p) => p.name === name);
}

/**
* Resolves platform names to Platform objects, merging built-ins with custom definitions.
* Custom definitions in cortex.toml take precedence over built-ins of the same name.
* Warns and skips names that resolve to neither a built-in nor a custom definition.
*/
export function resolvePlatforms(
names: string[],
custom?: Record<string, CustomPlatformDef>,
warn?: (msg: string) => void,
): Platform[] {
const platforms: Platform[] = [];
for (const name of names) {
const customDef = custom?.[name];
if (customDef) {
platforms.push({
name,
targetDir: path.normalize(customDef.targetDir.replace(/^~/, os.homedir())),
mcpConfigPath: path.normalize(customDef.mcpConfigPath.replace(/^~/, os.homedir())),
mcpKey: customDef.mcpKey,
});
} else {
const builtin = getPlatform(name);
if (!builtin) {
warn?.(`Unknown platform "${name}" — skipping.`);
continue;
}
platforms.push(builtin);
}
}
return platforms;
}
Loading