From 9e44cfc8c0140c9c8a88890054a5eeb366fd0375 Mon Sep 17 00:00:00 2001 From: TrueNine Date: Thu, 19 Mar 2026 04:01:10 +0800 Subject: [PATCH] feat: simplify cli outputs and bump release version --- CODE_OF_CONDUCT.md | 6 - Cargo.lock | 10 +- Cargo.toml | 2 +- README.md | 39 +- SECURITY.md | 12 +- cli/npm/darwin-arm64/package.json | 2 +- cli/npm/darwin-x64/package.json | 2 +- cli/npm/linux-arm64-gnu/package.json | 2 +- cli/npm/linux-x64-gnu/package.json | 2 +- cli/npm/win32-x64-msvc/package.json | 2 +- cli/package.json | 17 +- cli/scripts/finalize-bundle.ts | 143 +++++++ cli/scripts/write-main-wrapper.ts | 174 --------- cli/src/commands/CleanupUtils.test.ts | 48 ++- cli/src/commands/CleanupUtils.ts | 49 ++- cli/src/index.ts | 21 + cli/src/main.ts | 6 - cli/src/plugins/CodexCLIOutputPlugin.test.ts | 157 ++++++-- cli/src/plugins/CodexCLIOutputPlugin.ts | 164 ++++---- cli/src/plugins/desk-paths.ts | 81 ++-- cli/src/plugins/plugin-core.ts | 2 + .../plugin-core/AbstractOutputPlugin.ts | 367 ++++++++++++------ cli/src/plugins/plugin-core/plugin.ts | 7 +- cli/tsdown.config.ts | 4 +- cli/vite.config.ts | 7 + doc/package.json | 2 +- gui/package.json | 2 +- gui/src-tauri/Cargo.toml | 2 +- gui/src-tauri/tauri.conf.json | 2 +- libraries/logger/package.json | 2 +- libraries/logger/src/index.ts | 3 +- libraries/md-compiler/package.json | 2 +- libraries/md-compiler/src/index.ts | 8 + libraries/md-compiler/src/markdown/index.ts | 3 +- libraries/md-compiler/src/mdx-to-md.ts | 3 +- libraries/md-compiler/src/toml.test.ts | 122 ++++++ libraries/md-compiler/src/toml.ts | 269 +++++++++++++ libraries/script-runtime/package.json | 2 +- libraries/script-runtime/src/index.ts | 3 +- mcp/package.json | 2 +- package.json | 2 +- 41 files changed, 1214 insertions(+), 541 deletions(-) create mode 100644 cli/scripts/finalize-bundle.ts delete mode 100644 cli/scripts/write-main-wrapper.ts delete mode 100644 cli/src/main.ts create mode 100644 libraries/md-compiler/src/toml.test.ts create mode 100644 libraries/md-compiler/src/toml.ts diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index befffbb4..b3ae05d5 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -16,7 +16,6 @@ We are rats. We accept that. - **Students and beginners**: Genuinely willing to learn and get hands dirty, not here to beg for ready-made answers - **Anyone, any language, any region**: If you use this tool, you are part of the community - **AI Agents**: Automation pipelines, Agent workflows, LLM-driven toolchains — as long as behaviour complies with this code, Issues and PRs from Agents are treated equally - We welcome Issues, PRs, discussions, rants — as long as you are serious, regardless of whether the author is human or Agent. --- @@ -31,7 +30,6 @@ The following behaviours result in immediate Issue closure / PR rejection / acco - **Resource predators**: Stable income, corporate budget, yet competing with marginal developers for free resources and community attention - **Harassment**: Personal attacks, discrimination, stalking, harassing maintainers or other contributors - **Hustle-culture pushers**: Glorify overwork, promote 996, or use this tool to exploit other developers - --- ## Contributor Obligations @@ -42,14 +40,12 @@ If you submit an Issue (human or Agent): - State your OS, Node.js version, and tool version - Agent submissions must include trigger context (call chain, input params, error stack) - Do not rush maintainers — they are humans, not customer support - If you submit a PR (human or Agent): - Open an Issue first to discuss, avoid wasted effort - Follow existing code style (TypeScript strict, functional, immutable-first) - Do not sneak unrelated changes into a PR - Agent-generated PRs must declare the generation tool and prompt source in the description; do not disguise as hand-written - --- ## Maintainer Rights @@ -59,13 +55,11 @@ Maintainers may: - Close any Issue or PR without explanation - Ban any account violating this code - Amend this code at any time - Maintainers are not obligated to: - Respond to every Issue - Accept every PR - Be responsible for anyone's commercial needs - --- ## Licence and Enforcement diff --git a/Cargo.lock b/Cargo.lock index 64f8a95e..61b89d84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2071,7 +2071,7 @@ dependencies = [ [[package]] name = "memory-sync-gui" -version = "2026.10318.12034" +version = "2026.10319.10359" dependencies = [ "dirs", "proptest", @@ -4439,7 +4439,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tnmsc" -version = "2026.10318.12034" +version = "2026.10319.10359" dependencies = [ "clap", "dirs", @@ -4459,7 +4459,7 @@ dependencies = [ [[package]] name = "tnmsc-logger" -version = "2026.10318.12034" +version = "2026.10319.10359" dependencies = [ "chrono", "napi", @@ -4471,7 +4471,7 @@ dependencies = [ [[package]] name = "tnmsc-md-compiler" -version = "2026.10318.12034" +version = "2026.10319.10359" dependencies = [ "markdown", "napi", @@ -4486,7 +4486,7 @@ dependencies = [ [[package]] name = "tnmsc-script-runtime" -version = "2026.10318.12034" +version = "2026.10319.10359" dependencies = [ "napi", "napi-build", diff --git a/Cargo.toml b/Cargo.toml index a3301ccb..710980f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "2026.10318.12034" +version = "2026.10319.10359" edition = "2024" license = "AGPL-3.0-only" authors = ["TrueNine"] diff --git a/README.md b/README.md index 3438a055..61ef467e 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,6 @@ So as a rat, I eat whatever I can reach: maggots in the sewer, leftovers in the - Does not expect any platform to grant an "official all-in-one solution" - Does not rely on privileged interfaces of any single IDE / CLI - Treats every readable config, prompt, and memory file as "edible matter" to be carried, dismantled, and recombined - In this ecosystem, giants monopolise the resources, and developers are thrown into the corner like rats. `memory-sync` accepts this cruel reality, does not fantasise about fairness, and focuses on one thing only: **to chew up every fragment of resource you already have, and convert it into portable "memory" that can flow between any AI tool.** @@ -27,7 +26,6 @@ What can it help you do? - **Read-only source files**: never modifies your original repository directly, only reads and transforms, then materialises the result on the target tool side. - **Full wipe**: on sync, erases all stale prompt traces in target tools—prompts are fully computable and auditable, leaving no residue for bad actors. - **Prompts grow with you only**: memory follows you as a person, not the project. Someone else takes over the project—they cannot take your context. You move to a new project—your accumulated knowledge moves with you intact. - ## Install ```sh @@ -36,10 +34,10 @@ npm install -g @truenine/memory-sync ## Supported Tools -| Type | Tools | -| ---- | --------------------------------------- | -| IDE | Cursor, Kiro, Windsurf, JetBrains AI | -| CLI | Claude CLI, Gemini CLI, Codex CLI, Warp | +| Type | Tools | +| --- | --- | +| IDE | Cursor, Kiro, Windsurf, JetBrains AI | +| CLI | Claude CLI, Gemini CLI, Codex CLI, Warp | More platforms being added continuously. @@ -49,7 +47,6 @@ More platforms being added continuously. - **Core** (Rust): file I/O, directory traversal, format conversion. - **Config DSL** (JSON): reads only the global config file `~/.aindex/.tnmsc.json`, which defines sync rules and target tools. - **GUI** (Tauri): desktop app that calls the CLI as its backend, providing a visual interface. - ## FAQ **When AI tools finally have a unified standard, what use will this project be?** @@ -73,40 +70,37 @@ To use `memory-sync` you need: - Solid development experience, years of working with various dev tools - Proficiency with version control (Git) - Proficiency with the terminal - --- - You are writing code in a forgotten sewer. - No one will proactively feed you, not even a tiny free quota, not even a half-decent document. +No one will proactively feed you, not even a tiny free quota, not even a half-decent document. - As a rat, you can barely get your hands on anything good: - scurrying between free tiers, trial credits, education discounts, and random third-party scripts. +scurrying between free tiers, trial credits, education discounts, and random third-party scripts. - What can you do? - Keep darting between IDEs, CLIs, browser extensions, and cloud Agents, copying and pasting the same memory a hundred times. +Keep darting between IDEs, CLIs, browser extensions, and cloud Agents, copying and pasting the same memory a hundred times. - You leech API offers from vendors day after day: - today one platform runs a discount so you top up a little; tomorrow another launches a promo so you rush to scrape it. +today one platform runs a discount so you top up a little; tomorrow another launches a promo so you rush to scrape it. - Once they have harvested the telemetry, user profiles, and usage patterns they want, - they can kick you—this stinking rat—away at any moment: price hikes, rate limits, account bans, and you have no channel to complain. - +they can kick you—this stinking rat—away at any moment: price hikes, rate limits, account bans, and you have no channel to complain. If you are barely surviving in this environment, `memory-sync` is built for you: carry fewer bricks, copy prompts fewer times—at least on the "memory" front, you are no longer completely on the passive receiving end. ## Who is NOT welcome - Your income is already fucking high. - Stable salary, project revenue share, budget to sign official APIs yearly. +Stable salary, project revenue share, budget to sign official APIs yearly. - And yet you still come down here, - competing with us filthy sewer rats for the scraps in the slop bucket. +competing with us filthy sewer rats for the scraps in the slop bucket. - If you can afford APIs and enterprise plans, go pay for them. - Do things that actually create value—pay properly, give proper feedback, nudge the ecosystem slightly in the right direction. +Do things that actually create value—pay properly, give proper feedback, nudge the ecosystem slightly in the right direction. - Instead of coming back down - to strip away the tiny gap left for marginalised developers, squeezing out the last crumbs with us rats. +to strip away the tiny gap left for marginalised developers, squeezing out the last crumbs with us rats. - You are a freeloader. - Everything must be pre-chewed and spoon-fed; you won't even touch a terminal. +Everything must be pre-chewed and spoon-fed; you won't even touch a terminal. - You love the grind culture. - Treating "hustle" as virtue, "996" as glory, stepping on peers as a promotion strategy. +Treating "hustle" as virtue, "996" as glory, stepping on peers as a promotion strategy. - You leave no room for others. - Not about whether you share—it's about actively stomping on people, competing maliciously, sustaining your position by suppressing peers, using others' survival space as your stepping stone. - +Not about whether you share—it's about actively stomping on people, competing maliciously, sustaining your position by suppressing peers, using others' survival space as your stepping stone. In other words: **this is not a tool for optimising capital costs, but a small counterattack prepared for the "rats with no choice" in a world of extreme resource inequality.** @@ -114,7 +108,6 @@ In other words: - [TrueNine](https://github.com/TrueNine) - [zjarlin](https://github.com/zjarlin) - ## License [AGPL-3.0](LICENSE) \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md index a3cfa4ff..d08111a9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,9 +5,9 @@ Only the latest release receives security fixes. No backport patches for older versions. | Version | Supported | -| ------- | --------- | -| Latest | ✅ | -| Older | ❌ | +| --- | --- | +| Latest | ✅ | +| Older | ❌ | ## Reporting a Vulnerability @@ -17,14 +17,12 @@ Contact the maintainer privately via: - GitHub Security Advisory: submit a private report under the repository's **Security** tab - Email: contact [@TrueNine](https://github.com/TrueNine) directly - Please include: - Vulnerability description and impact scope - Reproduction steps (minimal example) - Your OS, Node.js version, and `memory-sync` version - Suggested fix if any - ## Response Timeline The maintainer is a person, not a security team. No SLA, no 24-hour response guarantee. @@ -32,7 +30,6 @@ The maintainer is a person, not a security team. No SLA, no 24-hour response gua - Will acknowledge receipt as soon as possible - Will release a patch within a reasonable timeframe after confirmation - Will publicly disclose vulnerability details after the fix is released - Don't rush. ## Scope @@ -42,20 +39,17 @@ Don't rush. - **Reads**: user `.src.mdx` source files, the global config file (`~/.aindex/.tnmsc.json`) - **Writes**: target tool config directories (`.cursor/`, `.claude/`, `.kiro/`, etc.) - **Cleans**: removes stale files from target directories during sync - The following are **out of scope**: - Security vulnerabilities in target AI tools themselves - Compliance of user prompt content - Supply chain security of third-party plugins (`packages/`) — all plugins are `private` and not published to npm - ## Design Principles - **Never modifies source files**: read-only on source; writes only to target - **Full clean mode**: after sync, only explicitly authorised content remains in target directories — no hidden residue - **No network requests**: CLI core makes no outbound network requests (version check excepted, and times out gracefully) - **No telemetry**: no user data collected or reported - ## License This project is licensed under [AGPL-3.0](LICENSE). Unauthorised commercial use in violation of the licence will be pursued legally. \ No newline at end of file diff --git a/cli/npm/darwin-arm64/package.json b/cli/npm/darwin-arm64/package.json index cea43f2d..92987d80 100644 --- a/cli/npm/darwin-arm64/package.json +++ b/cli/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-darwin-arm64", - "version": "2026.10318.12034", + "version": "2026.10319.10359", "os": [ "darwin" ], diff --git a/cli/npm/darwin-x64/package.json b/cli/npm/darwin-x64/package.json index f0295e7f..3dec9327 100644 --- a/cli/npm/darwin-x64/package.json +++ b/cli/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-darwin-x64", - "version": "2026.10318.12034", + "version": "2026.10319.10359", "os": [ "darwin" ], diff --git a/cli/npm/linux-arm64-gnu/package.json b/cli/npm/linux-arm64-gnu/package.json index 76718c77..55157ed1 100644 --- a/cli/npm/linux-arm64-gnu/package.json +++ b/cli/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-linux-arm64-gnu", - "version": "2026.10318.12034", + "version": "2026.10319.10359", "os": [ "linux" ], diff --git a/cli/npm/linux-x64-gnu/package.json b/cli/npm/linux-x64-gnu/package.json index 4d7d4b9b..4357834d 100644 --- a/cli/npm/linux-x64-gnu/package.json +++ b/cli/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-linux-x64-gnu", - "version": "2026.10318.12034", + "version": "2026.10319.10359", "os": [ "linux" ], diff --git a/cli/npm/win32-x64-msvc/package.json b/cli/npm/win32-x64-msvc/package.json index 315f7f9b..b1af444c 100644 --- a/cli/npm/win32-x64-msvc/package.json +++ b/cli/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-win32-x64-msvc", - "version": "2026.10318.12034", + "version": "2026.10319.10359", "os": [ "win32" ], diff --git a/cli/package.json b/cli/package.json index d9b3c7b6..9f55e517 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/memory-sync-cli", "type": "module", - "version": "2026.10318.12034", + "version": "2026.10319.10359", "description": "TrueNine Memory Synchronization CLI", "author": "TrueNine", "license": "AGPL-3.0-only", @@ -14,14 +14,11 @@ "exports": { ".": { "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "require": "./dist/index.cjs" + "import": "./dist/index.mjs" }, "./globals": { - "types:require": "./dist/globals.d.cts", "types": "./dist/globals.d.mts", - "import": "./dist/globals.mjs", - "require": "./dist/globals.cjs" + "import": "./dist/globals.mjs" }, "./schema.json": "./dist/tnmsc.schema.json", "./package.json": "./package.json" @@ -30,7 +27,7 @@ "module": "./dist/index.mjs", "types": "./dist/index.d.mts", "bin": { - "tnmsc": "./dist/main.mjs" + "tnmsc": "./dist/index.mjs" }, "files": [ "dist", @@ -51,19 +48,19 @@ "registry": "https://registry.npmjs.org/" }, "scripts": { - "build": "run-s build:deps build:napi bundle write:main-wrapper generate:schema check", + "build": "run-s build:deps build:napi bundle finalize:bundle generate:schema check", "build:napi": "tsx ../scripts/copy-napi.ts", "build:deps": "pnpm -F @truenine/logger -F @truenine/md-compiler -F @truenine/script-runtime run build", "bundle": "tsx ../scripts/build-quiet.ts", "check": "run-p typecheck lint", + "finalize:bundle": "tsx scripts/finalize-bundle.ts", "generate:schema": "tsx scripts/generate-schema.ts", "lint": "eslint --cache .", "prepublishOnly": "run-s build", "test": "run-s build:deps test:run", "test:run": "vitest run", "lintfix": "eslint --fix --cache .", - "typecheck": "tsc --noEmit -p tsconfig.lib.json", - "write:main-wrapper": "tsx scripts/write-main-wrapper.ts" + "typecheck": "tsc --noEmit -p tsconfig.lib.json" }, "dependencies": { "json5": "catalog:", diff --git a/cli/scripts/finalize-bundle.ts b/cli/scripts/finalize-bundle.ts new file mode 100644 index 00000000..d53fa142 --- /dev/null +++ b/cli/scripts/finalize-bundle.ts @@ -0,0 +1,143 @@ +import {spawnSync} from 'node:child_process' +import {copyFileSync, existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {dirname, join, resolve} from 'node:path' +import {fileURLToPath, pathToFileURL} from 'node:url' + +const scriptDir = dirname(fileURLToPath(import.meta.url)) +const cliDir = resolve(scriptDir, '..') +const distDir = resolve(cliDir, 'dist') +const indexEntryPath = resolve(distDir, 'index.mjs') +const bundledJitiBabelRuntimeSourcePath = resolve(cliDir, 'node_modules', 'jiti', 'dist', 'babel.cjs') +const bundledJitiBabelRuntimeTargetPath = resolve(distDir, 'babel.cjs') + +function getCombinedOutput(stdout?: string | null, stderr?: string | null): string { + return `${stdout ?? ''}${stderr ?? ''}`.trim() +} + +function runNodeProcess( + args: readonly string[], + options?: { + readonly env?: NodeJS.ProcessEnv + } +) { + return spawnSync(process.execPath, [...args], { + cwd: cliDir, + encoding: 'utf8', + ...options?.env != null && {env: options.env} + }) +} + +function assertProcessSucceeded( + result: ReturnType, + lines: readonly string[] +): void { + if (result.error != null) { + throw result.error + } + + if (result.status === 0) { + return + } + + const combinedOutput = getCombinedOutput(result.stdout, result.stderr) + throw new Error([ + ...lines, + combinedOutput.length === 0 ? 'No output captured.' : combinedOutput + ].join('\n')) +} + +function withTempDir(prefix: string, callback: (tempDir: string) => T): T { + const tempDir = mkdtempSync(join(tmpdir(), prefix)) + + try { + return callback(tempDir) + } + finally { + rmSync(tempDir, {recursive: true, force: true}) + } +} + +function ensureIndexBundleExists(): void { + if (existsSync(indexEntryPath)) return + throw new Error(`Expected bundled CLI entry at "${indexEntryPath}" before finalizing bundle assets.`) +} + +function findBundledJitiChunkPath(): string | undefined { + const bundledJitiChunkName = readdirSync(distDir) + .find(fileName => /^jiti-.*\.mjs$/u.test(fileName)) + + return bundledJitiChunkName == null ? void 0 : resolve(distDir, bundledJitiChunkName) +} + +function ensureBundledJitiRuntimeAssets(): string | undefined { + const bundledJitiChunkPath = findBundledJitiChunkPath() + if (bundledJitiChunkPath == null) return void 0 + + if (!existsSync(bundledJitiBabelRuntimeSourcePath)) { + throw new Error( + `Bundled jiti chunk "${bundledJitiChunkPath}" requires "${bundledJitiBabelRuntimeSourcePath}", but it does not exist.` + ) + } + + copyFileSync(bundledJitiBabelRuntimeSourcePath, bundledJitiBabelRuntimeTargetPath) + return bundledJitiChunkPath +} + +function smokeTestBundledJitiTransform(bundledJitiChunkPath: string | undefined): void { + if (bundledJitiChunkPath == null) return + + withTempDir('tnmsc-bundled-jiti-', tempDir => { + const probeModulePath = join(tempDir, 'probe.ts') + const probeRunnerPath = join(tempDir, 'probe-runner.mjs') + + writeFileSync(probeModulePath, 'export default {ok: true}\n', 'utf8') + writeFileSync(probeRunnerPath, [ + "import {pathToFileURL} from 'node:url'", + '', + 'const [, , bundledJitiChunkPathArg, probeModulePathArg] = process.argv', + '', + 'const {createJiti} = await import(pathToFileURL(bundledJitiChunkPathArg).href)', + 'const runtime = createJiti(import.meta.url, {', + ' fsCache: false,', + ' moduleCache: false,', + ' interopDefault: false', + '})', + 'const loaded = await runtime.import(probeModulePathArg)', + '', + 'if (loaded.default?.ok !== true) {', + " throw new Error('Bundled jiti smoke test loaded an unexpected module shape.')", + '}', + '' + ].join('\n'), 'utf8') + + const smokeTest = runNodeProcess([probeRunnerPath, bundledJitiChunkPath, probeModulePath]) + assertProcessSucceeded(smokeTest, [ + `Bundled jiti chunk "${pathToFileURL(bundledJitiChunkPath).href}" failed the transform smoke test.` + ]) + }) +} + +function smokeTestCliEntry(): void { + withTempDir('tnmsc-index-entry-home-', isolatedHomeDir => { + const smokeTest = runNodeProcess([indexEntryPath, '--version'], { + env: { + ...process.env, + HOME: isolatedHomeDir, + USERPROFILE: isolatedHomeDir + } + }) + + assertProcessSucceeded(smokeTest, [ + `Bundled CLI entry "${indexEntryPath}" failed the runtime smoke test.`, + `Exit code: ${smokeTest.status ?? 'unknown'}` + ]) + }) +} + +ensureIndexBundleExists() +const bundledJitiChunkPath = ensureBundledJitiRuntimeAssets() +smokeTestBundledJitiTransform(bundledJitiChunkPath) +smokeTestCliEntry() + +console.log(`Finalized bundled CLI assets for ${indexEntryPath}`) diff --git a/cli/scripts/write-main-wrapper.ts b/cli/scripts/write-main-wrapper.ts deleted file mode 100644 index 38748fe6..00000000 --- a/cli/scripts/write-main-wrapper.ts +++ /dev/null @@ -1,174 +0,0 @@ -import {spawnSync} from 'node:child_process' -import {chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync} from 'node:fs' -import {tmpdir} from 'node:os' -import {dirname, join, resolve} from 'node:path' -import {fileURLToPath, pathToFileURL} from 'node:url' - -const scriptDir = dirname(fileURLToPath(import.meta.url)) -const cliDir = resolve(scriptDir, '..') -const distDir = resolve(cliDir, 'dist') -const indexEntryPath = resolve(distDir, 'index.mjs') -const mainWrapperPath = resolve(distDir, 'main.mjs') -const bundledJitiBabelRuntimeSourcePath = resolve(cliDir, 'node_modules', 'jiti', 'dist', 'babel.cjs') -const bundledJitiBabelRuntimeTargetPath = resolve(distDir, 'babel.cjs') - -const SHEBANG = '#!/usr/bin/env node' - -const MAIN_WRAPPER_CONTENT = [ - SHEBANG, - "import process from 'node:process'", - "import {runCli} from './index.mjs'", - '', - 'void runCli(process.argv).then(exitCode => process.exit(exitCode))', - '' -].join('\n') - -function ensureIndexBundleExists(): void { - if (existsSync(indexEntryPath)) { - return - } - - throw new Error(`Expected bundled CLI entry at "${indexEntryPath}" before writing main wrapper.`) -} - -function writeMainWrapper(): void { - mkdirSync(distDir, {recursive: true}) - writeFileSync(mainWrapperPath, MAIN_WRAPPER_CONTENT, 'utf8') - - if (process.platform !== 'win32') { - chmodSync(mainWrapperPath, 0o755) - } -} - -function findBundledJitiChunkPath(): string | undefined { - const bundledJitiChunkName = readdirSync(distDir) - .find(fileName => /^jiti-.*\.mjs$/u.test(fileName)) - - return bundledJitiChunkName == null ? void 0 : resolve(distDir, bundledJitiChunkName) -} - -function ensureBundledJitiRuntimeAssets(): string | undefined { - const bundledJitiChunkPath = findBundledJitiChunkPath() - if (bundledJitiChunkPath == null) return void 0 - - if (!existsSync(bundledJitiBabelRuntimeSourcePath)) { - throw new Error( - `Bundled jiti chunk "${bundledJitiChunkPath}" requires "${bundledJitiBabelRuntimeSourcePath}", but it does not exist.` - ) - } - - copyFileSync(bundledJitiBabelRuntimeSourcePath, bundledJitiBabelRuntimeTargetPath) - return bundledJitiChunkPath -} - -function assertMainWrapperSize(): void { - const wrapperStats = statSync(mainWrapperPath) - - if (wrapperStats.size <= `${SHEBANG}\n`.length) { - throw new Error(`Generated "${mainWrapperPath}" is unexpectedly small (${wrapperStats.size} bytes).`) - } -} - -function smokeTestBundledJitiTransform(bundledJitiChunkPath: string | undefined): void { - if (bundledJitiChunkPath == null) return - - const tempDir = mkdtempSync(join(tmpdir(), 'tnmsc-bundled-jiti-')) - const probeModulePath = join(tempDir, 'probe.ts') - const probeRunnerPath = join(tempDir, 'probe-runner.mjs') - - writeFileSync(probeModulePath, 'export default {ok: true}\n', 'utf8') - writeFileSync(probeRunnerPath, [ - "import process from 'node:process'", - "import {pathToFileURL} from 'node:url'", - '', - 'const [, , bundledJitiChunkPathArg, probeModulePathArg] = process.argv', - '', - 'const {createJiti} = await import(pathToFileURL(bundledJitiChunkPathArg).href)', - 'const runtime = createJiti(import.meta.url, {', - ' fsCache: false,', - ' moduleCache: false,', - ' interopDefault: false', - '})', - 'const loaded = await runtime.import(probeModulePathArg)', - '', - 'if (loaded.default?.ok !== true) {', - " throw new Error('Bundled jiti smoke test loaded an unexpected module shape.')", - '}', - '' - ].join('\n'), 'utf8') - - const smokeTest = (() => { - try { - return spawnSync(process.execPath, [probeRunnerPath, bundledJitiChunkPath, probeModulePath], { - cwd: cliDir, - encoding: 'utf8' - }) - } - finally { - rmSync(tempDir, {recursive: true, force: true}) - } - })() - - if (smokeTest.error != null) { - throw smokeTest.error - } - - const combinedOutput = `${smokeTest.stdout ?? ''}${smokeTest.stderr ?? ''}`.trim() - - if (smokeTest.status !== 0) { - throw new Error([ - `Bundled jiti chunk "${pathToFileURL(bundledJitiChunkPath).href}" failed the transform smoke test.`, - combinedOutput.length === 0 ? 'No output captured.' : combinedOutput - ].join('\n')) - } -} - -function smokeTestMainWrapper(): void { - const isolatedHomeDir = mkdtempSync(join(tmpdir(), 'tnmsc-main-wrapper-home-')) - const smokeTest = (() => { - try { - return spawnSync(process.execPath, [mainWrapperPath, '--help'], { - cwd: cliDir, - encoding: 'utf8', - env: { - ...process.env, - HOME: isolatedHomeDir, - USERPROFILE: isolatedHomeDir - } - }) - } - finally { - rmSync(isolatedHomeDir, {recursive: true, force: true}) - } - })() - - if (smokeTest.error != null) { - throw smokeTest.error - } - - const combinedOutput = `${smokeTest.stdout ?? ''}${smokeTest.stderr ?? ''}` - - if (smokeTest.status !== 0) { - throw new Error([ - `Generated "${mainWrapperPath}" failed the CLI smoke test.`, - `Exit code: ${smokeTest.status ?? 'unknown'}`, - combinedOutput.trim().length === 0 ? 'No CLI output captured.' : combinedOutput.trim() - ].join('\n')) - } - - if (!/(?:USAGE:|tnmsc v)/u.test(combinedOutput)) { - throw new Error([ - `Generated "${mainWrapperPath}" did not print CLI help output.`, - combinedOutput.trim().length === 0 ? 'No CLI output captured.' : combinedOutput.trim() - ].join('\n')) - } -} - -ensureIndexBundleExists() -writeMainWrapper() -const bundledJitiChunkPath = ensureBundledJitiRuntimeAssets() -assertMainWrapperSize() -smokeTestBundledJitiTransform(bundledJitiChunkPath) -smokeTestMainWrapper() - -console.log(`Wrote and validated CLI main wrapper at ${mainWrapperPath}`) diff --git a/cli/src/commands/CleanupUtils.test.ts b/cli/src/commands/CleanupUtils.test.ts index 46d17c49..ac0a4978 100644 --- a/cli/src/commands/CleanupUtils.test.ts +++ b/cli/src/commands/CleanupUtils.test.ts @@ -10,7 +10,7 @@ import { IDEKind, PluginKind } from '../plugins/plugin-core' -import {collectDeletionTargets} from './CleanupUtils' +import {collectDeletionTargets, performCleanup} from './CleanupUtils' function createMockLogger(): ILogger { return { @@ -585,3 +585,49 @@ describe('collectDeletionTargets', () => { } }) }) + +describe('performCleanup', () => { + it('deletes files and directories in one cleanup pass', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-perform-cleanup-')) + const outputFile = path.join(tempDir, 'project-a', 'AGENTS.md') + const outputDir = path.join(tempDir, '.codex', 'prompts') + const stalePrompt = path.join(outputDir, 'demo.md') + + fs.mkdirSync(path.dirname(outputFile), {recursive: true}) + fs.mkdirSync(outputDir, {recursive: true}) + fs.writeFileSync(outputFile, '# agent', 'utf8') + fs.writeFileSync(stalePrompt, '# prompt', 'utf8') + + try { + const ctx = createCleanContext({ + workspace: { + directory: { + pathKind: FilePathKind.Absolute, + path: tempDir, + getDirectoryName: () => path.basename(tempDir), + getAbsolutePath: () => tempDir + }, + projects: [] + } + }) + const plugin = createMockOutputPlugin('MockOutputPlugin', [outputFile], { + delete: [{kind: 'directory', path: outputDir}] + }) + + const result = await performCleanup([plugin], ctx, createMockLogger()) + + expect(result).toEqual(expect.objectContaining({ + deletedFiles: 1, + deletedDirs: 1, + errors: [], + violations: [], + conflicts: [] + })) + expect(fs.existsSync(outputFile)).toBe(false) + expect(fs.existsSync(outputDir)).toBe(false) + } + finally { + fs.rmSync(tempDir, {recursive: true, force: true}) + } + }) +}) diff --git a/cli/src/commands/CleanupUtils.ts b/cli/src/commands/CleanupUtils.ts index 755fd8c3..5b7d1413 100644 --- a/cli/src/commands/CleanupUtils.ts +++ b/cli/src/commands/CleanupUtils.ts @@ -128,6 +128,22 @@ async function collectPluginCleanupDeclarations( return plugin.declareCleanupPaths({...cleanCtx, dryRun: true}) } +async function collectPluginCleanupSnapshot( + plugin: OutputPlugin, + cleanCtx: OutputCleanContext +): Promise<{ + readonly plugin: OutputPlugin + readonly outputs: Awaited> + readonly cleanup: OutputCleanupDeclarations +}> { + const [outputs, cleanup] = await Promise.all([ + plugin.declareOutputFiles({...cleanCtx, dryRun: true}), + collectPluginCleanupDeclarations(plugin, cleanCtx) + ]) + + return {plugin, outputs, cleanup} +} + function compactDeletionTargets( filesByKey: Map, dirsByKey: Map @@ -256,10 +272,7 @@ export async function collectDeletionTargets( const excludeScanGlobSet = new Set(DEFAULT_CLEANUP_SCAN_EXCLUDE_GLOBS) const outputPathOwners = new Map() - const pluginSnapshots: { - readonly plugin: OutputPlugin - readonly cleanup: OutputCleanupDeclarations - }[] = [] + const pluginSnapshots = await Promise.all(outputPlugins.map(async plugin => collectPluginCleanupSnapshot(plugin, cleanCtx))) const addDeletePath = (rawPath: string, kind: 'file' | 'directory'): void => { if (kind === 'directory') deleteDirs.add(resolveAbsolutePath(rawPath)) @@ -307,19 +320,15 @@ export async function collectDeletionTargets( ) } - for (const plugin of outputPlugins) { - const declarations = await plugin.declareOutputFiles({...cleanCtx, dryRun: true}) - for (const declaration of declarations) { + for (const snapshot of pluginSnapshots) { + for (const declaration of snapshot.outputs) { const resolvedOutputPath = resolveAbsolutePath(declaration.path) addDeletePath(resolvedOutputPath, 'file') const existingOwners = outputPathOwners.get(resolvedOutputPath) - if (existingOwners == null) outputPathOwners.set(resolvedOutputPath, [plugin.name]) - else if (!existingOwners.includes(plugin.name)) existingOwners.push(plugin.name) + if (existingOwners == null) outputPathOwners.set(resolvedOutputPath, [snapshot.plugin.name]) + else if (!existingOwners.includes(snapshot.plugin.name)) existingOwners.push(snapshot.plugin.name) } - - const cleanupDeclarations = await collectPluginCleanupDeclarations(plugin, cleanCtx) - for (const ignoreGlob of cleanupDeclarations.excludeScanGlobs ?? []) excludeScanGlobSet.add(normalizeGlobPattern(ignoreGlob)) - pluginSnapshots.push({plugin, cleanup: cleanupDeclarations}) + for (const ignoreGlob of snapshot.cleanup.excludeScanGlobs ?? []) excludeScanGlobSet.add(normalizeGlobPattern(ignoreGlob)) } const excludeScanGlobs = [...excludeScanGlobSet] @@ -404,9 +413,9 @@ export async function collectDeletionTargets( * Logs warnings for failed deletions and continues with remaining files. * Uses deletePathSync from @truenine/desk-paths for cross-platform safe deletion. */ -export function deleteFiles(files: string[], logger: ILogger): {deleted: number, errors: CleanupError[]} { +export async function deleteFiles(files: string[], logger: ILogger): Promise<{deleted: number, errors: CleanupError[]}> { const resolved = files.map(f => path.isAbsolute(f) ? f : path.resolve(f)) - const result = deskDeleteFiles(resolved) + const result = await deskDeleteFiles(resolved) for (const f of resolved) { if (!result.errors.some(e => e.path === f)) logger.debug({action: 'delete', type: 'file', path: f}) @@ -435,9 +444,9 @@ export function deleteFiles(files: string[], logger: ILogger): {deleted: number, * Sorts by length descending to handle nested dirs properly. * Logs warnings for failed deletions and continues with remaining directories. */ -export function deleteDirectories(dirs: string[], logger: ILogger): {deleted: number, errors: CleanupError[]} { +export async function deleteDirectories(dirs: string[], logger: ILogger): Promise<{deleted: number, errors: CleanupError[]}> { const resolved = dirs.map(d => path.isAbsolute(d) ? d : path.resolve(d)) - const result = deskDeleteDirectories(resolved) + const result = await deskDeleteDirectories(resolved) for (const d of resolved) { if (!result.errors.some(e => e.path === d)) logger.debug({action: 'delete', type: 'directory', path: d}) @@ -531,8 +540,10 @@ export async function performCleanup( } } - const fileResult = deleteFiles(cleanupTargets.filesToDelete, logger) - const dirResult = deleteDirectories(cleanupTargets.dirsToDelete, logger) + const [fileResult, dirResult] = await Promise.all([ + deleteFiles(cleanupTargets.filesToDelete, logger), + deleteDirectories(cleanupTargets.dirsToDelete, logger) + ]) return { deletedFiles: fileResult.deleted, diff --git a/cli/src/index.ts b/cli/src/index.ts index 4b8dd474..dffa366e 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,3 +1,10 @@ +#!/usr/bin/env node + +import {existsSync, realpathSync} from 'node:fs' +import process from 'node:process' +import {fileURLToPath} from 'node:url' +import {runCli} from './cli-runtime' + export * from './Aindex' export * from './cli-runtime' export * from './config' @@ -12,3 +19,17 @@ export { } from './plugins/plugin-core' export * from './prompts' + +function isCliEntrypoint(argv: readonly string[] = process.argv): boolean { + const entryPath = argv[1] + if (entryPath == null || entryPath.length === 0 || !existsSync(entryPath)) return false + + try { + return realpathSync(entryPath) === realpathSync(fileURLToPath(import.meta.url)) + } + catch { + return false + } +} + +if (isCliEntrypoint()) void runCli(process.argv).then(exitCode => process.exit(exitCode)) diff --git a/cli/src/main.ts b/cli/src/main.ts deleted file mode 100644 index 3363c275..00000000 --- a/cli/src/main.ts +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env node - -import process from 'node:process' -import {runCli} from './cli-runtime' - -void runCli(process.argv).then(exitCode => process.exit(exitCode)) diff --git a/cli/src/plugins/CodexCLIOutputPlugin.test.ts b/cli/src/plugins/CodexCLIOutputPlugin.test.ts index 7c4e3210..5d3758a5 100644 --- a/cli/src/plugins/CodexCLIOutputPlugin.test.ts +++ b/cli/src/plugins/CodexCLIOutputPlugin.test.ts @@ -1,4 +1,4 @@ -import type {CommandPrompt, InputCapabilityContext, OutputWriteContext} from './plugin-core' +import type {CommandPrompt, InputCapabilityContext, OutputWriteContext, SubAgentPrompt} from './plugin-core' import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' @@ -19,6 +19,22 @@ class TestCodexCLIOutputPlugin extends CodexCLIOutputPlugin { } } +async function withTempCodexDirs( + prefix: string, + run: (paths: {readonly workspace: string, readonly homeDir: string}) => Promise +): Promise { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-workspace-`)) + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-home-`)) + + try { + await run({workspace, homeDir}) + } + finally { + fs.rmSync(workspace, {recursive: true, force: true}) + fs.rmSync(homeDir, {recursive: true, force: true}) + } +} + function createInputContext(tempWorkspace: string): InputCapabilityContext { return { logger: createLogger('CodexCLIOutputPluginTest', 'error'), @@ -33,6 +49,7 @@ function createInputContext(tempWorkspace: string): InputCapabilityContext { function createWriteContext( tempWorkspace: string, commands: readonly CommandPrompt[], + subAgents: readonly SubAgentPrompt[] = [], pluginOptions?: OutputWriteContext['pluginOptions'] ): OutputWriteContext { return { @@ -59,9 +76,19 @@ function createWriteContext( getAbsolutePath: () => path.join(tempWorkspace, 'project-a') }, isPromptSourceProject: true + }, { + name: 'project-b', + dirFromWorkspacePath: { + pathKind: FilePathKind.Relative, + path: 'project-b', + basePath: tempWorkspace, + getDirectoryName: () => 'project-b', + getAbsolutePath: () => path.join(tempWorkspace, 'project-b') + } }] }, - commands + commands, + subAgents } } as OutputWriteContext } @@ -89,14 +116,47 @@ function createProjectCommandPrompt(): CommandPrompt { } as CommandPrompt } +function createSubAgentPrompt(scope: 'project' | 'global'): SubAgentPrompt { + return { + type: PromptKind.SubAgent, + content: 'Review changes carefully.\nFocus on concrete regressions.', + length: 55, + filePathKind: FilePathKind.Relative, + dir: { + pathKind: FilePathKind.Relative, + path: 'subagents/qa/reviewer.mdx', + basePath: path.resolve('tmp/dist/subagents'), + getDirectoryName: () => 'qa', + getAbsolutePath: () => path.resolve('tmp/dist/subagents/qa/reviewer.mdx') + }, + agentPrefix: 'qa', + agentName: 'reviewer', + yamlFrontMatter: { + name: 'review-helper', + description: 'Review pull requests', + scope, + model: 'gpt-5.2', + allowTools: ['shell'], + color: 'blue', + nickname_candidates: ['guard'], + sandbox_mode: 'workspace-write', + mcp_servers: { + docs: { + command: 'node', + args: ['mcp.js'] + } + } + } as unknown as SubAgentPrompt['yamlFrontMatter'], + markdownContents: [] + } as SubAgentPrompt +} + describe('codexCLIOutputPlugin command output', () => { it('renders codex commands from dist content instead of the zh source prompt', async () => { - const tempWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-codex-command-')) - const tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-codex-home-')) - const srcDir = path.join(tempWorkspace, 'aindex', 'commands', 'find') - const distDir = path.join(tempWorkspace, 'aindex', 'dist', 'commands', 'find') + await withTempCodexDirs('tnmsc-codex-command', async ({workspace, homeDir}) => { + const srcDir = path.join(workspace, 'aindex', 'commands', 'find') + const distDir = path.join(workspace, 'aindex', 'dist', 'commands', 'find') - try { fs.mkdirSync(srcDir, {recursive: true}) fs.mkdirSync(distDir, {recursive: true}) @@ -118,13 +178,13 @@ describe('codexCLIOutputPlugin command output', () => { ].join('\n'), 'utf8') const commandInputCapability = new CommandInputCapability() - const collected = await commandInputCapability.collect(createInputContext(tempWorkspace)) + const collected = await commandInputCapability.collect(createInputContext(workspace)) const commands = collected.commands ?? [] expect(commands).toHaveLength(1) - const codexPlugin = new TestCodexCLIOutputPlugin(tempHomeDir) - const writeCtx = createWriteContext(tempWorkspace, commands) + const codexPlugin = new TestCodexCLIOutputPlugin(homeDir) + const writeCtx = createWriteContext(workspace, commands) const declarations = await codexPlugin.declareOutputFiles(writeCtx) const commandDeclaration = declarations.find( declaration => declaration.path.replaceAll('\\', '/').endsWith('/.codex/prompts/find-opensource.md') @@ -137,34 +197,75 @@ describe('codexCLIOutputPlugin command output', () => { expect(String(rendered)).toContain('English dist command body') expect(String(rendered)).not.toContain('中文源描述') expect(String(rendered)).not.toContain('中文源命令内容') - } - finally { - fs.rmSync(tempWorkspace, {recursive: true, force: true}) - fs.rmSync(tempHomeDir, {recursive: true, force: true}) - } + }) }) it('keeps project-scoped commands in the global codex directory and never mirrors them into workspace root', async () => { - const tempWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-codex-project-command-')) - const tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-codex-project-home-')) - - try { - const plugin = new TestCodexCLIOutputPlugin(tempHomeDir) - const writeCtx = createWriteContext(tempWorkspace, [createProjectCommandPrompt()]) + await withTempCodexDirs('tnmsc-codex-project-command', async ({workspace, homeDir}) => { + const plugin = new TestCodexCLIOutputPlugin(homeDir) + const writeCtx = createWriteContext(workspace, [createProjectCommandPrompt()]) const declarations = await plugin.declareOutputFiles(writeCtx) expect(declarations.map(declaration => declaration.path)).toContain( - path.join(tempHomeDir, '.codex', 'prompts', 'dev-build.md') + path.join(homeDir, '.codex', 'prompts', 'dev-build.md') ) expect(declarations.map(declaration => declaration.path)).not.toContain( - path.join(tempWorkspace, '.codex', 'prompts', 'dev-build.md') + path.join(workspace, '.codex', 'prompts', 'dev-build.md') ) expect(declarations.every(declaration => declaration.scope === 'global')).toBe(true) - } - finally { - fs.rmSync(tempWorkspace, {recursive: true, force: true}) - fs.rmSync(tempHomeDir, {recursive: true, force: true}) - } + }) + }) + + it('writes project-scoped subagents into each project .codex/agents directory as toml', async () => { + await withTempCodexDirs('tnmsc-codex-project-subagent', async ({workspace, homeDir}) => { + const plugin = new TestCodexCLIOutputPlugin(homeDir) + const writeCtx = createWriteContext(workspace, [], [createSubAgentPrompt('project')]) + + const declarations = await plugin.declareOutputFiles(writeCtx) + const paths = declarations.map(declaration => declaration.path) + + expect(paths).toContain(path.join(workspace, 'project-a', '.codex', 'agents', 'review-helper.toml')) + expect(paths).toContain(path.join(workspace, 'project-b', '.codex', 'agents', 'review-helper.toml')) + expect(paths).not.toContain(path.join(homeDir, '.codex', 'agents', 'review-helper.toml')) + + const declaration = declarations.find(item => item.path === path.join(workspace, 'project-a', '.codex', 'agents', 'review-helper.toml')) + expect(declaration).toBeDefined() + + const rendered = await plugin.convertContent(declaration!, writeCtx) + expect(String(rendered)).toContain('name = "review-helper"') + expect(String(rendered)).toContain('description = "Review pull requests"') + expect(String(rendered)).toContain([ + 'developer_instructions = """', + 'Review changes carefully.', + 'Focus on concrete regressions."""' + ].join('\n')) + expect(String(rendered)).toContain('model = "gpt-5.2"') + expect(String(rendered)).toContain('nickname_candidates = ["guard"]') + expect(String(rendered)).toContain('sandbox_mode = "workspace-write"') + expect(String(rendered)).toContain('allowedTools = "shell"') + expect(String(rendered)).toContain('[mcp_servers]') + expect(String(rendered)).toContain('[mcp_servers.docs]') + expect(String(rendered)).not.toContain('scope = ') + expect(String(rendered)).not.toContain('allowTools') + expect(String(rendered)).not.toContain('color = ') + }) + }) + + it('remaps global-scoped subagents to project outputs instead of writing to the global codex directory', async () => { + await withTempCodexDirs('tnmsc-codex-global-subagent', async ({workspace, homeDir}) => { + const plugin = new TestCodexCLIOutputPlugin(homeDir) + const writeCtx = createWriteContext(workspace, [], [createSubAgentPrompt('global')]) + + const declarations = await plugin.declareOutputFiles(writeCtx) + + expect(declarations.map(declaration => declaration.path)).toContain( + path.join(workspace, 'project-a', '.codex', 'agents', 'review-helper.toml') + ) + expect(declarations.map(declaration => declaration.path)).not.toContain( + path.join(homeDir, '.codex', 'agents', 'review-helper.toml') + ) + expect(declarations.every(declaration => declaration.scope === 'project')).toBe(true) + }) }) }) diff --git a/cli/src/plugins/CodexCLIOutputPlugin.ts b/cli/src/plugins/CodexCLIOutputPlugin.ts index dbfc881d..9be606d7 100644 --- a/cli/src/plugins/CodexCLIOutputPlugin.ts +++ b/cli/src/plugins/CodexCLIOutputPlugin.ts @@ -1,109 +1,83 @@ -import type { - CommandPrompt, - OutputFileDeclaration, - OutputWriteContext -} from './plugin-core' -import * as path from 'node:path' -import {AbstractOutputPlugin, filterByProjectConfig, PLUGIN_NAMES} from './plugin-core' +import type {AbstractOutputPluginOptions} from './plugin-core' +import {AbstractOutputPlugin, PLUGIN_NAMES} from './plugin-core' const PROJECT_MEMORY_FILE = 'AGENTS.md' const GLOBAL_CONFIG_DIR = '.codex' const PROMPTS_SUBDIR = 'prompts' +const AGENTS_SUBDIR = 'agents' +const CODEX_SUBAGENT_FIELD_ORDER = ['name', 'description', 'developer_instructions'] as const +const CODEX_EXCLUDED_SUBAGENT_FIELDS = ['scope', 'seriName', 'argumentHint', 'color', 'namingCase'] as const -type CodexOutputSource - = {readonly kind: 'globalMemory', readonly content: string} - | {readonly kind: 'command', readonly command: CommandPrompt} +function transformCodexSubAgentFrontMatter( + sourceFrontMatter?: Record +): Record { + const frontMatter = {...sourceFrontMatter} -export class CodexCLIOutputPlugin extends AbstractOutputPlugin { - constructor() { - super('CodexCLIOutputPlugin', { - globalConfigDir: GLOBAL_CONFIG_DIR, - outputFileName: PROJECT_MEMORY_FILE, - commands: { - subDir: PROMPTS_SUBDIR, - transformFrontMatter: (_cmd, context) => context.sourceFrontMatter ?? {} - }, - cleanup: { - delete: { - global: { - files: ['.codex/AGENTS.md'], - dirs: ['.codex/prompts'] - } - }, - protect: { - global: { - dirs: ['.codex/skills/.system'] - } - } - }, - dependsOn: [PLUGIN_NAMES.AgentsOutput], - capabilities: { - prompt: { - scopes: ['global'], - singleScope: false - }, - commands: { - scopes: ['project', 'global'], - singleScope: true - } - } - }) - } + if (Array.isArray(frontMatter['allowTools']) && frontMatter['allowTools'].length > 0) frontMatter['allowedTools'] = frontMatter['allowTools'].join(', ') - override async declareOutputFiles(ctx: OutputWriteContext): Promise { - const {globalMemory, commands} = ctx.collectedOutputContext - const globalDir = this.getGlobalConfigDir() - const declarations: OutputFileDeclaration[] = [] - const activePromptScopes = new Set(this.selectPromptScopes(ctx, ['global'])) + delete frontMatter['allowTools'] + return frontMatter +} - if (globalMemory != null && activePromptScopes.has('global')) { - declarations.push({ - path: path.join(globalDir, PROJECT_MEMORY_FILE), - scope: 'global', - source: { - kind: 'globalMemory', - content: globalMemory.content as string - } satisfies CodexOutputSource - }) +const CODEX_OUTPUT_OPTIONS = { + globalConfigDir: GLOBAL_CONFIG_DIR, + outputFileName: PROJECT_MEMORY_FILE, + commands: { + subDir: PROMPTS_SUBDIR, + scopeRemap: { + project: 'global' + }, + transformFrontMatter: (_cmd, context) => context.sourceFrontMatter ?? {} + }, + subagents: { + subDir: AGENTS_SUBDIR, + sourceScopes: ['project'], + scopeRemap: { + global: 'project' + }, + ext: '.toml', + artifactFormat: 'toml', + bodyFieldName: 'developer_instructions', + fileNameSource: 'frontMatterName', + excludedFrontMatterFields: CODEX_EXCLUDED_SUBAGENT_FIELDS, + transformFrontMatter: (_subAgent, context) => transformCodexSubAgentFrontMatter(context.sourceFrontMatter), + fieldOrder: CODEX_SUBAGENT_FIELD_ORDER + }, + cleanup: { + delete: { + project: { + dirs: ['.codex/agents'] + }, + global: { + files: ['.codex/AGENTS.md'], + dirs: ['.codex/prompts'] + } + }, + protect: { + global: { + dirs: ['.codex/skills/.system'] + } } - - if (commands == null || commands.length === 0) return declarations - - const projectConfig = this.resolvePromptSourceProjectConfig(ctx) - const transformOptions = this.getTransformOptionsFromContext(ctx) - const scopedCommands = this.selectSingleScopeItems( - commands, - this.commandsConfig.sourceScopes, - cmd => this.resolveCommandSourceScope(cmd), - this.getTopicScopeOverride(ctx, 'commands') - ) - if (scopedCommands.items.length === 0) return declarations - - const filteredCommands = filterByProjectConfig(scopedCommands.items, projectConfig, 'commands') - for (const cmd of filteredCommands) { - const fileName = this.transformCommandName(cmd, transformOptions) - declarations.push({ - path: path.join(globalDir, PROMPTS_SUBDIR, fileName), - scope: 'global', - source: { - kind: 'command', - command: cmd - } satisfies CodexOutputSource - }) + }, + dependsOn: [PLUGIN_NAMES.AgentsOutput], + capabilities: { + prompt: { + scopes: ['global'], + singleScope: false + }, + commands: { + scopes: ['global'], + singleScope: true + }, + subagents: { + scopes: ['project'], + singleScope: true } - - return declarations } +} satisfies AbstractOutputPluginOptions - override async convertContent( - declaration: OutputFileDeclaration, - ctx: OutputWriteContext - ): Promise { - const source = declaration.source as CodexOutputSource - - if (source.kind === 'globalMemory') return source.content - if (source.kind === 'command') return this.buildCommandContent(source.command, ctx) - - throw new Error(`Unsupported declaration source for ${this.name}`) +export class CodexCLIOutputPlugin extends AbstractOutputPlugin { + constructor() { + super('CodexCLIOutputPlugin', CODEX_OUTPUT_OPTIONS) } } diff --git a/cli/src/plugins/desk-paths.ts b/cli/src/plugins/desk-paths.ts index 844c013c..e8dc920a 100644 --- a/cli/src/plugins/desk-paths.ts +++ b/cli/src/plugins/desk-paths.ts @@ -231,26 +231,55 @@ export interface DeletionResult { readonly errors: readonly DeletionError[] } +async function deletePath(p: string): Promise { + try { + const stat = await fs.promises.lstat(p) + if (stat.isSymbolicLink()) { + await (process.platform === 'win32' ? fs.promises.rm(p, {recursive: true, force: true}) : fs.promises.unlink(p)) + return true + } + + if (stat.isDirectory()) { + await fs.promises.rm(p, {recursive: true, force: true}) + return true + } + + await fs.promises.unlink(p) + return true + } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false + throw error + } +} + /** * Delete multiple files. Skips non-existent files. Collects errors without throwing. * * @param files - Array of absolute file paths to delete * @returns DeletionResult with count and errors */ -export function deleteFiles(files: readonly string[]): DeletionResult { - let deleted = 0 - const errors: DeletionError[] = [] - - for (const file of files) { +export async function deleteFiles(files: readonly string[]): Promise { + const results = await Promise.all(files.map(async file => { try { - if (fs.existsSync(file)) { - deletePathSync(file) - deleted++ - } + const deleted = await deletePath(file) + return {path: file, deleted} + } + catch (error) { + return {path: file, error} } - catch (e) { - errors.push({path: file, error: e}) + })) + + const errors: DeletionError[] = [] + let deleted = 0 + + for (const result of results) { + if ('error' in result) { + errors.push({path: result.path, error: result.error}) + continue } + + if (result.deleted) deleted++ } return {deleted, errors} @@ -263,22 +292,28 @@ export function deleteFiles(files: readonly string[]): DeletionResult { * @param dirs - Array of absolute directory paths to delete * @returns DeletionResult with count and errors */ -export function deleteDirectories(dirs: readonly string[]): DeletionResult { - let deleted = 0 - const errors: DeletionError[] = [] - +export async function deleteDirectories(dirs: readonly string[]): Promise { const sorted = [...dirs].sort((a, b) => b.length - a.length) - - for (const dir of sorted) { + const results = await Promise.all(sorted.map(async dir => { try { - if (fs.existsSync(dir)) { - fs.rmSync(dir, {recursive: true, force: true}) - deleted++ - } + const deleted = await deletePath(dir) + return {path: dir, deleted} } - catch (e) { - errors.push({path: dir, error: e}) + catch (error) { + return {path: dir, error} + } + })) + + const errors: DeletionError[] = [] + let deleted = 0 + + for (const result of results) { + if ('error' in result) { + errors.push({path: result.path, error: result.error}) + continue } + + if (result.deleted) deleted++ } return {deleted, errors} diff --git a/cli/src/plugins/plugin-core.ts b/cli/src/plugins/plugin-core.ts index 1929fe04..43d066b4 100644 --- a/cli/src/plugins/plugin-core.ts +++ b/cli/src/plugins/plugin-core.ts @@ -17,6 +17,8 @@ export type { RuleOutputConfig, SkillFrontMatterOptions, SkillsOutputConfig, + SubAgentArtifactFormat, + SubAgentFileNameSource, SubAgentNameTransformOptions, SubAgentsOutputConfig } from './plugin-core/AbstractOutputPlugin' diff --git a/cli/src/plugins/plugin-core/AbstractOutputPlugin.ts b/cli/src/plugins/plugin-core/AbstractOutputPlugin.ts index 67015a15..91b02fe4 100644 --- a/cli/src/plugins/plugin-core/AbstractOutputPlugin.ts +++ b/cli/src/plugins/plugin-core/AbstractOutputPlugin.ts @@ -1,3 +1,4 @@ +import type {BuildPromptTomlArtifactOptions} from '@truenine/md-compiler' import type {RegistryWriter} from './RegistryWriter' import type {CommandPrompt, CommandSeriesPluginOverride, ILogger, OutputCleanContext, OutputCleanupDeclarations, OutputCleanupPathDeclaration, OutputCleanupScope, OutputDeclarationScope, OutputFileDeclaration, OutputPlugin, OutputPluginCapabilities, OutputPluginContext, OutputScopeSelection, OutputScopeTopic, OutputTopicCapability, OutputWriteContext, Path, Project, ProjectConfig, RegistryData, RegistryOperationResult, RulePrompt, RuleScope, SkillPrompt, SubAgentPrompt} from './types' @@ -5,7 +6,7 @@ import {Buffer} from 'node:buffer' import * as os from 'node:os' import * as path from 'node:path' import process from 'node:process' -import {mdxToMd} from '@truenine/md-compiler' +import {buildPromptTomlArtifact, mdxToMd} from '@truenine/md-compiler' import {buildMarkdownWithFrontMatter, buildMarkdownWithRawFrontMatter} from '@truenine/md-compiler/markdown' import {AbstractPlugin} from './AbstractPlugin' import {FilePathKind, PluginKind} from './enums' @@ -20,6 +21,8 @@ import {OUTPUT_SCOPE_TOPICS} from './types' interface ScopedSourceConfig { /** Allowed source scopes for the topic */ readonly sourceScopes?: readonly OutputDeclarationScope[] + /** Optional source-scope remap before output selection */ + readonly scopeRemap?: Partial> } /** @@ -73,8 +76,13 @@ export interface CommandOutputConfig { }) => Record /** Allowed command source scopes, default ['project', 'global'] */ readonly sourceScopes?: readonly OutputDeclarationScope[] + /** Optional source-scope remap before output selection */ + readonly scopeRemap?: Partial> } +export type SubAgentArtifactFormat = 'markdown' | 'toml' +export type SubAgentFileNameSource = 'derivedPath' | 'frontMatterName' + /** * SubAgent output configuration (declarative) */ @@ -87,6 +95,20 @@ export interface SubAgentsOutputConfig extends ScopedSourceConfig { readonly linkSymbol?: string /** SubAgent file extension, default '.md' */ readonly ext?: string + /** Output artifact format, default 'markdown' */ + readonly artifactFormat?: SubAgentArtifactFormat + /** Field name that receives prompt body when artifactFormat='toml' */ + readonly bodyFieldName?: string + /** Source for output file name, default 'derivedPath' */ + readonly fileNameSource?: SubAgentFileNameSource + /** Front matter field remap before artifact emission */ + readonly fieldNameMap?: Readonly> + /** Front matter fields to exclude from artifact emission */ + readonly excludedFrontMatterFields?: readonly string[] + /** Additional fields injected into emitted artifact */ + readonly extraFields?: Readonly> + /** Preferred root-level field order for emitted artifact */ + readonly fieldOrder?: readonly string[] /** Optional frontmatter transformer */ readonly transformFrontMatter?: (subAgent: SubAgentPrompt, context: { readonly sourceFrontMatter?: Record @@ -222,6 +244,7 @@ export abstract class AbstractOutputPlugin extends AbstractPlugin implements Out readonly isRecompiled: boolean }) => Record readonly sourceScopes: readonly OutputDeclarationScope[] + readonly scopeRemap?: Partial> } protected readonly subAgentsConfig: { @@ -230,6 +253,14 @@ export abstract class AbstractOutputPlugin extends AbstractPlugin implements Out readonly includePrefix: boolean readonly linkSymbol: string readonly ext: string + readonly artifactFormat: SubAgentArtifactFormat + readonly bodyFieldName?: string + readonly fileNameSource: SubAgentFileNameSource + readonly fieldNameMap?: Readonly> + readonly excludedFrontMatterFields?: readonly string[] + readonly extraFields?: Readonly> + readonly fieldOrder?: readonly string[] + readonly scopeRemap?: Partial> readonly transformFrontMatter?: (subAgent: SubAgentPrompt, context: { readonly sourceFrontMatter?: Record }) => Record @@ -242,6 +273,7 @@ export abstract class AbstractOutputPlugin extends AbstractPlugin implements Out protected readonly skillsConfig: { readonly subDir: string readonly sourceScopes: readonly OutputDeclarationScope[] + readonly scopeRemap?: Partial> } protected readonly skillOutputEnabled: boolean @@ -266,27 +298,12 @@ export abstract class AbstractOutputPlugin extends AbstractPlugin implements Out this.treatWorkspaceRootProjectAsProject = options?.treatWorkspaceRootProjectAsProject ?? false this.indexignore = options?.indexignore - const commandFrontMatterTransformer = options?.commands?.transformFrontMatter this.commandOutputEnabled = options?.commands != null - this.commandsConfig = { - subDir: options?.commands?.subDir ?? 'commands', - sourceScopes: options?.commands?.sourceScopes ?? ['project', 'global'], - ...commandFrontMatterTransformer != null && {transformFrontMatter: commandFrontMatterTransformer} - } // Initialize command output config with defaults + this.commandsConfig = this.createCommandsConfig(options?.commands) this.subAgentOutputEnabled = options?.subagents != null - this.subAgentsConfig = { - subDir: options?.subagents?.subDir ?? 'agents', - sourceScopes: options?.subagents?.sourceScopes ?? ['project', 'global'], - includePrefix: options?.subagents?.includePrefix ?? true, - linkSymbol: options?.subagents?.linkSymbol ?? '-', - ext: options?.subagents?.ext ?? '.md', - ...options?.subagents?.transformFrontMatter != null && {transformFrontMatter: options.subagents.transformFrontMatter} - } // Initialize subAgent output config with defaults + this.subAgentsConfig = this.createSubAgentsConfig(options?.subagents) this.skillOutputEnabled = options?.skills != null - this.skillsConfig = { - subDir: options?.skills?.subDir ?? 'skills', - sourceScopes: options?.skills?.sourceScopes ?? ['project', 'global'] - } + this.skillsConfig = this.createSkillsConfig(options?.skills) this.toolPreset = options?.toolPreset this.ruleOutputEnabled = options?.rules != null @@ -302,6 +319,48 @@ export abstract class AbstractOutputPlugin extends AbstractPlugin implements Out : this.buildInferredCapabilities() } + private createCommandsConfig( + config?: CommandOutputConfig + ): AbstractOutputPlugin['commandsConfig'] { + return { + subDir: config?.subDir ?? 'commands', + sourceScopes: config?.sourceScopes ?? ['project', 'global'], + ...config?.scopeRemap != null && {scopeRemap: config.scopeRemap}, + ...config?.transformFrontMatter != null && {transformFrontMatter: config.transformFrontMatter} + } + } + + private createSubAgentsConfig( + config?: SubAgentsOutputConfig + ): AbstractOutputPlugin['subAgentsConfig'] { + return { + subDir: config?.subDir ?? 'agents', + sourceScopes: config?.sourceScopes ?? ['project', 'global'], + includePrefix: config?.includePrefix ?? true, + linkSymbol: config?.linkSymbol ?? '-', + ext: config?.ext ?? '.md', + artifactFormat: config?.artifactFormat ?? 'markdown', + fileNameSource: config?.fileNameSource ?? 'derivedPath', + ...config?.bodyFieldName != null && {bodyFieldName: config.bodyFieldName}, + ...config?.fieldNameMap != null && {fieldNameMap: config.fieldNameMap}, + ...config?.excludedFrontMatterFields != null && {excludedFrontMatterFields: config.excludedFrontMatterFields}, + ...config?.extraFields != null && {extraFields: config.extraFields}, + ...config?.fieldOrder != null && {fieldOrder: config.fieldOrder}, + ...config?.scopeRemap != null && {scopeRemap: config.scopeRemap}, + ...config?.transformFrontMatter != null && {transformFrontMatter: config.transformFrontMatter} + } + } + + private createSkillsConfig( + config?: SkillsOutputConfig + ): AbstractOutputPlugin['skillsConfig'] { + return { + subDir: config?.subDir ?? 'skills', + sourceScopes: config?.sourceScopes ?? ['project', 'global'], + ...config?.scopeRemap != null && {scopeRemap: config.scopeRemap} + } + } + private buildInferredCapabilities(): OutputPluginCapabilities { const capabilities: OutputPluginCapabilities = {} @@ -601,6 +660,10 @@ export abstract class AbstractOutputPlugin extends AbstractPlugin implements Out return content // No front matter } + protected buildTomlContent(options: BuildPromptTomlArtifactOptions): string { + return buildPromptTomlArtifact(options) + } + protected extractGlobalMemoryContent(ctx: OutputWriteContext): string | undefined { return ctx.collectedOutputContext.globalMemory?.content as string | undefined } @@ -640,16 +703,158 @@ export abstract class AbstractOutputPlugin extends AbstractPlugin implements Out subAgent: SubAgentPrompt, options?: SubAgentNameTransformOptions ): string { + const {fileNameSource} = this.subAgentsConfig const includePrefix = options?.includePrefix ?? this.subAgentsConfig.includePrefix const linkSymbol = options?.linkSymbol ?? this.subAgentsConfig.linkSymbol const ext = options?.ext ?? this.subAgentsConfig.ext const normalizedExt = ext.startsWith('.') ? ext : `.${ext}` - const hasPrefix = includePrefix && subAgent.agentPrefix != null && subAgent.agentPrefix.length > 0 + if (fileNameSource === 'frontMatterName') { + const configuredName = subAgent.yamlFrontMatter?.name + if (configuredName == null || configuredName.trim().length === 0) { + throw new Error(`Sub-agent "${subAgent.agentName}" is missing yamlFrontMatter.name required for fileNameSource="frontMatterName"`) + } + + return `${this.normalizeOutputFileStem(configuredName)}${normalizedExt}` + } + const hasPrefix = includePrefix && subAgent.agentPrefix != null && subAgent.agentPrefix.length > 0 if (!hasPrefix) return `${subAgent.agentName}${normalizedExt}` return `${subAgent.agentPrefix}${linkSymbol}${subAgent.agentName}${normalizedExt}` } + protected normalizeOutputFileStem(value: string): string { + const sanitizedCharacters = [...value.trim()].map(character => { + const codePoint = character.codePointAt(0) ?? 0 + if (codePoint <= 31 || '<>:"/\\|?*'.includes(character)) return '-' + return character + }) + let normalized = sanitizedCharacters.join('') + + while (normalized.endsWith('.') || normalized.endsWith(' ')) normalized = normalized.slice(0, -1) + + if (normalized.length === 0) throw new Error(`Cannot derive a valid output file name from "${value}"`) + + return normalized + } + + protected appendSubAgentDeclarations( + declarations: OutputFileDeclaration[], + basePath: string, + scope: OutputDeclarationScope, + scopedSubAgents: readonly SubAgentPrompt[] + ): void { + const seenPaths = new Map() + + for (const subAgent of scopedSubAgents) { + const fileName = this.transformSubAgentName(subAgent) + const targetPath = path.join(basePath, this.subAgentsConfig.subDir, fileName) + const existingAgentName = seenPaths.get(targetPath) + + if (existingAgentName != null) { + throw new Error( + `Sub-agent output collision in ${this.name}: "${subAgent.yamlFrontMatter?.name ?? subAgent.agentName}" and "${existingAgentName}" both resolve to ${targetPath}` + ) + } + + seenPaths.set(targetPath, subAgent.yamlFrontMatter?.name ?? subAgent.agentName) + declarations.push({ + path: targetPath, + scope, + source: {kind: 'subAgent', subAgent} + }) + } + } + + protected appendCommandDeclarations( + declarations: OutputFileDeclaration[], + basePath: string, + scope: OutputDeclarationScope, + commands: readonly CommandPrompt[], + transformOptions: CommandNameTransformOptions + ): void { + for (const cmd of commands) { + const fileName = this.transformCommandName(cmd, transformOptions) + declarations.push({ + path: path.join(basePath, this.commandsConfig.subDir, fileName), + scope, + source: {kind: 'command', command: cmd} + }) + } + } + + protected appendSkillDeclarations( + declarations: OutputFileDeclaration[], + basePath: string, + scope: OutputDeclarationScope, + scopedSkills: readonly SkillPrompt[] + ): void { + for (const skill of scopedSkills) { + const skillName = skill.yamlFrontMatter?.name ?? skill.dir.getDirectoryName() + const skillDir = path.join(basePath, this.skillsConfig.subDir, skillName) + + declarations.push({ + path: path.join(skillDir, 'SKILL.md'), + scope, + source: {kind: 'skillMain', skill} + }) + + if (skill.childDocs != null) { + for (const childDoc of skill.childDocs) { + declarations.push({ + path: path.join(skillDir, childDoc.dir.path.replace(/\.mdx$/, '.md')), + scope, + source: {kind: 'skillReference', content: childDoc.content as string} + }) + } + } + + if (skill.resources != null) { + for (const resource of skill.resources) { + declarations.push({ + path: path.join(skillDir, resource.relativePath), + scope, + source: {kind: 'skillResource', content: resource.content, encoding: resource.encoding} + }) + } + } + } + } + + protected appendRuleDeclarations( + declarations: OutputFileDeclaration[], + basePath: string, + scope: OutputDeclarationScope, + rules: readonly RulePrompt[] + ): void { + const rulesDir = path.join(basePath, this.rulesConfig.subDir ?? 'rules') + + for (const rule of rules) { + declarations.push({ + path: path.join(rulesDir, this.buildRuleFileName(rule)), + scope, + source: {kind: 'rule', rule} + }) + } + } + + protected buildSubAgentTomlContent( + agent: SubAgentPrompt, + frontMatter: Record | undefined + ): string { + const {bodyFieldName} = this.subAgentsConfig + if (bodyFieldName == null || bodyFieldName.length === 0) throw new Error(`subagents.bodyFieldName is required when artifactFormat="toml" for ${this.name}`) + + return this.buildTomlContent({ + content: agent.content, + bodyFieldName, + ...frontMatter != null && {frontMatter}, + ...this.subAgentsConfig.fieldNameMap != null && {fieldNameMap: this.subAgentsConfig.fieldNameMap}, + ...this.subAgentsConfig.excludedFrontMatterFields != null && {excludedKeys: this.subAgentsConfig.excludedFrontMatterFields}, + ...this.subAgentsConfig.extraFields != null && {extraFields: this.subAgentsConfig.extraFields}, + ...this.subAgentsConfig.fieldOrder != null && {fieldOrder: this.subAgentsConfig.fieldOrder} + }) + } + protected getCommandSeriesOptions(ctx: OutputWriteContext): CommandSeriesPluginOverride { const globalOptions = ctx.pluginOptions?.commandSeriesOptions const pluginOverride = globalOptions?.pluginOverrides?.[this.name] @@ -721,20 +926,27 @@ export abstract class AbstractOutputPlugin extends AbstractPlugin implements Out return 'project' } + protected remapDeclarationScope( + scope: OutputDeclarationScope, + remap?: Partial> + ): OutputDeclarationScope { + return remap?.[scope] ?? scope + } + protected resolveCommandSourceScope(cmd: CommandPrompt): OutputDeclarationScope { if (cmd.globalOnly === true) return 'global' const scope = (cmd.yamlFrontMatter as {scope?: RuleScope} | undefined)?.scope - return this.normalizeSourceScope(scope) + return this.remapDeclarationScope(this.normalizeSourceScope(scope), this.commandsConfig.scopeRemap) } protected resolveSubAgentSourceScope(subAgent: SubAgentPrompt): OutputDeclarationScope { const scope = (subAgent.yamlFrontMatter as {scope?: RuleScope} | undefined)?.scope - return this.normalizeSourceScope(scope) + return this.remapDeclarationScope(this.normalizeSourceScope(scope), this.subAgentsConfig.scopeRemap) } protected resolveSkillSourceScope(skill: SkillPrompt): OutputDeclarationScope { const scope = (skill.yamlFrontMatter as {scope?: RuleScope} | undefined)?.scope - return this.normalizeSourceScope(scope) + return this.remapDeclarationScope(this.normalizeSourceScope(scope), this.skillsConfig.scopeRemap) } protected selectSingleScopeItems( @@ -952,43 +1164,6 @@ export abstract class AbstractOutputPlugin extends AbstractPlugin implements Out rulesByScope[ruleScope].push(rule) } - const pushSkillDeclarations = ( - basePath: string, - scope: OutputDeclarationScope, - scopedSkills: readonly SkillPrompt[] - ): void => { - for (const skill of scopedSkills) { - const skillName = skill.yamlFrontMatter?.name ?? skill.dir.getDirectoryName() - const skillDir = path.join(basePath, this.skillsConfig.subDir, skillName) - - declarations.push({ - path: path.join(skillDir, 'SKILL.md'), - scope, - source: {kind: 'skillMain', skill} - }) - - if (skill.childDocs != null) { - for (const childDoc of skill.childDocs) { - declarations.push({ - path: path.join(skillDir, childDoc.dir.path.replace(/\.mdx$/, '.md')), - scope, - source: {kind: 'skillReference', content: childDoc.content as string} - }) - } - } - - if (skill.resources != null) { - for (const resource of skill.resources) { - declarations.push({ - path: path.join(skillDir, resource.relativePath), - scope, - source: {kind: 'skillResource', content: resource.content, encoding: resource.encoding} - }) - } - } - } - } - for (const project of this.getProjectOutputProjects(ctx)) { const projectRootDir = this.resolveProjectRootDir(ctx, project) const basePath = this.resolveProjectConfigDir(ctx, project) @@ -1022,31 +1197,17 @@ export abstract class AbstractOutputPlugin extends AbstractPlugin implements Out if (selectedCommands.selectedScope === 'project' && selectedCommands.items.length > 0) { const filteredCommands = filterByProjectConfig(selectedCommands.items, projectConfig, 'commands') - for (const cmd of filteredCommands) { - const fileName = this.transformCommandName(cmd, transformOptions) - declarations.push({ - path: path.join(basePath, this.commandsConfig.subDir, fileName), - scope: 'project', - source: {kind: 'command', command: cmd} - }) - } + this.appendCommandDeclarations(declarations, basePath, 'project', filteredCommands, transformOptions) } if (selectedSubAgents.selectedScope === 'project' && selectedSubAgents.items.length > 0) { const filteredSubAgents = filterByProjectConfig(selectedSubAgents.items, projectConfig, 'subAgents') - for (const subAgent of filteredSubAgents) { - const fileName = this.transformSubAgentName(subAgent) - declarations.push({ - path: path.join(basePath, this.subAgentsConfig.subDir, fileName), - scope: 'project', - source: {kind: 'subAgent', subAgent} - }) - } + this.appendSubAgentDeclarations(declarations, basePath, 'project', filteredSubAgents) } if (selectedSkills.selectedScope === 'project' && selectedSkills.items.length > 0) { const filteredSkills = filterByProjectConfig(selectedSkills.items, projectConfig, 'skills') - pushSkillDeclarations(basePath, 'project', filteredSkills) + this.appendSkillDeclarations(declarations, basePath, 'project', filteredSkills) } if (activeRuleScopes.has('project')) { @@ -1054,14 +1215,7 @@ export abstract class AbstractOutputPlugin extends AbstractPlugin implements Out filterByProjectConfig(rulesByScope.project, projectConfig, 'rules'), projectConfig ) - const rulesDir = path.join(basePath, this.rulesConfig.subDir ?? 'rules') - for (const rule of projectRules) { - declarations.push({ - path: path.join(rulesDir, this.buildRuleFileName(rule)), - scope: 'project', - source: {kind: 'rule', rule} - }) - } + this.appendRuleDeclarations(declarations, basePath, 'project', projectRules) } if ( @@ -1084,33 +1238,19 @@ export abstract class AbstractOutputPlugin extends AbstractPlugin implements Out if (selectedCommands.selectedScope === 'global' && selectedCommands.items.length > 0) { const filteredCommands = filterByProjectConfig(selectedCommands.items, promptSourceProjectConfig, 'commands') const basePath = this.getGlobalConfigDir() - for (const cmd of filteredCommands) { - const fileName = this.transformCommandName(cmd, transformOptions) - declarations.push({ - path: path.join(basePath, this.commandsConfig.subDir, fileName), - scope: 'global', - source: {kind: 'command', command: cmd} - }) - } + this.appendCommandDeclarations(declarations, basePath, 'global', filteredCommands, transformOptions) } if (selectedSubAgents.selectedScope === 'global' && selectedSubAgents.items.length > 0) { const filteredSubAgents = filterByProjectConfig(selectedSubAgents.items, promptSourceProjectConfig, 'subAgents') const basePath = this.getGlobalConfigDir() - for (const subAgent of filteredSubAgents) { - const fileName = this.transformSubAgentName(subAgent) - declarations.push({ - path: path.join(basePath, this.subAgentsConfig.subDir, fileName), - scope: 'global', - source: {kind: 'subAgent', subAgent} - }) - } + this.appendSubAgentDeclarations(declarations, basePath, 'global', filteredSubAgents) } if (selectedSkills.selectedScope === 'global' && selectedSkills.items.length > 0) { const filteredSkills = filterByProjectConfig(selectedSkills.items, promptSourceProjectConfig, 'skills') const basePath = this.getGlobalConfigDir() - pushSkillDeclarations(basePath, 'global', filteredSkills) + this.appendSkillDeclarations(declarations, basePath, 'global', filteredSkills) } for (const ruleScope of ['global'] as const) { @@ -1120,14 +1260,7 @@ export abstract class AbstractOutputPlugin extends AbstractPlugin implements Out filterByProjectConfig(rulesByScope[ruleScope], promptSourceProjectConfig, 'rules'), promptSourceProjectConfig ) - const rulesDir = path.join(basePath, this.rulesConfig.subDir ?? 'rules') - for (const rule of filteredRules) { - declarations.push({ - path: path.join(rulesDir, this.buildRuleFileName(rule)), - scope: ruleScope, - source: {kind: 'rule', rule} - }) - } + this.appendRuleDeclarations(declarations, basePath, ruleScope, filteredRules) } if ( @@ -1183,13 +1316,17 @@ export abstract class AbstractOutputPlugin extends AbstractPlugin implements Out protected buildSubAgentContent(agent: SubAgentPrompt, ctx?: OutputPluginContext): string { const subAgentFrontMatterTransformer = this.subAgentsConfig.transformFrontMatter - if (subAgentFrontMatterTransformer != null) { - const transformedFrontMatter = subAgentFrontMatterTransformer(agent, { - ...agent.yamlFrontMatter != null && {sourceFrontMatter: agent.yamlFrontMatter as Record} - }) - return this.buildMarkdownContent(agent.content, transformedFrontMatter, ctx) + const transformedFrontMatter = subAgentFrontMatterTransformer?.(agent, { + ...agent.yamlFrontMatter != null && {sourceFrontMatter: agent.yamlFrontMatter as Record} + }) + + if (this.subAgentsConfig.artifactFormat === 'toml') { + const sourceFrontMatter = transformedFrontMatter ?? agent.yamlFrontMatter + return this.buildSubAgentTomlContent(agent, sourceFrontMatter) } + if (transformedFrontMatter != null) return this.buildMarkdownContent(agent.content, transformedFrontMatter, ctx) + return this.buildMarkdownContentWithRaw( agent.content, agent.yamlFrontMatter, diff --git a/cli/src/plugins/plugin-core/plugin.ts b/cli/src/plugins/plugin-core/plugin.ts index 7287a923..0e92c8e0 100644 --- a/cli/src/plugins/plugin-core/plugin.ts +++ b/cli/src/plugins/plugin-core/plugin.ts @@ -473,8 +473,11 @@ export async function collectAllPluginOutputs( validateOutputScopeOverridesForPlugins(plugins, ctx.pluginOptions) - for (const plugin of plugins) { - const declarations = await plugin.declareOutputFiles({...ctx, dryRun: true}) + const declarationGroups = await Promise.all( + plugins.map(async plugin => plugin.declareOutputFiles({...ctx, dryRun: true})) + ) + + for (const declarations of declarationGroups) { for (const declaration of declarations) { if (declaration.scope === 'global') globalFiles.push(declaration.path) else projectFiles.push(declaration.path) diff --git a/cli/tsdown.config.ts b/cli/tsdown.config.ts index 0d880a22..5f24aab9 100644 --- a/cli/tsdown.config.ts +++ b/cli/tsdown.config.ts @@ -70,7 +70,7 @@ export default defineConfig([ ...pluginAliases }, noExternal: noExternalDeps, - format: ['esm', 'cjs'], + format: ['esm'], minify: true, dts: {sourcemap: false}, outputOptions: {exports: 'named'}, @@ -128,7 +128,7 @@ export default defineConfig([ '@': resolve('src'), ...pluginAliases }, - format: ['esm', 'cjs'], + format: ['esm'], minify: false, dts: {sourcemap: false} } diff --git a/cli/vite.config.ts b/cli/vite.config.ts index 93f0d4fa..2f0ccae7 100644 --- a/cli/vite.config.ts +++ b/cli/vite.config.ts @@ -5,6 +5,12 @@ import {defineConfig} from 'vite' const pkg = JSON.parse(readFileSync('./package.json', 'utf8')) as {version: string, name: string} const kiroGlobalPowersRegistry = '{"version":"1.0.0","powers":{},"repoSources":{}}' +const workspacePackageAliases: Record = { + '@truenine/md-compiler/errors': resolve('../libraries/md-compiler/dist/errors/index.mjs'), + '@truenine/md-compiler/globals': resolve('../libraries/md-compiler/dist/globals/index.mjs'), + '@truenine/md-compiler/markdown': resolve('../libraries/md-compiler/dist/markdown/index.mjs'), + '@truenine/md-compiler': resolve('../libraries/md-compiler/dist/index.mjs') +} const pluginAliases: Record = { '@truenine/desk-paths': resolve('src/plugins/desk-paths.ts'), @@ -56,6 +62,7 @@ export default defineConfig({ resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)), + ...workspacePackageAliases, ...pluginAliases } }, diff --git a/doc/package.json b/doc/package.json index 1f6d69ae..15b0668c 100644 --- a/doc/package.json +++ b/doc/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-docs", - "version": "2026.10318.12034", + "version": "2026.10319.10359", "private": true, "description": "Documentation site for @truenine/memory-sync, built with Next.js 16 and MDX.", "engines": { diff --git a/gui/package.json b/gui/package.json index a09f0b71..75149082 100644 --- a/gui/package.json +++ b/gui/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-gui", - "version": "2026.10318.12034", + "version": "2026.10319.10359", "private": true, "engines": { "node": ">=25.2.1", diff --git a/gui/src-tauri/Cargo.toml b/gui/src-tauri/Cargo.toml index 24d92282..3aa32bb1 100644 --- a/gui/src-tauri/Cargo.toml +++ b/gui/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "memory-sync-gui" -version = "2026.10318.12034" +version = "2026.10319.10359" description = "Memory Sync desktop GUI application" authors.workspace = true edition.workspace = true diff --git a/gui/src-tauri/tauri.conf.json b/gui/src-tauri/tauri.conf.json index 91198f07..a76e2b7c 100644 --- a/gui/src-tauri/tauri.conf.json +++ b/gui/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "$schema": "https://schema.tauri.app/config/2", - "version": "2026.10318.12034", + "version": "2026.10319.10359", "productName": "Memory Sync", "identifier": "org.truenine.memory-sync", "build": { diff --git a/libraries/logger/package.json b/libraries/logger/package.json index 018af915..deba242a 100644 --- a/libraries/logger/package.json +++ b/libraries/logger/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/logger", "type": "module", - "version": "2026.10318.12034", + "version": "2026.10319.10359", "private": true, "description": "Rust-powered structured logger for Node.js via N-API", "license": "AGPL-3.0-only", diff --git a/libraries/logger/src/index.ts b/libraries/logger/src/index.ts index fe400042..fee874c1 100644 --- a/libraries/logger/src/index.ts +++ b/libraries/logger/src/index.ts @@ -111,8 +111,7 @@ function loadBindingFromCliBinaryPackage( if (isNapiLoggerModule(loggerModule)) return loggerModule } catch { - // Fall through to the package-directory probe below. - } + } // Fall through to the package-directory probe below. const packageJsonPath = runtimeRequire.resolve(`${packageName}/package.json`) const packageDir = dirname(packageJsonPath) diff --git a/libraries/md-compiler/package.json b/libraries/md-compiler/package.json index 06d7a7ae..087f3371 100644 --- a/libraries/md-compiler/package.json +++ b/libraries/md-compiler/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/md-compiler", "type": "module", - "version": "2026.10318.12034", + "version": "2026.10319.10359", "private": true, "description": "Rust-powered MDX→Markdown compiler for Node.js with pure-TS fallback", "license": "AGPL-3.0-only", diff --git a/libraries/md-compiler/src/index.ts b/libraries/md-compiler/src/index.ts index 898fe6f2..98ab17d5 100644 --- a/libraries/md-compiler/src/index.ts +++ b/libraries/md-compiler/src/index.ts @@ -50,3 +50,11 @@ export { export { mdxToMd } from './mdx-to-md' +export { + buildPromptTomlArtifact, + buildTomlDocument +} from './toml' +export type { + BuildPromptTomlArtifactOptions, + BuildTomlDocumentOptions +} from './toml' diff --git a/libraries/md-compiler/src/markdown/index.ts b/libraries/md-compiler/src/markdown/index.ts index 48075fbb..54f545b3 100644 --- a/libraries/md-compiler/src/markdown/index.ts +++ b/libraries/md-compiler/src/markdown/index.ts @@ -43,8 +43,7 @@ function loadBindingFromCliBinaryPackage( if (isNapiMdCompilerModule(binding)) return binding } catch { - // Fall through to the package-directory probe below. - } + } // Fall through to the package-directory probe below. try { const packageJsonPath = requireFn.resolve(`${packageName}/package.json`) diff --git a/libraries/md-compiler/src/mdx-to-md.ts b/libraries/md-compiler/src/mdx-to-md.ts index f52cc42a..154b7400 100644 --- a/libraries/md-compiler/src/mdx-to-md.ts +++ b/libraries/md-compiler/src/mdx-to-md.ts @@ -48,8 +48,7 @@ function loadBindingFromCliBinaryPackage( if (isNapiMdCompilerModule(binding)) return binding } catch { - // Fall through to the package-directory probe below. - } + } // Fall through to the package-directory probe below. try { const packageJsonPath = requireFn.resolve(`${packageName}/package.json`) diff --git a/libraries/md-compiler/src/toml.test.ts b/libraries/md-compiler/src/toml.test.ts new file mode 100644 index 00000000..a6cbc3d9 --- /dev/null +++ b/libraries/md-compiler/src/toml.test.ts @@ -0,0 +1,122 @@ +import {describe, expect, it} from 'vitest' +import {buildPromptTomlArtifact, buildTomlDocument} from './toml' + +describe('toml', () => { + describe('buildTomlDocument', () => { + it('renders root keys before nested tables', () => { + const result = buildTomlDocument({ + name: 'reviewer', + description: 'Checks patches', + mcp_servers: { + docs: { + command: 'node', + args: ['mcp.js'] + } + } + }, { + fieldOrder: ['name', 'description'] + }) + + expect(result).toBe([ + 'name = "reviewer"', + 'description = "Checks patches"', + '', + '[mcp_servers]', + '', + '[mcp_servers.docs]', + 'args = ["mcp.js"]', + 'command = "node"' + ].join('\n')) + }) + + it('renders arrays of tables', () => { + const result = buildTomlDocument({ + reviewers: [ + {name: 'alpha', enabled: true}, + {name: 'beta', enabled: false} + ] + }) + + expect(result).toBe([ + '[[reviewers]]', + 'name = "alpha"', + 'enabled = true', + '', + '[[reviewers]]', + 'name = "beta"', + 'enabled = false' + ].join('\n')) + }) + + it('keeps nested tables scoped under array-of-table parents', () => { + const result = buildTomlDocument({ + reviewers: [ + { + name: 'alpha', + policy: { + mode: 'strict' + } + } + ] + }) + + expect(result).toBe([ + '[[reviewers]]', + 'name = "alpha"', + '', + '[reviewers.policy]', + 'mode = "strict"' + ].join('\n')) + }) + + it('renders multiline strings with triple quotes', () => { + const result = buildTomlDocument({ + developer_instructions: 'Review changes carefully.\nFocus on concrete regressions.' + }) + + expect(result).toBe([ + 'developer_instructions = """', + 'Review changes carefully.', + 'Focus on concrete regressions."""' + ].join('\n')) + }) + }) + + describe('buildPromptTomlArtifact', () => { + it('maps prompt front matter into a TOML artifact and excludes internal keys', () => { + const result = buildPromptTomlArtifact({ + content: 'Review changes carefully.\nReport concrete bugs only.', + bodyFieldName: 'developer_instructions', + fieldOrder: ['name', 'description', 'developer_instructions'], + excludedKeys: ['scope', 'seriName', 'allowTools', 'argumentHint', 'color', 'namingCase'], + frontMatter: { + name: 'reviewer', + description: 'Review patches', + scope: 'global', + nickname_candidates: ['guard', 'critic'], + sandbox_mode: 'workspace-write', + allowTools: ['shell'], + skills: { + config: { + web_search: true + } + } + } + }) + + expect(result).toContain('name = "reviewer"') + expect(result).toContain('description = "Review patches"') + expect(result).toContain([ + 'developer_instructions = """', + 'Review changes carefully.', + 'Report concrete bugs only."""' + ].join('\n')) + expect(result).toContain('nickname_candidates = ["guard", "critic"]') + expect(result).toContain('sandbox_mode = "workspace-write"') + expect(result).toContain('[skills]') + expect(result).toContain('[skills.config]') + expect(result).not.toContain('scope = ') + expect(result).not.toContain('allowTools') + }) + }) +}) diff --git a/libraries/md-compiler/src/toml.ts b/libraries/md-compiler/src/toml.ts new file mode 100644 index 00000000..96a68f63 --- /dev/null +++ b/libraries/md-compiler/src/toml.ts @@ -0,0 +1,269 @@ +export interface BuildTomlDocumentOptions { + readonly fieldOrder?: readonly string[] +} + +export interface BuildPromptTomlArtifactOptions extends BuildTomlDocumentOptions { + readonly content: string + readonly bodyFieldName: string + readonly frontMatter?: Readonly> + readonly fieldNameMap?: Readonly> + readonly excludedKeys?: readonly string[] + readonly extraFields?: Readonly> +} + +type TomlScalar = string | number | boolean | Date | bigint +type TomlObject = Readonly> + +function isPlainObject(value: unknown): value is TomlObject { + return value != null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date) +} + +function isTomlScalar(value: unknown): value is TomlScalar { + return typeof value === 'string' + || typeof value === 'number' + || typeof value === 'boolean' + || typeof value === 'bigint' + || value instanceof Date +} + +function isArrayOfTables(value: readonly unknown[]): value is readonly TomlObject[] { + return value.length > 0 && value.every(item => isPlainObject(item)) +} + +function normalizeValue(value: unknown): unknown { + if (value == null) return void 0 + if (value instanceof Date) return value.toISOString() + if (typeof value === 'bigint') return value.toString() + + if (Array.isArray(value)) { + return value + .map(item => normalizeValue(item)) + .filter((item): item is Exclude => item !== void 0) + } + + if (!isPlainObject(value)) return value + + const normalizedObject: Record = {} + for (const [key, item] of Object.entries(value)) { + const normalizedItem = normalizeValue(item) + if (normalizedItem !== void 0) normalizedObject[key] = normalizedItem + } + return normalizedObject +} + +function isBareTomlKey(key: string): boolean { + return /^[\w-]+$/u.test(key) +} + +function formatTomlKey(key: string): string { + if (isBareTomlKey(key)) return key + + return JSON.stringify(key) +} + +function formatTomlKeyPath(path: readonly string[]): string { + return path.map(part => formatTomlKey(part)).join('.') +} + +function formatMultilineTomlString(value: string): string { + const normalizedValue = value.replaceAll(/\r\n?/gu, '\n') + let escapedValue = '' + + for (const character of normalizedValue) { + switch (character) { + case '\\': escapedValue += '\\\\'; break + case '"': escapedValue += '\\"'; break + case '\b': escapedValue += '\\b'; break + case '\t': escapedValue += '\\t'; break + case '\f': escapedValue += '\\f'; break + case '\n': escapedValue += '\n'; break + default: { + const codePoint = character.codePointAt(0) + if (codePoint != null && codePoint < 0x20) { + escapedValue += `\\u${codePoint.toString(16).padStart(4, '0')}` + break + } + + escapedValue += character + } + } + } + + return `"""\n${escapedValue}"""` +} + +function formatTomlScalar(value: TomlScalar): string { + if (typeof value === 'string') { + if (value.includes('\n') || value.includes('\r')) return formatMultilineTomlString(value) + + return JSON.stringify(value) + } + + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new TypeError(`Unsupported TOML number value: ${value}`) + + return String(value) + } + + if (typeof value === 'boolean') return value ? 'true' : 'false' + + if (typeof value === 'bigint') return value.toString() + + return JSON.stringify(value.toISOString()) +} + +function formatInlineTomlValue(value: unknown): string { + if (value == null) throw new TypeError('TOML inline value cannot be null or undefined') + + if (isTomlScalar(value)) return formatTomlScalar(value) + + if (Array.isArray(value)) { + if (isArrayOfTables(value)) throw new TypeError('TOML inline arrays of tables are not supported') + + const inlineItems: string[] = [] + const inlineArray: readonly unknown[] = value + for (const item of inlineArray) inlineItems.push(formatInlineTomlValue(item)) + return `[${inlineItems.join(', ')}]` + } + + const entries: string[] = [] + for (const [key, item] of Object.entries(value)) entries.push(`${formatTomlKey(key)} = ${formatInlineTomlValue(item)}`) + return `{ ${entries.join(', ')} }` +} + +function orderEntries( + entries: readonly [string, unknown][], + fieldOrder?: readonly string[] +): readonly [string, unknown][] { + if (fieldOrder == null || fieldOrder.length === 0) return [...entries] + + const priority = new Map() + for (const [index, key] of fieldOrder.entries()) priority.set(key, index) + + return [...entries].sort(([leftKey], [rightKey]) => { + const leftPriority = priority.get(leftKey) + const rightPriority = priority.get(rightKey) + + if (leftPriority != null && rightPriority != null) return leftPriority - rightPriority + + if (leftPriority != null) return -1 + + if (rightPriority != null) return 1 + + return leftKey.localeCompare(rightKey) + }) +} + +function splitTomlEntries( + value: TomlObject, + fieldOrder?: readonly string[] +): { + readonly scalarEntries: readonly [string, unknown][] + readonly tableEntries: readonly [string, TomlObject][] + readonly arrayTableEntries: readonly [string, readonly TomlObject[]][] +} { + const orderedEntries = orderEntries(Object.entries(value), fieldOrder) + const scalarEntries: [string, unknown][] = [] + const tableEntries: [string, TomlObject][] = [] + const arrayTableEntries: [string, readonly TomlObject[]][] = [] + + for (const [key, entryValue] of orderedEntries) { + if (entryValue == null) continue + + if (Array.isArray(entryValue)) { + if (isArrayOfTables(entryValue)) { + arrayTableEntries.push([key, entryValue]) + continue + } + + scalarEntries.push([key, entryValue]) + continue + } + + if (isPlainObject(entryValue)) { + tableEntries.push([key, entryValue]) + continue + } + + scalarEntries.push([key, entryValue]) + } + + return { + scalarEntries, + tableEntries, + arrayTableEntries + } +} + +function renderTomlSection( + path: readonly string[], + value: TomlObject, + options?: BuildTomlDocumentOptions, + emitTableHeader = true +): readonly string[] { + const lines: string[] = [] + const {scalarEntries, tableEntries, arrayTableEntries} = splitTomlEntries(value, options?.fieldOrder) + + if (emitTableHeader && path.length > 0) lines.push(`[${formatTomlKeyPath(path)}]`) + + for (const [key, entryValue] of scalarEntries) lines.push(`${formatTomlKey(key)} = ${formatInlineTomlValue(entryValue)}`) + + for (const [key, tableValue] of tableEntries) { + if (lines.length > 0) lines.push('') + + lines.push(...renderTomlSection([...path, key], tableValue, options)) + } + + for (const [key, tableValues] of arrayTableEntries) { + for (const tableValue of tableValues) { + if (lines.length > 0) lines.push('') + + lines.push(`[[${formatTomlKeyPath([...path, key])}]]`) + const nestedLines = renderTomlSection([...path, key], tableValue, options, false) + lines.push(...nestedLines) + } + } + + return lines +} + +export function buildTomlDocument( + value: Readonly>, + options?: BuildTomlDocumentOptions +): string { + const normalizedValue = normalizeValue(value) + if (!isPlainObject(normalizedValue)) throw new TypeError('TOML document root must be an object') + + const lines = renderTomlSection([], normalizedValue, options) + return lines.join('\n') +} + +export function buildPromptTomlArtifact(options: BuildPromptTomlArtifactOptions): string { + const { + content, + bodyFieldName, + frontMatter, + fieldNameMap, + excludedKeys, + extraFields, + fieldOrder + } = options + const excludedKeySet = new Set(excludedKeys ?? []) + const mappedFields: Record = {} + + for (const [key, value] of Object.entries(frontMatter ?? {})) { + if (excludedKeySet.has(key)) continue + + const mappedKey = fieldNameMap?.[key] ?? key + mappedFields[mappedKey] = value + } + + if (extraFields != null) { + for (const [key, value] of Object.entries(extraFields)) mappedFields[key] = value + } + + mappedFields[bodyFieldName] = content + return buildTomlDocument(mappedFields, { + ...fieldOrder != null && {fieldOrder} + }) +} diff --git a/libraries/script-runtime/package.json b/libraries/script-runtime/package.json index 286d332d..13602247 100644 --- a/libraries/script-runtime/package.json +++ b/libraries/script-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/script-runtime", "type": "module", - "version": "2026.10318.12034", + "version": "2026.10319.10359", "private": true, "description": "Rust-backed TypeScript proxy runtime for tnmsc", "license": "AGPL-3.0-only", diff --git a/libraries/script-runtime/src/index.ts b/libraries/script-runtime/src/index.ts index f1a08f60..a9ebb547 100644 --- a/libraries/script-runtime/src/index.ts +++ b/libraries/script-runtime/src/index.ts @@ -95,8 +95,7 @@ function loadBindingFromCliBinaryPackage( if (isScriptRuntimeBinding(runtimeBinding)) return runtimeBinding } catch { - // Fall through to the package-directory probe below. - } + } // Fall through to the package-directory probe below. const packageJsonPath = runtimeRequire.resolve(`${packageName}/package.json`) const packageDir = dirname(packageJsonPath) diff --git a/mcp/package.json b/mcp/package.json index 49901b17..c29e8a08 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/memory-sync-mcp", "type": "module", - "version": "2026.10318.12034", + "version": "2026.10319.10359", "description": "MCP stdio server for managing memory-sync prompt sources and translation artifacts", "author": "TrueNine", "license": "AGPL-3.0-only", diff --git a/package.json b/package.json index 6644c25b..7f91796d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync", - "version": "2026.10318.12034", + "version": "2026.10319.10359", "description": "Cross-AI-tool prompt synchronisation toolkit (CLI + Tauri desktop GUI) — one ruleset, multi-target adaptation. Monorepo powered by pnpm + Turbo.", "license": "AGPL-3.0-only", "keywords": [