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
63 changes: 55 additions & 8 deletions src/cli/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { importConfigs } from "../lib/import.js";
import { extractTemplateVars } from "../lib/template.js";
import { detectMachineContext, resolveProfileVariables } from "../lib/machine.js";
import { applySessionRender, restoreSessionRenderSnapshot } from "../lib/session-apply.js";
import { planSessionRender, resolveSessionPath, sourceFromConfig, sourceFromFilePath, sourcesFromIdentityExport, SESSION_INSTRUCTION_LAYERS, SESSION_RENDER_TOOLS, type SessionInstructionLayer, type SessionInstructionSource, type SessionRenderFile, type SessionRenderPlan, type SessionRenderTool } from "../lib/session-render.js";
import { normalizeSessionInstructionSourceId, planSessionRender, resolveSessionPath, sourceFromConfig, sourceFromFilePath, sourcesFromIdentityExport, SESSION_INSTRUCTION_LAYERS, SESSION_RENDER_TOOLS, type SessionInstructionLayer, type SessionInstructionSource, type SessionRenderFile, type SessionRenderPlan, type SessionRenderTool } from "../lib/session-render.js";
import { accountedGlobalSourceSlugs, computeGlobalSourceCoverage, formatGlobalSourceCoverageWarnings, type GlobalSourceCoverageResult } from "../lib/global-source-coverage.js";
import { ensurePlatformProfiles } from "../lib/platform-profiles.js";
import { ensureProjectDashboardStandardConfig } from "../lib/project-dashboard-standard.js";
Expand Down Expand Up @@ -127,7 +127,7 @@ function parseSessionLayer(value: string): SessionInstructionLayer {
throw new Error(`Invalid source layer "${value}"`);
}

