From 6cad0dca071418be90855c04954eb49b8a1f73ba Mon Sep 17 00:00:00 2001 From: James Sesler Date: Thu, 23 Jul 2026 00:49:09 -0400 Subject: [PATCH] feat: add library init and config inspection commands (#61 #68) Library init: - core/library.ts: initLibrary() creates directory + marker, idempotent - core/orchestrator-config.ts: writeLibraryConfig() writes library block - Pi: /codecarto-library-init [--namespace ] - MCP: codecarto_library_init tool Config inspection: - Pi: /codecarto-config shows merged config + marker status - MCP: codecarto_config tool Both commands close the first-publish dead end where users had to manually create the marker file and config with no guidance. --- README.md | 2 + core/library.ts | 45 ++++++++++++++- core/orchestrator-config.ts | 33 ++++++++++- extensions/codecarto/index.ts | 72 ++++++++++++++++++++++++ mcp-server/server.ts | 97 ++++++++++++++++++++++++++++++++- tests/default-pipeline.test.mjs | 2 +- 6 files changed, 247 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c3c86cd..f1b7f20 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,8 @@ Beyond the slash commands, the Pi extension layers on: | `/codecarto-complete [phase]` | Validate and atomically apply the phase handoff, canonical status, closeout, and log entry | | `/codecarto-skill ` | Run a post-pipeline skill once all phases are complete | | `/codecarto-publish` | Publish the reimplementation spec to the configured library after reviewing an explicit confirmation preview | +| `/codecarto-library-init [--namespace ]` | Create a library directory with marker and write the config — fixes the first-publish dead end | +| `/codecarto-config` | Show the effective merged configuration (global + workspace) and library marker status | | `/codecarto-usage` | Cumulative + per-phase token usage | | `/codecarto-dashboard [--narrate]` | Regenerate `.codecarto/dashboard.html`; `--narrate` for the LLM executive summary | diff --git a/core/library.ts b/core/library.ts index 4bd4d49..56f9b5f 100644 --- a/core/library.ts +++ b/core/library.ts @@ -26,7 +26,7 @@ import { createHash } from "node:crypto"; import { spawn } from "node:child_process"; import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { basename, join, resolve } from "node:path"; import { isPlainObject, pathExists } from "./utils.ts"; import { parseSimpleYaml, stringifySimpleYaml } from "./yaml.ts"; @@ -170,6 +170,49 @@ function isVisibility(v: string): v is LibraryVisibility { return v === "internal" || v === "shared" || v === "public"; } +// ─── Library initialization ──────────────────────────────────────────────── + +export interface InitLibraryOptions { + /** Library name (defaults to basename of the path). */ + name?: string; + /** Visibility level. Default "internal". */ + visibility?: LibraryVisibility; + /** Whether this is a namespaced (shared) library. Default false. */ + namespaced?: boolean; +} + +export interface InitLibraryResult { + libraryPath: string; + marker: LibraryMarker; + /** True if the marker already existed (idempotent re-run). */ + alreadyExisted: boolean; +} + +/** + * Initialize a CodeCartographer library at the given path: create the + * directory if needed, write the `.codecarto-library` marker if missing, + * and return the marker. Idempotent — re-running on an existing library + * is safe and preserves the existing marker. + */ +export async function initLibrary(libraryPath: string, options: InitLibraryOptions = {}): Promise { + const existing = await discoverLibrary(libraryPath); + if (existing) { + return { libraryPath, marker: existing, alreadyExisted: true }; + } + + const name = options.name?.trim() || basename(libraryPath); + const marker: LibraryMarker = { + schema_version: MARKER_SCHEMA_VERSION, + name, + namespaced: options.namespaced ?? false, + visibility: options.visibility ?? "internal", + created_at: new Date().toISOString(), + }; + + await writeMarker(libraryPath, marker); + return { libraryPath, marker, alreadyExisted: false }; +} + // ─── Slug helpers ─────────────────────────────────────────────────────────── export function isValidSlug(slug: string): boolean { diff --git a/core/orchestrator-config.ts b/core/orchestrator-config.ts index 32ab2a3..70dd980 100644 --- a/core/orchestrator-config.ts +++ b/core/orchestrator-config.ts @@ -17,8 +17,9 @@ import { homedir } from "node:os"; import { join, resolve } from "node:path"; import type { PathLike } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { expandTilde, pathExists } from "./utils.ts"; -import { loadYamlFile } from "./yaml.ts"; +import { loadYamlFile, parseSimpleYaml, stringifySimpleYaml } from "./yaml.ts"; export interface OrchestratorConfig { /** When true, /codecarto-next runs an LLM rewriter to produce a seed prompt @@ -152,3 +153,33 @@ function cloneDefault(): CodecartoConfig { library: { ...DEFAULT_CONFIG.library }, }; } + +/** + * Write a `library:` block into a config file (user-global or workspace). + * Creates the file and parent directories if needed. Preserves any existing + * `orchestrator:` block. Overwrites the `library:` block if present. + */ +export async function writeLibraryConfig( + configPath: string, + libraryPath: string, + namespace: string | null = null, + publishConfirm = true, +): Promise { + let existing: Record = {}; + if (await pathExists(configPath)) { + try { + const raw = await readFile(configPath, "utf8"); + existing = parseSimpleYaml(raw) as Record; + } catch { + // Malformed file — start fresh + } + } + + const library: Record = { path: libraryPath, publish_confirm: publishConfirm }; + if (namespace) library.namespace = namespace; + + const updated: Record = { ...existing, library }; + const dir = configPath.includes("/") ? configPath.slice(0, configPath.lastIndexOf("/")) : "."; + await mkdir(dir, { recursive: true }); + await writeFile(configPath, `${stringifySimpleYaml(updated)}\n`, "utf8"); +} diff --git a/extensions/codecarto/index.ts b/extensions/codecarto/index.ts index 7421364..48603c8 100644 --- a/extensions/codecarto/index.ts +++ b/extensions/codecarto/index.ts @@ -1,4 +1,5 @@ import { cp, mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; import { basename, join, resolve } from "node:path"; import { type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext } from "@earendil-works/pi-coding-agent"; @@ -47,7 +48,10 @@ import { switchPipeline, validatePhaseOutput, type WorkspaceState, + writeLibraryConfig, } from "../../core/index.ts"; +import { initLibrary } from "../../core/library.ts"; +import { resolveUserConfigPath, USER_CONFIG_DIR } from "../../core/orchestrator-config.ts"; const STATUS_WIDGET_ID = "codecarto-widget"; const STATUS_LINE_ID = "codecarto-status"; @@ -732,6 +736,74 @@ export default function codeCartographerExtension(pi: ExtensionAPI) { }, }); + pi.registerCommand("codecarto-library-init", { + description: "Initialize a CodeCartographer library and configure it: /codecarto-library-init [--namespace ]", + handler: async (args, ctx) => { + const parts = args.trim().split(/\s+/); + const pathArg = parts[0]; + const namespaceIdx = parts.indexOf("--namespace"); + const namespace = namespaceIdx >= 0 ? parts[namespaceIdx + 1] : null; + + if (!pathArg) { + ctx.ui.notify("Usage: /codecarto-library-init [--namespace ]", "warning"); + return; + } + + const libraryPath = pathArg.startsWith("~") ? join(homedir(), pathArg.slice(1)) : resolve(pathArg); + + try { + const result = await initLibrary(libraryPath, { + namespaced: !!namespace, + ...(namespace ? {} : {}), + }); + + // Write the config to the user-global location + const configPath = resolveUserConfigPath(); + await writeLibraryConfig(configPath, libraryPath, namespace); + + const msg = result.alreadyExisted + ? `Library already exists at ${libraryPath} (marker preserved). Config updated.` + : `Created library at ${libraryPath} with marker "${result.marker.name}".`; + lastFeedbackLines = [msg, `Config written to ${configPath}`]; + if (ctx.hasUI) { + ctx.ui.notify(msg, "info"); + ctx.ui.notify(`Config written to ${configPath}`, "info"); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + ctx.ui.notify(`Library init failed: ${message}`, "error"); + } + }, + }); + + pi.registerCommand("codecarto-config", { + description: "Show the effective merged CodeCartographer configuration (global + workspace)", + handler: async (_args, ctx) => { + const state = await ensureWorkspaceState(ctx); + const config = await loadCodecartoConfig(state ? state.workspaceDir : join(ctx.cwd, ".codecarto")); + + const lines = [ + "Effective CodeCartographer configuration:", + ` library.path: ${config.library.path ?? "(not set)"}`, + ` library.namespace: ${config.library.namespace ?? "(not set)"}`, + ` library.publish_confirm: ${config.library.publish_confirm}`, + ` orchestrator.llm_steer_next_phase: ${config.orchestrator.llm_steer_next_phase}`, + "", + ` User-global config: ${resolveUserConfigPath()}`, + ` Workspace config: ${state ? join(state.workspaceDir, "workflow/config.yaml") : "(no workspace)"}`, + ]; + + if (config.library.path) { + const marker = await discoverLibrary(config.library.path); + lines.push(` Library marker: ${marker ? `found ("${marker.name}", namespaced: ${marker.namespaced})` : "MISSING — run /codecarto-library-init"}`); + } + + lastFeedbackLines = lines; + if (state) setUiState(ctx, state, lastFeedbackLines); + ctx.ui.notify("Configuration shown in status widget.", "info"); + }, + }); + pi.registerCommand("codecarto-usage", { description: "Show cumulative + per-phase token usage from local phase runs", handler: async (_args, ctx) => { diff --git a/mcp-server/server.ts b/mcp-server/server.ts index 75d0ff5..6fc67be 100644 --- a/mcp-server/server.ts +++ b/mcp-server/server.ts @@ -62,10 +62,12 @@ import { type StatusFile, stringifySimpleYaml, switchPipeline, - validatePhaseOutput, type WorkspaceState, + writeLibraryConfig, } from "../core/index.ts"; +import { initLibrary } from "../core/library.ts"; +import { loadUserConfig, resolveUserConfigPath } from "../core/orchestrator-config.ts"; // ---------- input helpers ---------- @@ -625,6 +627,72 @@ export async function handleLibraryReindex(args: Record) { ); } +export async function handleLibraryInit(args: { library_path: string; name?: string; namespace?: string; cwd?: string }) { + if (!args.library_path || typeof args.library_path !== "string") { + throw new McpError(ErrorCode.InvalidParams, "library_path is required."); + } + + const libraryPath = args.library_path; + const namespaced = !!args.namespace; + + const result = await initLibrary(libraryPath, { + name: args.name, + namespaced, + }); + + // Write config to user-global location + const configPath = resolveUserConfigPath(); + await writeLibraryConfig(configPath, libraryPath, args.namespace ?? null); + + const msg = result.alreadyExisted + ? `Library already exists at ${libraryPath} (marker preserved). Config written to ${configPath}.` + : `Created library at ${libraryPath} with marker "${result.marker.name}". Config written to ${configPath}.`; + + return textResult(msg, { + libraryPath, + markerName: result.marker.name, + namespaced: result.marker.namespaced, + alreadyExisted: result.alreadyExisted, + configPath, + }); +} + +export async function handleConfig(args: { cwd?: string }) { + const config = args.cwd + ? await loadCodecartoConfig(join(args.cwd, ".codecarto")) + : await loadUserConfig(); + + const userConfigPath = resolveUserConfigPath(); + const workspaceConfigPath = args.cwd ? join(args.cwd, ".codecarto", "workflow", "config.yaml") : null; + + let markerStatus = "not configured"; + if (config.library.path) { + const marker = await discoverLibrary(config.library.path); + markerStatus = marker ? `found ("${marker.name}", namespaced: ${marker.namespaced})` : "MISSING"; + } + + return textResult( + [ + "Effective CodeCartographer configuration:", + ` library.path: ${config.library.path ?? "(not set)"}`, + ` library.namespace: ${config.library.namespace ?? "(not set)"}`, + ` library.publish_confirm: ${config.library.publish_confirm}`, + ` orchestrator.llm_steer_next_phase: ${config.orchestrator.llm_steer_next_phase}`, + ` Library marker: ${markerStatus}`, + ` User-global config: ${userConfigPath}`, + ` Workspace config: ${workspaceConfigPath ?? "(no cwd provided)"}`, + ].join("\n"), + { + libraryPath: config.library.path, + libraryNamespace: config.library.namespace, + publishConfirm: config.library.publish_confirm, + llmSteerNextPhase: config.orchestrator.llm_steer_next_phase, + userConfigPath, + workspaceConfigPath, + }, + ); +} + // ---------- tool registry ---------- const TOOLS = [ @@ -802,6 +870,31 @@ const TOOLS = [ }, }, }, + { + name: "codecarto_library_init", + description: + "Initialize a CodeCartographer library at the given path: create the directory, write the .codecarto-library marker, and write the library.path into the user-global config. Idempotent — safe to re-run on an existing library. Pass a namespace to create a namespaced (shared) library.", + inputSchema: { + type: "object", + properties: { + library_path: { type: "string", description: "Absolute path for the library directory." }, + name: { type: "string", description: "Library name (defaults to the directory basename)." }, + namespace: { type: "string", description: "Default namespace for a namespaced (shared) library." }, + }, + required: ["library_path"], + }, + }, + { + name: "codecarto_config", + description: + "Show the effective merged CodeCartographer configuration (library.path, library.namespace, publish_confirm, llm_steer_next_phase) and whether the library marker was found. Pass cwd to include workspace-level config in the merge.", + inputSchema: { + type: "object", + properties: { + cwd: { type: "string", description: "Absolute path to a repository with a .codecarto/ workspace (optional)." }, + }, + }, + }, ] as const; const HANDLERS: Record Promise> = { @@ -816,6 +909,8 @@ const HANDLERS: Record Promise> = { codecarto_publish: handlePublish, codecarto_library_list: handleLibraryList, codecarto_library_reindex: handleLibraryReindex, + codecarto_library_init: handleLibraryInit, + codecarto_config: handleConfig, }; // ---------- server bootstrap ---------- diff --git a/tests/default-pipeline.test.mjs b/tests/default-pipeline.test.mjs index e0709a8..3fefb5f 100644 --- a/tests/default-pipeline.test.mjs +++ b/tests/default-pipeline.test.mjs @@ -61,7 +61,7 @@ test("every PIPELINE_ALIASES target resolves to a real file", async () => { test("Pi extension registers a command for every CodeCartographer operation", async () => { // The set of operations the framework exposes; both wrappers must surface them. - const expected = ["init", "open", "switch-pipeline", "status", "next", "phase", "validate", "complete", "skill", "publish", "usage", "dashboard"]; + const expected = ["init", "open", "switch-pipeline", "status", "next", "phase", "validate", "complete", "skill", "publish", "library-init", "config", "usage", "dashboard"]; const indexSrc = await readFile(join(REPO_ROOT, "extensions", "codecarto", "index.ts"), "utf8"); const missing = expected.filter((op) => !indexSrc.includes(`pi.registerCommand("codecarto-${op}"`)); assert.deepEqual(