function parseSessionSource(value: string, order: number, replaceIds: Set<string>): SessionInstructionSource {
function parseSessionSource(value: string, order: number): SessionInstructionSource {
const idx = value.indexOf("=");
let id = idx > 0 ? value.slice(0, idx).trim() : "";
const path = idx > 0 ? value.slice(idx + 1).trim() : value.trim();
Expand All @@ -148,10 +148,49 @@ function parseSessionSource(value: string, order: number, replaceIds: Set<string
id: resolvedId,
label: id ? resolvedId : source.label ?? resolvedId,
layer,
merge: replaceIds.has(resolvedId) ? "replace" : "append",
merge: "append",
};
}

interface SessionSourceReplacement {
replacerId: string;
replacementScope?: string;
}

function parseSessionSourceReplacement(value: string): SessionSourceReplacement {
const trimmed = value.trim();
const separator = trimmed.indexOf("=");
const replacerId = (separator >= 0 ? trimmed.slice(0, separator) : trimmed).trim();
if (!replacerId) {
throw new Error(`Invalid --replace-source "${value}" (expected replacer-id or replacer-id=target-source-id)`);
}
if (separator < 0) return { replacerId };
const targetId = trimmed.slice(separator + 1).trim();
if (!targetId) {
throw new Error(`Invalid --replace-source "${value}" (target source id is required after "=")`);
}
return {
replacerId,
replacementScope: `source:${normalizeSessionInstructionSourceId(targetId)}`,
};
}

function sessionSourceReplacements(values: string[]): Map<string, SessionSourceReplacement> {
const replacements = new Map<string, SessionSourceReplacement>();
for (const value of values) {
const replacement = parseSessionSourceReplacement(value);
const existing = replacements.get(replacement.replacerId);
if (existing && existing.replacementScope !== replacement.replacementScope) {
throw new Error(
`Conflicting --replace-source values for "${replacement.replacerId}": `
+ `${existing.replacementScope ?? "broad"} and ${replacement.replacementScope ?? "broad"}.`,
);
}
replacements.set(replacement.replacerId, replacement);
}
return replacements;
}

function readSessionInstructionSourceFile(path: string): string {
const stat = lstatSync(path);
if (stat.isSymbolicLink()) {
Expand Down Expand Up @@ -191,8 +230,8 @@ async function collectSessionSources(
tool: SessionRenderTool,
store: ConfigStore,
): Promise<SessionInstructionSource[]> {
const replaceIds = new Set<string>(opts.replaceSource ?? []);
const sources = (opts.source ?? []).map((value, index) => parseSessionSource(value, index, replaceIds));
const replacements = sessionSourceReplacements(opts.replaceSource ?? []);
const sources = (opts.source ?? []).map((value, index) => parseSessionSource(value, index));

for (const value of opts.config ?? []) {
const { layer, id } = parseLayeredReference(value);
Expand All @@ -206,7 +245,15 @@ async function collectSessionSources(
sources.push(...sourcesFromIdentityExport(parsed, { path, tool, orderOffset: sources.length }));
}

return sources.map((source) => replaceIds.has(source.id) ? { ...source, merge: "replace" } : source);
return sources.map((source) => {
const replacement = replacements.get(source.id);
if (!replacement) return source;
return {
...source,
merge: "replace",
replacementScope: replacement.replacementScope,
};
});
}

// Reconcile-and-warn for todos 102d6d0a/5dcd60ec: `--config global:<slug>` entries
Expand Down Expand Up @@ -1158,7 +1205,7 @@ sessionCmd.command("plan")
.option("--source <layer:id=path>", `instruction source file; layers: ${SESSION_SOURCE_LAYER_HELP}`, collectOption, [])
.option("--config <layer:id-or-slug>", "stored config source by id/slug; repeatable; layer aliases match --source", collectOption, [])
.option("--identity-export <path>", "OpenIdentities configs instruction export JSON; repeatable", collectOption, [])
.option("--replace-source <id>", "source id that replaces earlier layers instead of appending", collectOption, [])
.option("--replace-source <replacer-id>[=<target-source-id>]", "source id that broadly replaces earlier layers, or targets one earlier source", collectOption, [])
.option("--codewith-native-imports", "select the gated Codewith native @ import adapter")
.option("--allow-empty-sources", "allow an explicit empty render plan")
.option("--check-global-coverage", "warn (non-fatal) when a registered, non-retired global-* source is absent from this render's --config list; expected is read fresh from the registry, independent of this plan (todos 102d6d0a)")
Expand Down Expand Up @@ -1226,7 +1273,7 @@ sessionCmd.command("apply")
.option("--source <layer:id=path>", `instruction source file; layers: ${SESSION_SOURCE_LAYER_HELP}`, collectOption, [])
.option("--config <layer:id-or-slug>", "stored config source by id/slug; repeatable; layer aliases match --source", collectOption, [])
.option("--identity-export <path>", "OpenIdentities configs instruction export JSON; repeatable", collectOption, [])
.option("--replace-source <id>", "source id that replaces earlier layers instead of appending", collectOption, [])
.option("--replace-source <replacer-id>[=<target-source-id>]", "source id that broadly replaces earlier layers, or targets one earlier source", collectOption, [])
.option("--codewith-native-imports", "select the gated Codewith native @ import adapter")
.option("--allow-empty-sources", "allow an explicit empty render")
.option("--check-global-coverage", "warn (non-fatal) when a registered, non-retired global-* source is absent from this render's --config list; expected is read fresh from the registry, independent of this plan (todos 102d6d0a)")
Expand Down
167 changes: 167 additions & 0 deletions src/cli/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,173 @@ describe("configs session CLI", () => {
}
});

test("--replace-source replacer=target matches identity-exported replacementScope", () => {
const home = makeTempRoot("open-configs-session-cli-");
try {
const cliScopedExport = join(home, "cli-scoped.json");
const identityScopedExport = join(home, "identity-scoped.json");
const sources = [
{
id: "shared-review",
title: "Shared Review",
kind: "global-rules",
precedence: 0,
mergePolicy: "append",
content: "Shared review requires two reviewers.",
},
{
id: "r11-recording",
title: "R11 Recording",
kind: "global-rules",
precedence: 1,
mergePolicy: "append",
content: "R11 remains.",
},
{
id: "codewith-review",
title: "Codewith Review",
kind: "global-rules",
precedence: 2,
mergePolicy: "append",
content: "Codewith review requires one reviewer.",
},
];
const exportPayload = (scoped: boolean) => ({
contract: "hasna.identities.configs-instructions/v1",
validation: { valid: true },
sources: sources.map((source) =>
source.id === "codewith-review" && scoped
? {
...source,
mergePolicy: "replace",
replacementScope: "source:shared-review",
}
: source
),
});
writeFileSync(cliScopedExport, JSON.stringify(exportPayload(false)));
writeFileSync(identityScopedExport, JSON.stringify(exportPayload(true)));
const env = {
HOME: home,
HASNA_CONFIGS_HOME: join(home, ".hasna", "configs"),
};
const common = [
"session",
"plan",
"--tool",
"codex",
"--profile",
"account999",
"--target-home",
"~/session-home",
"--json",
];

const fromCli = runCli([
...common,
"--identity-export",
cliScopedExport,
"--replace-source",
"codewith-review=Shared Review",
], env);
const fromIdentity = runCli([
...common,
"--identity-export",
identityScopedExport,
], env);

expect(fromCli.status).toBe(0);
expect(fromIdentity.status).toBe(0);
const cliPlan = JSON.parse(fromCli.stdout) as {
manifest: {
sourceHash: string;
sources: Array<{ id: string; replacementScope: string | null; provenance: unknown }>;
skippedSources: Array<{ id: string; reason: string }>;
};
};
const identityPlan = JSON.parse(fromIdentity.stdout) as typeof cliPlan;
expect(cliPlan.manifest.sourceHash).toBe(identityPlan.manifest.sourceHash);
expect(cliPlan.manifest.sources).toEqual(identityPlan.manifest.sources);
expect(cliPlan.manifest.skippedSources).toEqual(identityPlan.manifest.skippedSources);
expect(cliPlan.manifest.sources.map((source) => source.id)).toEqual([
"r11-recording",
"codewith-review",
]);
expect(cliPlan.manifest.sources[1]?.replacementScope).toBe("source:shared-review");
expect(cliPlan.manifest.skippedSources.map((source) => source.id)).toEqual(["shared-review"]);
} finally {
rmSync(home, { recursive: true, force: true });
}
});

test("--replace-source replacer keeps broad replacement compatibility", () => {
const home = makeTempRoot("open-configs-session-cli-");
try {
const exportPath = join(home, "broad-replace.json");
writeFileSync(exportPath, JSON.stringify({
contract: "hasna.identities.configs-instructions/v1",
validation: { valid: true },
sources: [
{
id: "ordinary-a",
title: "Ordinary A",
kind: "global-rules",
precedence: 0,
mergePolicy: "append",
content: "Ordinary A.",
},
{
id: "ordinary-b",
title: "Ordinary B",
kind: "global-rules",
precedence: 1,
mergePolicy: "append",
content: "Ordinary B.",
},
{
id: "broad-replacer",
title: "Broad Replacer",
kind: "global-rules",
precedence: 2,
mergePolicy: "append",
content: "Broad replacement.",
},
],
}));

const result = runCli([
"session",
"plan",
"--tool",
"codex",
"--profile",
"account999",
"--identity-export",
exportPath,
"--replace-source",
"broad-replacer",
"--json",
], {
HOME: home,
HASNA_CONFIGS_HOME: join(home, ".hasna", "configs"),
});

expect(result.status).toBe(0);
const plan = JSON.parse(result.stdout) as {
manifest: {
sources: Array<{ id: string; replacementScope: string | null }>;
skippedSources: Array<{ id: string }>;
};
};
expect(plan.manifest.sources).toEqual([
expect.objectContaining({ id: "broad-replacer", replacementScope: null }),
]);
expect(plan.manifest.skippedSources.map((source) => source.id)).toEqual(["ordinary-a", "ordinary-b"]);
} finally {
rmSync(home, { recursive: true, force: true });
}
});

test("applies canonical identity exports with source paths and filters rule provider blocks", () => {
const home = makeTempRoot("open-configs-session-cli-");
try {
Expand Down
Loading
Loading